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.json or poetry.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 gh or cosign
  • 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.

CI Job Flow for SBOM Generation and Validation A left-to-right flow: a trusted Checkout and Install step feeds a boundary Generate SBOM step, which feeds a boundary Validate gate. The Validate gate branches downward to an untrusted Fail Build outcome and rightward to a trusted Attest and Upload step. Checkout + Install npm ci --ignore-scripts Generate SBOM cyclonedx-npm Validate Gate count + purl check Attest + Upload Fail Build incomplete SBOM

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.