Chapter 26Lesson 02~225 minutes

SBOM Generation, Dependency Lists, Vulnerability Reports, Policy Evaluation, and Supply-Chain Evidence: Guided Hands-On Workflow and Core Operations

Generate a CycloneDX 1.6 SBOM in a disposable Python lab, hash the built artifact and SBOM, correlate a synthetic vulnerability report, apply an explicit policy, and retain an evidence bundle.

Hands-onCycloneDX 1.6Artifact digestSynthetic findingEvidence bundle

Learning objectives

  • Create a disposable Python artifact and record its SHA-256 digest.
  • Generate a CycloneDX 1.6 SBOM with pinned cyclonedx-bom==7.3.1 in a virtual environment.
  • Create and validate a synthetic vulnerability-correlation report without relying on a changing live advisory service.
  • Evaluate an explicit local policy and retain the inputs/result as normal artifacts.
  • Prove that source SHA, artifact digest, SBOM, finding, and policy result all describe the same lab run.

1. Disposable scenario and preflight

Create a throwaway project named glci-ch26-sbom-lab. The lab uses synthetic source and a synthetic advisory so the result remains deterministic. No production package registry, cloud account, vulnerability database, or real credential is required.

Assumptions used in the examples: Python 3.13-compatible tooling, cyclonedx-bom==7.3.1, CycloneDX 1.6 output, and ordinary GitLab job artifacts. If your runner image differs, record it in the evidence packet rather than hiding the difference.

Preflight: confirm the project is disposable, record CI_PIPELINE_SOURCE, CI_COMMIT_SHA, pipeline/job IDs, and do not substitute a production artifact or live secret into the exercise.

2. Small source and deterministic build artifact

glci-ch26-sbom-lab/
├── app/
│   └── message.txt
├── requirements.txt
├── policy/
│   └── evaluate.py
├── tools/
│   └── make_synthetic_findings.py
└── .gitlab-ci.yml
# app/message.txt
chapter26 supply-chain evidence lab

# requirements.txt
requests==2.32.5

The “application artifact” is intentionally tiny. What matters is that the bytes are stable and hashable.

mkdir -p dist evidence
cp app/message.txt dist/app.txt
sha256sum dist/app.txt | tee evidence/artifact.sha256
printf 'source_sha=%s\n' "$CI_COMMIT_SHA" | tee evidence/build-identity.txt
printf 'pipeline_id=%s job_id=%s source=%s\n' \
  "$CI_PIPELINE_ID" "$CI_JOB_ID" "$CI_PIPELINE_SOURCE" >> evidence/build-identity.txt

3. Generate CycloneDX 1.6 in an isolated virtual environment

cyclonedx-bom is a Python package; its CLI entry point is cyclonedx-py. Pin the generator version because generator behavior is part of the evidence.

python -m venv .venv
. .venv/bin/activate
python -m pip install --disable-pip-version-check 'cyclonedx-bom==7.3.1'
cyclonedx-py --version | tee evidence/cyclonedx-generator-version.txt
cyclonedx-py requirements requirements.txt \
  --spec-version 1.6 \
  --output-format json \
  --output-file evidence/sbom.cdx.json
sha256sum evidence/sbom.cdx.json | tee evidence/sbom.sha256
python -m json.tool evidence/sbom.cdx.json >/dev/null

Version 7.x removed older deprecated CLI switches such as --schema-version and --outfile; current syntax uses --spec-version and --output-file. If you upgrade the tool, run cyclonedx-py requirements --help and record the new version before changing the course command.

4. Inspect the SBOM rather than trusting the filename

from pathlib import Path
import json

bom = json.loads(Path("evidence/sbom.cdx.json").read_text())
assert bom["bomFormat"] == "CycloneDX"
assert bom["specVersion"] == "1.6"
components = bom.get("components", [])
print("component_count=", len(components))
for component in components:
    print(component.get("name"), component.get("version"), component.get("purl", "<no-purl>"))

This step proves that a machine-readable inventory exists and that its schema/version is the intended one. It still does not prove the package is vulnerable or safe.

5. Correlate a synthetic vulnerability deterministically

A live vulnerability database changes over time, which is useful operationally but poor for a deterministic teaching checkpoint. Instead, the lab uses a synthetic advisory that deliberately matches one PURL from the SBOM.

# tools/make_synthetic_findings.py
from pathlib import Path
import json

bom = json.loads(Path("evidence/sbom.cdx.json").read_text())
purls = {c.get("purl") for c in bom.get("components", [])}
target = "pkg:pypi/requests@2.32.5"
findings = []
if target in purls:
    findings.append({
        "id": "CVE-LAB-2026-0001",
        "source": "chapter26-synthetic-db-1",
        "component": target,
        "severity": "HIGH",
        "status": "detected",
        "fixed_version": "2.32.6-LAB"
    })
report = {"database":"chapter26-synthetic-db-1", "findings":findings}
Path("evidence/vulnerabilities.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"findings={len(findings)}")
python tools/make_synthetic_findings.py
python -m json.tool evidence/vulnerabilities.json >/dev/null
sha256sum evidence/vulnerabilities.json | tee evidence/vulnerabilities.sha256

6. Apply policy as a separate, reviewable decision

# policy/evaluate.py
from pathlib import Path
import json, sys

report = json.loads(Path("evidence/vulnerabilities.json").read_text())
blocked = [f for f in report.get("findings", []) if f.get("severity") in {"HIGH", "CRITICAL"}]
result = {
    "policy": "chapter26-no-unexcepted-high-v1",
    "database": report.get("database"),
    "decision": "block" if blocked else "allow",
    "blocking_ids": [f["id"] for f in blocked],
}
Path("evidence/policy-result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
print(json.dumps(result))
sys.exit(1 if blocked else 0)

The first run should block. That is expected evidence, not a broken scanner. The generator succeeded, the SBOM was valid, the synthetic correlator found one issue, and the policy independently decided that the issue is not currently acceptable.

7. Put the workflow into a bounded GitLab pipeline

stages: [build, evidence, policy]

build_artifact:
  stage: build
  image: python:3.13-alpine
  script:
    - mkdir -p dist evidence
    - cp app/message.txt dist/app.txt
    - sha256sum dist/app.txt | tee evidence/artifact.sha256
    - printf 'source_sha=%s\npipeline_id=%s\nsource=%s\n' "$CI_COMMIT_SHA" "$CI_PIPELINE_ID" "$CI_PIPELINE_SOURCE" > evidence/build-identity.txt
  artifacts:
    expire_in: 7 days
    paths: [dist/, evidence/]

sbom_and_correlation:
  stage: evidence
  image: python:3.13-alpine
  needs:
    - job: build_artifact
      artifacts: true
  script:
    - python -m venv .venv
    - . .venv/bin/activate
    - python -m pip install --disable-pip-version-check 'cyclonedx-bom==7.3.1'
    - cyclonedx-py --version | tee evidence/cyclonedx-generator-version.txt
    - cyclonedx-py requirements requirements.txt --spec-version 1.6 --output-format json --output-file evidence/sbom.cdx.json
    - python tools/make_synthetic_findings.py
    - sha256sum evidence/sbom.cdx.json evidence/vulnerabilities.json | tee evidence/evidence-SHA256SUMS
  artifacts:
    when: always
    expire_in: 7 days
    paths: [dist/, evidence/]

policy_gate:
  stage: policy
  image: python:3.13-alpine
  needs:
    - job: sbom_and_correlation
      artifacts: true
  script:
    - python policy/evaluate.py
  artifacts:
    when: always
    expire_in: 7 days
    paths: [evidence/]

The mandatory pipeline intentionally uses ordinary artifacts. On Ultimate, a conforming CycloneDX report can additionally be declared through artifacts:reports:cyclonedx for GitLab ingestion, but that is an integration enhancement, not a prerequisite for the evidence chain.

8. Optional GitLab-native ingestion path (Ultimate)

# Optional integration only; keep ordinary paths as raw downloadable evidence.
artifacts:
  paths:
    - evidence/sbom.cdx.json
  reports:
    cyclonedx:
      - evidence/sbom.cdx.json

Current GitLab docs place the CycloneDX report type and Dependency List in Ultimate. When enabled, the UI can aggregate dependency data. The raw SBOM should still be retained according to your evidence policy because UI retention and vulnerability state have their own lifecycle.

9. Challenge: choose the layer

The SBOM job passes, but the policy job blocks. Which layer should you change?

  • If the component inventory is wrong, fix the SBOM generation/input layer.
  • If the synthetic advisory is wrong, fix the vulnerability correlation/data layer and preserve the old report.
  • If the finding is real but accepted temporarily, create a bounded policy exception with owner/rationale/expiry; do not suppress the component from the SBOM.
  • If the artifact digest differs from the evidence bundle, fix the build/artifact lineage before discussing vulnerability severity.

10. Cleanup

deactivate 2>/dev/null || true
rm -rf .venv dist evidence
# In GitLab, delete only the exact disposable project if you created it solely for this lab.
# Do not bulk-delete artifacts or security records from unrelated projects.

Knowledge check

Why does the lab use a synthetic advisory instead of deliberately pinning a currently vulnerable real package?

Why is the policy gate a separate job from SBOM generation?

What must be preserved if the first policy run intentionally fails?

Why keep the SBOM as an ordinary artifact even when GitLab UI ingestion is enabled?

What does matching the SBOM component to an advisory prove?

Next lesson

Configuration and design choices

Choose where and when to generate inventories, where policy should live, and how to balance central governance with project autonomy.

Version and compatibility note

GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.

Official references and version notes

Documentation verification date: 2026-09-12. GitLab currently documents the Dependency List, artifacts:reports:cyclonedx, Vulnerability Report, and organization-wide security policies as Ultimate features. The mandatory course path therefore keeps the SBOM, vulnerability correlation, policy decision, hashes, and evidence bundle as ordinary artifacts and local scripts so it remains Free/disposable-compatible. GitLab's Dependency List can ingest CycloneDX 1.4, 1.5, or 1.6 documents from the latest default-branch pipeline. Security-policy evaluation trusts scanner artifact reports; policy evaluation does not itself prove the authenticity or integrity of the scanner that produced them. The lab pins cyclonedx-bom==7.3.1, released 2026-07-23, and requests CycloneDX 1.6 explicitly. The runnable path intentionally avoids declaring a CycloneDX report to GitLab so Free users can complete it. The optional report-ingestion block is labeled Ultimate and should be tested against the target GitLab instance before adoption.

Current assumptions used in this chapter: Version-sensitive YAML, Runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations must be verified against the exact GitLab, GitLab Runner, tool, and external-system versions used in production. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for reproducible work.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.