Trivy en modo Web

CiberSeguridad 20 de jun. de 2026

En el artículo anterior vimos como ofrecer de forma interna en nuestra organización de un servicio Web para un anális de tipo linter de los ficheros con los que creamos nuestras imágenes que formarán parte de los contedores que se desplegarán.

Ahora nos toca ofrecer una solución Web que nos permita averiguar las vulnerabilidades que puedar una imagen.

Debemos de poder analizar tanto una imagen externa, que pueda por ejemplo ser la base de la que vamos a crear nuestra imagen final, como poder analizar cualquier imagen que esté disponible para nosotros de forma interna en nuestra organización o una imagen que hayamos generado de forma previa en nuestro equipo.

La herramienta que actualmente es un estandar de facto en el mundo de los contenedores es Trivy.

¿Qué es Trivy?

Trivy es una herramienta de escaneo de vulnerabilidades y seguridad de código abierto, desarrollada por Aqua Security.

Es ampliamente utilizada en entornos de desarrollo y plataformas DevSecOps por su rapidez, facilidad de uso y bajo costo de configuración.

¿Qué analiza Trivy?

  • Imágenes de contenedores: Detecta fallos de seguridad en sistemas operativos (Alpine, Debian, Ubuntu, etc.) y paquetes de software.
  • Dependencias y lenguajes: Analiza librerías de aplicaciones en lenguajes como Node.js, Python, Ruby, Java y Go.
  • Infraestructura como Código (IaC): Identifica configuraciones erróneas en ficheros de configuración como Kubernetes, Terraform y Dockerfiles.
  • Secretos: Busca credenciales filtradas, contraseñas, claves API o tokens expuestos.
  • Repositorios Git: Escanea el código fuente en busca de vulnerabilidades antes de pasarlo a producción.

Características principales

  • Velocidad y simplicidad: Está compilado en un solo archivo binario (Go), lo que permite ejecutarlo de inmediato sin instalaciones complejas.
  • Integración continua: Se adapta de forma nativa a pipelines de CI/CD (como GitHub Actions, GitLab CI) para automatizar los controles de seguridad en cada actualización.

Trivy en Modo Web

Al igual que hicimos en el artículo anterior para Hadolint vamos a realizar un proceso similar para ofrecer Trivy mediante un servicio Web.

Nos creamos un directorio para almacenar los ficheros de nuestro proyecto y en él tan solo necesitamos tres ficheros:

├── server.js          # Backend en Node.js (El que ejecuta Trivy)
├── index.html         # Interfaz web oscura y elegante
└── Dockerfile         # Contenedor con Node.js + binario de Trivy

Veamos el cotenido de cada uno de los ficheros:

server.js

const express = require('express');
const { spawn } = require('child_process');
const path = require('path');

const app = express();
const PORT = 4000; // Usamos el 4000 para que no choque con Hadolint-WEB

app.use(express.json());
app.use(express.static(path.join(__dirname)));

app.post('/analyze', (req, res) => {
    const { imageName } = req.body;

    if (!imageName) {
        return res.status(400).json({ error: 'Debes introducir el nombre de una imagen.' });
    }

    // Ejecutamos trivy para analizar la imagen en formato JSON
    // Usamos --quiet para evitar logs innecesarios y --no-progress
    const trivy = spawn('trivy', ['image', '--format', 'json', '--quiet', '--no-progress', imageName]);

    let stdout = '';
    let stderr = '';

    trivy.stdout.on('data', (data) => { stdout += data; });
    trivy.stderr.on('data', (data) => { stderr += data; });

    trivy.on('close', (code) => {
        if (code !== 0 && !stdout) {
            return res.status(500).json({ error: 'Error al ejecutar Trivy', details: stderr });
        }

        try {
            const results = JSON.parse(stdout || '{}');
            res.json({ success: true, results });
        } catch (e) {
            res.status(500).json({ error: 'Error al procesar el reporte JSON de Trivy.' });
        }
    });
});

app.listen(PORT, () => {
    console.log(`Trivy Web corriendo en http://localhost:${PORT}`);
});

index.html

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Trivy Web Vulnerability Scanner</title>
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="bg-slate-900 text-slate-100 min-h-screen flex flex-col justify-between font-sans">

    <header class="border-b border-slate-800 bg-slate-900/50 backdrop-blur py-4 px-6">
        <div class="max-w-5xl mx-auto flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
            <h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight text-rose-400 flex items-center gap-3">
                <span class="text-4xl sm:text-5xl">🛡️</span> Trivy UI <span class="text-slate-500 font-normal text-lg sm:text-xl ml-1"></span>
            </h1>
            <span class="text-xs font-mono bg-slate-800 text-slate-400 px-2.5 py-1 rounded-md border border-slate-700 self-start sm:self-auto">
                Image Scanner by SoloConLinux
            </span>
        </div>
    </header>

    <main class="flex-grow max-w-5xl w-full mx-auto p-6 space-y-6">
        <div class="bg-slate-850 border border-slate-800 rounded-xl p-5 shadow-xl space-y-4">
            <label class="block text-sm font-medium text-slate-300">Introduce el nombre de la imagen Docker a escanear:</label>
            <div class="flex flex-col sm:flex-row gap-3">
                <input type="text" id="image-name" placeholder="ej. ubuntu:latest, node:20-alpine, nginx:1.25" class="flex-grow bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-rose-500 font-mono text-sm">
                <button id="btn-scan" class="bg-rose-600 hover:bg-rose-500 text-white font-medium py-2.5 px-6 rounded-lg transition-all duration-200 cursor-pointer shadow-lg shadow-rose-600/20 active:scale-[0.99] shrink-0">
                    Escanear Imagen
                </button>
            </div>
        </div>

        <div class="bg-slate-850 border border-slate-800 rounded-xl p-5 shadow-xl space-y-3">
            <h2 class="text-sm font-medium text-slate-300">Reporte de Vulnerabilidades:</h2>
            <div id="result-box" class="bg-slate-950 p-4 rounded-lg border border-slate-800 min-h-[150px] font-mono text-sm overflow-x-auto text-slate-400">
                Escribe una imagen arriba y pulsa "Escanear Imagen". (El primer escaneo puede tardar un poco mientras Trivy descarga las bases de datos).
            </div>
        </div>
    </main>

    <footer class="text-center py-4 text-xs text-slate-600 border-t border-slate-800">
        SoloConLinux · Trivy Web Wrapper
    </footer>

    <script>
        const btnScan = document.getElementById('btn-scan');
        const imageNameInput = document.getElementById('image-name');
        const resultBox = document.getElementById('result-box');

        const severityStyles = {
            CRITICAL: { bg: 'bg-red-950/40', border: 'border-red-800/60', text: 'text-red-400' },
            HIGH: { bg: 'bg-orange-950/40', border: 'border-orange-800/60', text: 'text-orange-400' },
            MEDIUM: { bg: 'bg-yellow-950/40', border: 'border-yellow-800/60', text: 'text-yellow-400' },
            LOW: { bg: 'bg-blue-950/40', border: 'border-blue-800/60', text: 'text-blue-400' }
        };

        btnScan.addEventListener('click', async () => {
            const imageName = imageNameInput.value.trim();
            if(!imageName) return alert('Por favor, escribe el nombre de una imagen.');

            btnScan.disabled = true;
            btnScan.innerText = 'Escaneando...';
            resultBox.innerHTML = '<span class="text-rose-400 animate-pulse">Trivy está analizando las capas de la imagen y buscando CVEs...</span>';

            try {
                const response = await fetch('/analyze', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ imageName })
                });

                const data = await response.json();
                if (!response.ok) throw new Error(data.error || 'Error en el escaneo');

                // Procesar resultados de Trivy
                const vulnerabilities = [];
                if (data.results && data.results.Results) {
                    data.results.Results.forEach(target => {
                        if (target.Vulnerabilities) {
                            vulnerabilities.push(...target.Vulnerabilities);
                        }
                    });
                }

                if (vulnerabilities.length === 0) {
                    resultBox.innerHTML = '<div class="text-emerald-400 font-medium">¡Felicidades! No se han encontrado vulnerabilidades en esta imagen.</div>';
                } else {
                    resultBox.innerHTML = `<div class="text-slate-400 mb-4 font-sans text-xs">Se encontraron <b>${vulnerabilities.length}</b> vulnerabilidades.</div>`;
                    
                    vulnerabilities.forEach(vuln => {
                        const style = severityStyles[vuln.Severity] || severityStyles.LOW;
                        const card = `
                            <div class="mb-3 p-3.5 rounded-lg border ${style.bg} ${style.border} space-y-1.5">
                                <div class="flex items-center justify-between flex-wrap gap-2">
                                    <div>
                                        <span class="inline-block px-1.5 py-0.5 rounded text-xs font-black ${style.text} bg-slate-900 border ${style.border} mr-2">${vuln.Severity}</span>
                                        <span class="text-slate-200 font-bold">${vuln.VulnerabilityID}</span>
                                        <span class="text-slate-400 text-xs ml-2">(${vuln.PkgName} v${vuln.InstalledVersion})</span>
                                    </div>
                                    ${vuln.PrimaryURL ? `<a href="${vuln.PrimaryURL}" target="_blank" class="text-xs text-rose-400 hover:underline font-sans">Detalles ↗</a>` : ''}
                                </div>
                                <p class="text-slate-300 text-xs font-sans">${vuln.Title || vuln.Description || 'Sin descripción disponible.'}</p>
                                ${vuln.FixedVersion ? `<div class="text-xs text-emerald-400 font-sans">Solución: Actualizar a la versión <b>${vuln.FixedVersion}</b></div>` : ''}
                            </div>
                        `;
                        resultBox.innerHTML += card;
                    });
                }

            } catch (error) {
                resultBox.innerHTML = `<div class="text-red-400 font-medium">Error: ${error.message}</div>`;
            } finally {
                btnScan.disabled = false;
                btnScan.innerText = 'Escanear Imagen';
            }
        });
    </script>
</body>
</html>

Dockerfile

FROM node:20-alpine

# Instalar dependencias necesarias para descargar Trivy y para que funcione correctamente
RUN apk add --no-cache curl rpm

# Descargar e instalar el binario oficial de Trivy
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

WORKDIR /app

# Inicializar proyecto Node.js e instalar Express
COPY server.js index.html ./
RUN npm init -y && npm install express

EXPOSE 4000

CMD ["node", "server.js"]

Crear la imagen de trivy-web

La construcción de la imagen es realmente sencilla, tan solo ejecuta el comando:

docker build -t trivy-web .

Iniciar el servicio web de Trivy-Web

Como tenemos en el puerto 3000 ejecutandose Hadolint-Web, este contenedor lo ejecutaremos en el puerto 4000 para que no "choquen"

# Ejecutar dándole acceso al socket de Docker
docker run -d \
  -p 4000:4000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  --name trivy-web \
  trivy-web

En este caso a nuestro contenedor le hemos dado acceso al socket de docker, para que pueda acceder a las imágenes que se quieran escanear, tanto locales como remotas, si son accesibles mediante la red del anfitrión del contenedor.

Análisis y Resultado

Si por ejemplo analizamos una imagen antigua de node node:18-alpine , que pensabamos usar para crear una imagen, vemos que no sería recomendable por la cantidad de CVEs que contiene:


Es una manera sencilla y cómoda de ofrecer a un grupo de desarrolladores que están usando una imagen de Docker una herramienta vía Web para detectar posibles vulnerabilidades si escogen una imagen, e incluso de revisar las vulnerabilidades actuales de las imágenes que están generando.

Es un paso previo que no depende de los flujos de CI/CD de los departamentos de QA y CiberSeguridad.
Permite a los propios usuarios detectar en fases previas posibles vulnerabilidades, antes de la solicitud definitiva de creación y publicación/registry de una imagen.

Nos vemos en la siguiente herramienta de CiberSeguridad

Etiquetas

Luis GuLo

🐧 SysAdmin GNU/Linux - 🐳 Docker - 🖥️ Bash Scripting - 🐪 Perl - 🐬 MySQL - 👥 Formador de TI - 👥 Formador de SysAdmin's - 💢 Ansible - ☁️ Cloud Computing - ❤️ Debian GNU/Linux