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.
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.
| Check | Passes when |
|---|---|
in_toto_envelope | _type is https://in-toto.io/Statement/v1 and subject is a non-empty array |
predicate_type | predicateType is https://slsa.dev/provenance/v1 or https://slsa.dev/provenance/v0.2 |
builder_identity | predicate.runDetails.builder.id is in the trusted list or in custom_trusted_builders |
artifact_digest | a subject entry carries the same sha256 digest the request is attesting |
source_digest_chain | a resolved dependency with a git+ URI carries a pinned sha256, sha1 or gitCommit digest |
sigstore_tlog | the bundle's first transparency-log entry has an inclusion proof or an inclusion promise |
build_freshness | predicate.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_indexis parsed from the bundle's first transparency-log entry and stored.sigstore_log_idis thelogId.keyIdfrom that entry, stored as submitted.rekor_entry_uuidis a column on the table that is always written null.- The
sigstore_tlogcheck requires aninclusionProof.rootHashor aninclusionPromise.signedEntryTimestampto 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.componentsand the storedsbom_formatis hardcoded tocyclonedx. An SPDX document yields either 422SBOM contains no componentsor rows of misparsed values, so treat SPDX as unsupported whatever the column name suggests. - Digest. SHA-256 over
sbom_rawwhen you send it, otherwise over the re-serializedsbomobject. Sendsbom_rawwhen the digest has to match a file byte for byte. - Signing.
signatureandsigner_identityare stored exactly as submitted, andsigned_atis stamped when a signature is present. The platform neither produces a signature nor verifies the one you send. - Linking.
provenance_idis 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_totalandtruncatedso 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.jsonattest. 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.
| Status | Body | Cause |
|---|---|---|
| 400 | Missing 'statement' (in-toto Statement v1) | attest without a statement |
| 400 | Statement subject missing sha256 digest | the first subject carries no sha256 |
| 400 | Missing 'sbom' (CycloneDX JSON) | ingest_sbom without a document |
| 400 | Missing x-pb-tenant-id header | API-key call without the tenant header |
| 400 | Unknown action. Expected: attest, verify, ingest_sbom, list | any other action |
| 401 | Missing authorization | neither an API key nor a bearer token |
| 403 | x-pb-reason: missing-scope | the key lacks the supply_chain scope |
| 403 | code: plan_required | the tenant is below Enterprise |
| 404 | Attestation not found | verify matched no row for this tenant |
| 422 | SBOM contains no components | the 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.