10 KiB
Software Bill of Materials (SBOM)
This document is the canonical SBOM workflow for the platform. It explains what a Software Bill of Materials is, why we ship one, how it is generated, how it is distributed, and how it is consumed by vulnerability scanners.
Cross-references:
- Generator:
scripts/generate-sbom.mjs- Threat model component coverage:
threat-model.md- Incident-response forensics:
incident-response.md§4 "Forensics"
1. What is an SBOM?
A Software Bill of Materials is a machine-readable inventory of every software component that ships in a build artifact. For this platform it includes:
- Every npm/pnpm workspace package shipped in the runtime images (Web, API, Worker)
- Every transitive dependency resolved by pnpm (including dev-only and production-only)
- The version, license (when declared), and resolved tarball hash of each dependency
- The git commit and build identifier of the repository at the time of build
An SBOM is not a vulnerability report. It is the input to a vulnerability scanner — the scanner correlates the SBOM against a CVE feed (NIST NVD, GHSA, OSV) and produces the actual vulnerability report.
2. Why we ship one
- Customer procurement — enterprise procurement teams require an SBOM with every release. Without one, the platform cannot be evaluated by a regulated customer.
- Executive Order 14028 / NIST SP 800-218 (SSDF) — US federal procurement requires SBOMs for software shipped to federal customers. Even if the operator is not a US federal supplier today, the SBOM lowers the bar to that market.
- EU Cyber Resilience Act (CRA) — once the CRA's reporting requirements bite, an SBOM is the input to the operator's CRA-mandated vulnerability handling process.
- Internal vulnerability handling — when a new CVE lands, the operator can answer "are we affected?" in minutes by querying the SBOM, not by re-resolving the lockfile under time pressure.
- Incident forensics — when investigating an incident potentially caused by a third-party library, the SBOM is the canonical record of what shipped.
3. Format
The repository currently ships a pnpm-native dependency manifest rather than a strict CycloneDX or SPDX document. The reason is documented at the top of scripts/generate-sbom.mjs:
@cyclonedx/cyclonedx-npmand SPDX-tooling for npm tend to mis-handle pnpm workspaces (apps/*,packages/*), producing partial trees or wrongly-attributed transitive edges.- pnpm's own resolver is the source of truth for what ships in our images, so we capture its
pnpm list -r --json --depth=Infinityoutput verbatim. - The artifact is consumed for audit traceability ("which version of which package shipped in this image"), not for CycloneDX-validator gating.
The output JSON has this shape:
{
"schema": "toolctrl.sbom-pnpm/v1",
"generatedAt": "<ISO timestamp>",
"generator": "scripts/generate-sbom.mjs",
"source": "pnpm list -r --json --depth=Infinity",
"repo": { "name": "<root pkg name>", "version": "<root pkg version>" },
"workspaces": [ /* pnpm list output */ ]
}
The file is named sbom-pnpm.json and is committed at the build artifact level only — it is generated at build time and shipped inside the runtime images. It is not committed to the source repository.
3.1 Migration path to CycloneDX
When a customer procurement step requires a strict CycloneDX 1.4+ document, the operator can:
- Run
pnpm dlx @cyclonedx/cyclonedx-npm --output-format JSON --output-file sbom.cdx.json --omit dev - Validate with
pnpm dlx @cyclonedx/cyclonedx-cli validate --input-file sbom.cdx.json - Diff against
sbom-pnpm.jsonto confirm component coverage
The operator-side conversion is out of band from the build; it does not gate releases. If/when a future task makes CycloneDX a build gate, this section becomes the migration target.
4. When the SBOM is generated
The Dockerfile attempts to generate the SBOM at every Docker build. It invokes scripts/generate-sbom.mjs in the build stage and copies the resulting sbom-pnpm.json into each runtime stage (Web, API, Worker). SBOM generation is intentionally skip-safe in the Dockerfile: a generator failure logs SBOM generation skipped instead of failing the image build, because the SBOM is an audit-traceability artifact rather than a deploy gate.
Each runtime image therefore carries the same repository dependency inventory for the built revision. Treat it as the npm/pnpm component record for that build, then pair it with an image scanner such as Trivy for OS packages and runtime binaries.
Manual local generation (for example, to answer "are we affected by CVE-X?" before a release):
pnpm exec node scripts/generate-sbom.mjs
# Output: ./sbom-pnpm.json
The script reads SBOM_OUTPUT_PATH for a non-default destination; it is otherwise side-effect free.
5. Distribution
5.1 Inside runtime images
Every container image carries /myapp/sbom-pnpm.json. Operators can inspect it with:
docker run --rm <image> cat /myapp/sbom-pnpm.json | jq '.workspaces | length'
5.2 Per release
Release owners may publish the SBOM as a release asset named sbom-pnpm-v<version>.json when a customer or procurement process requires a standalone artifact. The baseline deploy workflow always embeds the SBOM in the runtime images; standalone release-asset publication is operator-owned unless a release workflow explicitly adds that upload step.
5.3 To customers
Customers who need an SBOM under their procurement agreement receive:
- The
sbom-pnpm.jsonfor the version they run - On request: a converted CycloneDX 1.4 JSON (see §3.1)
- On request: a signed manifest (see §6)
The operator does not publish SBOMs for unreleased branches or pre-release tags. Customers running staging or pre-release builds receive the SBOM bundled with the build.
5.4 To researchers
The SBOM is not published publicly by default. It is included in the artifact pack provided to penetration testers under engagement (see pen-test-scope.md §10) and is provided to coordinated-disclosure reporters who request it.
Rationale: SBOM transparency is a security trade-off. The SBOM lets a scanner correlate against CVE feeds, which is good for defence; it also tells an attacker exactly which library versions to target. NIST EO-14028 and the EU CRA accept the trade-off; the operator accepts the same trade-off for procurement customers but does not publish to the open internet without a reason.
6. Signing
When the operator's threat model requires it (typically: enterprise customers requiring tamper-evident SBOM delivery), the SBOM is signed with Cosign:
cosign sign-blob \
--key cosign.key \
--output-signature sbom-pnpm-v<version>.json.sig \
sbom-pnpm-v<version>.json
Customers verify with:
cosign verify-blob \
--key cosign.pub \
--signature sbom-pnpm-v<version>.json.sig \
sbom-pnpm-v<version>.json
The signing key is held in the operator's password manager. The public key is published on the operator's security page. Rotation cadence: every 12 months or on suspected compromise.
7. Vulnerability scanner integration
The SBOM is the input to whichever scanner the operator uses. Documented integrations:
7.1 Snyk
snyk test --file=sbom-pnpm.json --package-manager=npm
Snyk consumes the dependency tree and reports CVE matches.
7.2 Trivy
trivy sbom sbom-pnpm.json
Trivy is the recommended per-image scanner: it can also scan the OS-layer of the runtime image (Alpine packages) which the pnpm SBOM does not cover.
7.3 Dependency-Track
The operator's preferred long-term SBOM-management tool is Dependency-Track. Workflow:
- Convert
sbom-pnpm.json→ CycloneDX (see §3.1) - Upload the CycloneDX document to the Dependency-Track project for this release
- Dependency-Track watches the OSV / NVD / GHSA feeds and notifies the operator when a new CVE matches a shipped component
7.4 GitHub Dependabot / Gitea Renovate
These tools operate on the source repository's lockfile, not on the shipped SBOM. They are complementary — Dependabot/Renovate raises PRs to upgrade dependencies; the SBOM proves which version actually shipped.
8. Incident workflow
When a new CVE is announced that may affect a shipped dependency, the incident-response runbook Phase 1 ("Detection") and Phase 2 ("Triage") apply. The SBOM-specific steps are:
- Identify the affected component name and version range from the CVE record
- Query the SBOM for the affected component across every shipped image:
jq -r '.workspaces[] | .. | objects | select(.name=="<component>") | "\(.name)@\(.version)"' sbom-pnpm.json | sort -u - Decide impact: is the affected codepath invoked by our code? (Mapping to call-sites is operator engineering.)
- If yes, the incident enters Phase 3 ("Containment") as documented in
incident-response.md§3.
9. Limitations
The current SBOM:
- Covers npm/pnpm dependencies only. It does not include OS-level packages (Alpine apk), runtime binaries (node itself, ClamAV daemon, ffmpeg, etc.), nor Rust/Go native modules compiled into Prisma / sharp / similar. Trivy on the image fills that gap.
- Does not include declared licenses in a normalised form. The pnpm-list output includes whatever the package metadata declares; license-compliance work is operator-side.
- Is generated at build time, not at run time. If an operator hot-patches a node_module inside a running container (which is forbidden by the deployment policy but possible), the SBOM does not reflect that mutation.
10. Maintenance
This document is reviewed:
- When the SBOM generator script changes
- When the format requirement from a procurement customer changes
- When the operator adds or replaces the vulnerability scanner integration
- At every major release tag
Update the "Last review" line below as part of the review.
Last review: docs/improvement-plan/10-compliance-polish/security-docs.md task #69.