Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

### Added

- **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50).
- **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50).

### Security

Expand Down
4 changes: 2 additions & 2 deletions docs/build-provenance-depth.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ Nor does it assure the artifact was built from the source you think. A trusted b
|---|---|---|
| Surface | The artifact digest and a trusted-builder list | Nothing. It always runs. |
| Builder-chain | Attestation retrieval at verification time, or a cached bundle | A record whose `provenance_uri` is absent or unresolvable cannot be verified past surface |
| Dependency-chain | A publisher attestation per input, and ecosystem coverage for those inputs | Inputs from ecosystems without attestation coverage fail, benign ones included |
| Dependency-chain | A publisher attestation per input, and ecosystem coverage for those inputs | Inputs from ecosystems without attestation coverage cannot be verified past builder, benign ones included |

Deeper is not free, and the honest reason deployments stop early is usually not laziness. Dependency-chain verification fails closed on unattested inputs, and attestation coverage across package ecosystems is uneven, so a strict verifier rejects a lot of software that is fine.
Deeper is not free, and the honest reason deployments stop early is usually not laziness. An unattested input is unverifiable rather than incriminating, so [verification.md](verification.md) has the verifier record `builder` and name what it could not fetch; a deployment whose floor is `transitive` then treats that record as `contraindicated`. Attestation coverage across package ecosystems is uneven, so such a deployment turns away a lot of software that is fine — the strictness lives in the floor, not in a finding against the record.

That trade-off is a legitimate choice. Picking Surface because it was the cheapest to implement, without recording that "trusted builder" is then a claim nobody checked, is not the same choice.

Expand Down
46 changes: 40 additions & 6 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,33 @@ have walked. A verifier MUST record what it actually checked in
`appraisal.provenance_depth_verified`, which is a statement about the verifier, not about the
record.

| Claimed depth | Verifier MUST | Verifier MAY downgrade to |
|---|---|---|
| `surface` (or absent) | Confirm `digest` matches the workload artifact and `builder` resolves to the configured trusted-builder set. | Already the floor |
| `builder` | All of surface, plus fetch `provenance_uri`, verify the SLSA attestation signature, check the attestation `subject` matches `digest`, and check the attestation `builder.id` matches `builder`. | `surface`, when `provenance_uri` is unreachable or its signature does not resolve. The appraisal MUST then record the lower depth rather than a failure of the higher one. |
| `transitive` | All of builder, plus enumerate the SLSA `materials` / `resolvedDependencies` and confirm every entry has a verifiable publisher attestation (npm OIDC, PyPI Trusted Publisher, Sigstore Rekor entry, or platform equivalent). | `builder`, when transitive coverage is unavailable for any input, or `surface` per the row above. |
| Claimed depth | Verifier MUST | MAY downgrade to — evidence does not resolve | MUST fail — evidence resolves and contradicts |
|---|---|---|---|
| `surface` (or absent) | Confirm `digest` matches the workload artifact and `builder` resolves to the configured trusted-builder set. | Already the floor. | `digest` does not match the artifact the verifier independently holds, or `builder` is outside the trusted-builder set. |
| `builder` | All of surface, plus fetch `provenance_uri`, verify the SLSA attestation signature, check the attestation `subject` matches `digest`, and check the attestation `builder.id` matches `builder`. | `surface`, when `provenance_uri` is absent or unreachable, or its signature does not resolve. | The attestation resolves and its `subject` does not match `digest`, or its `builder.id` does not match `builder`. |
| `transitive` | All of builder, plus enumerate the SLSA `materials` / `resolvedDependencies` and confirm every entry has a verifiable publisher attestation (npm OIDC, PyPI Trusted Publisher, Sigstore Rekor entry, or platform equivalent). | `builder`, when an input carries no publisher attestation or the attestation declares no inputs at all; or `surface` per the row above. | An input's publisher attestation resolves and was signed under an issuer outside the configured trusted set. |

The two right-hand columns are disjoint, and which one applies turns on whether the evidence
resolved — not on how serious the finding is.

**Evidence that does not resolve** leaves a check unrun. The verifier MAY stop at the depth
below, MUST then record that lower depth in `appraisal.provenance_depth_verified`, and MUST
NOT report the missing evidence as a failure of the record. A record is not defective because
someone else's transparency log is unreachable, and a verifier that rejects on this is failing
records for the weather.

**Evidence that resolves and contradicts the record** MUST fail the appraisal. A verifier MUST
NOT downgrade to escape it. Downgrading there would record a narrower claim that is true while
suppressing a wider one that is false — the record would pass as `builder` on evidence that
positively refutes it at `transitive`, and the appraisal would say nothing about why.

A verifier MUST NOT record `provenance_depth_verified` at a depth higher than it executed.
Downgrading is how a verifier stays honest when evidence does not resolve; claiming depth it did
not run is what the field exists to prevent.
not run is what the field exists to prevent. This last rule cannot be expressed in JSON Schema:
the record is byte-identical whether the verifier walked the chain or merely says it did. The
conformance vectors in
[`examples/build-provenance-depth/`](https://github.com/agentrust-io/trace-spec/tree/main/examples/build-provenance-depth)
hold it instead, against a verifier's own output, and encode the split above vector by vector.

Records that omit `provenance_depth` MUST be treated as `surface`. This keeps every record issued
before this field existed valid and correctly interpreted.
Expand Down Expand Up @@ -176,6 +194,22 @@ attestation does not chain back to the legitimate maintainer. Without a recorded
conformant verifiers reach opposite conclusions on the same record and neither says why, which
is the federation gap [section 1](../spec/trace-v0.2.md) names.

That case is a failure and not a downgrade, and it is the sharpest reason the two are kept
apart. The poisoned input's attestation resolved: the verifier holds it and can see the issuer
is outside the trusted set. A verifier permitted to call that "transitive coverage unavailable"
would record `builder`, accept, and report exactly what a verifier that never looked reports —
which would make the depth field cover for the attack it was added to expose.

### `transitive` is a floor on effort, not a comparable claim

Until evidence resolution is standardized, two verifiers can both honestly record `transitive`
over different material sets. Nothing above specifies which inputs must be enumerated or where a
publisher attestation must be looked up, so the value states how far a verifier walked, not what
ground it covered. `builder` has no such gap, because `provenance_uri` names its own evidence.
A consumer comparing `transitive` across verifiers is therefore comparing effort; it does not
license the inference that the same dependencies were checked. Specifying a transitive coverage
URI is left to a follow-up.

[Build provenance depth](build-provenance-depth.md) is the informative companion to this section:
it states what each depth does not assure.

Expand Down
16 changes: 11 additions & 5 deletions examples/build-provenance-depth/01-all-depths-accept.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,21 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "accept",
"failures": []
"verified_depth": "transitive",
"failures": [],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,25 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "reject",
"verified_depth": "builder",
"failures": [
"attestation_subject_mismatch"
]
],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"attestation_subject_mismatch"
]
],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,25 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "reject",
"verified_depth": "builder",
"failures": [
"attestation_builder_mismatch"
]
],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"attestation_builder_mismatch"
]
],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "builder-chain-accepts-dependency-unattested",
"description": "Subject and builder both bind, so Builder-chain accepts. One resolved build input has no publisher attestation at all, which is the shape a compromised-dependency build presents: the top-level attestation is honest about a poisoned input.",
"name": "builder-accepts-dependency-unattested",
"description": "Subject and builder both bind, so `builder` accepts. One resolved build input has no publisher attestation at all, which is the shape a compromised-dependency build presents: the top-level attestation is honest about a poisoned input.",
"profile": "trace.build_provenance.depth.v0",
"context": {
"artifact_digest": "sha256:361044e7e99a17fef9f0d4d454286b1c888a28819ad0fb756d05deb0b1a272e8",
Expand Down Expand Up @@ -124,15 +124,21 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"outcome": "reject",
"failures": [
"transitive": {
"outcome": "accept",
"verified_depth": "builder",
"failures": [],
"unresolved": [
"dependency_attestation_missing"
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "builder-chain-accepts-dependency-publisher-untrusted",
"name": "builder-accepts-dependency-publisher-untrusted",
"description": "Every resolved input carries an attestation, so a verifier checking only for presence accepts. One was signed under an OIDC issuer the verifier does not trust, so the input's publisher is unverified rather than verified-and-wrong.",
"profile": "trace.build_provenance.depth.v0",
"context": {
Expand Down Expand Up @@ -31,21 +31,21 @@
},
"resolvedDependencies": [
{
"uri": "pkg:npm/%40example-org/agent-core@1.8.2",
"uri": "pkg:npm/%40example-org/telemetry@0.4.7",
"digest": {
"sha256": "fb6bfb2c7ffab3de12cbac5639014ae049aafb4dbb36390f251c82f63cf2d2cb"
"sha256": "3b6f7d63eeee3e1a792c0fa54e5dbb581ba3a651cfc20672fb6ca2af3fea0fb3"
}
},
{
"uri": "pkg:pypi/requests@2.32.3",
"uri": "pkg:npm/%40example-org/agent-core@1.8.2",
"digest": {
"sha256": "a30964f520316ef09eb682027001f6f142547f350fadaeef8be4bb1b48f6d49c"
"sha256": "fb6bfb2c7ffab3de12cbac5639014ae049aafb4dbb36390f251c82f63cf2d2cb"
}
},
{
"uri": "pkg:npm/%40example-org/telemetry@0.4.7",
"uri": "pkg:pypi/requests@2.32.3",
"digest": {
"sha256": "3b6f7d63eeee3e1a792c0fa54e5dbb581ba3a651cfc20672fb6ca2af3fea0fb3"
"sha256": "a30964f520316ef09eb682027001f6f142547f350fadaeef8be4bb1b48f6d49c"
}
}
]
Expand Down Expand Up @@ -149,17 +149,23 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"transitive": {
"outcome": "reject",
"verified_depth": "transitive",
"failures": [
"dependency_publisher_untrusted"
]
],
"unresolved": []
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "builder-chain-accepts-resolved-dependencies-absent",
"description": "The attestation binds subject and builder but declares no build inputs. A verifier that walks resolvedDependencies accepts vacuously: the loop has nothing to check. Dependency-chain assurance cannot be met by an attestation that enumerates nothing.",
"name": "builder-accepts-resolved-dependencies-absent",
"description": "The attestation binds subject and builder but declares no build inputs. A verifier that walks resolvedDependencies accepts vacuously: the loop has nothing to check. `transitive` assurance cannot be met by an attestation that enumerates nothing, so the evidence never resolves and the verifier records `builder`.",
"profile": "trace.build_provenance.depth.v0",
"context": {
"artifact_digest": "sha256:361044e7e99a17fef9f0d4d454286b1c888a28819ad0fb756d05deb0b1a272e8",
Expand Down Expand Up @@ -53,15 +53,21 @@
"expected": {
"surface": {
"outcome": "accept",
"failures": []
"verified_depth": "surface",
"failures": [],
"unresolved": []
},
"builder_chain": {
"builder": {
"outcome": "accept",
"failures": []
"verified_depth": "builder",
"failures": [],
"unresolved": []
},
"dependency_chain": {
"outcome": "reject",
"failures": [
"transitive": {
"outcome": "accept",
"verified_depth": "builder",
"failures": [],
"unresolved": [
"resolved_dependencies_absent"
]
}
Expand Down
Loading