Generating a CycloneDX SBOM in GitHub Actions
You have a Node or Python repository and you need CI to emit a trustworthy component inventory on every build — one that captures the full transitive graph, ships as both a downloadable artifact and a verifiable attestation, and blocks the merge when a dependency violates policy. This walkthrough builds that pipeline end to end in GitHub Actions. It is a companion to Software Bill of Materials (SBOM), which covers formats and VEX in depth, and sits under Supply Chain & Dependency Security more broadly. For the reasoning about why this inventory belongs in your risk analysis at all, see how threat modeling fundamentals treat the component list as the starting point for system decomposition.
Prerequisites
- A GitHub repository with a committed lockfile (
package-lock.jsonorpoetry.lock/requirements.txt) - Node.js 18+ or Python 3.10+ configured in the workflow
- Actions enabled with permission to write attestations (default on public repos; enable on private)
- Familiarity with basic GitHub Actions syntax (
jobs,steps,permissions)
Expected Outcomes
- A CycloneDX JSON SBOM generated from the resolved dependency graph on every push
- The SBOM validated for completeness and uploaded as a build artifact
- A signed provenance attestation binding the SBOM to the build, verifiable with
ghorcosign - A policy gate that fails the build when a high-severity finding or disallowed license appears
Step 1: Add a CycloneDX Generator to the Workflow
Start with a job that checks out the code, installs dependencies with scripts disabled, and runs the ecosystem-native CycloneDX generator. For Node, that is @cyclonedx/cyclonedx-npm; for Python, cyclonedx-py. Disabling install scripts (--ignore-scripts) prevents a malicious postinstall hook from executing in CI before the SBOM even exists.
name: SBOM
on: [push, pull_request]
permissions:
contents: read
jobs:
generate-sbom:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # OIDC for keyless attestation
attestations: write # record the attestation
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies (no lifecycle scripts)
run: npm ci --ignore-scripts
- name: Generate CycloneDX SBOM
run: npx @cyclonedx/cyclonedx-npm --output-format JSON --output-file sbom.cdx.json
For a Python project, swap the install and generate steps:
- name: Generate CycloneDX SBOM (Python)
run: |
pip install cyclonedx-bom
cyclonedx-py requirements requirements.txt --output-format json --outfile sbom.cdx.json
Step 2: Generate and Validate the SBOM
A generated SBOM must be checked before you trust it — a manifest-only or truncated file creates false confidence. The CI job flow below shows where validation sits relative to generation and attestation.
A CycloneDX document is JSON with a components array; each entry carries a purl (package URL) used to match against advisory databases. The validation step asserts a realistic component count and that no component is missing its purl.
- name: Validate SBOM completeness
run: |
COUNT=$(jq '.components | length' sbom.cdx.json)
echo "Components: $COUNT"
if [ "$COUNT" -lt 40 ]; then
echo "::error::SBOM has $COUNT components — expected the full transitive graph"
exit 1
fi
MISSING=$(jq '[.components[] | select(.purl == null)] | length' sbom.cdx.json)
if [ "$MISSING" -gt 0 ]; then
echo "::error::$MISSING components missing a package URL"
exit 1
fi
A trimmed CycloneDX structure looks like this — useful for knowing what your jq queries are traversing:
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"version": 1,
"components": [
{
"type": "library",
"name": "express",
"version": "4.19.2",
"purl": "pkg:npm/[email protected]",
"licenses": [{ "license": { "id": "MIT" } }]
}
]
}
Step 3: Attest, Sign, and Upload the SBOM
With a validated SBOM, record a signed attestation and publish the file. The actions/attest-sbom action produces a keyless signature via OIDC — no long-lived key to manage — and binds the SBOM to the built artifact’s digest. Uploading the raw SBOM as a build artifact makes it downloadable for auditors and downstream scanners.
- name: Package the build artifact
run: npm run build && tar -czf app.tar.gz dist/
- name: Attest the SBOM against the artifact
uses: actions/attest-sbom@v1
with:
subject-path: app.tar.gz
sbom-path: sbom.cdx.json
- name: Upload SBOM and artifact
uses: actions/upload-artifact@v4
with:
name: sbom-and-build
path: |
sbom.cdx.json
app.tar.gz
If you publish container images, cosign attest --type cyclonedx --predicate sbom.cdx.json <image@digest> records the same binding against the image in your registry instead of a build artifact.
Step 4: Fail the Build on Policy Violations
The final gate correlates the SBOM against advisory data and enforces a license allowlist, failing the build on any violation. Placing this after attestation means the SBOM is always recorded even for a failing build, preserving the audit trail.
- name: Scan SBOM and enforce policy
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype sbom:sbom.cdx.json --fail-on high
- name: Enforce license allowlist
run: |
DISALLOWED=$(jq -r '
[.components[]
| .licenses[]?.license.id // "UNKNOWN"
| select(. as $l | ["MIT","Apache-2.0","BSD-3-Clause","ISC"] | index($l) | not)
] | unique | join(", ")' sbom.cdx.json)
if [ -n "$DISALLOWED" ]; then
echo "::error::Disallowed licenses present: $DISALLOWED"
exit 1
fi
Verification
Confirm the pipeline works by inspecting the SBOM locally and verifying the attestation that CI recorded.
# Inspect the generated SBOM: count components and list any without a purl
jq '.components | length' sbom.cdx.json
jq -r '.components[] | select(.purl == null) | .name' sbom.cdx.json
# List every distinct license in the build
jq -r '[.components[].licenses[]?.license.id] | unique[]' sbom.cdx.json
# Verify the attestation was signed by the expected workflow
gh attestation verify app.tar.gz \
--repo your-org/your-repo \
--predicate-type https://cyclonedx.org/bom
Expected: the component count matches the full dependency graph, the missing-purl list is empty, the license list contains only allowlisted identifiers, and gh attestation verify reports a successful verification tied to your repository’s workflow identity.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| SBOM has only a handful of components | Generated from the manifest, not the resolved lockfile | Run npm ci before generation and confirm node_modules is populated |
Error: Resource not accessible by integration on attest step |
Missing id-token: write or attestations: write permission |
Add both to the job-level permissions block |
grype: command not found |
Install script did not place the binary on PATH |
Install to /usr/local/bin with -b /usr/local/bin, or use the anchore/scan-action |
| License gate fails on a component with no license field | Package omits SPDX metadata, so jq yields UNKNOWN |
Investigate the package; add an explicit exception only after review |
gh attestation verify fails after download |
Verifying against a different digest than was attested | Verify the exact artifact file CI produced, not a rebuilt copy |
Common Implementation Mistakes
Frequently Asked Questions
Which permissions does the attestation step need?
The job needs three permissions: contents: read to check out the repository, id-token: write so the runner can obtain an OIDC token for keyless Sigstore signing, and attestations: write to record the attestation against the artifact. Grant them in a job-level permissions: block rather than at the workflow level, so only this job holds the elevated scope and the rest of the workflow runs read-only. On private repositories you may also need to enable attestations in the repository settings.
Should I generate the SBOM before or after building the artifact?
Install dependencies first, then generate the SBOM, then build and attest — all in the same job. The SBOM must describe the exact resolved dependencies that end up in the artifact, so it has to come from the same node_modules (or virtual environment) that the build consumes. Attesting binds the SBOM to the built artifact’s digest, which is only meaningful if both were produced from one consistent resolution in a single job.
How do I verify the SBOM attestation later?
Use gh attestation verify <artifact> --repo <org>/<repo> or cosign verify-attestation against the artifact or image, checking that the certificate identity and OIDC issuer match your workflow. A successful verification proves the SBOM was produced by the expected GitHub Actions build and has not been altered; a mismatch means the inventory came from somewhere else and the artifact should be rejected before deploy.
Related
- Software Bill of Materials (SBOM) — the parent guide on formats, VEX enrichment, and storage
- Supply Chain & Dependency Security — the full supply chain control set this pipeline fits into
- Attack Surface Mapping Techniques — how the component inventory feeds entry-point enumeration in a threat model