Module 6: Supply Chain Sbom And Signing

3. Hands-on: generating Andes Cargo's SBOM

Description

This is the lesson where lesson 2's theory becomes a real file on your disk. You're going to declare, for the first time in andes-cargo-infra/, the third-party dependency lambda/handler.py needs to run (boto3), resolve that dependency to its complete tree by really installing it, and run trivy fs --format cyclonedx over the project — the same Trivy 0.74.0 you installed in Module 5, applied here to a different purpose: not scanning insecure configuration, but inventorying what's inside the project that gets deployed.

Connection to the module

This lesson produces sbom.cyclonedx.json, one of the three new artifacts this module adds to andes-cargo-infra/ (along with lesson 5's cosign.key/cosign.pub and lesson 6's manifest.sig). Lesson 8 is going to integrate this exact command as one more step in the inherited pipeline — what you run here, by hand, once, is exactly what that final project automates.


Step 1 — Why lambda/ needs a requirements.txt, even though Lambda already bundles boto3

Before generating anything, it's worth resolving a legitimate question: if AWS Lambda's managed Python runtime already includes boto3 preinstalled in the execution environment — and that's why terraform-and-iac-guide never needed to package it inside function.zip, which still weighs exactly 890 bytes, just handler.py — why declare boto3 as a dependency at all?

The reason is a real, common practice, not a contrivance of this lesson: the boto3 bundled with Lambda's managed runtime is pinned to the version AWS packaged into that base image, and it usually runs behind the most recent version published on PyPI — sometimes by several months. A team that needs a new feature from an AWS service, or simply wants to reproduce on their development machine the exact same behavior running in production, declares boto3 explicitly in a requirements.txt — for local development, testing, and so any supply-chain tool (like this lesson's) has something concrete to inventory —, without that changing what function.zip contains: the deployment package remains just the team's own code, and boto3 keeps coming from the runtime in production. This lesson declares that dependency in lambda/requirements.txt, a new file, sibling to handler.py:

# lambda/requirements.txt
boto3==1.40.76

A single line, a single pinned version — exactly the kind of file lesson 2 used as a starting point to explain what an SBOM sees that this file, by itself, doesn't show.


Step 2 — Resolving the dependency for real, not just declaring it

A line in requirements.txt is a declaration of intent — it says what package you want, not what ends up installed. For an SBOM to capture the real tree, that tree needs to actually exist in some environment, resolved by Python's package installer:

python3 -m venv .venv
.venv/bin/pip install --quiet -r lambda/requirements.txt

pip doesn't just install boto3 — it resolves its complete dependency tree, and installs all of it. Confirm exactly what ended up installed, with the same command any Python team uses to freeze a reproducible environment:

.venv/bin/pip freeze --local > lambda/requirements.txt
cat lambda/requirements.txt

What to expect (literal, run to write this lesson):

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

Read it carefully: seven lines, not one. pip freeze didn't invent anything — it simply made visible, in a plain text file, the dependency tree pip install had already resolved in the previous step. A single hand-written line (boto3==1.40.76) turned, once really resolved, into seven real packages — exactly the gap between "what a developer declares" and "what actually ends up installed" that lesson 2 anticipated, now with a concrete number: six transitive dependencies nobody at Andes Cargo ever wrote by hand.

Note on this frozen requirements.txt: from here on, this file lists the complete resolved tree — seven packages —, not the original one-line declaration. It's a real, common practice (freezing the exact environment a team tested, so anyone else reproduces it bit for bit), and it's also how Trivy is going to be able to detect all seven components in Step 3, not just the first.


Step 3 — trivy fs --format cyclonedx, run

With lambda/requirements.txt frozen, generate the SBOM from andes-cargo-infra/'s root:

trivy fs --format cyclonedx --output sbom.cyclonedx.json .

What to expect (literal, run to write this lesson — Trivy 0.74.0, the same version confirmed in Module 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

Three lines, and all three matter:

  • The first is informational, not an error: --format cyclonedx is, by design, an inventory mode, not a vulnerability-scanning one — Trivy explicitly tells you that if you also want vulnerabilities inside the same document, you need to ask for it with --scanners vuln. This lesson doesn't: the purpose here is inventory (TM-02, the lessons-4-through-7 signature), not a dependency scan (you already practiced that with trivy fs --scanners secret in Module 3, for a different purpose: detecting leaked secrets, not generating an SBOM).
  • The second is the honest limitation lesson 2 already anticipated: Trivy couldn't complete license detection for this SBOM's components, because its license analyzer for the Python ecosystem looks for a site-packages directory with a conventional virtual-environment structure alongside the dependency file, and didn't find one in this specific run (the frozen requirements.txt does accurately describe the package tree, but, as this lesson ran it, without that adjacent directory Trivy doesn't complete the license enrichment). It's exactly the case the previous lesson warned about: the CycloneDX format has a field for license, but the tool, in this specific run, couldn't fill it in — and the message says so unambiguously, instead of silently failing with an unexplained empty field.
  • The third confirms the scan's scope: a single language dependency file found (lambda/requirements.txt) — the .zip doesn't count as a language file (Trivy doesn't unpack it by default in this mode), and neither does handler.py (it isn't a dependency file, it's source code).

Step 4 — Reading the real SBOM, component by component

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', ''))
"

What to expect (literal, run to write this lesson):

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

Eight components, not seven: the first (type: application, name: lambda/requirements.txt) is the manifest file itself, treated as a root component the seven real packages depend on — it's the node the dependencies graph uses to say "this is what the project declared," distinct from the seven type: library components that are the real installed packages. Each of the seven carries its complete purl (pkg:pypi/boto3@1.40.76) — the identifier that, as lesson 2 explained, points unambiguously to exactly that package, at exactly that version, in the PyPI registry.

Confirm the complete dependency graph, not just the flat list:

python3 -c "
import json
d = json.load(open('sbom.cyclonedx.json'))
for dep in d['dependencies']:
    if dep['dependsOn']:
        print(dep['ref'], '->', dep['dependsOn'])
"

What to expect (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']

This block's two identifiers 1ca0e7.../9aff7b3... are UUIDs Trivy generates at run time — VARIABLE, different every time you regenerate the SBOM. The content that matters — what depends on what — is stable; the internal identifiers Trivy uses to reference it are not.

Two relationships, and the second is what shows the document's complete structure: the project's root component (9aff7b3..., the . you passed to trivy fs) depends on the manifest (1ca0e70..., lambda/requirements.txt), which in turn depends on the seven real packages. It's exactly the three-level tree a single, unresolved requirements.txt line could never express on its own.


Step 5 — Confirming what's fixed and what varies

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'])
"

What to expect (the first three fields are literal; the last two are variable, marked explicitly):

bomFormat: CycloneDX
specVersion: 1.7
tool: trivy 0.74.0
timestamp: 2026-08-14T17:07:11+00:00        ← VARIABLE: your machine's real clock, at the moment you run this command
serialNumber: urn:uuid:0f3b9d2b-68da-...    ← VARIABLE: a new UUID on every run, even if the content doesn't change

bomFormat, specVersion, and the tool's name/version (tool) are deterministic: the same pinned Trivy version, over the same requirements.txt, always reports them the same. timestamp and serialNumber, by contrast, change on every run by design — the first because it's, literally, your system clock's time; the second because the CycloneDX standard requires a unique identifier per generated document, even if the content is identical to the previous run's. If you run trivy fs again over the same requirements.txt tomorrow, you'll get the same eight components with the same versions, but a different timestamp and serialNumber: it's this guide's same design honesty table ("Timestamp field inside a CycloneDX SBOM: the student's machine's real clock — Variable, marked"), now confirmed with your own eyes.


Common mistakes

Running trivy fs without having resolved the dependencies first, and getting a single-component SBOM (sequencing mistake, this lesson's most frequent one). What happens: someone writes boto3==1.40.76 in requirements.txt, jumps straight to Step 3 without installing anything, and the resulting SBOM shows a single component (boto3), with none of the six transitive dependencies. How to spot it: if your component count is 2 (the manifest plus a single package), not 8. How to fix it: Trivy's requirements.txt analyzer reads exactly what the file declares — if the file has one line, it reports one package; if it has seven lines (because you froze them with pip freeze after really installing, as this lesson's Step 2 does), it reports seven. Trivy doesn't resolve transitive dependencies on its own from a single declared line — it needs the complete tree already expressed in the file, exactly why this lesson's Step 2 installs before freezing.

Interpreting the license WARN as something having gone wrong (output-reading mistake, already anticipated by lesson 2). What happens: someone sees the word WARN in capitals and assumes the generated SBOM is invalid or seriously incomplete. How to spot it: if your reaction to this lesson's WARN is to run the command again expecting a different result, without changing anything. How to fix it: it's a warning about a specific field (license) that couldn't be filled in on this run, not an error about the whole document — the eight components, their versions, and their complete purls are all still present and correct, verified in Step 4. An SBOM with an empty license field for some components is still a valid, useful SBOM; it simply doesn't answer, on this specific run, one of the three questions lesson 2 promised.

Asking for vulnerabilities and an SBOM in the same step without using --scanners vuln, and being surprised vulnerabilities comes back empty (flag-expectation mistake). What happens: someone notices the "vulnerabilities": [] field at the end of the JSON and assumes Trivy found no vulnerabilities in these seven packages. How to spot it: if your conclusion is "these dependencies are free of known CVEs," based only on this run. How to fix it: this same Step 3's very first output line already warned about it — --format cyclonedx disables security scanning by default. An empty vulnerabilities array here doesn't mean "no vulnerabilities," it means "none were searched for." To get a real vulnerability verdict, the command would need an explicit --scanners vuln — outside this specific lesson's scope, whose purpose is the inventory, not the scan.


Exercises

Exercise 1 — Explain, without looking at this lesson's output, why the SBOM has 8 components and not 7. A colleague counts lambda/requirements.txt's lines (seven) and expects the SBOM to have exactly seven components. Why is the real number eight?

See solution

Component number eight is the manifest file itself (lambda/requirements.txt, type: application), treated as a node of the dependency tree the seven real packages hang from — it isn't a PyPI package, it's the representation of the declaration's origin. This lesson's dependencies graph showed it explicitly: the project's root component depends on the manifest, and the manifest depends on the seven packages. Without that intermediate node, the SBOM couldn't express "these seven packages were declared together, in this specific file" — it would lose the origin traceability that distinguishes a real SBOM from a simple flat list of names and versions.

Exercise 2 — Predict what would happen if you added a second direct dependency to requirements.txt before resolving, for example requests. Without running the command, would you expect the final component count to be greater than, less than, or equal to 8? Justify.

See solution

Greater than 8 — probably quite a bit greater. requests brings its own transitive dependency tree (certifi, charset-normalizer or chardet, idna, and potentially a different version of urllib3 than the one boto3 already brings, which would also introduce a version-resolution question if both require different ranges). This lesson's principle holds regardless of how many direct dependencies you declare: each one resolves to its own tree, and the SBOM captures the complete union of all those trees, with the current eight components as the already-known subset, not the final total.

Exercise 3 — Diagnose a real discrepancy. If you ran trivy fs --format cyclonedx --output sbom.cyclonedx.json . a second time, without changing a single line of lambda/requirements.txt, which fields of the resulting document would you expect to be identical to the first, and which would you expect to differ? Use this lesson's Step 5 distinction.

See solution

Identical: bomFormat, specVersion, the complete components list (all eight, with the same names, versions, and purls), and the dependencies structure (what depends on what). Different: metadata.timestamp (the real clock changes between one run and the next) and serialNumber (a new UUID per generated document, part of the CycloneDX standard, independent of whether the content changed). This is exactly this guide's design honesty table applied to a concrete case: an SBOM's content over fixed dependencies is deterministic; its generation metadata (when, with what unique identifier) is not, by the standard's own design.


Summary and next step

In this lesson you generated this module's first new artifact: a real sbom.cyclonedx.json, with eight components — the manifest plus seven PyPI packages, each with its complete purl — generated with trivy fs --format cyclonedx on the same Trivy 0.74.0 from Module 5. You saw, with a concrete, executed number, the gap lesson 2 anticipated: a single hand-written line (boto3==1.40.76) resolves to seven real packages once really installed. You documented, without hiding it, a real limitation of this specific run — license detection didn't complete, with the exact reason explained by Trivy itself — and confirmed which fields of the document are deterministic and which vary by design on every generation.

Before moving on you should be able to: explain why trivy fs needs an already-resolved (frozen with pip freeze) requirements.txt to capture transitive dependencies; read a CycloneDX SBOM's dependencies graph; and, for any SBOM you see from here on, distinguish which fields you'd expect to be stable across runs and which not.

With the inventory resolved, lesson 4 opens the module's second half: how the artifact that inventory describes gets signed, and why this guide chooses a specific mechanism — a local keypair — instead of the ephemeral identity Sigstore offers by default.

Resources

  1. Trivy — SBOM — official documentation of the trivy fs --format cyclonedx/--format spdx-json command, including the complete list of supported package managers.
  2. CycloneDX — Official specification — the complete reference for the format generated in this lesson, including the components, dependencies, and metadata fields explored here.
  3. Package URL (PURL) — Specification — the identifier format (pkg:pypi/boto3@1.40.76) each component of this lesson's SBOM exposes.
  4. This course, Module 5, lesson 3 (03-hands-on-installing-trivy.md) — the installation of the same Trivy version (0.74.0) this lesson reuses for a different purpose.