Vetrix Docs

SBOM — Software Bill of Materials

Mapping: OWASP CI/CD Top-10 CICD-SEC-3 (Insufficient Pipeline-Based Access Controls over supply-chain artefacts)

A Vetrix SBOM is a CycloneDX or SPDX document produced by syft and attached to a pipeline artifact as an attestation of kind='sbom'. The attestation is stored by the control plane and read back through the REST API and the pipeline-detail attestations panel in the web UI.

Two different subsystems can produce that attestation, and they use different configuration languages. Read the right one:

Producer Configuration file Status
CI/CD pipeline job running the vetrix-sbom CLI vetrix-ci.yml (v1/v2 pipeline schema) shipped — the path both first-party repositories use
vetrix/sbom@<SHA> composite step .vetrix/automations/*.yml (Automations workflow schema) action manifest only — no shipped runner executes workflow steps

The pipeline schema and the Automations workflow schema are not interchangeable. Section The vetrix/sbom composite step carries the details and the exact parser errors you get for mixing them.


Supported formats

Format Default? predicateType
cyclonedx-json yes https://cyclonedx.org/bom
spdx-json opt-in https://spdx.dev/Document/v2.3
spdx-tag-value opt-in https://spdx.dev/Document/v2.3

Any other format string is rejected by the API with a 422 Unprocessable Entity response whose body carries the supported set:

{
  "error": "unsupported sbom format",
  "supported": ["cyclonedx-json", "spdx-json", "spdx-tag-value"]
}

SBOM from CI pipelines

This is the path that runs today. A pipeline job in vetrix-ci.yml:

  1. builds and pushes the image the SBOM is supposed to describe;
  2. scans the pushed image with syft, writing cyclonedx-json;
  3. registers a pipeline artifact for that image (its real manifest digest and size) against the running job;
  4. runs vetrix-sbom attach to POST the SBOM to the artifact's attestations collection.

The job is ordinary pipeline configuration — stage:, image:, commands:, variables: — with the four steps above expressed as shell commands. No composite-step syntax is involved. The pipeline schema itself is documented in Authoring a Vetrix CI/CD Pipeline File.

The end-to-end recipe — required service token and its scope, the artifact registration call, the vetrix-sbom attach invocation, the runner image that carries syft and the vetrix-sbom binary, and the operator prerequisites a job cannot satisfy for itself — lives in the SBOM enablement runbook, Enable SBOM attestation on a repository's pipeline. Do not duplicate it here.

Two conditions gate that path at runtime, and a job that hits either of them must say so rather than guess:

  • The worker must inject the control-plane API base URL into the job environment. Without it the job has no server address to register the artifact against, and defaulting to a hardcoded production URL would attest against the wrong instance. Skip the SBOM steps with a loud warning instead.
  • The per-job VETRIX_JOB_TOKEN authenticates both control-plane calls. The artifact-registration POST and the vetrix-sbom attach step run back-to-back in the same job and share this one credential — there is no separate ci:write-scoped service token to provision.

The vetrix/sbom composite step (Automations subsystem only)

This section describes Automations workflow syntax (.vetrix/automations/*.yml). It is NOT valid vetrix-ci.yml configuration. The pipeline parser rejects steps: outright and has no uses: concept at all. Copying the YAML below into a pipeline file produces an unparseable pipeline — see Why this does not work in vetrix-ci.yml.

The composite-action manifests that ship in the source tree are vetrix/oidc, vetrix/sbom and vetrix/slsa-attest (frontend tree, src/composite-steps/), and vetrix/publish-artifact, vetrix/sbom-emit and vetrix/security-scan (backend tree, internal/actions/). That is the complete set.

Availability. The Automations parser reads, validates and dispatches workflow definitions, and it validates each uses: reference — a tag or branch ref draws an advisory "pin to a 40-char SHA" warning, a SHA-pinned ref passes clean. Step execution is not implemented: no shipped runner resolves a uses: reference to an action manifest and runs it, and the runner images the manifests name are not published. Treat the manifests as the contract for a future executor, not as something you can schedule today.

# .vetrix/automations/release.yml  — Automations workflow, NOT vetrix-ci.yml
name: release
on:
  push:
    branches: [master]
jobs:
  build:
    steps:
      - uses: vetrix/publish-artifact@<40-char-commit-sha>
        id: build
        # Declares exactly three outputs: image_digest, image_ref, tags.
      - uses: vetrix/sbom@<40-char-commit-sha>
        with:
          # UUID of an existing pipeline_artifacts row. Supplied literally
          # here because no manifest emits it — see the note below.
          artifact-id: <pipeline artifact uuid>
          # Optional — default is cyclonedx-json.
          format: cyclonedx-json
          # Optional — defaults to the current working directory.
          scan-path: .
          # Optional. The manifest default is 'true'; see "Signing status"
          # below for why every shipped lane sets this to 'false'.
          sign: 'false'

vetrix/sbom's only required input is artifact-id, the UUID of a pipeline_artifacts row that already exists. Nothing in the manifest set produces that id, so it cannot be wired out of a preceding step: vetrix/publish-artifact declares exactly image_digest, image_ref and tags, and artifact-id appears across the manifests only as an input (of vetrix/sbom and vetrix/slsa-attest), never as an output. The row is created by the artifact-registration call against the control plane that the enablement runbook covers.

vetrix/sbom attaches to an existing artifact. It is distinct from vetrix/sbom-emit, whose inputs are repo-path, sbom-ingest-url, runner-token, commit-sha and workflow-run-id, and which uploads to the per-repository SBOM index (POST /api/v1/repos/{owner}/{repo}/sbom) rather than to an artifact's attestations collection. The two are not substitutes.

The attach path is idempotent on the (artifact_id, format) key: re-running it for the same artifact and format replaces the existing attestation row atomically. Different formats (for example cyclonedx-json plus spdx-json) on the same artifact coexist as separate rows.

Why this does not work in vetrix-ci.yml

The pipeline parser is version-dispatched on the top-level version: key: absent or 1 selects the v1 schema, 2 selects the v2 schema. Both schemas reject steps: inside a job with a named, key-anchored error, and neither has any uses: field. Under v1 every unreserved top-level key is read as a job name, in document order, so the workflow's own header keys fail before its job bodies are ever reached.

The errors below are what the parser emits; line numbers count from the first line of the fence above.

Document Parser result
The workflow above, saved as vetrix-ci.yml (no version: ⇒ v1) job "name": failed to decode: yaml: unmarshal errors: line 2: cannot unmarshal !!str `release` into parser.rawJob — v1 reserves only stages, variables, branch_order and version at the top level, so name is taken for a job and its scalar value fails the job decode
The same workflow with version: 2 prepended unsupported key "on": Vetrix does not support GitHub Actions `on:` triggers. Pushes and MRs auto-trigger the pipeline.
A v1 job carrying steps: job "build": job field steps is a GitHub Actions convention; Vetrix calls this field commands:
A v2 job (version: 2, top-level jobs:) carrying steps: job "build": unsupported key "steps": job field steps is a GitHub Actions convention; Vetrix v2 calls this field commands:

Strip the name:/on: header and the failure moves down to the jobs: key itself: under v1 a bare top-level jobs: map yields job "jobs": missing required field 'stage'. v2 does accept a top-level jobs: map — that is where the resemblance ends. Job bodies in both schemas run commands:, never steps:/uses:.


REST API

POST /api/v1/repos/{owner}/{repo}/artifacts/{id}/attestations

The request body:

{
  "kind":     "sbom",
  "format":   "cyclonedx-json",
  "payload":  "<base64 of the SBOM bytes>",
  "unsigned": true
}

Requires PermCIWrite on the repository. Returns 201 Created with the attestation record — shown here in the unsigned shape every shipped lane currently produces:

{
  "id":                 "…",
  "artifact_id":        "…",
  "kind":               "sbom",
  "format":             "cyclonedx-json",
  "predicate_type":     "https://cyclonedx.org/bom",
  "payload_cid":        "sha256:…",
  "payload_sha256":     "…",
  "payload_meta":       { "format": "cyclonedx-json" },
  "signature_required": false,
  "created_at":         "2026-04-19T00:00:00Z"
}

A signed row additionally carries signer_subject, the signing daemon id, and the signature bytes, and reports signature_required: true.

List attestations for an artifact:

GET /api/v1/repos/{owner}/{repo}/artifacts/{id}/attestations

Requires PermCIRead. Returns newest first.


Where SBOMs surface in the UI

Attestations are read on the pipeline detail page, in an attestations panel rendered below the coverage summary. There is no artifact detail page and no artifact-level SBOM tab in the shipped frontend.

The framing is pipeline-level but the data is per-artifact: the attestations collection is mounted under the artifact, not the pipeline. The panel takes only owner, repo and pipelineId, and fetches the artifacts itself from the pipeline's artifacts collection — GET /api/v1/repos/{owner}/{repo}/pipelines/{pipelineId}/artifacts — then issues one attestations list request per artifact that carries an id, and groups the returned rows under the artifact they belong to. That collection endpoint is the panel's only artifacts source: the pipeline-detail response does not populate the embedded stages[].jobs[].artifacts[] list, which is absent or empty on the wire, so flattening it yields nothing and any surface built on it renders permanently empty. A pipeline may carry several artifacts and an artifact several attestation rows in different formats; the panel renders every row and never collapses to "the" SBOM.

Each row shows:

  • a signed / unsigned badge derived from signature_required, with signer_subject surfaced for signed rows;
  • the format (rendered as "unknown format" when absent);
  • the truncated payload sha256, with a copy control for the full digest;
  • created_at as a relative time, with the absolute value on hover;
  • a download control that fetches the raw payload from GET /api/v1/repos/{owner}/{repo}/artifacts/{id}/attestations/{attestationId}/payload.

The panel has four mutually exclusive states — loading, error with a retry, an empty state, and the populated per-artifact list. An artifact with no id cannot be queried and is skipped; if every artifact is unqueryable or every query comes back empty, the panel shows the empty state rather than an error.


Signing status

The signing daemon is not deployed. No deployment manifest in the source tree runs it, and every shipped SBOM lane in both first-party repositories attaches with --sign=false, which sends "unsigned": true and persists signature_required: false.

An unsigned row carries:

  • signature_required: false
  • no signer_subject / signing_daemon_id
  • no signature bytes on the attestation row

The UI badges such a row unsigned so downstream verifiers do not treat it as trusted.

Requesting a signed attestation — sign: 'true' on the composite step, --sign=true on the CLI (the flag's own default), or "unsigned": false on the raw API — makes the control plane call the signing daemon before the row is written. With no daemon reachable, that call fails and the whole attach fails with it: an SBOM that would have been attached unsigned is instead not attached at all.

So, for the current state:

  • Use unsigned mode. It is what the shipped lanes do and the only mode that completes.
  • Unsigned mode is also the right choice on a dev/test runner group with no daemon by design, and when capturing an SBOM from a third-party scanner whose output is not yet trusted.

Preferring signed attestations for production pipelines is the intended end state once the daemon is deployed and the signing keys are provisioned; it is not the current default behaviour, and configuration written today should not assume it.


Large SBOMs

SBOMs for large images (Node monorepos, Python data-science images) routinely pass 10 MiB. Vetrix stores the payload in the content-addressable blob store, keyed by sha256(payload); only the CID, sha256 hex, and metadata land in the attestations row. There is no practical size cap for the attestation path itself — the runner image's memory limit is the only ceiling.


Verifying an SBOM

Signature verification applies only to signed rows, and therefore to none of the attestations produced today. Once signing is deployed, the stored envelope bytes are the exact bytes the signing daemon signed, and verification is:

cosign verify-attestation \
  --key vetrix.pub \
  --type https://cyclonedx.org/bom \
  <payload>

The JWKS endpoint supplies the public keys.

For an unsigned row, the only integrity check available is the recorded payload_sha256: download the payload and compare digests.


Follow-on integrations

Generating and attaching is all the SBOM path does. The following are tracked as later CI/CD phases:

  • govulncheck + trivy — vulnerability scanning driven off the stored SBOM. A focused vetrix/scan-sbom@<SHA> composite step is the planned Automations surface for it.
  • OPA policy gatevetrix/policy-gate@<SHA> consults the SBOM attestation for license/CVE/blocklist enforcement.
  • SBOM diff between releases — a "compared to previous release" view in the attestations panel, once artifact promotion history is wired through the UI.

Security notes

  • The signing daemon's Unix socket is reachable only from the Vetrix control plane, never from the job container — same threat model as the SLSA attestation step.
  • Raw payload bytes never appear in the attestations table; only the sha256 hex + CID. Audit log entries record actor + envelope digest, never the SBOM body itself.
  • SBOM content can leak dependency names — treat it like source code for ACL purposes. The List / Create endpoints both gate on repo-level CI permissions.