SBOM generation in Tekton

A Software Bill of Materials is the container image equivalent of an ingredient list. Golem Trust generates one for every image it builds, in both CycloneDX and SPDX formats, because different customers and regulatory frameworks prefer different formats, and because generating both costs almost nothing compared to being asked for one you do not have. The SBOM generation step runs immediately after the image build, before signing or pushing, so that the SBOM can be co-signed and co-attested with the image itself. Licence compliance checking runs at the same time, and a build with a forbidden licence fails before it ever reaches Harbor. This runbook covers the Syft Task, SBOM attestation with Cosign, licence compliance checking, and the monthly archive to Hetzner Object Storage.

Syft Task definition

Syft runs as a dedicated Tekton Task after the build Task. It receives the image reference as a parameter and writes both CycloneDX JSON and SPDX JSON to the shared workspace:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: golemtrust-syft-sbom
  namespace: tekton-pipelines
spec:
  workspaces:
    - name: source
      mountPath: /workspace/source
  params:
    - name: image-ref
  results:
    - name: cyclonedx-sbom-path
    - name: spdx-sbom-path
  steps:
    - name: generate-cyclonedx
      image: harbor.golems.internal/tools/syft:1.4.1
      command:
        - syft
      args:
        - packages
        - $(params.image-ref)
        - --output
        - cyclonedx-json=/workspace/source/sbom.cyclonedx.json
        - --scope
        - all-layers

    - name: generate-spdx
      image: harbor.golems.internal/tools/syft:1.4.1
      command:
        - syft
      args:
        - packages
        - $(params.image-ref)
        - --output
        - spdx-json=/workspace/source/sbom.spdx.json
        - --scope
        - all-layers

    - name: record-paths
      image: harbor.golems.internal/tools/busybox:1.36
      script: |
        echo -n "/workspace/source/sbom.cyclonedx.json" | tee $(results.cyclonedx-sbom-path.path)
        echo -n "/workspace/source/sbom.spdx.json" | tee $(results.spdx-sbom-path.path)

Attaching the SBOM as a Cosign attestation

After Syft generates both files, a subsequent step attaches the CycloneDX SBOM as a Cosign attestation and the SPDX SBOM as a second attestation. Both are stored in Harbor as referrers to the image manifest.

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: golemtrust-sbom-attest
  namespace: tekton-pipelines
spec:
  params:
    - name: image-ref
    - name: cyclonedx-path
    - name: spdx-path
  steps:
    - name: attest-cyclonedx
      image: harbor.golems.internal/tools/cosign:2.2.4
      command:
        - cosign
      args:
        - attest
        - --predicate
        - $(params.cyclonedx-path)
        - --type
        - cyclonedx
        - $(params.image-ref)

    - name: attest-spdx
      image: harbor.golems.internal/tools/cosign:2.2.4
      command:
        - cosign
      args:
        - attest
        - --predicate
        - $(params.spdx-path)
        - --type
        - spdx
        - $(params.image-ref)

To confirm both attestations are present after a build:

cosign download attestation \
  harbor.golems.internal/golem-trust/example-service@sha256:<digest> \
  | jq -r '.predicateType'

The output should show at least https://cyclonedx.org/bom and https://spdx.dev/Document.

Application dependency SBOM from source

Syft generates an SBOM from the built image. For licence compliance checking, an additional SBOM is generated from the application’s source code before the build, using cdxgen. This captures the full dependency tree including transitive dependencies that may not be visible in the final image layers:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: golemtrust-cdxgen-sbom
  namespace: tekton-pipelines
spec:
  workspaces:
    - name: source
      mountPath: /workspace/source
  steps:
    - name: generate-source-sbom
      image: harbor.golems.internal/tools/cdxgen:10.6.0
      command:
        - cdxgen
      args:
        - --output
        - /workspace/source/sbom-source.cyclonedx.json
        - --type
        - auto
        - /workspace/source/src

Licence compliance checking

After the source SBOM is generated, a custom script checks each component’s SPDX licence identifier against the Golem Trust allowed list. The script lives at golem-trust/platform/scripts/check-licences.py:

#!/usr/bin/env python3
import json
import sys

ALLOWED = {"MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD"}
REVIEW_REQUIRED = {"GPL-3.0-only", "GPL-3.0-or-later", "LGPL-2.1-only", "LGPL-2.1-or-later"}

with open(sys.argv[1]) as f:
    sbom = json.load(f)

failures = []
reviews = []

for component in sbom.get("components", []):
    name = component.get("name", "unknown")
    version = component.get("version", "unknown")
    licences = [
        lic.get("expression") or lic.get("license", {}).get("id", "UNKNOWN")
        for lic in component.get("licenses", [{"license": {"id": "UNKNOWN"}}])
    ]
    for lic in licences:
        if lic in REVIEW_REQUIRED:
            reviews.append(f"{name}@{version}: {lic} (legal review required)")
        elif lic not in ALLOWED and lic != "UNKNOWN":
            failures.append(f"{name}@{version}: {lic} (forbidden)")

if reviews:
    print("LICENCE REVIEW REQUIRED:")
    for r in reviews:
        print(f"  {r}")

if failures:
    print("LICENCE VIOLATIONS (build failed):")
    for f in failures:
        print(f"  {f}")
    sys.exit(1)

print(f"Licence check passed. {len(reviews)} component(s) require legal review.")

The Task that runs this script:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: golemtrust-licence-check
  namespace: tekton-pipelines
spec:
  workspaces:
    - name: source
      mountPath: /workspace/source
  steps:
    - name: check
      image: harbor.golems.internal/tools/python:3.12-slim
      command:
        - python3
      args:
        - /scripts/check-licences.py
        - /workspace/source/sbom-source.cyclonedx.json

If the script exits with a non-zero status, the Tekton pipeline fails and the image is not pushed to Harbor. The build log shows exactly which component and licence triggered the failure. GPL-3.0 components require a ticket in Jira for legal review before the dependency can be approved; the approval is recorded in the hash database maintained by Ludmilla’s team.

Monthly SBOM archive

On the first day of each month, a CronJob collects all SBOM attestations generated during the previous month from Harbor and archives them to Hetzner Object Storage. The bucket is golemtrust-sbom-archive, with versioning enabled and a two-year retention policy.

The archive script authenticates to Harbor using a read-only robot account, retrieves the list of images pushed in the previous month, downloads the CycloneDX and SPDX attestations for each, and uploads them to a date-partitioned prefix in the bucket:

s3cmd put sbom-2026-02-example-service-sha256-abc123.cyclonedx.json \
  s3://golemtrust-sbom-archive/2026/02/example-service/sha256-abc123.cyclonedx.json

s3cmd put sbom-2026-02-example-service-sha256-abc123.spdx.json \
  s3://golemtrust-sbom-archive/2026/02/example-service/sha256-abc123.spdx.json

The CronJob manifest and archive script live in golem-trust/platform/jobs/sbom-archive/. Cheery monitors the monthly archive job completion via a Grafana alert; a failed archive job creates a PagerDuty incident routed to Dr. Crucible.