Supply-Chain Scan
The supply-chain formation is a nightly, working-tree dependency-advisory scan across every managed project. It is advisory and never fails a project run. Its remediation engine is gated off by default; when explicitly enabled, it may apply verified dependency fixes and commit them locally without pushing.
Why it is its own formation
A supply-chain advisory is an external, time-triggered fact. It appears because the world changed — a CVE was published against a dependency — not because the project's own code regressed. It can go red on a repo with zero diff landed.
That makes it categorically different from a quality gate, which answers "is my code correct?" and fails because of your change. Cramming a time-triggered, externally-owned advisory check into the change-triggered, blocking preflight gate set has three failure modes:
- it aborts preflight, killing the very maintain step that could bump the dep;
- it has no memory — it re-discovers and re-fails the same CVE every night;
- it conflates "my code regressed" with "an advisory dropped" under one red checkmark, so a project with perfect code reads as failed.
The release-tag audit action (ReleaseTagAudited) already handles supply-chain
at release time. This formation is the missing nightly working-tree lane:
it scans what is checked out now, on a schedule, independent of whether code
changed.
The chain
nightly-supply-chain sentinel → SupplyChainScanStarted
→ ScanSupplyChain → SupplyChainScanned
→ RemediateSupplyChain → SupplyChainRemediated
→ WriteSupplyChainDigest → SupplyChainScanCompleted
ScanSupplyChainiterates every active registry project, runs the stack's audit tool (cargo audit,npm audit,mix deps.audit,osv-scanneron Swift'sPackage.resolved) against the working-tree lockfile, classifies each advisory against that repo's committed allowlist, and emitsSupplyChainScanned. Each finding carries a fix version when the audit tool reports one and, where npm supplies it, the direct fix package whose upgrade removes a vulnerable transitive package. Python is the exception to "global tool":pip-auditis a project dependency, so it is run from the project's own.venv/bin/pip-audit— a repo that hasn't installed it reports cleanly under "Not scanned" rather than relying on a global PATH. Kotlin is the other exception: Foundry runs the project's own./gradlew dependencyCheckAggregateand reads the JSON report it writes, so the project's suppression file decides what counts. Dependency-Check names no fix version, so every Kotlin finding is a policy call.- Every ecosystem in the repository is audited, not only the registered
stack. Foundry walks the repository (to a depth of four, skipping
target/,deps/,_build/,node_modules/,build/,dist/, fixtures and vendored trees) and audits each lockfile it finds with that ecosystem's tool:Cargo.lockwithcargo audit,package-lock.jsonwithnpm audit, a textbun.lock,yarn.lockorpnpm-lock.yamlwithosv-scanner,uv.lockwith the project'spip-audit,Package.resolvedwithosv-scanner, andgradlewwith Dependency-Check. Mix projects are found as before. A binarybun.lockbis reported as not scanned. The results are merged: every finding is kept, and any part that could not be audited makes the project "not scanned", with the failing part named (for exampleapps/cli (rust): …). - npm findings name the real advisory.
npm auditreports a package that is only vulnerable through a dependency with that dependency's name invia. Foundry reads the chain: each finding carries the advisory's GHSA ID (with npm's advisory number as an alias), the vulnerable package and range, and npm's fix availability.fixAvailable: trueis a fix even without a version (the digest saysnpm audit fix), and a semver-major fix says so (npm audit fix --force). OnlyfixAvailable: falseis a policy call. - A repository with no dependency manifest has nothing to audit. It is
listed under "No dependency manifests", not "Not scanned". A manifest
without its lockfile (a
package.jsonalone) is still not scanned. - Mix projects fetch before they audit. Foundry runs
mix deps.getin each audited Mix project beforemix deps.audit, because a lockfile that moved ahead ofdeps/makes the audit refuse to run. A failed fetch is reported with the project's path. RemediateSupplyChaintriages every live finding by fix availability and emitsSupplyChainRemediated, carrying the scan through. A populated fix version means the advisory is mechanically auto-fixable; an empty one means a policy call — an exploitability judgement about our usage that stays human. When the auto-fix engine is enabled (see below) it also applies the fixable ones, behind a verify-and-rollback rail; otherwise it only classifies.WriteSupplyChainDigestrenders a deterministic markdown digest (no agent — CVE identifiers must never be paraphrased or hallucinated) and writes it atomically to{FOUNDRY_SUPPLY_CHAIN_DIR}/{YYYY-MM-DD}.md. Dry-run skips the write.
The schedule is 0 6 * * * (06:00 local), offset past the 02:00 maintenance
run. The sentinel ships enabled in the canonical seed; disable it with
foundry sentinel disable nightly-supply-chain.
The allowlist — committed per-repo memory
A gate is stateless: it re-fails the same advisory forever. A function
remembers a decision. Each repo may carry a committed
.supply-chain-allow.json at its root — a neutral artifact Foundry reads (it
never writes it; acceptances are authored by a human and land through the repo's
normal commit flow, so every decision lives in git history):
{
"version": 1,
"allowed": [
{
"cve": "GHSA-gv7w-rqvm-qjhr",
"reason": "transitive dev-only dependency; not reachable in our runtime",
"expires": "2026-09-01"
}
]
}
Each entry classifies one advisory on the day of the scan:
| State | Condition | Effect |
|---|---|---|
| live | not in the allowlist | reported as a finding |
| accepted | present, expires today-or-later (or absent) | suppressed; noted under "Accepted" |
| lapsed | present, expires has passed | resurfaces as a live finding and is flagged under "Lapsed acceptances — re-decide" |
An entry matches a finding when it names the finding's ID or any alias the
scanner reports for the same advisory. pip-audit, osv-scanner and cargo-audit
report aliases, so one entry for CVE-2026-45829 also accepts the pip-audit
finding PYSEC-2026-311. An active acceptance under any alias wins over a
lapsed one. The post-push auditor and the scan_requested scan read the same
file with the same semantics.
The expiry is deliberate: an acceptance is a decision to revisit, not a
permanent mute. A malformed expires string fails safe — the advisory
resurfaces rather than hiding.
The digest
The digest opens with a triage line — N auto-fixable · M policy-call —
splitting the live findings by fix availability. It then groups findings into
sections: Live findings (a per-project CVE / package / severity / version /
fix table, where the fix column shows the resolving version or policy call), Lapsed acceptances (now live, need a fresh decision), Accepted
(active allowlist entries, for transparency), and Not scanned (projects
whose audit tool was unavailable or had no lockfile — reported, never failed). A
clean scan reads "No live supply-chain advisories."
The auto-fix engine (gated dark)
RemediateSupplyChain can do more than classify: it can apply a fixable
advisory's fix. This is off by default and stays inert on every install
until two conditions both hold — the env var FOUNDRY_SUPPLY_CHAIN_REMEDIATE is
truthy and the run is at Full throttle (never under dry_run). With the gate
off, the block is byte-for-byte the classifier.
When enabled, each project with fixable findings goes through a mandatory verify-and-rollback rail, and every change is reversible — committed locally, never pushed:
-
Refuse a dirty tree, or one with no gates. A project whose working tree carries uncommitted changes is skipped, so a rollback can always return to a known-clean
HEAD. A repo with no.hone-gates.jsongates is skipped too — an unverifiable fix is never applied. -
Full compatible update first. Rather than pinning one package, the engine first moves every dependency to the newest version the manifest already allows. This clears the advisory the same way a routine dependency refresh would, and keeps the lockfile close to current instead of accumulating one-off pins:
- Rust:
cargo updaterefreshesCargo.lock. - TypeScript:
npm update --package-lock-onlyorbun update --lockfile-onlyrefreshes the native lockfile (withpackage.jsoncommitted or restored alongside it). - Python:
uv lock --upgraderefreshesuv.lock. - Swift:
swift package updaterefreshesPackage.resolved(neverPackage.swift).
The engine then re-runs the same scanner that detected the findings to confirm which ones the update cleared, and re-runs the repo's gates. If at least one finding cleared and the required gates pass, the lockfile is committed as
chore: update dependency lockfile to latest compatible versions (fixes <CVE>). If the update fails, clears nothing, cannot be re-scanned, or fails a required gate, its files are unstaged and restored fromHEAD. - Rust:
-
Targeted fallback. Every finding the full update did not clear — or every finding, when the full update was reverted — falls back to a single-package fix that follows the project's stack and lockfile:
- Rust:
cargo update -p <pkg> --precise <fix>updatesCargo.lock. - TypeScript: Bun and npm projects update their native lockfile. A matching
direct dependency or override pin is rewritten in
package.jsonfirst; transitive advisories target npm's explicitfixAvailable.namepackage. - Python: uv projects rewrite a matching
pyproject.tomlrequirement and runuv lock --upgrade-package <pkg>==<fix>. - Swift: no targeted pin. osv-scanner names packages by repository URL and
SwiftPM pins by package identity, so Foundry does not guess the mapping;
the finding is reported as
apply_failed. - Kotlin, Elixir, C++: no fixer (
no_fixer).
The gates are re-run; on a pass only the files that fixer touched are committed (
chore(deps): bump … (supply-chain auto-fix)), otherwise they are restored fromHEAD. Unsupported stacks or projects without a supported lockfile report a visibleapply_failed/no_fixeroutcome rather than guessing. - Rust:
The engine follows the dependency update policy rule for security fixes:
- A fix that is a patch or a minor move is applied, whatever the project's policy.
- A fix that is a major move is never applied by the engine. The outcome is
task_lane, with the exactfoundry taskcommand, and the digest lists it under Major upgrades (run each as its own task). - If the full compatible update moved any direct dependency by a major (a
refresh inside an open constraint such as
>=can), it is reverted and the findings fall back to targeted pins. - A pinned fix above an active
.dependency-holds.jsonhold says in its outcome that it overrides the hold, because no fixed release exists inside it.
Each applied fix commits immediately, so a later finding's rollback can never
clobber an earlier success. Every outcome's detail starts with the path taken
— full_update or targeted_pin — and a targeted outcome names why the full
update was not enough (for example, targeted_pin (after gate verification failed after the full update): …).
The digest gains a Remediation section — Auto-fixed, Reverted, Major upgrades, and Not auto-fixed (needs attention) — only when the engine actually ran. Enable it by adding the env var to the daemon's launch environment; disable by removing it.
Release-tag audit scan errors
The ReleaseTagAudited event carries an optional scan_error field
(Option<String> in the SDK; absent from the JSON wire format when None).
Invariant: a scan that could not run is reported as unknown, never as a
clean result. When scan_error is set:
vulnerablereflects the upstream payload value (the last known state), not a fresh clean reading.- The
cvefield is likewise forwarded from upstream unchanged. - Downstream blocks that branch on
vulnerable: falseshould check for a non-nullscan_errorbefore treating a result as authoritative.
scan_error is populated in three situations:
| Cause | Example message |
|---|---|
git checkout <tag> returns non-zero | "git checkout v1.2.3 failed: ..." |
| Scanner tool returned a tool-level error | "cargo audit not found" |
Scanner gateway itself returned Err | "I/O error spawning audit tool" |
Environment variables
| Variable | Default | Purpose |
|---|---|---|
FOUNDRY_SUPPLY_CHAIN_DIR | ~/.foundry/supply-chain | Digest output directory |
FOUNDRY_SUPPLY_CHAIN_REMEDIATE | (unset → off) | Set truthy (1/true/yes/on) to enable the auto-fix engine. Off by default. |