Un relato de campo sobre cómo añadimos un Software Bill of Materials y un control de vulnerabilidades real a un pipeline de CI/CD .NET: las decisiones que no salen en los tutoriales y los tropiezos que nos costaron horas.
El punto de partida
Tenemos varias aplicaciones web en .NET. Se despliegan como paquetes ZIP sobre una plataforma gestionada (tipo App Service), no como imágenes de contenedor construidas por nosotros. El CI/CD corre en Azure DevOps, sobre un agente propio Linux.
El objetivo era producir un SBOM en formato CycloneDX por cada artefacto desplegable, analizarlo en busca de vulnerabilidades conocidas, detener el build ante problemas graves que podamos remediar, y conservar evidencias duraderas.
Conviene decir desde el principio para qué sirve realmente ese SBOM, porque condiciona todas las decisiones que vienen después. Sirve para dos cosas distintas:
- Probar qué había. Qué componentes, en qué versiones, en qué despliegue, en qué fecha.
- Preguntar hacia atrás. Poder responder mañana —cuando se publique un CVE que hoy no existe— si aquella versión que está en producción está afectada, sin necesidad de reconstruir nada.
Un análisis en tiempo de build solo te dice lo que se sabía ese día. Es una foto, no un proceso. Lo que describimos aquí es la primera fase: construir el inventario y conservarlo bien. La segunda —explotarlo de forma continua— depende por completo de que la primera se haya hecho con cabeza, y volvemos sobre ella al final.
Suena estándar. Lo interesante está donde la realidad se separa del camino feliz — y la primera sorpresa llegó antes de escribir una línea de pipeline.
Un inventario antes del inventario: qué entregas
Para decidir cuánto tiempo conservar la evidencia hay que saber qué marco te obliga. Y eso no depende de lo que hace tu software, sino de cómo lo entregas.
Conviene hacer bien la distinción, porque se puede malinterpretar. El Reglamento de Ciberresiliencia europeo —CRA, por sus siglas en inglés (Cyber Resilience Act)— y la directiva NIS2 no son alternativas: regulan objetos distintos y pueden aplicar de forma concurrente.
- El CRA regula productos con elementos digitales puestos en el mercado. Sus obligaciones recaen sobre fabricantes, importadores y distribuidores. Alcanza al backend solo cuando este es una solución de tratamiento remoto: software desarrollado por el propio fabricante, y sin el cual el producto no podría realizar una de sus funciones.
- NIS2 —la segunda directiva europea sobre seguridad de las redes y sistemas de información— no regula productos, sino la gestión de la ciberseguridad de las entidades esenciales e importantes que prestan determinados servicios. Obliga a quien opera, y solo si la entidad entra en su ámbito por sector y tamaño. Además es una directiva, no un reglamento: si aplica, el texto vinculante es la norma nacional de transposición.
Y sobre NIS2 conviene añadir algo que se pasa por alto, porque es la vía por la que alcanza a la mayoría de los que creen estar fuera. Aunque no superes los umbrales, NIS2 obliga a sus entidades a gestionar la seguridad de su cadena de suministro — y eso significa que si tu cliente está sujeto, tú acabas sujeto por contrato. Con un matiz que marca la diferencia: te van a pedir evidencia, no una declaración. Una cláusula firmada no acredita nada; un inventario con fechas, sí. Que hoy ninguno de nuestros clientes esté en ese supuesto no cambia el cálculo: el día que llegue el primer cuestionario, la respuesta ya tiene que existir.
Una misma organización puede estar sujeta a ambos por conceptos distintos. Y lo que más cuesta interiorizar: el ámbito se determina por artefacto, no por organización. Ser fabricante bajo el CRA respecto de un producto no arrastra a todo lo demás que haces.
El test del CRA es, en la práctica, un árbol de decisión corto. ¿Hay algo que se distribuya o instale — una app, un agente, un ejecutable, un plugin, un paquete? Si no lo hay, si se accede por navegador y punto, no hay producto puesto en el mercado. Si lo hay: ¿ese componente necesita tu backend para funcionar, y ese backend lo desarrollaste tú? Si sí, el backend es parte del producto y entra contigo.
Dos de nuestros productos caen en extremos opuestos del árbol.
Tempus es una aplicación web. Se abre en el navegador y no se instala nada — que se pueda añadir a la pantalla de inicio como PWA no cambia el análisis: eso es una función del navegador, no un artefacto que nosotros entreguemos. Primera pregunta, respuesta no, fin del test.
Cauzi Bot es el caso interesante, porque estuvo a punto de salir por el otro lado. Se ofrece como servicio, pero hay un pequeño ZIP: un paquete de aplicación de Teams, opcional, para quien quiera integrarlo. Eso sí es algo que se distribuye y se instala. Y la consecuencia habría sido grande: si ese paquete cuenta como producto, nuestro backend pasa a ser tratamiento remoto —lo desarrollamos nosotros, sin él la integración no hace nada— y el producto entero entra en ámbito. No hay término medio: es una bisagra binaria.
Lo que la resuelve es el contenido del ZIP. Son unos kilobytes de manifiesto y recursos declarativos: no contiene código ejecutable, no ejecuta nada, apunta a nuestros endpoints. Un descriptor de configuración está más cerca de un marcador que de un producto de software. Fuera de ámbito. Y hay un segundo motivo, independiente del primero: la integración es opcional y el producto es funcionalmente completo sin ella, lo que también aleja al servicio remoto del perímetro. Dos razones separadas sostienen mucho mejor que una.
Fíjate en la escala del asunto: la determinación de ámbito de un producto entero colgaba de si un fichero de unos pocos kilobytes es software. Por eso la conclusión se escribe con su condición, no solo con su resultado — no es producto mientras el paquete contenga únicamente manifiesto y recursos declarativos, sin código ejecutable. Así la determinación te avisa de cuándo ha caducado. El día que alguien añada una pestaña con código al paquete, se invalida sola y alguien lo nota. Una conclusión sin condición es una que nadie vuelve a mirar hasta que es tarde.
Aplicar todo esto a nuestras propias aplicaciones fue instructivo, aunque no por el resultado. Fue instructivo porque la pregunta que bloquea no es regulatoria, es de inventario: ¿esta aplicación web es Blazor Server o WebAssembly? ¿lo que se instala contiene código o es un manifiesto declarativo? ¿ese componente es necesario para el uso principal o es accesorio? Ninguna de esas respuestas estaba a mano de inmediato, y sin ellas el análisis no arranca.
Ahí hay una lección que va más allá del SBOM. Un SBOM te dice qué hay dentro de lo que despliegas. El ámbito regulatorio depende de un inventario distinto y anterior: qué entregas, por qué canal, y qué necesita para funcionar. Uno no sustituye al otro, y el segundo suele no existir en ninguna parte. Merece la pena escribirlo — con fecha y razonamiento — porque la guía de la Comisión es un documento vivo, y una determinación de ámbito sin razonamiento escrito no sobrevive a que te la pregunten dentro de tres años.
Elección de herramientas
- CycloneDX para .NET (
dotnet CycloneDX, una herramienta global) para inventariar el grafo de dependencias .NET —directas y transitivas— en JSON. - Trivy para el análisis de vulnerabilidades, tanto de las dependencias .NET (lee el SBOM CycloneDX directamente) como de la capa de sistema operativo.
CycloneDX es el formato de facto en el ecosistema .NET y el que consumen las herramientas de análisis posteriores. Elegirlo mantiene las opciones abiertas para la fase 2.
Decisión 1: no puedes analizar un contenedor que no construyes
Todos los tutoriales de SBOM asumen que construyes una imagen Docker y analizas esa. Nosotros no: desplegamos ZIP sobre un runtime base gestionado por el proveedor. No hay ninguna imagen nuestra que analizar, y la capa de sistema operativo es de la plataforma.
Nuestra respuesta fue analizar la imagen base pública del runtime como aproximación a la capa de sistema operativo. Trivy puede descargar y analizar mcr.microsoft.com/dotnet/aspnet:<versión> directamente. La versión la derivamos de la variable runtimeStack del pipeline —una única fuente de verdad que la tarea de despliegue ya utiliza—, de modo que el objetivo del análisis sigue al runtime automáticamente.
Y aquí toca ser honestos, porque es una aproximación imperfecta: la imagen que ejecuta la plataforma no es exactamente esa. El proveedor construye su propia imagen sobre el runtime base, con su propio userland, y no es descargable. Lo que analizamos es lo más cerca que podemos estar del sistema operativo real: comparte la distribución base y la mayor parte de los paquetes, pero no es idéntico. Preferimos una aproximación declarada como tal a un punto ciego, y por eso —entre otras razones— estos hallazgos no bloquean.
Decisión 2: bloquea solo lo que realmente puedes arreglar
Esta es la decisión que importa. La imagen base siempre arrastrará algún CVE crítico o alto de sistema operativo: paquetes que la distribución aún no ha parcheado, o que el proveedor aún no ha reconstruido. No podemos remediarlos directamente. No construimos la imagen; no hay un apt upgrade bajo nuestro control.
Si detienes el build ante los CVE críticos y altos de la imagen base, acabas de atar tus despliegues al calendario de parches de otro y a vulnerabilidades sin corrección disponible. Cada despliegue se bloquea por cosas que no puedes tocar. Ese es un control que alguien desactiva en una semana, y con razón.
Así que dividimos el criterio en dos:
- Tus dependencias .NET → control estricto. Las controlas: una vulnerabilidad con corrección disponible es una subida de versión de una línea. Crítica o alta detiene el build.
- Imagen base y capa de sistema operativo → solo informe. Los hallazgos se generan y se conservan como evidencia, pero nunca bloquean.
Ahora bien, «solo informe, para siempre» tiene un problema evidente: un informe sin dueño no lo lee nadie, y «ya se resolverá cuando el proveedor reconstruya la imagen» es una esperanza, no un proceso. Entre lo que parcheas tú y lo que no puedes tocar hay una tercera categoría real: hallazgos que no parcheas pero sobre los que sí puedes actuar. Subir la versión del runtime a una rama ya reconstruida. Aplicar una mitigación de configuración. Abrir incidencia con el proveedor. O aceptar el riesgo de forma explícita.
Por eso el modelo final no es «solo informe» a secas, sino solo informe, con revisión periódica y decisión registrada. Cada revisión del baseline de la imagen base termina en una de cuatro salidas —actualizar runtime, mitigar, escalar al proveedor, o aceptar con fecha de recaída— y esa decisión queda escrita junto a la evidencia. Es la misma decisión técnica de no bloquear el despliegue, pero deja de ser un cajón: pasa a ser un riesgo gestionado. La diferencia entre las dos cosas es exactamente lo que un auditor va a buscar.
Y hay un supuesto enterrado en todo esto que conviene sacar a la luz, porque tiene fecha de caducidad. «Se cura solo cuando el proveedor reconstruya la imagen» solo es cierto mientras el proveedor la reconstruya — es decir, mientras el runtime esté en soporte. El día que llega a fin de vida, el tag deja de moverse, los hallazgos dejan de limpiarse, y «se cura solo» se convierte en silencio en «se acumula solo». Nadie ha decidido nada; simplemente la premisa dejó de ser cierta y el informe siguió generándose igual.
Ese es justo el fallo: un control que degrada sin que nadie lo note. Por eso la fecha de fin de vida del runtime es un disparador explícito de la revisión, no una tarea de mantenimiento más. Es la fecha en la que la premisa de esta decisión deja de sostenerse, y hay que tenerla marcada antes de que llegue — no descubrirla al leer un informe que lleva meses sin cambiar.
Decisión 3: las excepciones, y por qué el fichero de exclusiones es la herramienta equivocada
Nuestra regla de excepciones es simple: si un CVE tiene corrección disponible, se remedia (subida de versión); no se exceptúa. Solo se exceptúa lo que no tiene corrección.
La primera implementación fue un fichero .trivyignore acotado al control de dependencias. Funciona, pero descubrimos dos cosas que la mejoran:
Si tu regla es «solo lo que no tiene corrección», no necesitas una lista: necesitas un flag. Trivy trae --ignore-unfixed de serie: descarta los hallazgos que no tienen corrección disponible y deja que el control vea todos los que sí la tienen. Eso es la política, escrita directamente y en un solo sitio.
La diferencia con el fichero no es de comodidad, es de caducidad. Un .trivyignore es una foto: anotas un CVE porque hoy no tiene parche, y ahí se queda. El día que salga la corrección —que es exactamente el día en que tu propia regla dice que hay que remediarlo— el fichero lo sigue silenciando, porque un fichero no se entera de nada. Acabas manteniendo una lista que incumple la política que la lista pretendía implementar, y que solo se corrige si alguien la repasa a mano y a tiempo. El flag, en cambio, se reevalúa en cada ejecución contra la base de datos del día: no puede desincronizarse porque no recuerda nada.
Y un matiz fino sobre --ignorefile: no solo evita que un hallazgo detenga el build, sino que lo elimina del informe por completo. Para la imagen base —que conservamos precisamente como evidencia— eso es lo contrario de lo que queremos: estarías ocultando lo que pretendes guardar. Nuestra primera conclusión fue «entonces las exclusiones nunca tocan el análisis de imagen». La conclusión correcta es más de fondo: el fichero de exclusiones es la herramienta equivocada para justificar un hallazgo. La herramienta correcta es VEX (Vulnerability Exploitability eXchange, en sus implementaciones CycloneDX VEX u OpenVEX), un formato pensado exactamente para ese problema: conserva el hallazgo en el informe con su justificación adjunta —no afectado, mitigado, bajo investigación— en lugar de borrarlo. Un hallazgo borrado es información perdida; un hallazgo justificado es evidencia.
En nuestra primera pasada, todos los hallazgos altos tenían corrección disponible, así que remediamos y la lista de excepciones quedó vacía. Ese es el estado sano, y conviene decirlo: la métrica de salud de un programa de excepciones no es que esté bien mantenido, es que esté corto.
Decisión 4: evidencia duradera, deduplicada y en su propio contenedor
El artefacto del pipeline —el SBOM adjunto al build— es efímero. Para evidencia de auditoría quieres algo duradero y desacoplado del ciclo de vida del registro de imágenes, para que purgar imágenes antiguas no destruya el rastro. Lo archivamos en almacenamiento de blobs, y solo en despliegues a producción: los builds que nunca llegan a producción no archivan.
Tres cosas que merece la pena copiar:
Deduplica por el conjunto de componentes, no por el fichero. La metadata de un documento CycloneDX —marca de tiempo, número de serie— cambia en cada build, así que comparar ficheros completos no deduplicaría jamás. Calculamos el hash del array components normalizado (ordenado por package URL) y lo comparamos con el último hash archivado. Si el conjunto no ha cambiado, no se crea copia nueva.
Esto tiene un efecto secundario que hay que cubrir: si no archivas copia, pierdes la respuesta a «¿qué SBOM estaba vivo en producción el 14 de marzo?» —que es justo la pregunta que hace un auditor—. La deduplicación es correcta; lo que falta es un índice: una línea por despliegue con identificador, hash del SBOM y fecha. Es un append a un fichero, cuesta cero, y no se puede reconstruir retroactivamente, igual que el propio SBOM. Registrarlo desde el día uno es barato; explotarlo puede esperar.
Usa una cuenta de almacenamiento dedicada. Deliberadamente no reutilizamos una cuenta que ya custodiaba material criptográfico: dar acceso de escritura ahí a la identidad del pipeline le habría dado alcance sobre ese material. Una cuenta separada mantiene limpio el radio de impacto.
Autentica con identidad federada, sin secretos. El paso de archivado usa la conexión de servicio federada que el pipeline ya tenía y control de acceso basado en roles sobre el blob (--auth-mode login). Nada embebido en el pipeline.
Y entonces, ¿cuánto tiempo lo guardamos?
Aquí es donde el análisis de ámbito deja de ser teoría.
Hoy, ninguna de estas aplicaciones es un producto puesto en el mercado como tal. Son aplicaciones web: el usuario abre un navegador, no instala nada. No hay artefacto que entregar, luego no hay obligación CRA. Y NIS2, en lo que respecta a la operación del servicio, depende del sector y del umbral de tamaño de la entidad — algo que hay que verificar, no suponer.
Así que la respuesta honesta es que hoy no hay una cifra de retención que nos vincule legalmente. Podríamos guardar la evidencia un año. Podríamos no guardarla.
La guardamos diez, y por dos razones que no son la norma.
La primera es operativa, y es la de arriba: la fase 2 solo puede consultar lo que la fase 1 guardó. El día que salga el CVE que importa, la pregunta será «¿qué había desplegado hace dieciocho meses?», y esa respuesta no se reconstruye. El almacenamiento de blobs para ficheros JSON diminutos no cuesta prácticamente nada; el error de haber guardado poco no tiene arreglo. La asimetría decide sola.
La segunda es que el marco de hoy no es el de mañana. Una de nuestras aplicaciones —Tempus, nuestra plataforma de gestión de fichajes— contempla desplegarse en el centro de datos del cliente. El día que eso ocurra deja de ser un servicio que operamos y pasa a ser un producto que entregamos: expediente técnico, periodo de soporte declarado, política de divulgación de vulnerabilidades, y un SBOM por versión conservado durante al menos diez años desde la primera puesta en el mercado, o el periodo de soporte si es mayor. Y aquí está el problema: esa evidencia no se fabrica hacia atrás. No puedes archivar retroactivamente el SBOM de un despliegue de hace dos años, ni el índice de qué versión estuvo viva en qué fecha. Un expediente técnico que empieza el día que la norma te obliga es un expediente que arranca vacío.
Por eso el inventario existe ya, antes de que nadie nos lo exija. No es cumplimiento anticipado por virtud: es que la alternativa —montarlo el día que haga falta— no existe.
Un matiz que conviene retener del propio CRA, porque valida lo que decidimos en la Decisión 2: la conformidad exige verificar que ningún componente arrastra una vulnerabilidad conocida ya parcheada. Incluir un CVE con corrección disponible es incumplimiento directo. Es exactamente nuestro control estricto sobre dependencias propias: bloqueas lo que tiene arreglo. La norma, cuando la lees bien, respalda el criterio de ingeniería en lugar de contradecirlo.
Ejecutar Trivy en un agente propio: las partes que muerden
Binario nativo, no docker run. La tentación es lanzar docker run aquasec/trivy. Pero en nuestro agente el workspace es un bind-mount del host y el demonio Docker es el del host, con el socket montado. Así que docker run -v $(pwd):/work monta una ruta del host que no existe: Trivy ve un directorio vacío y no encuentra tu SBOM. Instalamos Trivy como binario nativo en la imagen del agente. Ve el workspace directamente, y la base de datos de vulnerabilidades se cachea en un volumen persistente.
La base de datos no viene de donde crees. Las versiones recientes de Trivy descargan la base de datos de vulnerabilidades de mirror.gcr.io, no de ghcr.io. La lista de permitidos de salida de nuestro agente estaba construida sobre la suposición antigua; la corrección fue habilitar conectividad con mirror.gcr.io, no abrir ghcr.io. Ahórrate el diagnóstico: comprueba de dónde descarga tu versión de Trivy (trivy image --download-db-only), no lo que dicen los tutoriales viejos.
Instala desde el repositorio de paquetes, no desde el script de instalación. Primero fijamos una versión de Trivy y se la pasamos al install.sh oficial; la URL del asset de la release devolvió un 404, porque la versión fijada ya estaba obsoleta. El repositorio apt oficial gestiona versión, checksums y reintentos, y no se rompe cuando cambian los nombres de los assets.
Reconstruir el agente actualiza tu herramienta en silencio. La imagen de nuestro agente instalaba la CLI de la nube sin fijar versión. Reconstruirla para añadir Trivy subió esa CLI una versión mayor, que traía un cambio incompatible en un comando que usaba nuestro trabajo de despliegue: un argumento había cambiado de nombre. No tocamos ni una línea de nuestro código y aun así los despliegues se rompieron.
La lección va más allá del incidente: las versiones de las herramientas de un agente propio son parte del contrato de tu pipeline. O las fijas, o presupuestas una revisión de cambios incompatibles en cada reconstrucción. Lo que no puedes es asumir que reconstruir la imagen del agente es una operación neutra.
Tropiezos menores
- CLI de CycloneDX para .NET: el flag de JSON es
-F Json(--output-format). El antiguo-j/--jsonestá obsoleto o eliminado; pasarlo directamente da error. - Expresiones de plantilla de Azure DevOps: la función
notnecesita paréntesis —not(parameters.x). Y no puedes embeber una directiva${{ if }}dentro de una línea de script de shell: lee el booleano como cadena ('True'/'False') y ramifica en bash. - BuildKit sobre ZFS: el equipo que aloja el agente usa ZFS como almacenamiento, y los builds de imagen fallaban de forma intermitente con un error
zfs mount ... no such file.docker builder prune -af—que limpia la caché de build corrupta— lo resolvió sin tocar imágenes, contenedores ni volúmenes. - No bloquees por hallazgos sobre los que no puedes actuar. Sí, es la Decisión 2 otra vez. Es la más importante.
Disciplina de despliegue: primero solo informe
No pusimos todas las aplicaciones en modo bloqueante de golpe. Cada aplicación tiene su propio grafo de dependencias —suites de componentes de interfaz, librerías de autenticación— con sus propios hallazgos potenciales. Integramos el análisis en modo informe en todas primero, observamos el baseline de cada una, remediamos lo que tenía corrección, y entonces activamos el control aplicación por aplicación.
Un control activado a ciegas es un control que bloquea una release que no esperabas. Y el primer bloqueo inesperado de una release urgente es, casi siempre, el último día que ese control estuvo activo.
Lo que queda para la siguiente fase
Merece la pena ser explícitos, porque lo construido hasta aquí es un inventario bien conservado, no todavía un programa de gestión continua de vulnerabilidades. La diferencia es real y la fase 2 tiene nombre y forma:
- Ingesta de los SBOM archivados en un sistema de análisis continuo (Dependency-Track u equivalente), que reevalúe el inventario contra la base de datos de vulnerabilidades del día y alerte sin necesidad de un nuevo build. Eso es lo que convierte una foto en un proceso: te enteras de que el CVE de esta mañana afecta a lo que desplegaste en marzo, sin tocar nada.
- Explotación del índice de trazabilidad despliegue → SBOM, para responder consultas del tipo «qué había en producción en tal fecha» en segundos en vez de a mano.
- Inmutabilidad del almacenamiento de evidencia. Sin una política de retención inmutable sobre el contenedor de blobs, la evidencia es técnicamente refutable: si se puede reescribir, prueba menos de lo que crees.
Ninguna de las tres bloquea la fase 1. Las tres dependen de que la fase 1 esté hecha correctamente — y ninguna se puede aplicar hacia atrás sobre datos que no se guardaron.
Lo que nos llevamos
- Antes del SBOM, inventaría qué entregas. El SBOM dice qué hay dentro; el ámbito regulatorio depende de qué distribuyes, por qué canal, y qué necesita para funcionar. Y se determina por artefacto, no por organización.
- Si despliegas artefactos sobre una imagen base gestionada, analiza la imagen base como aproximación —declarando que lo es— pero bloquea solo tus propias dependencias: no puedes remediar lo que no construyes.
- «Solo informe» no significa «sin dueño». Un hallazgo que no bloquea sigue necesitando revisión periódica y una decisión registrada, o el informe deja de ser gestión de riesgo y pasa a ser papel.
- Remedia cuando hay corrección; justifica con VEX lo que de verdad no tiene arreglo. Un hallazgo borrado del informe es información perdida; uno justificado es evidencia.
- Deduplica la evidencia por conjunto de componentes, no por fichero, guárdala en su propio contenedor, indexa qué SBOM estaba vivo en cada despliegue, y consérvala mucho tiempo — no porque una norma lo exija hoy, sino porque es barata y es irrecuperable.
- En agentes propios, las versiones de tus herramientas son parte del contrato. Fíjalas, y comprueba de dónde descarga Trivy su base de datos realmente.
- Despliega primero en modo informe. El control en el que confías es el que encendiste a propósito.
El estado final de esta fase: un SBOM por artefacto desplegable, un control de dependencias que solo detiene el build ante problemas accionables, visibilidad completa de la capa de sistema operativo como evidencia gestionada, y un rastro de auditoría duradero y deduplicado. Sin fingir que podemos parchear una imagen base que no construimos, y sin fingir que archivar es lo mismo que vigilar.
Nada de esto nos lo exige hoy ninguna norma. Lo hacemos porque el expediente que necesitaremos el día que nos lo exijan tiene que existir desde antes — y porque los productos sobre los que corre, Tempus y Cauzi Bot, los construimos nosotros, y nos parece razonable aplicarnos primero el criterio que defendemos.
