SBOM Generation and Consumption: CycloneDX, SPDX, and Vulnerability Correlation

SBOM Generation and Consumption: CycloneDX, SPDX, and Vulnerability Correlation

Problem

Generating an SBOM is trivial. Running syft my-image:latest -o cyclonedx-json produces a JSON file in seconds. The hard part is what happens next: that file needs to be accurate, attached to the artifact it describes, ingested into a vulnerability database, correlated against CVEs, and queried when a new critical vulnerability drops.

Most teams stop at generation. They produce SBOMs as a compliance artefact, store them somewhere, and never build the consumption infrastructure. When Log4Shell-class vulnerabilities emerge, the question is “which of our 400 deployed images contain this component?” A pile of JSON files does not answer that question in the ten minutes the incident team has before leadership starts asking.

This article covers the full lifecycle: format selection, generation from images and source trees, OCI attestation storage, continuous vulnerability correlation in OWASP Dependency-Track, VEX suppression of false positives, and CI/CD policy gates.

Target versions: Syft 1.x; Grype 0.82+; cdxgen 10.x; OWASP Dependency-Track 4.11+; CycloneDX 1.5/1.6; SPDX 2.3.

CycloneDX vs SPDX

Both are open SBOM standards with broad tooling support. They are not interchangeable; choosing the wrong format for your use case creates downstream problems.

SPDX (Software Package Data Exchange) originated at the Linux Foundation and was designed for license compliance. Its data model is optimised for expressing licence obligations across component relationships. SPDX 2.3 is an ISO standard (ISO/IEC 5962:2021). It supports JSON, RDF, YAML, and tag-value formats. Every component has a SPDXID, and relationships between components are expressed as explicit triples (DESCRIBES, DEPENDS_ON, CONTAINS). The relationship graph is the canonical data structure.

CycloneDX originated at OWASP and was designed for security use cases: vulnerability management, supply chain risk, and attestation. Its component model includes purl (package URL) identifiers natively, which map directly to vulnerability database identifiers in NVD, OSV, and GitHub Advisory. CycloneDX 1.5+ added support for VEX documents, formulation (build environment details), and machine-readable service dependencies. The 1.6 spec introduced CBOM (Cryptography Bill of Materials) extensions.

In practice:

Capability SPDX 2.3 CycloneDX 1.6
License compliance First-class Supported
Vulnerability correlation Via NVD CPE matching Via purl (more accurate)
VEX support Separate OpenVEX spec Native
OCI attestation Via in-toto predicates Via in-toto predicates
Tooling breadth Good Excellent
NTIA minimum elements Yes Yes

For vulnerability management workflows, use CycloneDX. For licence auditing or NTIA compliance reporting, SPDX is the more natural fit. If you need both, generate both — Syft and Trivy can output both formats in a single pipeline step.

Generating Container Image SBOMs with Syft

Syft analyses container image layers and produces SBOMs that enumerate OS packages (dpkg, rpm, apk), language ecosystem packages (pip, npm, gem, cargo, go modules, maven), and binary files with matching heuristics.

Install:

curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
  | sh -s -- -b /usr/local/bin v1.4.1

Generate in CycloneDX JSON format:

syft ghcr.io/myorg/myapp:latest \
  -o cyclonedx-json=sbom.cdx.json \
  --source-name myapp \
  --source-version "$(git rev-parse HEAD)"

Generate in both formats simultaneously:

syft ghcr.io/myorg/myapp:latest \
  -o cyclonedx-json=sbom.cdx.json \
  -o spdx-json=sbom.spdx.json

Syft can also analyse a local directory (before building an image), a local tarball produced by docker save, or an OCI layout directory. For source tree analysis:

syft dir:./src \
  -o cyclonedx-json=sbom-source.cdx.json \
  --exclude './vendor/**' \
  --exclude './.git/**'

By default, Syft scans image layers in order and de-duplicates packages. Pass --scope all-layers to include packages present in intermediate layers that were deleted by subsequent RUN commands. This matters if you want to audit multi-stage build inputs, not just the final runtime image.

Trivy SBOM Generation

Trivy 0.50+ generates SBOMs as a scan output format, which is useful when you are already running Trivy for vulnerability scanning and want to produce an SBOM in the same step:

trivy image \
  --format cyclonedx \
  --output sbom.cdx.json \
  ghcr.io/myorg/myapp:latest

Trivy’s SBOM output includes vulnerability findings embedded in the CycloneDX vulnerabilities array, producing a combined SBOM-plus-scan document. This is convenient for one-shot reporting but creates a document that conflates two concerns. For Dependency-Track ingestion, generate a clean SBOM without embedded vulnerability data — Dependency-Track performs its own correlation and embedded vuln data is ignored on import.

Generating SBOMs from Source with cdxgen

cdxgen is purpose-built for source tree analysis and understands the build semantics of each ecosystem more deeply than image-layer scrapers. It reads lock files, manifest files, and build descriptors to produce accurate transitive dependency trees.

Install:

npm install -g @cyclonedx/cdxgen

Node.js (reads package-lock.json or yarn.lock):

cdxgen -t nodejs -o sbom.cdx.json ./my-node-app

Python (reads requirements.txt, Pipfile.lock, poetry.lock, or pyproject.toml):

cdxgen -t python -o sbom.cdx.json ./my-python-app

Maven (executes mvn dependency:tree):

cdxgen -t maven -o sbom.cdx.json ./my-java-app

Go modules (reads go.sum):

cdxgen -t go -o sbom.cdx.json ./my-go-app

Multi-language monorepo — let cdxgen auto-detect:

cdxgen -o sbom.cdx.json ./monorepo --recurse

cdxgen supports server mode for high-throughput pipelines where the startup cost of the Node.js runtime is a bottleneck:

cdxgen --server --server-port 9090 &
curl -X POST http://localhost:9090/sbom \
  -H 'Content-Type: application/json' \
  -d '{"path": "/workspace/myapp", "type": "python"}' \
  -o sbom.cdx.json

For Maven and Gradle projects, cdxgen invokes the build tool to resolve the dependency graph. This requires the build toolchain to be present in the CI environment, but produces significantly more accurate transitive dependency data than lock file parsing alone.

Attaching SBOMs to Container Images as OCI Attestations

An SBOM stored as a pipeline artefact is disconnected from the image it describes. If the image is copied to a different registry, promoted through environments, or pulled six months later, there is no reliable way to locate the corresponding SBOM. OCI attestations solve this by co-locating the SBOM with the image in the registry as a referrer object.

cosign attaches attestations using the in-toto framework. The SBOM is wrapped in a signed in-toto statement, stored as an OCI artefact in the same repository as the image, and indexed by the image digest.

Full pipeline step combining build, SBOM generation, signing, and attestation:

# .github/workflows/build-attest.yml
name: Build and Attest

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write   # Required for keyless cosign signing via OIDC

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Install Syft
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
            | sh -s -- -b /usr/local/bin v1.4.1

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Generate SBOM
        run: |
          syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
            -o cyclonedx-json=sbom.cdx.json \
            --source-name "${{ github.repository }}" \
            --source-version "${{ github.sha }}"

      - name: Attest SBOM
        run: |
          cosign attest \
            --predicate sbom.cdx.json \
            --type cyclonedx \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

To verify and retrieve the SBOM later:

cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity-regexp "^https://github.com/myorg/myapp/" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp@sha256:abc123 \
  | jq -r '.payload | @base64d | fromjson | .predicate'

This integrates with the SLSA provenance workflow: a single image can carry both a provenance attestation and one or more SBOM attestations as separate referrer objects indexed to the same digest.

Consuming SBOMs: Grype for Local Scanning

Grype is Anchore’s vulnerability scanner and the natural complement to Syft — it can consume Syft’s SBOM output directly, skipping re-analysis of the image.

Scan an SBOM file:

grype sbom:./sbom.cdx.json

Fail on CRITICAL severity (exit code 1 when any CRITICAL finding exists):

grype sbom:./sbom.cdx.json \
  --fail-on critical \
  --output table

Output in SARIF format for GitHub Security tab upload:

grype sbom:./sbom.cdx.json \
  --output sarif \
  --file grype-results.sarif

Grype downloads its vulnerability database on first run and caches it. In CI environments, cache the database between runs to avoid repeated downloads:

- name: Cache Grype DB
  uses: actions/cache@v4
  with:
    path: ~/.cache/grype/db
    key: grype-db-${{ runner.os }}-${{ hashFiles('**/grype-db-version') }}
    restore-keys: grype-db-${{ runner.os }}-

- name: Scan SBOM with Grype
  run: |
    grype sbom:./sbom.cdx.json \
      --fail-on critical \
      --output sarif \
      --file grype-results.sarif

- name: Upload SARIF to GitHub Security
  uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: grype-results.sarif

The if: always() on the upload step is important — it ensures SARIF results reach the Security tab even when the scan step fails the build. Without it, developers cannot see what caused the failure from the GitHub UI.

OWASP Dependency-Track for Continuous Monitoring

Grype provides point-in-time scans. Dependency-Track provides continuous correlation: as new CVEs are published in NVD, OSV, and GitHub Advisory, it re-evaluates every SBOM in its inventory and raises new findings without requiring a new build. This answers the “which deployed images are affected?” question when a new CVE drops.

Dependency-Track exposes a REST API. Upload an SBOM to create or update a project:

DTRACK_URL="https://dtrack.internal.example.com"
DTRACK_API_KEY="<your-api-key>"
PROJECT_NAME="myapp"
PROJECT_VERSION="${GITHUB_SHA}"

# Base64-encode the SBOM
SBOM_B64=$(base64 -w 0 sbom.cdx.json)

curl -s -X PUT "${DTRACK_URL}/api/v1/bom" \
  -H "X-Api-Key: ${DTRACK_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{
    \"projectName\": \"${PROJECT_NAME}\",
    \"projectVersion\": \"${PROJECT_VERSION}\",
    \"autoCreate\": true,
    \"bom\": \"${SBOM_B64}\"
  }"

Dependency-Track processes the SBOM asynchronously. Poll for completion before querying findings:

sleep 30

FINDINGS=$(curl -s \
  -H "X-Api-Key: ${DTRACK_API_KEY}" \
  "${DTRACK_URL}/api/v1/finding/project?name=${PROJECT_NAME}&version=${PROJECT_VERSION}&suppressed=false")

CRITICAL_COUNT=$(echo "$FINDINGS" | jq '[.[] | select(.vulnerability.severity == "CRITICAL")] | length')

if [ "$CRITICAL_COUNT" -gt 0 ]; then
  echo "FATAL: ${CRITICAL_COUNT} critical vulnerabilities found in Dependency-Track"
  exit 1
fi

Dependency-Track supports policy management through its UI: define policies that fail on CVSS score thresholds, specific CPEs, or licence violations, and expose those policy violations via the API for CI/CD querying.

VEX Documents to Suppress False Positives

Vulnerability scanners produce false positives. Common scenarios:

  • A CVE affects a code path that is not reachable in your application (e.g., a CVE in an XML parser that your application never invokes).
  • A CVE exists in a component that is present in the image’s build stage but not in the final runtime image. Syft scans the final layer; sometimes mis-attribution still occurs.
  • A CVE is in a test dependency (devDependencies, Maven test scope) that is not shipped.
  • A CVE has a CVSS score of 9.8 but affects Windows only; your images run on Linux.

VEX (Vulnerability Exploitability eXchange) is the mechanism for documenting these assessments. A VEX document asserts the exploitability status of a CVE against a specific product version: not_affected, affected, fixed, or under_investigation.

CycloneDX VEX embedded in an SBOM:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "vulnerabilities": [
    {
      "id": "CVE-2024-12345",
      "source": {
        "name": "NVD",
        "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345"
      },
      "analysis": {
        "state": "not_affected",
        "justification": "code_not_reachable",
        "detail": "The affected XML parsing code path is only invoked when the LEGACY_XML feature flag is set. This flag is not enabled in production configuration."
      },
      "affects": [
        {
          "ref": "urn:cdx:sbom-id/component-id"
        }
      ]
    }
  ]
}

For standalone VEX, use OpenVEX format:

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://openvex.example.com/vex/myapp-2024-001",
  "author": "Security Team <security@example.com>",
  "timestamp": "2026-05-09T00:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": {
        "@id": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345"
      },
      "products": [
        {
          "@id": "pkg:oci/myapp@sha256:abc123?repository_url=ghcr.io/myorg"
        }
      ],
      "status": "not_affected",
      "justification": "code_not_reachable"
    }
  ]
}

Grype supports VEX suppression with --vex:

grype sbom:./sbom.cdx.json \
  --vex ./vex.json \
  --fail-on critical

Dependency-Track ingests VEX via its API and suppresses matching findings in its vulnerability dashboard. VEX documents must be versioned, reviewed, and stored in source control alongside the SBOM. They represent security assessments and should go through the same review process as security policy changes.

CI/CD Integration: Full Pipeline

A complete GitHub Actions workflow combining SBOM generation, attestation, vulnerability scanning with policy gate, and Dependency-Track upload:

# .github/workflows/sbom-pipeline.yml
name: SBOM Pipeline

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read
  packages: write
  id-token: write
  security-events: write

jobs:
  build-and-attest:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Install tools
        run: |
          curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
            | sh -s -- -b /usr/local/bin v1.4.1
          curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
            | sh -s -- -b /usr/local/bin

      - uses: sigstore/cosign-installer@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          push: ${{ github.event_name != 'pull_request' }}
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Generate SBOM
        run: |
          syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
            -o cyclonedx-json=sbom.cdx.json \
            --source-name "${{ github.repository }}" \
            --source-version "${{ github.sha }}"

      - name: Scan SBOM with Grype (policy gate)
        run: |
          grype sbom:./sbom.cdx.json \
            --fail-on critical \
            --vex ./vex.json \
            --output sarif \
            --file grype-results.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: grype-results.sarif

      - name: Attest SBOM
        if: github.event_name != 'pull_request'
        run: |
          cosign attest \
            --predicate sbom.cdx.json \
            --type cyclonedx \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

      - name: Upload to Dependency-Track
        if: github.event_name != 'pull_request'
        env:
          DTRACK_URL: ${{ secrets.DTRACK_URL }}
          DTRACK_API_KEY: ${{ secrets.DTRACK_API_KEY }}
        run: |
          curl -sf -X PUT "${DTRACK_URL}/api/v1/bom" \
            -H "X-Api-Key: ${DTRACK_API_KEY}" \
            -H "Content-Type: application/json" \
            -d "{
              \"projectName\": \"${{ github.repository }}\",
              \"projectVersion\": \"${{ github.sha }}\",
              \"autoCreate\": true,
              \"bom\": \"$(base64 -w 0 sbom.cdx.json)\"
            }"

Grype’s --fail-on flag maps to exit codes: exit 0 means no findings at or above the threshold; exit 1 means at least one finding at or above the threshold. Any other non-zero exit code indicates a tool error (network failure, parse error). Treat tool errors as build failures by default.

For pull requests, the pipeline generates and scans the SBOM but does not push the image or attest — there is no registry digest to reference until the image is actually pushed. Run the scan on a locally-loaded image using --load in the build step.

SBOM Accuracy Problems

SBOMs from automated tooling are frequently inaccurate in specific, predictable ways. Understanding these failure modes is necessary for assessing how much trust to place in a given SBOM.

Phantom dependencies. Some ecosystem package managers report packages that are listed in lock files but never actually installed in the target environment. Conda environments with platform-specific markers, Maven optional dependencies, and npm peerDependencies are common sources. A phantom dependency appears in the SBOM, generates a vulnerability finding, and there is no actual vulnerable package in the runtime environment. The fix is to filter SBOMs by the scope field (CycloneDX) or relationship type (SPDX), and to verify scanner output against the actual installed package list for a running container (dpkg -l, pip list, rpm -qa).

Missing transitive dependencies. Image-layer scanners enumerate installed packages; they do not always reconstruct the full dependency graph from source. A Go binary compiled with CGO_DISABLED=0 and stripped of debug info may have no detectable Go module metadata in the final image — Syft identifies it as a Go binary but cannot enumerate its imports. cdxgen operating on source before the build captures what image-layer scanners miss.

Wrong CPE mappings. Common Platform Enumeration identifiers are the primary key used to match software components against the NVD database. Incorrect CPEs produce false positives (CVE matched to the wrong package) and false negatives (a vulnerable package not matched at all). This is especially common for forked packages, rebranded OS packages, and vendored libraries where the package name in the image differs from the upstream name that NVD uses. Package URL (purl) identifiers are more accurate than CPEs for ecosystem packages because they encode the registry, namespace, name, and version in a standardised format that maps unambiguously to the package in its native registry.

Multi-stage build leakage. Syft by default scans the final image. If a multi-stage Dockerfile copies a binary compiled in a heavy build stage into a minimal runtime stage, Syft correctly does not enumerate build-stage packages. But if the final image inadvertently copies non-binary artefacts from the build stage (e.g., a Python .venv directory, a node_modules tree left behind by a bad COPY --chown), those packages appear in the SBOM with no indication that they were an unintended inclusion. Diff the SBOM against the intended package manifest and treat unexpected packages as build configuration findings.

Clock skew in SBOM timestamps. Reproducible build environments and base image pinning are prerequisites for SBOM reproducibility. An SBOM generated from FROM python:3.11 (a mutable tag) today will differ from one generated tomorrow because the underlying digest may change. Pin base images by digest; generate SBOMs from the pinned digest rather than the mutable tag.

Verification at Deployment Time

Producing and storing attestations is useful only if they are verified before workloads are admitted to production. A Kubernetes admission controller can verify that every image carries a valid cosign-signed SBOM attestation before allowing the pod to be scheduled.

Policy Admission Controller (using Kyverno):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-sbom-attestation
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-sbom-attestation
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [production]
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestations:
            - predicateType: https://cyclonedx.org/bom
              attestors:
                - entries:
                    - keyless:
                        subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
                        issuer: "https://token.actions.githubusercontent.com"

This rejects any pod in the production namespace whose image does not have a CycloneDX SBOM attestation signed by GitHub Actions OIDC from the main branch, closing the loop from generation to enforcement.