amplihack-supply-chain-audit — Supply Chain Audit Reference¶
The amplihack-supply-chain-audit crate is the native Rust implementation of
the supply-chain-audit skill. It audits software supply chain security across
CI/CD pipelines, container images, and language package ecosystems, and emits
structured findings with severity ratings, file:line references, and
copy-pasteable fix templates.
It ships as both a library (amplihack_supply_chain_audit) and a
standalone binary (amplihack-supply-chain-audit). The skill's
SKILL.md Prerequisites invoke the binary directly; other crates and tools may
depend on the library API.
The crate replaces the upstream Python package supply_chain_audit/ with an
equivalent, dependency-light Rust implementation. Detection logic, the finding
schema, report format, error conditions, and security invariants are preserved
verbatim so that existing eval scenarios and the SKILL.md contract remain
stable.
Contents¶
- When to use it
- Design guarantees
- Crate layout
- Command-line usage
- Synopsis
- Options
- Exit codes
- Invocation examples
- The 12 audit dimensions
- Scope detection and mapping
- The finding schema
- The report
- Markdown report (default)
- JSON report
- External tools
- Configuration
- Accepted risks file
- Environment variables
- Library API reference
run_auditAuditConfigAuditResultFindingandFindingIdSeveritydetect_ecosystemsandEcosystemScope- External-tool helpers
- Error type:
SupplyChainAuditError - Security invariants
- Tutorial: auditing a repository end to end
- Testing
- FAQ
When to use it¶
Reach for amplihack-supply-chain-audit whenever you need to:
- Gate a PR on High/Critical supply chain regressions before merge.
- Audit CI/CD for unpinned action refs, over-broad
permissions:, or secret leakage inrun:steps. - Check dependency pinning across Python, Node, Go, Rust, and .NET.
- Assess container supply chain — mutable base tags, non-root execution, multi-stage minimal final images.
- Map SLSA readiness and drive SBOM generation guidance.
It is a read-only tool. It never modifies the repository under audit, never escalates privileges, and never emits credential values.
Design guarantees¶
The crate honours the same non-negotiable contract as the rest of amplihack:
#![forbid(unsafe_code)]crate-wide. Nounsafeblocks anywhere.- No panics on untrusted input. All parsing returns
Result; the binary maps errors to stderr and exit codes rather than unwinding. - No silent fallbacks. A missing external tool degrades to an explicit
Info-level note in the report — it never fails the audit silently. - Brick limit. No source file exceeds 400 lines; large modules
(
checkers/actions,report) are split into submodules. - Schema parity. JSON field names match the upstream
asdict()snake_case keys exactly: the requiredid,dimension,severity,file,line,current_value,expected_value,rationale,offline_detectableplus the optionaltool_required,contains_secret,fix_url,accepted_risk.
Crate layout¶
crates/amplihack-supply-chain-audit/
├── Cargo.toml
├── README.md
└── src/
├── lib.rs # public re-exports; #![forbid(unsafe_code)]
├── main.rs # [[bin]] amplihack-supply-chain-audit (clap CLI)
├── error.rs # SupplyChainAuditError (thiserror)
├── schema.rs # Severity, Finding, FindingId, validation, serde
├── detector.rs # scope allowlist + ecosystem detection
├── audit/
│ ├── mod.rs # run_audit entry + AuditResult wiring
│ ├── paths.rs # path-traversal + symlink guard
│ ├── risks.rs # accepted-risks parse/apply + ID reassignment
│ ├── xpia.rs # advisory XPIA marker detection
│ └── handoffs.rs # inter-skill handoff + SLSA build
├── checkers/
│ ├── mod.rs # DimChecker registry
│ ├── utils.rs # relative_path, is_lock_file, load_workflows
│ ├── actions/ # dims 1-4 (split: sha_pinning/permissions/…)
│ ├── containers.rs # dims 5, 12
│ ├── credentials.rs # dim 6
│ ├── dotnet.rs # dim 7
│ ├── python.rs # dim 8
│ ├── rust.rs # dim 9
│ ├── node.rs # dim 10
│ └── go.rs # dim 11
├── external_tools/
│ ├── mod.rs # availability, install metadata, timeouts
│ └── circuit.rs # circuit breaker + backoff
└── report/
├── mod.rs # AuditReport render orchestration
├── sections.rs # summary / findings / next-steps
├── slsa.rs # SLSA L0/L1/L2 assessment
└── json.rs # AuditResult::to_json parity
Command-line usage¶
Synopsis¶
PATH defaults to . (the current directory / repo root).
Options¶
| Flag | Value | Default | Description |
|---|---|---|---|
--scope <LIST> |
comma-separated: gha, containers, credentials, dotnet, python, rust, node, go, all |
auto-detect | Restrict the audit to the given ecosystems / dimensions. |
--min-severity <LEVEL> |
Critical | High | Medium | Info |
Info |
Report only findings at or above this severity. |
--json |
— | off | Emit the machine-readable JSON report instead of markdown. |
--summary-only |
— | off | Emit only the severity-count summary table (no per-finding detail). |
--generate-sbom |
— | off | Attempt SBOM generation via syft (requires the tool; degrades to Info). |
--check-tools |
— | off | Print external-tool availability and install options, then exit. |
-h, --help |
— | — | Print help. |
-V, --version |
— | — | Print version. |
Markdown is the default output. --json and --summary-only are mutually
exclusive with respect to formatting; if both are given, --json wins.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Audit completed; no findings at or above --min-severity. |
1 |
Audit completed; one or more findings at or above --min-severity were reported. |
2 |
Usage error — bad flags, or INVALID_SCOPE. |
3 |
Refused to run — PATH_TRAVERSAL or ACCEPTED_RISKS_OVERFLOW. |
4 |
Runtime error — unreadable audit root, I/O failure. |
Exit code 1 is a deliberate "findings present" signal so the binary can be
used directly as a pre-merge gate (amplihack-supply-chain-audit --min-severity High).
Invocation examples¶
# Full audit of the current repo, all detected ecosystems, all severities
amplihack-supply-chain-audit
# Audit a subdirectory only
amplihack-supply-chain-audit ./services/api
# GitHub Actions dimensions only, suppress Medium/Info, machine-readable
amplihack-supply-chain-audit --scope gha --min-severity High --json
# Pre-merge gate: fail the job on any High/Critical finding
amplihack-supply-chain-audit --min-severity High || exit 1
# Check which external tools are installed before a full run
amplihack-supply-chain-audit --check-tools
The 12 audit dimensions¶
| # | Dimension | Ecosystem | Key check |
|---|---|---|---|
| 1 | Action SHA pinning | GitHub Actions | uses: refs must be @<40-char-SHA> # vX.Y.Z |
| 2 | Workflow permissions | GitHub Actions | Top-level permissions: read-all; job-level minimal grants |
| 3 | Secret exposure | GitHub Actions | No secrets in run: echo/env; ACTIONS_STEP_DEBUG guard |
| 4 | Cache poisoning | GitHub Actions | actions/cache key collision; restore-keys breadth |
| 5 | Base image pinning | Containers | FROM image@sha256:<digest> not :latest or semver tag |
| 6 | OIDC vs long-lived secrets | Credentials | Prefer id-token: write OIDC; verify subject constraints |
| 7 | NuGet lock & audit | .NET / NuGet | RestoreLockedMode, authorized sources, NuGetAudit gate |
| 8 | Python dep integrity | Python | --require-hashes, --extra-index-url risks, typosquat signals |
| 9 | Cargo supply chain | Rust | Cargo.lock committed, build.rs risk, [patch]/[replace] |
| 10 | Node.js integrity | Node.js | npm ci not npm install, npx resolution, postinstall |
| 11 | Go module integrity | Go | go.sum committed, GONOSUMCHECK, replace directive scope |
| 12 | Docker build chain | Containers | Multi-stage scratch/distroless final stage; non-root USER |
The per-dimension check criteria, fix templates, and SHA/digest lookup
procedures are documented in the skill definition,
SKILL.md
("12 Audit Dimensions"), which the crate implements.
Every triggered dimension runs; skipped dimensions are reported explicitly in the report's "Dimensions Checked / Skipped" table so an empty result is always distinguishable from a skipped audit.
Scope detection and mapping¶
When --scope is omitted, ecosystems are auto-detected from file signals:
| Signal | Ecosystem | Dimensions |
|---|---|---|
.github/workflows/*.yml |
GitHub Actions | 1, 2, 3, 4 |
Dockerfile / docker-compose.yml |
Containers | 5, 12 |
.github/workflows/ with secrets.* |
Credentials | 6 |
*.csproj / NuGet.Config |
.NET / NuGet | 7 |
requirements*.txt / pyproject.toml / setup.cfg |
Python | 8 |
Cargo.toml / Cargo.lock |
Rust | 9 |
package.json / package-lock.json / yarn.lock |
Node.js | 10 |
go.mod / go.sum |
Go | 11 |
When --scope is given, values are matched against a strict allowlist before
any conditional use. An unrecognised value produces INVALID_SCOPE (exit 2)
with the valid list printed to stderr.
--scope value |
Dimensions |
|---|---|
gha |
1, 2, 3, 4 |
containers |
5, 12 |
credentials |
6 |
dotnet |
7 |
python |
8 |
rust |
9 |
node |
10 |
go |
11 |
all |
1–12 |
The finding schema¶
Findings are the atomic output unit. The JSON representation uses these keys (matching upstream verbatim):
{
"id": "CRITICAL-001",
"dimension": 1,
"severity": "Critical",
"file": ".github/workflows/release.yml",
"line": 14,
"current_value": "uses: actions/checkout@v4",
"expected_value": "uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2",
"rationale": "Mutable semver tag allows silent code replacement without any file change in your repo.",
"offline_detectable": true,
"tool_required": null,
"contains_secret": false,
"fix_url": "https://github.com/actions/checkout/releases",
"accepted_risk": false
}
| Field | Required | Constraint |
|---|---|---|
id |
Yes | {SEVERITY}-{NNN} — severity prefix + zero-padded sequence, unique per report |
dimension |
Yes | Integer 1–12 |
severity |
Yes | Critical | High | Medium | Info |
file |
Yes | Relative POSIX path; never absolute; no .. traversal or null bytes |
line |
Yes | Integer ≥ 0; 0 = file-level finding |
current_value |
Yes | Exact offending string (grep-able); rendered as <REDACTED> when contains_secret |
expected_value |
Yes | Ready-to-use replacement — no guessing required |
rationale |
Yes | 1–3 sentences explaining exploitability |
offline_detectable |
Yes | true if confirmable without network access |
tool_required |
No | null or one of gh, crane, syft, grype, cosign, actionlint, zizmor, detect-secrets, cargo-audit, go-mod-verify, hadolint (default null) |
contains_secret |
No | bool, default false; when true both value fields render as <REDACTED> |
fix_url |
No | HTTPS URL for authoritative SHA/digest lookup (default null) |
accepted_risk |
No | bool, default false; true when matched by the accepted-risks config |
The nine required fields (id, dimension, severity, file, line,
current_value, expected_value, rationale, offline_detectable) plus the
four optional fields (tool_required, contains_secret, fix_url,
accepted_risk) reproduce the upstream Finding dataclass exactly, so the
serialized JSON is byte-for-byte key-compatible with upstream asdict().
Finding IDs are assigned last, in a single global pass after scope
suppression and min-severity filtering, so the {SEVERITY}-{NNN} sequence is
gap-free within each severity band and stable for a given repository state.
The report¶
Markdown report (default)¶
The default output is the structured markdown report defined by the skill
(SKILL.md,
"Step 4: Report Generation"):
a header block (Date / Root / Scope / Skipped / Tool availability), a severity
summary table, ordered findings (Critical → High → Medium → Info), a SLSA
readiness assessment, recommended next steps, and an accepted-risks section.
An empty report still lists every dimension as Checked or Skipped so that "no findings" is never confused with "audit did not run."
JSON report¶
With --json, the report serializes to:
{
"date": "2026-07-28",
"root": ".",
"scope": ["gha", "python", "node"],
"skipped": ["containers", "dotnet", "rust", "go"],
"tool_availability": { "gh": true, "crane": false, "syft": false, "grype": false, "cosign": false },
"summary": { "critical": 1, "high": 2, "medium": 3, "info": 1, "total": 7 },
"findings": [ /* Finding objects, ordered by severity */ ],
"slsa": { "level": "L1", "gaps": ["No signed provenance", "SBOM not published"] },
"accepted_risks": [ /* accepted-risk annotated findings */ ]
}
The JSON schema is stable at v1.x: existing keys are never renamed or removed
within a major version.
External tools¶
The audit runs fully offline. External tools only enrich checks that require
live lookups; their absence degrades to an Info note, never a failure.
| Tool | Enriches | Timeout | Lost without it |
|---|---|---|---|
gh |
Action tag → SHA resolution (Dims 1–4) | 15s | Cannot resolve action tags to SHAs via GitHub API |
crane |
Container digest resolution (Dim 5) | 20s | Cannot resolve container image digests |
syft |
SBOM generation (--generate-sbom) |
120s | Cannot generate SPDX/CycloneDX SBOMs |
grype |
Known-CVE scanning | 60s | Cannot scan for known CVEs |
cosign |
Signature / attestation verification | 30s | Cannot verify image signatures |
All tool invocations use argument arrays with no shell (std::process::Command
argv-only), resolve executables from the operator PATH only, and are wrapped
by a circuit breaker with per-tool timeouts. A timeout produces TOOL_TIMEOUT,
which is caught internally — the check is skipped and annotated, and the audit
continues in degraded mode.
amplihack-supply-chain-audit --check-tools lists which tools are present,
which are missing, what each does, and how to install each one.
Configuration¶
Accepted risks file¶
Place a .supply-chain-accepted-risks.yml at the audit root to acknowledge
known, reviewed findings:
- id: HIGH-003
dimension: 10
file: package.json
line: 0
reason: "Internal registry mirror pinned by digest out of band."
review_date: 2026-12-31
Behaviour:
- The file is size-capped at 64 KiB; larger files abort with
ACCEPTED_RISKS_OVERFLOW(exit3). Parsing is line-based (not a general YAML loader) to resist YAML-bomb / billion-laughs inputs. - Entries with wildcard characters in
idare rejected. - If
review_dateis in the past, the original severity is restored (the acknowledgement has expired). - Findings match by
dimension+file+line. - Critical findings are never suppressed by an accepted-risk entry.
- Matched non-Critical findings remain in the report, displayed as
Infowith an[ACCEPTED RISK — review: YYYY-MM-DD]annotation. They are never omitted.
Environment variables¶
| Variable | Effect |
|---|---|
RUST_LOG |
Standard tracing filter (e.g. RUST_LOG=debug) for diagnostic output. |
NO_COLOR |
Disables ANSI colour in the markdown report when set. |
PATH |
The only source for resolving gh/crane/syft/grype/cosign. |
Library API reference¶
Add the crate as a workspace dependency:
Then:
use amplihack_supply_chain_audit::{run_audit, AuditConfig, Severity};
let config = AuditConfig::new(".")
.with_min_severity(Severity::High);
let result = run_audit(&config)?;
println!("{}", result.to_markdown());
for finding in result.findings() {
eprintln!("{} {} {}:{}", finding.id(), finding.severity(), finding.file(), finding.line());
}
run_audit¶
The single entry point. Validates the path, detects (or applies) scope, runs
every triggered dimension checker, applies accepted-risks and min-severity
filtering, assigns finding IDs, and builds the AuditResult. Returns an error
only for the named abort conditions (invalid scope, path traversal, accepted-
risks overflow, unreadable root).
AuditConfig¶
pub struct AuditConfig { /* … */ }
impl AuditConfig {
pub fn new(path: impl AsRef<Path>) -> Self;
pub fn with_scope(self, scope: EcosystemScope) -> Self;
pub fn with_min_severity(self, min: Severity) -> Self;
pub fn with_generate_sbom(self, on: bool) -> Self;
}
Builder for an audit run. scope defaults to auto-detect; min_severity
defaults to Severity::Info.
AuditResult¶
impl AuditResult {
pub fn findings(&self) -> &[Finding];
pub fn summary(&self) -> &SeveritySummary; // counts per band + total
pub fn scope(&self) -> &EcosystemScope;
pub fn skipped(&self) -> &[Ecosystem];
pub fn slsa(&self) -> &SlsaAssessment; // L0 | L1 | L2 + gaps
pub fn to_markdown(&self) -> String; // default report
pub fn to_json(&self) -> String; // --json report (serde_json)
pub fn highest_severity(&self) -> Option<Severity>;
}
Finding and FindingId¶
impl Finding {
pub fn id(&self) -> &FindingId;
pub fn dimension(&self) -> u8; // 1..=12
pub fn severity(&self) -> Severity;
pub fn file(&self) -> &str; // relative POSIX
pub fn line(&self) -> u32; // 0 = file-level
pub fn current_value(&self) -> &str; // <REDACTED> when contains_secret
pub fn expected_value(&self) -> &str; // <REDACTED> when contains_secret
pub fn fix_url(&self) -> Option<&str>;
pub fn rationale(&self) -> &str;
pub fn tool_required(&self) -> Option<&str>;
pub fn contains_secret(&self) -> bool; // drives value redaction
pub fn offline_detectable(&self) -> bool;
pub fn accepted_risk(&self) -> bool; // set by accepted-risks config
}
Finding::new(..) validates ID format (rejecting wildcards), the 1..=12
dimension range, path safety, and the tool allowlist, returning
SupplyChainAuditError on violation. FindingId renders as {SEVERITY}-{NNN}.
Severity¶
Ordered Critical > High > Medium > Info. Serializes to the capitalized string
form ("Critical", …). Severity::from_str accepts the same forms and is used
to parse --min-severity.
detect_ecosystems and EcosystemScope¶
pub fn detect_ecosystems(root: &Path) -> Result<EcosystemScope, SupplyChainAuditError>;
impl EcosystemScope {
pub fn from_scope_flag(csv: &str) -> Result<Self, SupplyChainAuditError>; // allowlist
pub fn dimensions(&self) -> Vec<u8>; // triggered dimension numbers
pub fn ecosystems(&self) -> &[Ecosystem];
}
from_scope_flag enforces the strict scope allowlist and returns
INVALID_SCOPE for anything else.
External-tool helpers¶
pub fn check_tool_availability(tool: &str) -> bool;
pub fn check_missing_tools() -> Vec<ToolInfo>; // name, description, install_options
pub fn install_tool(name: &str) -> Result<String, SupplyChainAuditError>;
pub fn install_all_missing() -> Vec<(String, Result<String, SupplyChainAuditError>)>;
These mirror the upstream external_tools.py surface so the SKILL.md
Prerequisites step can enumerate and optionally install missing tools.
Error type: SupplyChainAuditError¶
A thiserror-derived enum whose Display output carries the verbatim upstream
message prefixes so downstream tooling can match on them:
| Variant | Message prefix | Exit code |
|---|---|---|
InvalidScope |
INVALID_SCOPE: |
2 |
PathTraversal |
PATH_TRAVERSAL: |
3 |
AcceptedRisksOverflow |
ACCEPTED_RISKS_OVERFLOW: |
3 |
ToolTimeout |
TOOL_TIMEOUT: |
(internal — degraded mode) |
XpiaEscalation |
XPIA_ESCALATION: |
(internal — advisory) |
Io |
(source I/O error) | 4 |
ToolTimeout and XpiaEscalation are handled internally and do not abort the
audit; the others propagate out of run_audit.
Security invariants¶
Seven invariants are enforced unconditionally and covered by
tests/security_invariants.rs:
- Path traversal rejection — paths containing
../, a null byte, or a symlink escaping the audit root producePATH_TRAVERSAL; the audit does not begin. Directory walking usesfollow_links(false). - Scope enum validation —
--scopeis matched against the strict allowlist before any use; unknown values produceINVALID_SCOPE. - Subprocess argument arrays — all external tools are invoked argv-only with no shell; user input is never interpolated into a command string.
- Secret redaction — a finding whose
contains_secretflag is set has bothcurrent_valueandexpected_valuerendered as<REDACTED>; the original secret never appears in markdown or JSON output. - XPIA escalation — LLM-instruction markers in scanned content trigger an
advisory
XPIA_ESCALATION; the dimension check halts for that file and file content is omitted from the report. Detection is advisory (never aborts) and uses a hand-rolled left-boundary check (theregexcrate has no lookbehind). - Critical findings are never suppressed — not by accepted-risks, not by
--min-severity. This prevents hidden findings. - Read-only operation — the tool never modifies the repository, escalates privileges, or ingests/emits credentials; it runs at caller privilege.
Tutorial: auditing a repository end to end¶
1. Check tools (optional).
amplihack-supply-chain-audit --check-tools
# Missing: gh — GitHub CLI, resolves action tags to SHAs
# Install: brew install gh | apt install gh | winget install GitHub.cli
2. Run a full audit.
You get a markdown report: a summary table, ordered findings with
file:line + copy-pasteable fixes, a SLSA readiness section, and next steps.
3. Fix the Critical finding. Each finding's Expected value is
ready-to-paste — e.g. replace uses: actions/checkout@v4 with the pinned SHA
line shown.
4. Acknowledge a reviewed non-Critical risk. Add it to
.supply-chain-accepted-risks.yml with a future review_date. Re-run: the
finding now shows as Info [ACCEPTED RISK — review: …] but remains visible.
5. Wire it into CI as a gate.
- name: Supply chain audit
run: amplihack-supply-chain-audit --min-severity High
# exit code 1 fails the job when any High/Critical finding is present
6. Emit JSON for downstream tooling.
Testing¶
The crate mirrors the upstream test suite:
| Test target | Covers |
|---|---|
tests/scope_detection.rs |
Ecosystem detection + scope allowlist |
tests/pattern_detection.rs |
Dimension 1–12 regex/pattern checkers |
tests/finding_schema.rs |
Finding validation + serde key parity |
tests/error_conditions.rs |
The five named error conditions |
tests/external_tools.rs |
Availability, install metadata, timeout/degrade |
tests/report_schema.rs |
Markdown + JSON report shape, SLSA logic |
tests/security_invariants.rs |
All seven security invariants |
tests/audit_workflow.rs |
run_audit end-to-end wiring |
tests/eval_scenarios.rs |
Fixtures scenario_a/b/c with documented expected counts |
tests/full_audit_e2e.rs |
Binary run against a fixture repo |
Fixtures live under tests/fixtures/scenario_{a,b,c}/ and are copied verbatim
from upstream (7 / 5 / 6 planted findings respectively).
Run:
cargo test -p amplihack-supply-chain-audit
cargo clippy -p amplihack-supply-chain-audit --all-targets
cargo fmt --check
FAQ¶
Does it modify my repository? No. It is strictly read-only.
What happens without gh/crane/syft/grype/cosign? The audit runs in
degraded mode: only offline-detectable findings are produced, and each degraded
check is noted in the report. Run --check-tools to see (and optionally
install) what's missing.
Why does the binary exit 1 on a clean-looking run? Exit 1 means findings
at or above --min-severity were reported — it is the pre-merge-gate signal, not
an error. Runtime/usage errors use 2, 3, and 4.
How do I suppress a known finding? Add it to
.supply-chain-accepted-risks.yml with a future review_date. Critical
findings cannot be suppressed.
Is the JSON schema stable? Yes — field names match the upstream Python
asdict() keys and are stable within a major version.