How it works¶
This page follows one finding from the pipeline to the PR comment, using the cve-origin
scenario of the live demo. The demo data is synthetic (PR #42,
dev@example.com); the same code path runs on the real repositories in the
Evaluation.

1. Every stage emits a signed event¶
Each CI stage writes one stage event and wraps it in a DSSE envelope:
| Stage | Producer | Payload |
|---|---|---|
commit |
tracegate lineage REPO MANIFEST |
the pins the commit introduced or changed, its PR number and author |
build |
tracegate ingest --syft |
the unchanged Syft (or CycloneDX) SBOM: packages, layers, image digest |
scan |
tracegate ingest --trivy / --sarif |
the unchanged Trivy or SARIF findings |
deploy, runtime |
your deploy job | which image runs as which service, and which modules containers loaded |
Envelopes are signed with Ed25519, HMAC, or (in CI) an ephemeral Ed25519 key whose public key is itself signed keylessly with Sigstore, so the gate can check that the key came from a specific GitHub workflow (Fulcio certificate plus Rekor transparency-log entry).
2. The collector verifies and fails closed¶
collector.py verifies every envelope against the configured trust root. An envelope with a
bad signature or an unknown keyid is rejected, and a run missing any of the required
commit, build and scan stages blocks with a provenance_integrity reason. With no trust
root configured at all, the CLI exits 2 and the API answers 503: nothing is trusted by
default.
3. The events become one content-addressed graph¶
Verified payloads are merged into a DAG:
commit -introduced-> dependency -installed_in-> layer -layer_of-> image -> deployment -> container.
Dependencies are keyed by a canonical purl, so the manifest pin, the Syft package and the Trivy
finding for pyyaml==5.3 become one node, and a base layer shared by three images is one
node too. In the screenshot the three image nodes share layers and builds with pyyaml==5.3.
4. Enrichment adds risk and reachability¶
- OSV and Warden.
OsvIndexmatches pins against the offline OSV dump, including malicious-package (MAL-*) records, and the typosquat detector scores names against the ecosystem's most-downloaded packages. - Reachability. Runtime facts win: here two containers report that module
yamlwas loaded, so the finding is reachable and keeps itsblock. Without runtime facts, a static pass over the app's imports, entrypoints and# viaedges can mark a packageunreached, which downgradesblocktowarnbut never hides the finding (see the threat model for why this is advisory on untrusted PRs).
5. The policy explains itself with a graph path¶
policy.py (mirrored in policies/tracegate.rego, parity-checked in CI) turns the enriched
graph into a verdict. Every reason carries the path that produced it and its evidence. This is
the PR comment tracegate gate --comment prints for the scenario:
## TRACEGATE: BLOCK
- **[block] vulnerable_dependency**: CVE-2020-14343
- path: `commit:a1b2c3d4e5f6 -> dependency:pyyaml==5.3`
- evidence: module 'yaml' loaded in ['api-7d9f-0', 'worker-7d9f-0']
The exit code is 1 on block, so the CI job fails.
6. Backtrack and blast radius¶
tracegate backtrack events.json CVE-2020-14343 walks the graph backwards to the commit that
introduced the vulnerable version and forwards to everything that inherits it:
{
"artifact": "pyyaml==5.3",
"introduced_by": {"sha": "a1b2c3d4e5f6...", "author": "dev@example.com", "pr": 42},
"builds": ["run-42-api", "run-42-web", "run-42-worker"],
"blast_radius": {"images": ["ghcr.io/demo/api", "ghcr.io/demo/web", "ghcr.io/demo/worker"],
"services": ["api", "web", "worker"]}
}
The introducing commit is found by version-aware diffing of the lock-file history (which pin
changed version in which first-parent commit), not by line blame, so a later commit that only
re-formats or re-hashes the line does not take the credit. A CVE disclosed months after the
merge still gets its origin story. The Evaluation measures this against
git blame, two git log -S pickaxes and commit-message labels on real repositories.
7. Version diff vs line blame¶
This is the part that is new. A lock file is a list of (package, version) entries, and many
commits rewrite its lines without changing any version: a package-manager upgrade, a format
migration, a merge that re-sorts entries, another package's bump that moves a shared line.
Line attribution (git blame, or git log -S on the pin line) credits whoever last wrote the
line. TRACEGATE instead diffs the set of (package, version) pairs between consecutive
first-parent commits and credits the commit where the pair appeared, keeping the newest such
commit if a version was removed and re-added.
flowchart LR
A["Dependabot PR 14636 bumps browserify-sign 4.2.0 to 4.2.1<br/>yarn v1 entry, version 4.2.1"]
B["757d7c73c0 Upgrade to Yarn 4 (2023)<br/>same entry rewritten in Yarn 4 syntax, version 4.2.1"]
S["later snapshot: which commit introduced browserify-sign 4.2.1?"]
A -->|"lock file rewritten, no version change"| B
S -.->|"git blame / pickaxe on the line"| B
S ==>|"version diff: the pair browserify-sign@4.2.1 first appears here"| A
Two real cases from the benchmark repositories:
- mastodon
757d7c73c0"Upgrade to Yarn 4, remove support for Node 16 (#27073)" rewroteyarn.lock(+17,813 / -12,955 lines) without changing the versions it pins. For packages bumped before it,git blameat later snapshots names the Yarn-4 commit: in the bot-bump oracle that happens for browserify-sign 4.2.1 (Dependabot #14636), cssnano 6.0.1, yargs 17.7.2, mixin-deep 1.3.2 and the webpack-cli 3.3.12 revert. The version diff keeps the original bump (results/lineage_bot_bump_oracle.json,misses). - bat
ansi_term 0.11.0has been inCargo.locksince the initial commit8f5a80e, next toansi_term 0.10.2. Commit5b421b4"Update dependencies" removed 0.10.2. The old one-version-per-name readers saw the first entry switch from 0.10.2 to 0.11.0 there and credited5b421b4, which is wrong;git blamewas right. Since the readers keep every version, the version diff credits8f5a80eas well.
The second case is the price of the approach: it is only as good as the lock-file reader. The
Evaluation reports both readers on the same data and checks every attribution
against git blame, a package-specific git log -S and commit-message labels.
8. Admission¶
The kind-admission CI workflow shows the last step: two images are built and pushed to a
localhost registry, only one is signed keylessly, and each is applied to a kind cluster only if
cosign verify accepts it against the workflow identity and tracegate gate passes on its
signed events. The signed image runs; the unsigned one is denied by cosign; a third image that is
validly signed but whose signed provenance lacks the build and scan stages is denied by the
TRACEGATE gate. Each reason is recorded in decisions.json.