Supply Chain Provenance (SLSA/Sigstore)

SLSA scoring of in-toto build statements, Sigstore bundle fields recorded as submitted, and CycloneDX SBOM ingestion, on the Enterprise plan.

Last reviewed September 16, 2026Fresh

Overview

Supply chain provenance records who built an artifact, from which source, and what a Sigstore bundle says about it, then scores that record against SLSA. It exists to answer one question at deploy time: is this image the output of the build I think it is?

This is a separate feature from Provenance Guard, which verifies physical devices. The two share a word and nothing else.

Where it sits. shield-supply-chain verifies and stores. shield_build_provenance holds one row per attested artifact and shield_sbom_entries one row per SBOM component. Dashboard, Security, Supply chain reads both.

Who can use it. The Enterprise plan. Authenticate with an API key carrying the supply_chain scope plus an x-pb-tenant-id header, or with a dashboard session, in which case the tenant resolves to your oldest membership. Key-authenticated calls count against the per-tenant quota.

SLSA levels and the checks behind them

attest runs seven checks over the submitted in-toto statement and awards a level from the results. Levels 0 to 4 are possible. The stored slsa_level is what the checks earned, while verified is true only when no check produced an error.

CheckPasses when
in_toto_envelope_type is https://in-toto.io/Statement/v1 and subject is a non-empty array
predicate_typepredicateType is https://slsa.dev/provenance/v1 or https://slsa.dev/provenance/v0.2
builder_identitypredicate.runDetails.builder.id is in the trusted list or in custom_trusted_builders
artifact_digesta subject entry carries the same sha256 digest the request is attesting
source_digest_chaina resolved dependency with a git+ URI carries a pinned sha256, sha1 or gitCommit digest
sigstore_tlogthe bundle's first transparency-log entry has an inclusion proof or an inclusion promise
build_freshnesspredicate.runDetails.metadata.startedOn is within 72 hours

The levels stack: a valid envelope and predicate give 1, a pinned source digest gives 2, a trusted builder with a matching artifact digest gives 3, and a passing transparency-log check on a submitted bundle gives 4.

The trusted list holds five slsa-github-generator reusable workflows, https://cloudbuild.googleapis.com/GoogleHostedWorker and https://tekton.dev/chains/v2. Add your own with custom_trusted_builders on the request.

A Sigstore bundle is required by default, so an attest without one is stored with verified: false and the error Sigstore bundle required but not provided, even when every other check passed. The row is still written and still carries the level the other checks earned.

Transparency log fields

What the platform does with a submitted Sigstore bundle is read it, not query it. Nothing in this build makes an outbound call to Rekor.

  • sigstore_log_index is parsed from the bundle's first transparency-log entry and stored.
  • sigstore_log_id is the logId.keyId from that entry, stored as submitted.
  • rekor_entry_uuid is a column on the table that is always written null.
  • The sigstore_tlog check requires an inclusionProof.rootHash or an inclusionPromise.signedEntryTimestamp to be present in what you submitted. Its contents are never compared against the log.

That is enough to record which log entry an artifact claims, so a verifier can look the entry up at Rekor independently. It stops short of being proof the entry exists, because nothing here fetched it.

Post-quantum countersignature status

No attestation in this build carries a post-quantum countersignature. shield_build_provenance has pqc_algorithm, pqc_signature and pqc_key_fingerprint columns, reserved when the table was created. attest writes none of them and the function never loads the signing module. list selects pqc_algorithm and the dashboard renders a badge when it is set, which no shipped code path sets.

Hybrid ML-DSA-65 signing is real elsewhere on the platform: chain checkpoints, entropy receipts, credential proofs, webhook boundary proofs and A2A attestations all carry it, over a classical ES256 or ES384 signature. Security and cryptography and the PQC migration guide describe those paths. Nothing connects them to supply chain records today.

SBOM ingestion

ingest_sbom takes a CycloneDX JSON document, computes its digest, flattens its components and stores one row per component.

  • Format. CycloneDX only. The parser reads sbom.components and the stored sbom_format is hardcoded to cyclonedx. An SPDX document yields either 422 SBOM contains no components or rows of misparsed values, so treat SPDX as unsupported whatever the column name suggests.
  • Digest. SHA-256 over sbom_raw when you send it, otherwise over the re-serialized sbom object. Send sbom_raw when the digest has to match a file byte for byte.
  • Signing. signature and signer_identity are stored exactly as submitted, and signed_at is stamped when a signature is present. The platform neither produces a signature nor verifies the one you send.
  • Linking. provenance_id is stored as given and is never checked against an existing attestation, so a wrong id silently orphans the rows.
  • Cap. The first 500 components are stored, and the response reports components_total and truncated so you can tell when it clipped.

Stored per component: name, version, type, package URL and the first SPDX license id. The vulnerability counters (vuln_critical, vuln_high, vuln_medium, vuln_low), last_vuln_scan_at and license_risk exist on the table and keep their defaults, because no scanner writes them in this build. That is why the dashboard's critical-vulnerability tile reads 0 for every tenant.

What this gives you is a digest-addressable component inventory, signed by you, sitting alongside the build record: the kind of SBOM delivery EO 14028 asks agencies to require. Producing and checking that signature remains work on your side.

Supply chain tab

Dashboard, Security, Supply chain is read-only. It queries both tables directly under row-level security, which admits any tenant member for reads, and it has no submit form: attestations and SBOMs arrive only through the API.

  • Tiles: verified count against total attestations, how many reached SLSA L3 or higher, distinct components, and critical vulnerabilities with the high count as a caption.
  • Build attestations lists the 50 newest rows, each with the artifact name, an SLSA badge, the transparency-log index when one was stored, a PQC algorithm badge when that column is set, the last two path segments of the source repo with a short ref, and the relative time.
  • Expanding a row reveals the builder id, the build type, the full artifact digest and any verification_errors.
  • SBOM inventory is a collapsed section listing up to 100 components ordered by critical vulnerability count, with version, type, the four severity counters, license and whether a signature was stored.

A newly attested artifact appears after Refresh. An artifact that answered duplicate: true writes no new row, so the list will not grow.

Request and response

One endpoint, four actions.

curl -X POST https://api.passkeybridge.io/v1/shield-supply-chain \
  -H "x-pb-api-key: pb_live_..." \
  -H "x-pb-tenant-id: <tenant-uuid>" \
  -H "Content-Type: application/json" \
  -d @attest.json

attest. statement is the whole in-toto Statement v1 and is the only required field. The artifact digest is read from statement.subject[0].digest.sha256 and the source digest from the first resolved dependency with a git+ URI, so neither is passed separately.

{
  "action": "attest",
  "statement": { "_type": "https://in-toto.io/Statement/v1", "subject": [], "predicateType": "https://slsa.dev/provenance/v1", "predicate": {} },
  "sigstore_bundle": {},
  "artifact_name": "api-gateway",
  "artifact_type": "container-image",
  "source_repo": "github.com/acme/api-gateway",
  "source_ref": "refs/tags/v1.4.0",
  "custom_trusted_builders": []
}
{
  "id": "uuid",
  "verified": true,
  "slsa_level": 4,
  "checks": [{ "name": "in_toto_envelope", "passed": true, "detail": "Valid in-toto Statement v1" }],
  "errors": [],
  "latency_ms": 31
}

Re-attesting the same artifact and source digest pair answers 200 with {"duplicate": true, "existing_id": "uuid"} and writes nothing.

verify. Takes provenance_id or artifact_digest and returns the stored row: verified, slsa_level, builder_id, source_repo, artifact_name, artifact_digest, verification_errors, verified_at and created_at. It reads the earlier verdict back and runs no check again, so it can never change a stored result.

ingest_sbom. Takes sbom, and optionally sbom_raw, provenance_id, signer_identity and signature. Returns sbom_digest, components_ingested, components_total and truncated.

list. Takes limit (default 25, capped at 100), offset and a source_repo substring filter. That filter is the only one; there is no filtering by builder, artifact type or verification status.

StatusBodyCause
400Missing 'statement' (in-toto Statement v1)attest without a statement
400Statement subject missing sha256 digestthe first subject carries no sha256
400Missing 'sbom' (CycloneDX JSON)ingest_sbom without a document
400Missing x-pb-tenant-id headerAPI-key call without the tenant header
400Unknown action. Expected: attest, verify, ingest_sbom, listany other action
401Missing authorizationneither an API key nor a bearer token
403x-pb-reason: missing-scopethe key lacks the supply_chain scope
403code: plan_requiredthe tenant is below Enterprise
404Attestation not foundverify matched no row for this tenant
422SBOM contains no componentsthe CycloneDX document had an empty or missing components array

attest and ingest_sbom write audit entries, supply_chain.attest and supply_chain.ingest_sbom. verify and list write none.

Related from the blog