Módulo 6: Supply Chain Sbom And Signing
3. Manos a la obra: generando el SBOM de Andes Cargo
Descripción
Esta es la lección donde la teoría de la lección 2 se vuelve un archivo real en tu disco. Vas a declarar, por primera vez en andes-cargo-infra/, las dependencias de terceros que lambda/handler.py necesita para ejecutarse (boto3), resolver esa dependencia a su árbol completo instalándola de verdad, y correr trivy fs --format cyclonedx sobre el proyecto — la misma Trivy 0.74.0 que instalaste en el Módulo 5, aplicada aquí a un propósito distinto: no escanear configuración insegura, sino inventariar qué hay adentro del proyecto que se despliega.
Conexión con el módulo
Esta lección produce sbom.cyclonedx.json, uno de los tres artefactos nuevos que este módulo agrega a andes-cargo-infra/ (junto con cosign.key/cosign.pub de la lección 5 y manifest.sig de la lección 6). La lección 8 va a integrar este mismo comando como un paso más del pipeline heredado — lo que corres aquí, a mano, una vez, es exactamente lo que ese proyecto final automatiza.
Paso 1 — Por qué lambda/ necesita un requirements.txt, aunque Lambda ya trae boto3
Antes de generar nada, vale la pena resolver una pregunta legítima: si el runtime administrado de AWS Lambda para Python ya incluye boto3 preinstalado en el entorno de ejecución —y por eso terraform-and-iac-guide nunca necesitó empaquetarlo dentro de function.zip, que sigue pesando exactamente 890 bytes, solo handler.py—, ¿por qué declarar boto3 como dependencia en absoluto?
La razón es una práctica real y común, no un artificio de esta lección: el boto3 que trae el runtime administrado de Lambda queda fijado a la versión que AWS empaquetó en esa imagen base, y suele quedar por detrás de la versión más reciente publicada en PyPI —a veces por varios meses—. Un equipo que necesita una funcionalidad nueva de un servicio de AWS, o simplemente quiere reproducir en su máquina de desarrollo el mismo comportamiento exacto que corre en producción, declara boto3 explícitamente en un requirements.txt —para desarrollo local, pruebas, y para que cualquier herramienta de la cadena de suministro (como la de esta lección) tenga algo concreto que inventariar—, sin que eso cambie lo que function.zip contiene: el paquete de despliegue sigue siendo solo el código propio, y boto3 sigue viniendo del runtime en producción. Esta lección declara esa dependencia en lambda/requirements.txt, un archivo nuevo, hermano de handler.py:
# lambda/requirements.txt
boto3==1.40.76
Una sola línea, una sola versión pineada —exactamente el tipo de archivo que la lección 2 usó como punto de partida para explicar qué es lo que un SBOM ve que este archivo, por sí solo, no muestra—.
Paso 2 — Resolviendo la dependencia de verdad, no solo declarándola
Una línea en requirements.txt es una declaración de intención — dice qué paquete quieres, no qué termina instalado. Para que un SBOM capture el árbol real, hace falta que ese árbol exista de verdad en algún entorno, resuelto por el instalador de paquetes de Python:
python3 -m venv .venv
.venv/bin/pip install --quiet -r lambda/requirements.txt
pip no solo instala boto3 — resuelve su árbol completo de dependencias, y las instala todas. Confírmalo capturando exactamente qué quedó instalado, con el mismo comando que cualquier equipo de Python usa para congelar un entorno reproducible:
.venv/bin/pip freeze --local > lambda/requirements.txt
cat lambda/requirements.txt
Qué esperar (literal, ejecutado para escribir esta lección):
boto3==1.40.76
botocore==1.40.76
jmespath==1.1.0
python-dateutil==2.9.0.post0
s3transfer==0.14.0
six==1.17.0
urllib3==2.7.0
Léelo con atención: siete líneas, no una. pip freeze no inventó nada — simplemente hizo visible, en un archivo de texto plano, el árbol de dependencias que pip install ya había resuelto en el Paso anterior. Una sola línea escrita a mano (boto3==1.40.76) se convirtió, al resolverse de verdad, en siete paquetes reales — exactamente la brecha entre "lo que un desarrollador declara" y "lo que realmente termina instalado" que la lección 2 anticipó, ahora con un número concreto: seis dependencias transitivas que nadie en Andes Cargo escribió a mano en ningún momento.
Nota sobre este
requirements.txtcongelado: a partir de aquí, este archivo lista el árbol completo resuelto —siete paquetes—, no la declaración original de una sola línea. Es una práctica real y común (congelar el entorno exacto que un equipo probó, para que cualquier otra persona lo reproduzca bit a bit), y es, además, la forma en que Trivy va a poder detectar los siete componentes en el Paso 3, no solo el primero.
Paso 3 — trivy fs --format cyclonedx, ejecutado
Con lambda/requirements.txt congelado, genera el SBOM desde la raíz de andes-cargo-infra/:
trivy fs --format cyclonedx --output sbom.cyclonedx.json .
Qué esperar (literal, ejecutado para escribir esta lección — Trivy 0.74.0, la misma versión confirmada en el Módulo 5):
INFO "--format cyclonedx" disables security scanning. Specify "--scanners vuln" explicitly if you want to include vulnerabilities in the "cyclonedx" report.
WARN [pip] Unable to find python `site-packages` directory. License detection is skipped. err="site-packages directory not found"
INFO Number of language-specific files num=1
Tres líneas, y las tres importan:
- La primera es informativa, no un error:
--format cyclonedxes, por diseño, un modo de inventario, no de escaneo de vulnerabilidades — Trivy te avisa, explícitamente, que si además quieres vulnerabilidades dentro del mismo documento, hace falta pedirlo con--scanners vuln. Esta lección no lo pide: el propósito aquí es el inventario (TM-02, la firma de la cadena 4-7), no un escaneo de dependencias (eso ya lo practicaste contrivy fs --scanners secreten el Módulo 3, sobre un propósito distinto: detectar secretos filtrados, no generar un SBOM). - La segunda es la limitación honesta que la lección 2 ya anticipó: Trivy no pudo completar la detección de licencia para los componentes de este SBOM, porque su analizador de licencias para el ecosistema Python busca un directorio
site-packagescon una estructura de entorno virtual convencional junto al archivo de dependencias, y no lo encontró en esta corrida específica (elrequirements.txtcongelado sí describe con precisión el árbol de paquetes, pero, tal como corrió esta lección, sin ese directorio adyacente Trivy no completa el enriquecimiento de licencia). Es exactamente el caso que la lección anterior advirtió: el formato CycloneDX tiene un campo para licencia, pero la herramienta, en esta corrida concreta, no pudo completarlo — y el mensaje lo dice sin ambigüedad, en vez de fallar en silencio con el campo vacío sin explicación. - La tercera confirma el alcance del escaneo: un solo archivo de dependencias de lenguaje encontrado (
lambda/requirements.txt) — el.zipno cuenta como archivo de lenguaje (Trivy no lo desempaqueta por defecto en este modo), yhandler.pytampoco (no es un archivo de dependencias, es código fuente).
Paso 4 — Leyendo el SBOM real, componente por componente
python3 -c "
import json
d = json.load(open('sbom.cyclonedx.json'))
print('components:', len(d['components']))
for c in d['components']:
print('-', c.get('type'), c.get('name'), c.get('version', ''), c.get('purl', ''))
"
Qué esperar (literal, ejecutado para escribir esta lección):
components: 8
- application lambda/requirements.txt
- library boto3 1.40.76 pkg:pypi/boto3@1.40.76
- library botocore 1.40.76 pkg:pypi/botocore@1.40.76
- library jmespath 1.1.0 pkg:pypi/jmespath@1.1.0
- library python-dateutil 2.9.0.post0 pkg:pypi/python-dateutil@2.9.0.post0
- library s3transfer 0.14.0 pkg:pypi/s3transfer@0.14.0
- library six 1.17.0 pkg:pypi/six@1.17.0
- library urllib3 2.7.0 pkg:pypi/urllib3@2.7.0
Ocho componentes, no siete: el primero (type: application, name: lambda/requirements.txt) es el propio archivo de manifiesto, tratado como un componente raíz del que los siete paquetes reales dependen — es el nodo que el grafo de dependencies usa para decir "esto es lo que declaró el proyecto", distinto de los siete componentes type: library que son los paquetes reales instalados. Cada uno de los siete lleva su purl completo (pkg:pypi/boto3@1.40.76) — el identificador que, como explicó la lección 2, apunta sin ambigüedad a exactamente ese paquete, en exactamente esa versión, en el registro de PyPI.
Confirma el grafo de dependencias completo, no solo la lista plana:
python3 -c "
import json
d = json.load(open('sbom.cyclonedx.json'))
for dep in d['dependencies']:
if dep['dependsOn']:
print(dep['ref'], '->', dep['dependsOn'])
"
Qué esperar (literal):
1ca0e709-5ca2-4744-949c-c1abc80b37a9 -> ['pkg:pypi/boto3@1.40.76', 'pkg:pypi/botocore@1.40.76', 'pkg:pypi/jmespath@1.1.0', 'pkg:pypi/python-dateutil@2.9.0.post0', 'pkg:pypi/s3transfer@0.14.0', 'pkg:pypi/six@1.17.0', 'pkg:pypi/urllib3@2.7.0']
9aff7b30-a00d-499d-a0d2-1645dba36342 -> ['1ca0e709-5ca2-4744-949c-c1abc80b37a9']
Los dos identificadores
1ca0e7.../9aff7b3...de este bloque son UUID generados por Trivy en el momento de la corrida — VARIABLES, distintos cada vez que regeneres el SBOM. El contenido que importa —qué depende de qué— es estable; los identificadores internos que Trivy usa para referenciarlo, no.
Dos relaciones, y la segunda es la que muestra la estructura completa del documento: el componente raíz del proyecto (9aff7b3..., el . que le pasaste a trivy fs) depende del manifiesto (1ca0e70..., lambda/requirements.txt), que a su vez depende de los siete paquetes reales. Es exactamente el árbol de tres niveles que un requirements.txt de una sola línea, sin resolver, nunca podría expresar por sí solo.
Paso 5 — Confirmando lo que sí es fijo y lo que varía
python3 -c "
import json
d = json.load(open('sbom.cyclonedx.json'))
print('bomFormat:', d['bomFormat'])
print('specVersion:', d['specVersion'])
print('tool:', d['metadata']['tools']['components'][0]['name'], d['metadata']['tools']['components'][0]['version'])
print('timestamp:', d['metadata']['timestamp'])
print('serialNumber:', d['serialNumber'])
"
Qué esperar (los primeros tres campos son literales; los dos últimos son variables, marcados explícitamente):
bomFormat: CycloneDX
specVersion: 1.7
tool: trivy 0.74.0
timestamp: 2026-08-14T17:07:11+00:00 ← VARIABLE: el reloj real de tu máquina, al momento de correr este comando
serialNumber: urn:uuid:0f3b9d2b-68da-... ← VARIABLE: un UUID nuevo en cada corrida, aunque el contenido no cambie
bomFormat, specVersion y el nombre/versión de la herramienta (tool) son deterministas: la misma versión pineada de Trivy, sobre el mismo requirements.txt, siempre los reporta igual. timestamp y serialNumber, en cambio, cambian en cada corrida por diseño —el primero porque es, literalmente, la hora del reloj de tu sistema; el segundo porque el estándar CycloneDX exige un identificador único por documento generado, incluso si el contenido es idéntico al de la corrida anterior—. Si vuelves a correr trivy fs sobre el mismo requirements.txt mañana, vas a obtener los mismos ocho componentes con las mismas versiones, pero un timestamp y un serialNumber distintos: es la misma tabla de honestidad del diseño de esta guía ("Campo de timestamp dentro de un SBOM CycloneDX: reloj real de la máquina del alumno — Variable, marcado"), ahora confirmada con tus propios ojos.
Errores comunes
Correr trivy fs sin haber resuelto las dependencias primero, y obtener un SBOM de un solo componente (de secuencia, el más frecuente de esta lección). Qué pasa: alguien escribe boto3==1.40.76 en requirements.txt, salta directo al Paso 3 sin instalar nada, y el SBOM resultante muestra un único componente (boto3), sin ninguna de las seis dependencias transitivas. Cómo detectarlo: si tu conteo de componentes es 2 (el manifiesto más un solo paquete), no 8. Cómo corregirlo: el analizador de requirements.txt de Trivy lee exactamente lo que el archivo declara — si el archivo tiene una línea, reporta un paquete; si tiene siete líneas (porque las congelaste con pip freeze después de instalar de verdad, como el Paso 2 de esta lección), reporta siete. Trivy no resuelve dependencias transitivas por sí mismo a partir de una sola línea declarada — necesita que el árbol completo ya esté expresado en el archivo, razón exacta por la que el Paso 2 de esta lección instala antes de congelar.
Interpretar el WARN de licencia como que algo salió mal (de lectura de la salida, ya anticipado por la lección 2). Qué pasa: alguien ve la palabra WARN en mayúsculas y asume que el SBOM generado es inválido o incompleto de forma grave. Cómo detectarlo: si tu reacción al WARN de esta lección es volver a correr el comando esperando un resultado distinto, sin cambiar nada. Cómo corregirlo: es una advertencia sobre un campo específico (licencia) que no se pudo completar en esta corrida, no un error sobre el documento completo — los ocho componentes, sus versiones y sus purl completos siguen presentes y correctos, verificado en el Paso 4. Un SBOM con un campo de licencia vacío para algunos componentes sigue siendo un SBOM válido y útil; simplemente no responde, en esta corrida específica, una de las tres preguntas que la lección 2 prometió.
Pedir vulnerabilidades y SBOM en el mismo paso sin usar --scanners vuln, y sorprenderse de que vulnerabilities salga vacío (de expectativa sobre el flag). Qué pasa: alguien nota el campo "vulnerabilities": [] al final del JSON y asume que Trivy no encontró ninguna vulnerabilidad en estos siete paquetes. Cómo detectarlo: si tu conclusión es "estas dependencias están libres de CVEs conocidos", basada solo en esta corrida. Cómo corregirlo: la primera línea de la salida de este mismo Paso 3 ya lo advirtió — --format cyclonedx deshabilita el escaneo de seguridad por defecto. Un arreglo vacío en vulnerabilities aquí no significa "sin vulnerabilidades", significa "no se buscaron". Para obtener un veredicto real sobre vulnerabilidades, el comando necesitaría --scanners vuln explícito — algo fuera del alcance de esta lección específica, cuyo propósito es el inventario, no el escaneo.
Ejercicios
Ejercicio 1 — Explica, sin mirar la salida de esta lección, por qué el SBOM tiene 8 componentes y no 7. Un compañero cuenta las líneas de lambda/requirements.txt (siete) y espera que el SBOM tenga exactamente siete componentes. ¿Por qué el número real es ocho?
Ver solución
El componente número ocho es el propio archivo de manifiesto (lambda/requirements.txt, type: application), tratado como un nodo del árbol de dependencias del que los siete paquetes reales cuelgan — no es un paquete de PyPI, es la representación del origen de la declaración. El grafo de dependencies de esta lección lo mostró explícitamente: el componente raíz del proyecto depende del manifiesto, y el manifiesto depende de los siete paquetes. Sin ese nodo intermedio, el SBOM no podría expresar "estos siete paquetes fueron declarados juntos, en este archivo específico" — perdería la trazabilidad de origen que distingue un SBOM real de una simple lista plana de nombres y versiones.
Ejercicio 2 — Predice qué pasaría si agregaras una segunda dependencia directa a requirements.txt antes de resolver, por ejemplo requests. Sin correr el comando, ¿esperarías que el conteo final de componentes fuera mayor, menor, o igual a 8? Justifica.
Ver solución
Mayor a 8 — probablemente bastante mayor. requests trae su propio árbol de dependencias transitivas (certifi, charset-normalizer o chardet, idna, y potencialmente una versión distinta de urllib3 de la que boto3 ya trae, lo que además introduciría la pregunta de resolución de versiones si ambas exigen rangos distintos). El principio de esta lección se sostiene sin importar cuántas dependencias directas declares: cada una resuelve a su propio árbol, y el SBOM captura la unión completa de todos esos árboles, con los ocho componentes actuales como el subconjunto ya conocido, no el total final.
Ejercicio 3 — Diagnostica una discrepancia real. Si corrieras trivy fs --format cyclonedx --output sbom.cyclonedx.json . una segunda vez, sin cambiar ni una sola línea de lambda/requirements.txt, ¿qué campos del documento resultante esperarías que sean idénticos al primero, y cuáles esperarías que sean distintos? Usa la distinción de la Paso 5 de esta lección.
Ver solución
Idénticos: bomFormat, specVersion, la lista completa de components (los ocho, con los mismos nombres, versiones y purl), y la estructura de dependencies (qué depende de qué). Distintos: metadata.timestamp (el reloj real cambia entre una corrida y la siguiente) y serialNumber (un UUID nuevo por documento generado, parte del estándar CycloneDX, independiente de si el contenido cambió). Esta es exactamente la tabla de honestidad del diseño de esta guía aplicada a un caso concreto: el contenido de un SBOM sobre dependencias fijas es determinista; los metadatos de generación (cuándo, con qué identificador único) no lo son, por diseño del propio estándar.
Resumen y siguiente paso
En esta lección generaste el primer artefacto nuevo de este módulo: sbom.cyclonedx.json, real, con ocho componentes —el manifiesto más siete paquetes de PyPI, cada uno con su purl completo—, generado con trivy fs --format cyclonedx sobre el mismo Trivy 0.74.0 del Módulo 5. Viste, con un número concreto y ejecutado, la brecha que la lección 2 anticipó: una sola línea escrita a mano (boto3==1.40.76) resuelve a siete paquetes reales cuando se instala de verdad. Documentaste, sin esconderlo, un límite real de esta corrida específica —la detección de licencia no se completó, con la razón exacta explicada por Trivy mismo— y confirmaste qué campos del documento son deterministas y cuáles varían por diseño en cada generación.
Antes de avanzar deberías poder: explicar por qué trivy fs necesita un requirements.txt ya resuelto (congelado con pip freeze) para capturar dependencias transitivas; leer el grafo de dependencies de un SBOM CycloneDX; y distinguir, en cualquier SBOM que veas de aquí en adelante, qué campos esperarías que sean estables entre corridas y cuáles no.
Con el inventario resuelto, la lección 4 abre la segunda mitad del módulo: cómo se firma el artefacto que ese inventario describe, y por qué esta guía elige un mecanismo específico —un keypair local— en vez de la identidad efímera que Sigstore ofrece por defecto.
Recursos
- Trivy — SBOM — documentación oficial del comando
trivy fs --format cyclonedx/--format spdx-json, incluida la lista completa de gestores de paquetes soportados. - CycloneDX — Especificación oficial — la referencia completa del formato generado en esta lección, incluidos los campos
components,dependenciesymetadataexplorados aquí. - Package URL (PURL) — Especificación — el formato de identificador (
pkg:pypi/boto3@1.40.76) que cada componente del SBOM de esta lección expone. - Este curso, Módulo 5, lección 3 (
03-hands-on-installing-trivy.md) — la instalación de la misma versión de Trivy (0.74.0) que esta lección reutiliza para un propósito distinto.