2026-06-06·5 min read·sota.io Team

CRA SBOM CI/CD Automation 2026: GitHub Actions and GitLab CI Integration Guide

Post #3 in the sota.io EU CRA SBOM Developer Guide Series (2026)

CRA SBOM CI/CD Automation 2026 — GitHub Actions and GitLab CI for EU Cyber Resilience Act compliance

The Cyber Resilience Act requires manufacturers to maintain an accurate Software Bill of Materials throughout the entire supported lifecycle of a product — not just at the moment it ships. Art.13(1) establishes that manufacturers must keep technical documentation updated whenever a product undergoes significant changes, and the SBOM under Annex VII is part of that documentation. This lifecycle maintenance obligation is the reason manual SBOM generation will not hold up under market surveillance scrutiny.

This post — the third in our five-part CRA SBOM series — covers how to integrate SBOM generation directly into your CI/CD pipeline so that every build produces a compliant, up-to-date SBOM automatically. Post #1 covered the regulatory foundation (Art.13, Art.31, Annex VII). Post #2 compared CycloneDX and SPDX formats. Here we get into implementation.

Why CI/CD Automation Is Mandatory Under the CRA

The CRA's lifecycle maintenance obligation transforms SBOM from a one-time deliverable into a continuous process. Three provisions make this explicit:

Art.13(1) requires manufacturers to keep technical documentation updated "when the product undergoes a significant change" — including dependency updates, which happen with every release cycle in modern software development.

Art.13(6) requires manufacturers to ensure that procedures are in place to keep the product compliant when it is in a series of production — effectively meaning that every build must produce a compliant artifact, not just the initial release.

Art.14(1) and Art.14(2) require manufacturers to report actively exploited vulnerabilities in their products to ENISA within the timeframes specified in the CRA implementing acts. To know whether a vulnerability is present in your product, you must have a current, accurate SBOM.

Together, these requirements mean a SBOM generated once during initial compliance prep will become stale within weeks, violating the lifecycle maintenance obligation. Pipeline automation is the only technically viable approach.

Tool Selection for CRA-Compliant CI/CD SBOM Generation

Three tools dominate the space for automated SBOM generation in CI/CD:

Syft (Anchore)

Syft generates CycloneDX and SPDX SBOMs from container images, directories, and package manifests. It supports a wide range of ecosystems (npm, pip, Maven, Gradle, Go modules, Cargo, RubyGems, NuGet, Alpine, Debian, RPM) and integrates directly with OCI image registries.

For CRA compliance: Syft reliably enumerates transitive dependencies, which is a hard requirement under Annex VII. Its CycloneDX 1.6 output includes component hashes (SHA-256), PURL identifiers, and license data.

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

# Generate CycloneDX 1.6 SBOM from a container image
syft <image>:<tag> -o cyclonedx-json=sbom.cdx.json

# Generate from a directory (source scan)
syft dir:. -o cyclonedx-json=sbom.cdx.json

cdxgen (OWASP)

cdxgen is OWASP's CycloneDX generator, focused on deep dependency analysis across language ecosystems. It supports JavaScript/TypeScript, Python, Java, C#, Go, Rust, Ruby, PHP, and C/C++ with build-time analysis (not just manifest parsing). For applications with compiled dependencies or build-time transformations, cdxgen is often more accurate than manifest-only tools.

# Install
npm install -g @cyclonedx/cdxgen

# Generate CycloneDX SBOM from project root
cdxgen -t nodejs -o sbom.cdx.json .

# Multi-language project
cdxgen -o sbom.cdx.json .

Trivy (Aqua Security)

Trivy combines SBOM generation with vulnerability scanning, making it useful for the CRA Art.14 reporting workflow: you generate an SBOM, then immediately scan that SBOM for known vulnerabilities. Trivy outputs CycloneDX and SPDX and integrates with OCI attestation workflows.

# Generate CycloneDX SBOM and vulnerability scan in one pass
trivy image --format cyclonedx --output sbom.cdx.json <image>:<tag>

# Scan a previously generated SBOM for vulnerabilities
trivy sbom sbom.cdx.json

GitHub Actions Integration

Here is a production-ready GitHub Actions workflow for CRA-compliant SBOM generation and storage. This workflow runs on every push to the main branch and on release tags.

# .github/workflows/cra-sbom.yml
name: CRA SBOM Generation

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

permissions:
  contents: read
  packages: write
  id-token: write  # Required for OIDC-based attestation

jobs:
  generate-sbom:
    name: Generate CRA-Compliant SBOM
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build container image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: ${{ github.repository }}:${{ github.sha }}
          load: true

      - name: Generate SBOM (CycloneDX 1.6)
        uses: anchore/sbom-action@v0
        with:
          image: ${{ github.repository }}:${{ github.sha }}
          format: cyclonedx-json
          output-file: sbom-${{ github.sha }}.cdx.json
          upload-artifact: true
          upload-release-assets: true

      - name: Validate SBOM completeness
        run: |
          # Verify SBOM contains required CRA Annex VII fields
          python3 - <<'EOF'
          import json, sys

          with open("sbom-${{ github.sha }}.cdx.json") as f:
              sbom = json.load(f)

          components = sbom.get("components", [])
          if not components:
              print("ERROR: SBOM contains no components")
              sys.exit(1)

          missing_hashes = []
          missing_purl = []
          for c in components:
              if not c.get("hashes"):
                  missing_hashes.append(c.get("name", "unknown"))
              if not c.get("purl"):
                  missing_purl.append(c.get("name", "unknown"))

          if missing_hashes:
              print(f"WARNING: {len(missing_hashes)} components missing hashes")
          if missing_purl:
              print(f"WARNING: {len(missing_purl)} components missing PURL")

          print(f"SBOM validated: {len(components)} components")
          EOF

      - name: Scan SBOM for vulnerabilities (Art.14 prep)
        run: |
          # Install Trivy for vulnerability scanning
          curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
          
          # Scan the generated SBOM
          trivy sbom sbom-${{ github.sha }}.cdx.json \
            --format json \
            --output vuln-report-${{ github.sha }}.json \
            --exit-code 0  # Don't fail build on findings; log for Art.14 review
          
          # Summarize critical/high findings for Art.14 triage
          python3 - <<'EOF'
          import json

          with open("vuln-report-${{ github.sha }}.json") as f:
              report = json.load(f)

          results = report.get("Results", [])
          critical = high = 0
          for result in results:
              for vuln in result.get("Vulnerabilities", []):
                  sev = vuln.get("Severity", "")
                  if sev == "CRITICAL":
                      critical += 1
                  elif sev == "HIGH":
                      high += 1

          print(f"Vulnerability summary: CRITICAL={critical}, HIGH={high}")
          if critical > 0:
              print("ACTION REQUIRED: Review CRITICAL findings for CRA Art.14 reporting obligation")
          EOF

      - name: Upload vulnerability report
        uses: actions/upload-artifact@v4
        with:
          name: vuln-report-${{ github.sha }}
          path: vuln-report-${{ github.sha }}.json
          retention-days: 365  # CRA Art.31: 10-year retention; use S3/GCS for long-term

      - name: Attach SBOM to release
        if: github.event_name == 'release'
        uses: softprops/action-gh-release@v2
        with:
          files: sbom-${{ github.sha }}.cdx.json

GitHub SBOM Attestation (Supply Chain Integrity)

GitHub's SBOM attestation feature links the SBOM to the specific workflow run that produced it, providing non-repudiation evidence that market surveillance authorities can verify. This is relevant to CRA Art.13(9), which requires manufacturers to keep records sufficient to demonstrate conformity.

      - name: Generate SBOM attestation
        uses: actions/attest-sbom@v1
        with:
          subject-name: ${{ github.repository }}
          subject-digest: ${{ needs.build.outputs.image-digest }}
          sbom-path: sbom-${{ github.sha }}.cdx.json

Attestations are stored in GitHub's transparency log and can be verified with:

gh attestation verify --repo <org>/<repo> sbom.cdx.json

GitLab CI Integration

GitLab's Dependency Scanning and Container Scanning features generate SBOMs as part of the GitLab Secure suite. For CRA compliance, you can use either the built-in features or integrate Syft/cdxgen directly.

Using GitLab's Built-in SBOM Generation

# .gitlab-ci.yml
include:
  - template: Security/Dependency-Scanning.gitlab-ci.yml
  - template: Security/Container-Scanning.gitlab-ci.yml

variables:
  DS_INCLUDE_DEV_DEPENDENCIES: "false"  # CRA: ship-time dependencies only
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

stages:
  - build
  - sbom
  - scan
  - archive

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

generate-cra-sbom:
  stage: sbom
  image: anchore/syft:latest
  script:
    - syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
        -o cyclonedx-json=sbom-${CI_COMMIT_SHA}.cdx.json
    - |
      python3 -c "
      import json
      with open('sbom-${CI_COMMIT_SHA}.cdx.json') as f:
          sbom = json.load(f)
      count = len(sbom.get('components', []))
      print(f'CRA SBOM generated: {count} components')
      assert count > 0, 'Empty SBOM — check Syft configuration'
      "
  artifacts:
    paths:
      - sbom-${CI_COMMIT_SHA}.cdx.json
    expire_in: never  # CRA Art.31: retain for 10 years minimum
    reports:
      cyclonedx: sbom-${CI_COMMIT_SHA}.cdx.json

vulnerability-scan:
  stage: scan
  image: aquasec/trivy:latest
  dependencies:
    - generate-cra-sbom
  script:
    - trivy sbom sbom-${CI_COMMIT_SHA}.cdx.json
        --format json
        --output vuln-${CI_COMMIT_SHA}.json
        --exit-code 0
    - |
      python3 -c "
      import json
      with open('vuln-${CI_COMMIT_SHA}.json') as f:
          r = json.load(f)
      crit = sum(
          1 for res in r.get('Results', [])
          for v in res.get('Vulnerabilities', [])
          if v.get('Severity') == 'CRITICAL'
      )
      print(f'Critical vulnerabilities: {crit}')
      if crit > 0:
          print('REVIEW REQUIRED: Art.14 CRA reporting triage needed')
      "
  artifacts:
    paths:
      - vuln-${CI_COMMIT_SHA}.json
    expire_in: never

archive-to-s3:
  stage: archive
  image: amazon/aws-cli:latest
  dependencies:
    - generate-cra-sbom
    - vulnerability-scan
  script:
    # Long-term archival: CRA Art.31 requires 10-year retention
    # Use EU-hosted S3-compatible storage (Hetzner Object Storage, Scaleway, OVHcloud)
    - aws s3 cp sbom-${CI_COMMIT_SHA}.cdx.json
        s3://${SBOM_BUCKET}/sboms/${CI_PROJECT_PATH}/${CI_COMMIT_SHA}/sbom.cdx.json
    - aws s3 cp vuln-${CI_COMMIT_SHA}.json
        s3://${SBOM_BUCKET}/vulns/${CI_PROJECT_PATH}/${CI_COMMIT_SHA}/vuln-report.json
  only:
    - main
    - tags

GitLab Dependency Scanning with CycloneDX Output

GitLab's built-in dependency scanning (via gemnasium-maven, gemnasium-python, etc.) can output CycloneDX format directly when configured:

dependency_scanning:
  variables:
    DS_DEFAULT_ANALYZERS: ""  # Use specific analyzer for your stack
    SECURE_ANALYZERS_PREFIX: "registry.gitlab.com/security-products"
    # CycloneDX output format
    DS_CYCLONEDX_OUTPUT: "true"

Note: GitLab's native scanners output GL-SBOM format by default; the cyclonedx artifact report type maps this to a CycloneDX-compatible output for third-party tools.

SBOM Storage Architecture for CRA Compliance

The CRA Art.31 retention requirement (10 years from product placement or end of supported lifecycle, whichever is later) means SBOM storage must be treated as a compliance archive, not just a build artifact.

Storage Tiers

Tier 1 — Build artifact storage (CI/CD native, 1–90 days): GitHub Actions artifacts, GitLab job artifacts. Sufficient for active development but not for CRA long-term retention.

Tier 2 — Object storage (1–10 years, cost-effective): S3-compatible storage. For CRA compliance, use EU-hosted providers — Hetzner Object Storage (Germany), Scaleway Object Storage (France), or OVHcloud Object Storage (France/Germany). This avoids US CLOUD Act jurisdiction over your compliance documentation.

Tier 3 — Immutable archive (legal hold): For products under EU market surveillance, use object versioning with Object Lock (WORM — Write Once Read Many). This provides tamper-evidence for market surveillance authority audit requests under CRA Art.14(3).

# store_sbom.py — EU-native SBOM archival with WORM
import boto3
from botocore.config import Config
from datetime import datetime

def archive_sbom(sbom_path: str, product_id: str, build_sha: str, endpoint_url: str):
    """
    Archive SBOM to EU-hosted S3-compatible object storage with versioning.
    
    endpoint_url: Use EU-native endpoint, e.g.:
    - Hetzner: https://fsn1.your-objectstorage.com
    - Scaleway: https://s3.fr-par.scw.cloud
    - OVHcloud: https://s3.gra.io.cloud.ovh.net
    """
    s3 = boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        config=Config(signature_version="s3v4"),
    )

    key = f"sboms/{product_id}/{build_sha}/sbom.cdx.json"
    
    with open(sbom_path, "rb") as f:
        s3.put_object(
            Bucket="cra-compliance-archive",
            Key=key,
            Body=f.read(),
            ContentType="application/vnd.cyclonedx+json",
            Metadata={
                "cra-article": "Art.31-Annex-VII",
                "generated-at": datetime.utcnow().isoformat(),
                "product-id": product_id,
                "build-sha": build_sha,
            },
        )
    
    print(f"SBOM archived: s3://{endpoint_url}/{key}")

Connecting SBOM Generation to CRA Art.14 Vulnerability Reporting

Art.14(1) requires manufacturers of products with digital elements to notify ENISA (via national CSIRTs) of actively exploited vulnerabilities without undue delay after they become aware. Art.14(2) sets a 24-hour window for early warning and a 72-hour window for the vulnerability notification.

The practical workflow connecting your CI/CD SBOM pipeline to the Art.14 obligation:

  1. CI/CD pipeline generates SBOM on every main-branch build (covered by this post)
  2. CVE scanner runs against SBOM to identify known vulnerabilities (Trivy, Grype)
  3. Security alert triggers when CRITICAL severity CVEs appear in deployed components
  4. Art.14 triage within 24 hours: is the CVE actively exploited in the wild?
  5. ENISA EUVDB early warning within 24 hours if actively exploited
  6. Full vulnerability notification to national CSIRT within 72 hours

Post #4 in this series covers SBOM storage and vulnerability tracking infrastructure in detail, including how to automate the CVE-to-Art.14 triage workflow.

Common CI/CD SBOM Failures That Will Not Satisfy Annex VII

Failure 1: SBOM generated from manifest files only

Many tools scan package.json, requirements.txt, or pom.xml but do not resolve transitive dependencies. Annex VII requires a complete inventory of software components — the CRA uses the word "components" to mean all software, not just direct dependencies. Use --scope allLayers with Syft for container scans, or use cdxgen's build-time analysis mode.

Failure 2: SBOM generated before build-time dependency resolution

If your build process generates files that become part of the product (compiled binaries, bundled assets, vendored code), the SBOM must reflect the final artifact state, not the source manifest state. Generate SBOMs from the built image or binary, not from source checkout.

Failure 3: Missing component hashes

The CRA Annex VII requires components to be identifiable and traceable. SBOMs without cryptographic hashes (SHA-256 for each component) fail this requirement. Verify your SBOM generation configuration includes hash generation — Syft generates hashes by default in CycloneDX output; confirm this is not disabled.

Failure 4: SBOM not linked to the shipped artifact

A SBOM stored separately from the artifact it describes can become detached. Use OCI attestations (GitHub actions/attest-sbom, GitLab container scanning) to cryptographically link the SBOM to the specific image digest. This ensures that when a vulnerability is found in a component version, you can definitively determine which deployed artifact is affected.

Failure 5: Single SBOM per project, not per version

The CRA's lifecycle maintenance requirement means you need a new SBOM for each released version. Configure your CI/CD pipeline to generate and archive a SBOM keyed by build SHA or release tag, not by project name alone.

Developer Checklist: CI/CD SBOM for CRA Compliance

Before each release:

Per release:

What Post #4 Covers

Post #4 in this series covers long-term SBOM storage architecture, the CRA Art.14 vulnerability reporting workflow end-to-end, and how to build a CVE triage system that can detect actively exploited vulnerabilities in your deployed SBOM components within the 24-hour early warning window.


sota.io is an EU-native managed PaaS — deploy any language on German infrastructure with no US-parent exposure and no CLOUD Act jurisdiction over your compliance documentation. Try sota.io free →

EU-Native Hosting

Ready to move to EU-sovereign infrastructure?

sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.