SLSA levels & provenance

The Build track, L1–L3, and non-falsifiable provenance.

Advanced35 min · lesson 2 of 15

Through the first half of 2020, someone was sitting quietly inside SolarWinds' build system. They watched their own backdoor get compiled into the Orion product, signed with SolarWinds' real code-signing certificate, and pushed out as a routine update that reached as many as 18,000 organizations. Nobody spotted it until that December. Every signature validated, because every signature was genuine. A signature works like the tamper seal on a bottle of pills: it proves the bottle came from that factory and that nobody opened it on the way to you. It proves nothing about what the factory put inside. Closing that gap is the entire reason SLSA (Supply-chain Levels for Software Artifacts, said out loud as 'salsa') exists. SLSA is a framework for producing and checking provenance, a signed, machine-readable record of how an artifact was built, sorted into levels that grade exactly one thing: how hard that record is to fake. In this lesson you take a real provenance file apart field by field, then verify it the way a deploy gate would.

The Build track: three rungs, each one you can test

SLSA v1.0 splits assurance into tracks, and the Build track is the one that shipped. Every level is written so a verifier can test it mechanically, the way a health inspector checks that the fridge has a working thermometer instead of asking the chef whether the food is good. Level 0 means no guarantees, a starting point rather than a target. Level 1 asks that provenance exist at all: the build emits a machine-readable record of its inputs and its process, and that record travels alongside the artifact so a consumer can read where it claims to come from. L1 is honest paperwork. Nothing yet stops the build from writing fiction about itself. Level 2 adds a hosted build platform plus provenance signed by that platform, so the record becomes authenticated and tamper-evident and a forger cannot rewrite it afterwards without breaking the signature. At L2 the build job and the thing signing its provenance can still sit inside the same trust boundary. Level 3 shuts that door: the build has to be isolated and the provenance non-falsifiable. 'Non-falsifiable' carries a precise meaning here. The record is generated by the platform's control plane, outside the reach of the tenant's build, using signing material the build steps cannot read. A malicious build at L3 can still produce malware. What it cannot do is write a clean record of where that malware came from. That single property is why L3, and not L2, is the level that means something for security.

Which Build level does this artifact meet?
SLSA Build track: one checkable requirement per level
each rung is a testable claim about the provenance, not a claim about the code's quality
L1
Provenance exists
a machine-readable record of inputs and process ships with the artifact, though the build itself could have written it
L2
Hosted and signed
built on a hosted platform, provenance signed by that platform so tampering shows up, yet the build can still sway what gets signed
L3
Isolated and non-falsifiable
provenance produced by the control plane, outside the build's reach, with signing material the build cannot read, so it cannot fake its own record
L3 is the rung where 'how it was built' becomes a fact a verifier can rely on instead of a claim the build itself could have faked.
the Sigstore bundle gh attestation download wrote to disk (application/vnd.dev.sigstore.bundle.v0.3+json)
{
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"verificationMaterial": {
"certificate": {
"rawBytes": "MIIC0jCCAlqgAwIBAgIU...Fulcio short-lived signing cert (DER)...c8vQ=="
},
"tlogEntries": [
{
"logIndex": "133027319",
"logId": { "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" },
"kindVersion": { "kind": "dsse", "version": "0.0.1" },
"integratedTime": "1718040000",
"inclusionProof": {
"logIndex": "88274901",
"rootHash": "Rtf5b1kQ8mS0pZ9c4uJd2wYv6nQh3aT1oX7rE0iG9lU=",
"treeSize": "88274902",
"hashes": [
"b3a1d0f8e2c47a6b90112d3c4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f50617",
"9f8e7d6c5b4a39281706f5e4d3c2b1a0ffeeddccbbaa99887766554433221100"
],
"checkpoint": {
"envelope": "rekor.sigstore.dev - 1193050959916656506 88274902 Rtf5b1kQ8mS0... - rekor.sigstore.dev wNI9ag== MEUCI...=="
}
}
}
]
},
"dsseEnvelope": {
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0Ijpbey4uLn1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwicHJlZGljYXRlIjp7Li4ufX0=",
"signatures": [
{ "keyid": "", "sig": "MEUCIQDf3xk2Yb9Qp1r7t...base64-ecdsa-p256-signature...c8vQ==" }
]
}
}

GitHub does not hand you bare provenance. What gh attestation download writes to disk is a Sigstore bundle, and the provenance sits inside its dsseEnvelope. DSSE stands for Dead Simple Signing Envelope, the wrapper Sigstore and in-toto use whenever they sign structured data. The envelope has three parts: payloadType (here application/vnd.in-toto+json), the base64-encoded payload, and one or more signatures, each holding a sig and an optional keyid. The subtle bit is what actually gets signed. DSSE does not sign the raw payload bytes. It signs the PAE (Pre-Authentication Encoding): the ASCII text DSSEv1, then the length and value of the payload type, then the length and value of the payload. Binding the type into the signature is deliberate. Without it, an attacker could take a validly signed payload and talk a verifier into reading it as a different kind of document, the way a signed cheque could be passed off as a signed contract if the pen stroke covered only the words and never the form they sat on. The signer's identity and the log receipt live one level up, in verificationMaterial. certificate.rawBytes is the short-lived certificate issued by Fulcio, Sigstore's certificate authority, which mints certificates that last minutes and name the workflow doing the signing. tlogEntries[].inclusionProof is the receipt from Rekor, Sigstore's public append-only transparency log. That inclusion proof is a Merkle audit path. A Merkle tree hashes records in pairs, then hashes those hashes, level after level, until one value at the top stands for everything in the log. The proof gives you the sibling hashes running from your entry up to that top value, plus a signed checkpoint stating the root hash and the tree size. Recompute the root from the leaf and the audit path, match it against the signed checkpoint, and you have proof the entry is committed to an append-only log without downloading the log. Decode the base64 payload and out comes an in-toto Statement, the standard wrapper that names the subject and carries a typed predicate.

pull the DSSE payload out of the bundle and decode its in-toto Statement
# 'gh attestation download api_linux_amd64 -R acme/api' fetched this bundle
jq -r '.dsseEnvelope.payload' bundle.jsonl | base64 -d | jq .
output — in-toto Statement wrapping a SLSA v1.0 predicate
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "api_linux_amd64",
"digest": { "sha256": "3f7c1e0a9b2d4c6e8f01a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7" }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://actions.github.io/buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/tags/v1.4.0",
"repository": "https://github.com/acme/api",
"path": ".github/workflows/release.yml"
}
},
"internalParameters": {
"github": {
"event_name": "push",
"repository_id": "492387411",
"repository_owner_id": "18133731",
"runner_environment": "github-hosted"
}
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/acme/api@refs/tags/v1.4.0",
"digest": { "gitCommit": "8cbf4d422367d8499d5980a837cb9cc8e1e67001" }
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/acme/ci/.github/workflows/build-and-attest.yml@refs/tags/v3.2.0"
},
"metadata": {
"invocationId": "https://github.com/acme/api/actions/runs/7891234567/attempts/1"
}
}
}
}

The predicate is where SLSA lives. predicateType is https://slsa.dev/provenance/v1, and the body splits into buildDefinition and runDetails. buildDefinition.buildType is a URI (uniform resource identifier, a name shaped like a web address), here https://actions.github.io/buildtypes/workflow/v1, and it tells a verifier exactly how to read the parameters that follow. It is the schema contract for a GitHub Actions workflow build. externalParameters.workflow records the inputs the tenant asked for: the workflow file, the repository, and the git ref that were requested. internalParameters.github carries the claims the build never got to choose, event_name, repository_id, repository_owner_id and runner_environment, lifted straight out of GitHub's OIDC token (OpenID Connect, the standard GitHub uses to issue short-lived identity tokens). resolvedDependencies pins what really went in, by digest: the source git+https://github.com/acme/api resolved to the exact commit 8cbf4d…, not to a tag somebody can move next week. runDetails.builder.id is the single field that carries the most weight at verification time. It is the workflow identity GitHub's OIDC provider attests actually ran and signed, taken from the job_workflow_ref claim, here acme/ci's pinned reusable build-and-attest.yml@refs/tags/v3.2.0, a trusted workflow living in a different repository from acme/api's own code. Committers on api cannot edit that pinned reusable workflow and never lay hands on the signing material, because GitHub's control plane mints the OIDC token and drives the Fulcio signing outside the reach of the build steps. That is what makes the record non-falsifiable, the property separating L3 from L2. metadata.invocationId points back at the exact run for audit. And the subject is a sha256 digest rather than a tag, so the claim is welded to these exact bytes and cannot be transplanted onto a lookalike artifact later.

Verifying it: what a passing gh attestation verify really proves

Producing provenance is half the work. Somebody downstream has to check it before trusting the artifact, and gh attestation verify is GitHub's reference checker for these attestations. Point it at the artifact and, this is the part people skip, at the repository and the signer workflow you expect. Under the hood it computes the artifact's sha256, fetches the matching attestation from GitHub's attestations API (scoped by --repo or --owner, or read from disk with --bundle), checks the DSSE signature against the embedded Fulcio certificate, confirms that certificate chains up to the trusted root, and confirms the Rekor inclusion proof commits the entry to the transparency log. Then it enforces policy. The certificate identity has to match --signer-workflow, the source repository has to match --repo, the predicate type defaults to https://slsa.dev/provenance/v1, and the OIDC issuer defaults to https://token.actions.githubusercontent.com. Only when all of that holds does it print a success line. The --signer-workflow argument is not decoration. It is you stating a rule out loud: I will trust this artifact only if acme/ci's pinned build workflow signed it. The price is one signature check plus a transparency-log proof, cheap enough to run in the release job and again at the admission gate on every deploy. Change the expectation and watch it fail closed.

verify the artifact against its attestation, pinning the trusted signer workflow
gh attestation verify api_linux_amd64 \
--repo acme/api \
--signer-workflow acme/ci/.github/workflows/build-and-attest.yml
output — signature, transparency-log proof, source repo and signer workflow all check out
Loaded digest sha256:3f7c1e0a…c4d5e6f7 for file://api_linux_amd64
Loaded 1 attestation from GitHub API
The following policy criteria will be enforced:
- Predicate type must match:................. https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:.... https://github.com/acme
- Source Repository URI must match:.......... https://github.com/acme/api
- Subject Alternative Name must match regex:. (?i)^https://github.com/acme/ci/.github/workflows/build-and-attest.yml@.*$
✓ Verification succeeded!
The following attestation matched the policy criteria
- Attestation #1
- Build repo:....... acme/api
- Build workflow:... .github/workflows/release.yml@refs/tags/v1.4.0
- Signer repo:...... acme/ci
- Signer workflow:.. .github/workflows/build-and-attest.yml@refs/tags/v3.2.0

Now the attack. An adversary publishes a malicious build of your tool from their own fork, carrying perfectly valid SLSA v1.0 provenance. Their build genuinely ran on GitHub, so the DSSE signature, the Fulcio certificate and the Rekor entry all check out cleanly. The provenance is not lying to you. It faithfully records the attacker's repository and the attacker's workflow as the signer. Verification is what turns that honesty against them. Scope the check to the repository and signer workflow you actually trust, and those bytes were never attested under acme/api by acme/ci's workflow, so GitHub returns no matching attestation and the gate refuses the artifact. Nothing about the cryptography broke. The claim points at a place you never agreed to trust.

pin YOUR repo and signer, and the attacker's artifact fails closed
$ gh attestation verify api_linux_amd64 \
--repo acme/api \
--signer-workflow acme/ci/.github/workflows/build-and-attest.yml
Loaded digest sha256:9f2a6b1c…6d7e8f90 for file://api_linux_amd64
✗ No attestations found for subject sha256:9f2a6b1c…6d7e8f90
# exit status 1 → the deploy gate blocks the artifact
An unpinned pass proves 'built somewhere on GitHub', nothing narrower
gh attestation verify makes --owner or --repo mandatory, but it never forces you to supply --signer-workflow. Scoping to --repo acme/api only says the attestation is associated with that repository. Without --signer-workflow (or --cert-identity), a success tells you that some workflow under that owner signed the artifact through GitHub's OIDC, and nothing at all about whether it was acme/ci's pinned build-and-attest.yml at the version you intended. Pin --signer-workflow, or pin --cert-identity together with --cert-oidc-issuer https://token.actions.githubusercontent.com, and treat an unpinned verify as informational, never as a deploy gate.
Quick check
01A build runs on a hosted CI (continuous integration) platform and signs its own provenance with a signing key the build job reads from an environment secret. What is the highest SLSA Build level it can honestly claim?
Incorrect — L3 also demands that the provenance be produced and signed outside the build's control, and a key the job can read fails that.
Correct — The build can reach the signing material, so a compromised build could mint a clean record of a dirty build. The honest ceiling is L2.
Incorrect — It runs on a hosted platform and the provenance is signed by that platform, which already clears L2's bar.
Incorrect — L2's requirements, a hosted platform and signed provenance, are met. The exposed key caps the artifact below L3.
02The provenance bundle carries a Rekor inclusionProof: a set of sibling hashes plus a signed checkpoint giving a root hash and a tree size. What does checking that proof establish, and how?
Incorrect — The payload is not encrypted, and an inclusion proof is about membership in the log, not decryption.
Incorrect — Rekor is an append-only transparency log, not a vulnerability database, and it says nothing about CVEs.
Incorrect — The checkpoint is signed by the log itself and names no human approver.
Correct — That leaf-to-root recomputation checked against the signed checkpoint is exactly the Merkle audit path this lesson walks through.
03An attacker builds a malicious copy of your tool on their own GitHub fork. Their build genuinely ran on GitHub Actions, so the DSSE signature, Fulcio certificate and Rekor entry all verify cleanly. You run gh attestation verify <artifact> --repo acme/api --signer-workflow acme/ci/.github/workflows/build-and-attest.yml. What happens?
Correct — The provenance is honest and it records the attacker's repo, so scoping to your repo and signer finds no match and the gate refuses the artifact.
Incorrect — Valid cryptography is not the gate. The identity and repo scope have to match, and here they do not.
Incorrect — There is no succeed-with-a-warning path. A subject that matches nothing in your scope produces no matching attestation and exit status 1.
Incorrect — The attacker's artifact has a completely different digest, and Rekor lookups do not hang on name collisions.

One caveat runs through every level: provenance is only as trustworthy as the build that emitted it. L3's non-falsifiability assumes the build platform itself is sound and that every input to the build is known. Miss that second half and you get a faithful record of a build nobody can reason about. Earning that assumption is what the next lesson is for: hermetic, reproducible builds that declare every input up front, so the 'how it was built' captured in these fields is something an independent party can rebuild and confirm byte for byte.

Try this

Work through “Verifying it: what a passing gh attestation verify really proves” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: an unpinned pass proves 'built somewhere on GitHub', nothing narrower. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related