SBOM Generation in CI: Syft vs Trivy, CycloneDX vs SPDX — What Actually Works in Production

The software supply chain attacks of the last few years made one thing painfully obvious: most teams have no idea what’s actually running in their containers. Log4Shell hit hard partly because nobody could quickly answer "do we ship log4j anywhere?" An SBOM — Software Bill of Materials — is the answer to that question, baked into your pipeline.

Governments are now pushing SBOM mandates (US Executive Order 14028, EU Cyber Resilience Act), enterprise procurement teams are demanding them, and honestly, even ignoring compliance pressure, knowing your dependency tree is just good hygiene.

The problem: there are two major formats, half a dozen tools, and zero consensus on what "done" looks like. This article cuts through that noise and shows you a working CI setup.

What an SBOM Actually Is

An SBOM is a machine-readable inventory of every component in your software artifact — libraries, operating system packages, language dependencies, checksums, licenses, and version data. Think of it like a nutrition label, but for your container image or binary.

It serves two separate use cases that often get conflated:

Vulnerability management — feed the SBOM into a scanner and get a CVE report without re-scanning the artifact. Useful when the artifact is already deployed and you can’t pull it again easily.

Compliance and audit — prove to a customer, auditor, or regulator that you know what’s in your software and that no GPL-licensed code snuck into a proprietary product.

These two use cases actually pull you toward different format choices, which we’ll get into.

The Two Formats Worth Knowing

Forget everything else. In 2024/2025 the market has converged on two:

CycloneDX

Originally from OWASP. JSON or XML, heavily tooled, and explicitly designed for security use cases. It has first-class support for vulnerability data, VEX (Vulnerability Exploitability eXchange) documents, and supply chain relationships. The JSON schema is clean and easy to parse programmatically.

If your primary goal is feeding SBOMs into vulnerability scanners, policy engines, or Dependency-Track, CycloneDX is your format.

SPDX

Linux Foundation project, now an ISO standard (ISO/IEC 5962:2021). It predates CycloneDX and was originally designed for license compliance. The spec supports JSON, YAML, RDF, and a tag-value text format that looks like it was designed by committee in 2010 (because it was). SPDX 3.0 is a significant cleanup but tooling support is still catching up.

If your primary goal is license compliance, legal review, or satisfying enterprise procurement requirements (especially government contracts), SPDX is what they’ll ask for.

The good news: both Syft and Trivy generate both formats, so you don’t have to commit to one tool per format.

The Two Tools Worth Using

Syft

GitHub: https://github.com/anchore/syft

Built by Anchore. Pure SBOM generation — that’s the whole job. Syft scans container images, filesystems, archives, and source directories. It’s fast, the output is deterministic, and it supports CycloneDX 1.4/1.5/1.6, SPDX 2.2/2.3, and its own syft-json format (richer internal data).

Syft pairs naturally with Grype (also from Anchore) for vulnerability scanning against the generated SBOM.

Trivy

GitHub: https://github.com/aquasecurity/trivy

Built by Aqua Security. Trivy started as a vulnerability scanner and SBOM generation was added later. That history shows — it’s excellent at container scanning and CVE matching, but the SBOM output has historically been less complete than Syft’s for edge cases (vendored Go code, nested JARs, some Python wheel layouts).

Where Trivy wins: it’s a single binary that does scanning AND SBOM generation, which simplifies CI setup when you want both in one step. Its --format cyclonedx output is solid and widely supported.

Rule of thumb: use Syft when SBOM completeness and accuracy matter most. Use Trivy when you want a single tool for scan + SBOM in one shot.


Setting Up SBOM Generation in CI

Let’s build a real pipeline. The examples use GitHub Actions but the logic translates to GitLab CI, Drone, or any other runner with minor syntax changes.

Prerequisites

Both tools are single binaries with no runtime dependencies. Installation in CI is trivial.

# Install Syft (latest stable)
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Install Trivy
curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

Gotcha #1: Never pipe arbitrary install scripts into sh in production CI without pinning to a specific version. The above is fine for demos. In real pipelines, pin the version and verify the checksum or use the official container images.

# Pinned, checksum-verified install
SYFT_VERSION="1.5.0"
curl -sSfL "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" -o syft.tar.gz
echo "CHECKSUM  syft.tar.gz" | sha256sum -c  # use the actual checksum from the release page
tar -xzf syft.tar.gz syft
mv syft /usr/local/bin/

Basic GitHub Actions Workflow

This workflow builds a Docker image, generates SBOMs in both formats with both tools, and attaches them as release artifacts.

# .github/workflows/sbom.yml
name: Build and Generate SBOM

on:
  push:
    branches: [main]
  release:
    types: [published]

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}
  SYFT_VERSION: "1.5.0"
  TRIVY_VERSION: "0.52.0"

jobs:
  build-and-sbom:
    runs-on: ubuntu-latest
    permissions:
      contents: write       # needed to upload release assets
      packages: write       # needed to push to GHCR
      id-token: write       # needed for OIDC signing with cosign

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@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 image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
          # Export a local copy for scanning — avoids a registry pull
          outputs: type=docker,dest=/tmp/image.tar

      # ── Syft: generate CycloneDX and SPDX ──────────────────────────────

      - name: Install Syft
        run: |
          curl -sSfL \
            "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
            -o /tmp/syft.tar.gz
          tar -xzf /tmp/syft.tar.gz -C /tmp syft
          sudo mv /tmp/syft /usr/local/bin/syft

      - name: Syft — CycloneDX JSON
        run: |
          syft docker-archive:/tmp/image.tar \
            --output cyclonedx-json=sbom.cyclonedx.json \
            --source-name "${{ github.repository }}" \
            --source-version "${{ github.sha }}"

      - name: Syft — SPDX JSON
        run: |
          syft docker-archive:/tmp/image.tar \
            --output spdx-json=sbom.spdx.json \
            --source-name "${{ github.repository }}" \
            --source-version "${{ github.sha }}"

      # ── Trivy: scan + SBOM in one pass ─────────────────────────────────

      - name: Install Trivy
        run: |
          curl -sSfL \
            "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
            -o /tmp/trivy.tar.gz
          tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
          sudo mv /tmp/trivy /usr/local/bin/trivy

      - name: Trivy — vulnerability scan + CycloneDX SBOM
        run: |
          trivy image \
            --format cyclonedx \
            --output sbom.trivy.cyclonedx.json \
            --severity HIGH,CRITICAL \
            --exit-code 1 \
            --input /tmp/image.tar
        # exit-code 1 fails the build on HIGH/CRITICAL CVEs
        # Remove or change to 0 if you want a non-blocking scan

      # ── Attach SBOMs as artifacts ───────────────────────────────────────

      - name: Upload SBOM artifacts
        uses: actions/upload-artifact@v4
        with:
          name: sboms-${{ github.sha }}
          path: |
            sbom.cyclonedx.json
            sbom.spdx.json
            sbom.trivy.cyclonedx.json
          retention-days: 90

      # Attach to GitHub Release if this is a release event
      - name: Attach SBOMs to release
        if: github.event_name == 'release'
        run: |
          gh release upload "${{ github.ref_name }}" \
            sbom.cyclonedx.json \
            sbom.spdx.json \
            sbom.trivy.cyclonedx.json
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Gotcha #2: Scanning the local tar archive with --input (Trivy) or docker-archive:// (Syft) is always preferable to pulling the image from a registry. It’s faster, doesn’t require authenticated registry access in the scan step, and scans the exact bytes you built — not what might have been pushed (rare but possible if push and pull race with a layer cache issue).

GitLab CI Equivalent

# .gitlab-ci.yml (relevant stage only)
generate-sbom:
  stage: security
  image: docker:24-dind
  services:
    - docker:24-dind
  variables:
    SYFT_VERSION: "1.5.0"
    DOCKER_TLS_CERTDIR: "/certs"
  script:
    - docker build -t app:${CI_COMMIT_SHA} .
    - docker save app:${CI_COMMIT_SHA} -o /tmp/image.tar
    # Install Syft
    - |
      curl -sSfL \
        "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
        -o /tmp/syft.tar.gz && \
        tar -xzf /tmp/syft.tar.gz -C /usr/local/bin syft
    # Generate both formats
    - syft docker-archive:/tmp/image.tar --output cyclonedx-json=sbom.cyclonedx.json
    - syft docker-archive:/tmp/image.tar --output spdx-json=sbom.spdx.json
  artifacts:
    paths:
      - sbom.cyclonedx.json
      - sbom.spdx.json
    expire_in: 90 days

Attaching SBOMs to Container Images with cosign

Generating SBOM files is step one. Attaching them as verifiable attestations to the image itself is how this becomes a real supply chain security control. Use cosign (from Sigstore):

# Install cosign
COSIGN_VERSION="2.2.4"
curl -sSfL \
  "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
  -o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign

# Attach the CycloneDX SBOM as an attestation (keyless via OIDC in GitHub Actions)
cosign attest \
  --predicate sbom.cyclonedx.json \
  --type cyclonedx \
  "${{ env.IMAGE_NAME }}:${{ github.sha }}"

# Verify it later
cosign verify-attestation \
  --type cyclonedx \
  --certificate-identity "https://github.com/YOUR_ORG/YOUR_REPO/.github/workflows/sbom.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "${{ env.IMAGE_NAME }}:${{ github.sha }}"

Gotcha #3: Keyless cosign attestations are tied to the OIDC identity of the workflow that created them. If you change the workflow file path or branch, the verification command must be updated to match. Document this or it will silently fail verification in 6 months when someone renames the workflow.


Feeding SBOMs into Dependency-Track

Generating SBOMs is useless if nobody looks at them. Dependency-Track (OWASP) is the standard self-hosted platform for SBOM ingestion and continuous vulnerability monitoring. It accepts CycloneDX and provides a dashboard, policy enforcement, and API.

# Upload SBOM to Dependency-Track via its API
# Set these in your CI secrets:
# DT_URL: https://dependencytrack.your-domain.com
# DT_API_KEY: your API key from DT settings

ENCODED_BOM=$(base64 -w 0 sbom.cyclonedx.json)

curl -s -X PUT \
  -H "X-Api-Key: ${DT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{
    \"projectName\": \"${CI_PROJECT_NAME}\",
    \"projectVersion\": \"${CI_COMMIT_SHA}\",
    \"autoCreate\": true,
    \"bom\": \"${ENCODED_BOM}\"
  }" \
  "${DT_URL}/api/v1/bom"

Dependency-Track will continuously re-evaluate the SBOM against updated NVD/OSV data even after the original build. This is the part that actually catches vulnerabilities that didn’t exist when you shipped — which is most of them.


Format Selection Decision Tree

Stop overthinking it. Here’s the cheat sheet:

Use CycloneDX when:

  • Feeding into Dependency-Track, Grype, or any vulnerability scanner
  • You need VEX documents (marking false positives as "not applicable")
  • Your consumers are other tools/APIs rather than humans
  • You want the richest metadata per component (hashes, purl, CPE, licenses all in one record)

Use SPDX when:

  • A customer, government agency, or legal team explicitly asks for it
  • License compliance audit is the primary goal
  • You’re integrating with the Linux Foundation ecosystem or OpenChain-aligned processes
  • Someone says "ISO/IEC 5962" in a procurement requirement

Generate both when your pipeline already runs and the extra 5 seconds of syft execution doesn’t matter. Disk space for two JSON files is irrelevant.

SPDX tag-value format (the .spdx text file): only generate this if a specific downstream tool requires it. It’s the least ergonomic format and the hardest to parse reliably. JSON-SPDX is strictly better for everything except compatibility with ancient tooling.


Gotchas and Production Notes

Gotcha #4 — Multi-arch images: Both Syft and Trivy will scan the architecture of the image they pull by default. If you’re building linux/amd64 and linux/arm64 manifests, generate separate SBOMs per arch and name them accordingly. A single SBOM for a multi-arch manifest index is not meaningful — the package lists differ.

Gotcha #5 — Layer caching and reproducibility: SBOM output isn’t fully deterministic across tool versions. If you need to compare SBOMs from two builds, pin your tool version hard. A Syft minor version bump can change how it identifies packages, which generates false diffs.

Gotcha #6 — Source SBOMs vs image SBOMs: Running Syft or Trivy against a container image gives you the OS packages AND the language ecosystem packages. Running them against a source directory gives you only the language packages, and may miss what the Dockerfile actually installs at build time. For compliance purposes, always SBOM the final built image, not the source.

Gotcha #7 — Private registries and CI auth: When pulling images from private registries during scanning, make sure your scanner is authenticated. Trivy respects ~/.docker/config.json and DOCKER_* environment variables. Syft does too. If your CI uses a separate registry login step, run it before the scan step, not after.

Production best practice — retention policy: SBOMs are evidence. Treat them like audit logs. Keep them for at least as long as you support the corresponding software version. A 30-day artifact retention in CI is appropriate for development builds; release SBOMs should go to object storage (S3, GCS) with proper versioning and lifecycle policies, not just as CI artifacts.

Production best practice — SBOM signing: An SBOM that isn’t signed is a claim you can’t verify. Use cosign (shown above) or in-toto attestations to cryptographically bind the SBOM to the artifact it describes. This is the difference between "we have SBOMs" and "our SBOMs are trustworthy."


Validating Your SBOM Output

Before trusting your pipeline, validate the output:

# Validate CycloneDX JSON against the official schema
pip install cyclonedx-bom
cyclonedx validate --input-format json --input-file sbom.cyclonedx.json

# Check SPDX JSON validity
pip install spdx-tools
pyspdxtools validate sbom.spdx.json

# Quick component count — sanity check that the scan found something
jq '.components | length' sbom.cyclonedx.json
# Expect >10 for any real container image; a near-zero count means the scan failed silently

Gotcha #8 — Silent failures: Both Syft and Trivy exit 0 even when they fail to identify any packages in some edge cases (unsupported base image, corrupted layer). Always add a component count assertion to your CI step. If a Debian-based image produces fewer than 50 OS packages in the SBOM, something went wrong.


Putting It All Together

The minimal viable SBOM pipeline for a container-based project looks like this:

  1. Build the image and save it to a local tar archive
  2. Run Syft against the archive, outputting CycloneDX JSON (primary) and SPDX JSON (compliance backup)
  3. Run Trivy for the vulnerability gate — fail the build on HIGH/CRITICAL unless there’s a documented exception
  4. Attach SBOMs as CI artifacts with 90-day retention, and to releases permanently
  5. Upload the CycloneDX SBOM to Dependency-Track for continuous monitoring
  6. Sign the SBOM with cosign if you’re distributing the image externally

This isn’t theoretical overhead. When the next Log4Shell-class event hits, teams with this pipeline will spend 20 minutes querying Dependency-Track to find affected services. Teams without it will spend two weeks manually auditing their entire estate.

The tooling is mature, free, and installs in seconds. There’s no good reason not to have this in every pipeline that ships container images.

Leave a comment

👁 Views: 24,119 · Unique visitors: 34,632