From 6747d50c2a0949bcd5669ff59dc99ce3930f3ccd Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:32:48 -0400 Subject: [PATCH 1/8] Resolve cyclic Rust globs and root extern aliases Preserve local source edges while terminating repeated glob searches and exposing crate-root extern aliases to child modules. Missing, ambiguous, recursive, shadowed and disabled-prelude imports remain explicit failures. Focused graph regressions and changed-source checks pass. The earlier glob candidate also passed rustfacts-unit and architecture-unit. Full follow-up verification and the remaining semantic/Cargo findings are still in progress. --- docs/design/cargo-facts.md | 11 +++ internal/rustfacts/graph.go | 3 +- internal/rustfacts/graph_paths.go | 14 ++++ internal/rustfacts/graph_test.go | 116 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 internal/rustfacts/graph_test.go diff --git a/docs/design/cargo-facts.md b/docs/design/cargo-facts.md index 725483c..22b42d9 100644 --- a/docs/design/cargo-facts.md +++ b/docs/design/cargo-facts.md @@ -190,6 +190,17 @@ re-exports. Function, block, closure, implementation and generic bindings retain lexical scope; they cannot leak into sibling scopes or substitute for a module's members. Prelude lookup respects edition, `no_std`, `no_implicit_prelude` and local shadowing. Qualified module members never inherit the implicit prelude. +Crate-root `extern crate` aliases, including `extern crate self`, enter the +[extern prelude](https://doc.rust-lang.org/reference/names/preludes.html#extern-prelude) +for descendant modules. A child declaration does not become a sibling's implicit +binding, and local shadowing or `no_implicit_prelude` still takes precedence. + +Local glob lookup visits each module/name pair once along the active search +path. Circular re-exports cannot introduce a binding by themselves; revisiting +that search contributes no candidate while other glob branches still resolve. +Distinct definitions remain ambiguous, missing names remain uncovered, and +recursive explicit aliases still fail. The search state is discarded on return, +so one lookup cannot hide a candidate from another. Known standard macro arguments are parsed using the same upstream Rust grammar with their original byte locations retained. This includes assertion/formatting diff --git a/internal/rustfacts/graph.go b/internal/rustfacts/graph.go index e2e0b3a..f758ecd 100644 --- a/internal/rustfacts/graph.go +++ b/internal/rustfacts/graph.go @@ -67,10 +67,11 @@ type moduleInventory struct { sources map[string]Source available map[string]bool crates []*rustCrate + globs map[rustGlobLookup]bool } func ResolveModules(repo repository.Repository, workspaces []Workspace, sources Sources, selected, allFiles []string) ModuleGraph { - inventory := moduleInventory{repo: repo, sources: map[string]Source{}, available: map[string]bool{}} + inventory := moduleInventory{repo: repo, sources: map[string]Source{}, available: map[string]bool{}, globs: map[rustGlobLookup]bool{}} for _, path := range allFiles { inventory.available[path] = true } diff --git a/internal/rustfacts/graph_paths.go b/internal/rustfacts/graph_paths.go index 62b5685..bbffae8 100644 --- a/internal/rustfacts/graph_paths.go +++ b/internal/rustfacts/graph_paths.go @@ -118,7 +118,18 @@ func (inventory *moduleInventory) resolveAlias(binding rustBinding, name string, return inventory.resolvePath(binding.scope, binding.alias, true, depth+1) } +type rustGlobLookup struct { + module *rustModule + name string +} + func (inventory *moduleInventory) resolveGlobName(module *rustModule, name string, depth int) (rustBinding, bool, error) { + lookup := rustGlobLookup{module: module, name: name} + if inventory.globs[lookup] { + return rustBinding{}, false, nil + } + inventory.globs[lookup] = true + defer delete(inventory.globs, lookup) var result rustBinding found := false for _, glob := range module.globs { @@ -149,6 +160,9 @@ func (inventory *moduleInventory) externalCrate(module *rustModule, name string) return rustBinding{external: true}, nil } if implicitPrelude(module) { + if binding, found := module.crate.root.bindings[name]; found && binding.extern { + return inventory.resolveAlias(binding, name, 0) + } binding, err := inventory.declaredCrate(module, name) if err == nil && !(binding.standard && name == "std" && module.crate.noStd) { return binding, nil diff --git a/internal/rustfacts/graph_test.go b/internal/rustfacts/graph_test.go new file mode 100644 index 0000000..a832a2c --- /dev/null +++ b/internal/rustfacts/graph_test.go @@ -0,0 +1,116 @@ +package rustfacts + +import ( + "slices" + "testing" + + "github.com/riteofstring/code-polishy/internal/runner" +) + +func TestRustModuleGraphResolvesCyclicLocalGlobs(t *testing.T) { + t.Parallel() + graph := rustModuleFixture(t, map[string]string{ + "src/lib.rs": "pub mod api;\npub mod domain;\npub use domain::*;\n", + "src/api.rs": "use crate::*;\npub fn value() { let _ = Value::new(); let _ = String::new(); assert_eq!(1, 1); }\n", + "src/domain.rs": "use crate::*;\npub struct Value;\nimpl Value { pub fn new() -> Self { Self } }\n", + }) + if len(graph.Findings) != 0 || len(graph.Sources) != 3 { + t.Fatalf("valid circular globs lost source coverage: %+v", graph) + } + if !slices.ContainsFunc(graph.Imports, func(imported ModuleImport) bool { + return imported.Source == "src/api.rs" && imported.Target == "src/domain.rs" && imported.Line == 2 + }) { + t.Fatalf("glob lookup lost the defining source dependency: %+v", graph.Imports) + } +} + +func TestRustModuleGraphKeepsFailuresAroundCyclicGlobs(t *testing.T) { + t.Parallel() + for name, files := range map[string]map[string]string{ + "missing member": { + "src/lib.rs": "pub mod api;\npub mod domain;\npub use domain::*;\n", + "src/api.rs": "use crate::*;\npub fn value() { Missing::new(); }\n", + "src/domain.rs": "use crate::*;\npub struct Value;\n", + }, + "ambiguous member": { + "src/lib.rs": "pub mod api;\npub mod domain;\npub use api::*;\npub use domain::*;\npub fn value() { Value::new(); }\n", + "src/api.rs": "use crate::*;\npub struct Value;\n", + "src/domain.rs": "use crate::*;\npub struct Value;\n", + }, + "recursive aliases": { + "src/lib.rs": "use crate::Second as First;\nuse crate::First as Second;\npub fn value() { First::new(); }\n", + }, + "external glob": { + "src/lib.rs": "use std::*;\npub fn value() { assert!(true); }\n", + }, + } { + t.Run(name, func(t *testing.T) { + graph := rustModuleFixture(t, files) + if len(graph.Findings) == 0 { + t.Fatalf("unresolved imports acquired complete coverage: %+v", graph) + } + }) + } +} + +func TestRustModuleGraphResolvesRootExternCrateAliases(t *testing.T) { + t.Parallel() + graph := rustModuleFixture(t, map[string]string{ + "src/lib.rs": "extern crate self as cocoa;\nextern crate std as standard;\npub mod api;\npub mod domain;\n", + "src/api.rs": "use cocoa::domain::Value;\npub fn value() { let _ = Value::new(); let _ = standard::string::String::new(); }\n", + "src/domain.rs": "pub struct Value;\nimpl Value { pub fn new() -> Self { Self } }\n", + }) + if len(graph.Findings) != 0 { + t.Fatalf("root extern-crate aliases were unavailable in a child: %+v", graph.Findings) + } + if !slices.ContainsFunc(graph.Imports, func(imported ModuleImport) bool { + return imported.Source == "src/api.rs" && imported.Target == "src/domain.rs" + }) { + t.Fatalf("self-crate alias lost the defining source: %+v", graph.Imports) + } +} + +func TestRustModuleGraphKeepsExternCrateAliasesScoped(t *testing.T) { + t.Parallel() + for name, files := range map[string]map[string]string{ + "local shadow": { + "src/lib.rs": "extern crate self as cocoa;\npub mod api;\npub struct Value;\n", + "src/api.rs": "mod cocoa {}\npub fn value() { cocoa::Value::new(); }\n", + }, + "child declaration": { + "src/lib.rs": "pub mod api;\npub mod domain;\n", + "src/api.rs": "pub fn value() { cocoa::domain::Value::new(); }\n", + "src/domain.rs": "extern crate self as cocoa;\npub struct Value;\n", + }, + "disabled prelude": { + "src/lib.rs": "extern crate self as cocoa;\npub mod api;\npub struct Value;\n", + "src/api.rs": "#![no_implicit_prelude]\npub fn value() { cocoa::Value::new(); }\n", + }, + } { + t.Run(name, func(t *testing.T) { + graph := rustModuleFixture(t, files) + if len(graph.Findings) == 0 { + t.Fatalf("unavailable extern alias entered the prelude: %+v", graph) + } + }) + } +} + +func rustModuleFixture(t *testing.T, sources map[string]string) ModuleGraph { + t.Helper() + repo := rustRepository(t) + write(t, repo.Root, "Cargo.toml", "[package]\nname='app'\nversion='1.0.0'\nedition='2024'\n[workspace]\n") + for path, content := range sources { + write(t, repo.Root, path, content) + } + files, err := repo.AllFiles() + if err != nil { + t.Fatal(err) + } + discovery := Discover(t.Context(), repo, PlanDiscovery(repo, files, files), runner.OSRunner{}) + parsed := ReadSources(t.Context(), repo, PlanSources(repo, files), runner.OSRunner{}) + if len(discovery.Findings) != 0 || len(parsed.Findings) != 0 { + t.Fatalf("graph fixture preparation failed: discovery=%+v syntax=%+v", discovery.Findings, parsed.Findings) + } + return ResolveModules(repo, discovery.Workspaces, parsed, files, files) +} From 2194fbb2a342e6477f416cc9e82c74e6e38bc24b Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:54:30 -0400 Subject: [PATCH 2/8] Use gate-safe identities for manifest-specific Cargo commands Nested manifest paths previously became raw command names, so checkpoint construction rejected them before any check ran. Derive bounded immutable names from the operation and manifest identity while retaining original paths in arguments and findings. The regression reproduces the rejected gate identity and covers nested, colliding-separator, spaced and long Unicode paths across discovery, resolution and compilation commands. Focused engine and quality tests pass; formatting and diff checks pass. Final checkpoint verification remains pending. --- docs/design/cargo-facts.md | 7 +++++ internal/engine/rust_tests_test.go | 42 ++++++++++++++++++++++++++++++ internal/quality/cargo_test.go | 9 +++++-- internal/rustfacts/compilation.go | 2 +- internal/rustfacts/discovery.go | 2 +- internal/rustfacts/execution.go | 5 ++++ internal/rustfacts/resolution.go | 2 +- 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/design/cargo-facts.md b/docs/design/cargo-facts.md index 22b42d9..c156b02 100644 --- a/docs/design/cargo-facts.md +++ b/docs/design/cargo-facts.md @@ -6,6 +6,13 @@ architecture and dependency policy consume facts without acquiring their own Cargo resolvers. Commands use the common structured runner so limits, cancellation and command evidence have one owner. +Manifest-specific command names use a bounded digest of the operation and +repository-relative manifest path as their immutable execution identity. Raw +paths can contain separators, spaces, Unicode or more bytes than the gate's +identifier limit. Replacing separators would merge distinct paths. The exact +manifest remains in command arguments and finding paths; this identifier does +not detect file changes or authenticate manifest contents. + Workspace discovery runs `cargo metadata --format-version 1 --no-deps --frozen`. It can discover members, targets, inherited metadata, declared dependencies and features before dependencies are materialized. A null `resolve` is deliberately diff --git a/internal/engine/rust_tests_test.go b/internal/engine/rust_tests_test.go index 594b5fe..a04646d 100644 --- a/internal/engine/rust_tests_test.go +++ b/internal/engine/rust_tests_test.go @@ -15,9 +15,51 @@ import ( "github.com/riteofstring/code-polishy/internal/gaterun" "github.com/riteofstring/code-polishy/internal/policy" "github.com/riteofstring/code-polishy/internal/runner" + "github.com/riteofstring/code-polishy/internal/rustfacts" testpolicy "github.com/riteofstring/code-polishy/internal/testing" ) +func TestRustCargoCommandsEnterGateForNestedManifests(t *testing.T) { + t.Parallel() + root := nativeRustEngineRepository(t) + manifests := []string{ + "content/native/app/Cargo.toml", + "content/native-app/Cargo.toml", + "content/with space/" + strings.Repeat("é", 80) + "/Cargo.toml", + } + for _, manifest := range manifests { + writeEngineFile(t, root, manifest, "[package]\nname='nested'\nversion='1.0.0'\nedition='2024'\n[workspace]\n[lib]\npath='lib.rs'\n", 0o600) + writeEngineFile(t, root, filepath.Join(filepath.Dir(manifest), "lib.rs"), "pub fn answer() -> u32 { 42 }\n", 0o600) + } + initializeEngineGitRepository(t, root) + engine, err := Open(root, enginePolicyRoot(t), "") + if err != nil { + t.Fatal(err) + } + files, err := engine.Repository.AllFiles() + if err != nil { + t.Fatal(err) + } + resolution := rustfacts.PlanResolution(engine.Repository, manifests, true) + compilation := rustfacts.PlanCompilation(engine.Repository, resolution.Discovery, files) + if len(resolution.Discovery.Findings) != 0 || len(compilation.Findings) != 0 || len(compilation.Commands) != len(manifests) { + t.Fatalf("Cargo command planning failed: %+v, %+v", resolution, compilation) + } + commands := mergeGateCheckCommands(gaterun.SupplyChain, resolution.Commands) + commands = append(commands, mergeGateCheckCommands(gaterun.Check, compilation.Commands)...) + identity, err := gateRunIdentity(engine, gaterun.CheckpointGate, "main", strings.Repeat("a", 40), strings.Repeat("b", 40), "changed", commands, testGateRunBehaviorReview(), nil) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, command := range identity.Commands { + if seen[command.Name] { + t.Fatalf("different Cargo commands share an identity: %s", command.Name) + } + seen[command.Name] = true + } +} + func TestRustNativeGateRetainsRealValidatedEvidence(t *testing.T) { t.Parallel() testRustNativeGate(t, false) diff --git a/internal/quality/cargo_test.go b/internal/quality/cargo_test.go index c1dfbb8..0a1feda 100644 --- a/internal/quality/cargo_test.go +++ b/internal/quality/cargo_test.go @@ -22,7 +22,7 @@ func TestCargoCheckDiscoversInvalidMemberFromSelectedRustSource(t *testing.T) { writeQualityFile(t, repo.Root, "app/src/lib.rs", "pub fn app() {}\n") findings := cargoDiscoveryFindings(t.Context(), repo, []string{"app/src/lib.rs"}, runner.OSRunner{}) if !slices.ContainsFunc(findings, func(finding policy.Finding) bool { - return finding.Check == "policy.rustProject" && finding.Subject == "policy-cargo-metadata-app/Cargo.toml" + return finding.Check == "policy.rustProject" && finding.Path == "app/Cargo.toml" && strings.HasPrefix(finding.Subject, "policy-cargo-metadata-") && strings.Contains(finding.Message, "missing") }) { t.Fatalf("missing workspace failure from selected source: %+v", findings) } @@ -34,11 +34,16 @@ func TestCargoCheckCommandsExposeActualDiscoveryPlan(t *testing.T) { writeQualityFile(t, repo.Root, "Cargo.toml", "[package]\nname='app'\nversion='1.0.0'\n") writeQualityFile(t, repo.Root, "src/lib.rs", "pub fn app() {}\n") commands := CheckCommands(repo, repository.Selection{Files: []string{"src/lib.rs"}}, "check") - for _, name := range []string{"policy-rust-version", "policy-cargo-version", "policy-cargo-metadata-Cargo.toml"} { + for _, name := range []string{"policy-rust-version", "policy-cargo-version"} { if !slices.ContainsFunc(commands, func(command policy.Command) bool { return command.Name == name && command.SealedEnvironment }) { t.Errorf("Cargo command %s is missing from check planning: %+v", name, commands) } } + if !slices.ContainsFunc(commands, func(command policy.Command) bool { + return strings.HasPrefix(command.Name, "policy-cargo-metadata-") && command.Argv[len(command.Argv)-1] == "Cargo.toml" && command.SealedEnvironment + }) { + t.Fatalf("Cargo manifest discovery is missing from check planning: %+v", commands) + } } func cargoQualityRepository(t *testing.T) repository.Repository { diff --git a/internal/rustfacts/compilation.go b/internal/rustfacts/compilation.go index 1026e9a..1a3a43d 100644 --- a/internal/rustfacts/compilation.go +++ b/internal/rustfacts/compilation.go @@ -58,7 +58,7 @@ func compilationSources(repo repository.Repository, manifest string, allFiles [] } func compilationCommand(repo repository.Repository, manifest string) policy.Command { - command := Command(repo, "policy-clippy-"+manifest, "cargo-clippy", "clippy", "--frozen", "--all-targets", "--no-deps", "--message-format=json", "--target", HostTarget(), "--manifest-path", manifest, "--", "--force-warn", "clippy::all", "--force-warn", "unused") + command := Command(repo, manifestCommandName("policy-clippy", manifest), "cargo-clippy", "clippy", "--frozen", "--all-targets", "--no-deps", "--message-format=json", "--target", HostTarget(), "--manifest-path", manifest, "--", "--force-warn", "clippy::all", "--force-warn", "unused") for _, lint := range strings.Fields(compilerWarningLints) { command.Argv = append(command.Argv, "--force-warn", lint) } diff --git a/internal/rustfacts/discovery.go b/internal/rustfacts/discovery.go index e61f77f..11997ec 100644 --- a/internal/rustfacts/discovery.go +++ b/internal/rustfacts/discovery.go @@ -55,7 +55,7 @@ func PlanDiscovery(repo repository.Repository, selected, allFiles []string) Disc } plan.Commands = append(plan.Commands, Command(repo, "policy-rust-version", "rustc", "--version"), Command(repo, "policy-cargo-version", "cargo", "--version")) for _, manifest := range plan.Manifests { - plan.Commands = append(plan.Commands, Command(repo, "policy-cargo-metadata-"+manifest, "cargo", "metadata", "--format-version", "1", "--no-deps", "--frozen", "--manifest-path", manifest)) + plan.Commands = append(plan.Commands, Command(repo, manifestCommandName("policy-cargo-metadata", manifest), "cargo", "metadata", "--format-version", "1", "--no-deps", "--frozen", "--manifest-path", manifest)) } return plan } diff --git a/internal/rustfacts/execution.go b/internal/rustfacts/execution.go index 1d1fa39..25f8145 100644 --- a/internal/rustfacts/execution.go +++ b/internal/rustfacts/execution.go @@ -1,6 +1,7 @@ package rustfacts import ( + "crypto/sha256" "errors" "fmt" "os" @@ -13,6 +14,10 @@ import ( "github.com/riteofstring/code-polishy/internal/repository" ) +func manifestCommandName(operation, manifest string) string { + return fmt.Sprintf("%s-%x", operation, sha256.Sum256([]byte(operation+"\x00"+manifest))) +} + func CargoHome(repo repository.Repository) string { return filepath.Join(repo.Root, ".tools", "cargo-home") } diff --git a/internal/rustfacts/resolution.go b/internal/rustfacts/resolution.go index 0729a2a..b6ffe30 100644 --- a/internal/rustfacts/resolution.go +++ b/internal/rustfacts/resolution.go @@ -38,7 +38,7 @@ func resolutionCommand(repo repository.Repository, manifest string, offline bool if !offline { mode = "--locked" } - command := Command(repo, "policy-cargo-resolution-"+manifest, "cargo", "metadata", "--format-version", "1", mode, "--manifest-path", manifest) + command := Command(repo, manifestCommandName("policy-cargo-resolution", manifest), "cargo", "metadata", "--format-version", "1", mode, "--manifest-path", manifest) if !offline { index := slices.Index(command.EnvironmentOverrides, "CARGO_NET_OFFLINE=true") command.EnvironmentOverrides[index] = "CARGO_NET_OFFLINE=false" From a0c97b8b9e614cbcfc5c84d06df6d90f4975e888 Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:16:25 -0400 Subject: [PATCH 3/8] Preserve RustSec maintenance notices without weakening vulnerability checks --- .code-polishy.json | 2 + docs/design/rust-inputs.md | 14 +++ docs/policies/supply-chain.md | 8 +- internal/supplychain/osv.go | 13 ++- .../supplychain/osv_cargo_runtime_test.go | 27 ++++- internal/supplychain/osv_informational.go | 74 +++++++++++++ .../supplychain/osv_informational_test.go | 100 ++++++++++++++++++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 internal/supplychain/osv_informational.go create mode 100644 internal/supplychain/osv_informational_test.go diff --git a/.code-polishy.json b/.code-polishy.json index cb75ecd..7dcd5d6 100644 --- a/.code-polishy.json +++ b/.code-polishy.json @@ -150,6 +150,8 @@ "internal/supplychain/osv_cargo.go", "internal/supplychain/osv_cargo_test.go", "internal/supplychain/osv_cargo_runtime_test.go", + "internal/supplychain/osv_informational.go", + "internal/supplychain/osv_informational_test.go", "internal/supplychain/release_age.go", "internal/supplychain/online_profile.go", "internal/supplychain/supplychain.go", diff --git a/docs/design/rust-inputs.md b/docs/design/rust-inputs.md index ea84508..34c4397 100644 --- a/docs/design/rust-inputs.md +++ b/docs/design/rust-inputs.md @@ -133,6 +133,20 @@ use the `crates.io` ecosystem and cannot approve a different lock or version. The pinned runtime contract is exercised against a temporary synthetic offline advisory database, without querying a live provider. +RustSec also publishes [informational advisory categories](https://github.com/RustSec/advisory-db/blob/main/README.md#advisory-format). +An `unmaintained` notice identifies a maintenance concern rather than an +unspecified exploit. The OSV reader retains affected-package metadata and emits +`supplyChain.osvUnmaintained` warnings with exact package, version, advisory, +aliases and original lock scope. Only RustSec records explicitly classifying +the matching crates.io package as unmaintained, without group severity, +advisory/affected severity or a CVSS score, qualify. Every grouped identifier +must be covered by such a record. Missing metadata, contradictory matching +records, security aliases, other ecosystems and `unsound` or other notice +categories remain blocking vulnerability findings. A maintenance notice cannot +mask a separate vulnerability for the same package or require an invented +non-exposure assessment. Complete warning-bearing scanner exits retain the same +protocol checks as vulnerability-bearing exits; operational failures still fail. + ## Configured license policy When `supplyChain.allowedLicenses` is configured, installed-license verification diff --git a/docs/policies/supply-chain.md b/docs/policies/supply-chain.md index c265566..8fdc187 100644 --- a/docs/policies/supply-chain.md +++ b/docs/policies/supply-chain.md @@ -413,7 +413,13 @@ missing-coverage finding, never a workspace that declared nothing. `lock-sync` provider. - Policy-pinned OSV-Scanner runs as an independent source advisory lane whenever a supported dependency graph is detected. Its JSON is parsed centrally and - all reported affected versions, including unknown-severity results, block. + all reported vulnerable versions, including unknown-severity results, block. + RustSec `unmaintained` notices remain visible maintenance warnings when every + advisory in the group explicitly classifies the matching crates.io package + that way and reports no severity or CVSS score. Missing or conflicting evidence, + `unsound` advisories and other notice categories remain security findings. + A maintenance warning does not establish non-exposure or waive dependency + pinning, age, source or vulnerability checks. - Python/uv must still declare module-scoped lock consistency; OSV supplies its default security coverage unless an exact governed module override applies. - Scanner errors and unavailable advisory services fail. diff --git a/internal/supplychain/osv.go b/internal/supplychain/osv.go index a53fdcb..fe65a13 100644 --- a/internal/supplychain/osv.go +++ b/internal/supplychain/osv.go @@ -274,6 +274,9 @@ func acceptedOSVFindingExit(ctx context.Context, result runner.Result, findings return false } for _, finding := range findings { + if finding.Check == "supplyChain.osvUnmaintained" && finding.Severity == policy.FindingWarning { + continue + } if finding.Check != "supplyChain.osvVulnerability" || finding.Vulnerability == nil { return false } @@ -310,8 +313,10 @@ type osvGroup struct { } type osvVulnerability struct { - ID string `json:"id"` - Aliases []string `json:"aliases"` + ID string `json:"id"` + Aliases []string `json:"aliases"` + Severity []json.RawMessage `json:"severity"` + Affected []osvAffectedRecord `json:"affected"` } func parseOSVReport(repo repository.Repository, root string, payload []byte) ([]policy.Finding, error) { @@ -416,6 +421,10 @@ func osvPackageFindings(scope string, observed osvPackageResult) []policy.Findin findings := []policy.Finding{} for _, group := range groups { identifiers := osvGroupIdentifiers(group, observed.Vulnerabilities) + if osvUnmaintainedGroup(group, observed, identifiers) { + findings = append(findings, osvUnmaintainedFinding(scope, observed, identifiers)) + continue + } advisory := preferredAdvisory(identifiers) if advisory == "" { advisory = "OSV:unknown" diff --git a/internal/supplychain/osv_cargo_runtime_test.go b/internal/supplychain/osv_cargo_runtime_test.go index c0ef51a..0195a53 100644 --- a/internal/supplychain/osv_cargo_runtime_test.go +++ b/internal/supplychain/osv_cargo_runtime_test.go @@ -5,8 +5,10 @@ import ( "bytes" "os" "path/filepath" + "slices" "testing" + "github.com/riteofstring/code-polishy/internal/policy" "github.com/riteofstring/code-polishy/internal/runner" ) @@ -57,9 +59,18 @@ func testOSVCargoPinnedRuntimeProjection(t *testing.T, aliased bool) { } continue } - if len(findings) != 1 || findings[0].Vulnerability == nil || findings[0].Vulnerability.Advisory != "RUSTSEC-2026-0001" || findings[0].Vulnerability.Scope != "Cargo.lock" { + if len(findings) != 3 || !slices.ContainsFunc(findings, func(f policy.Finding) bool { + return f.Vulnerability != nil && f.Vulnerability.Advisory == "RUSTSEC-2026-0001" && f.Vulnerability.Scope == "Cargo.lock" + }) { t.Fatalf("pinned Cargo scanner lost complete package or vulnerability evidence: %+v", findings) } + for _, version := range []string{"1.0.0", "2.0.0"} { + if !slices.ContainsFunc(findings, func(f policy.Finding) bool { + return f.Check == "supplyChain.osvUnmaintained" && f.Severity == policy.FindingWarning && f.Path == "Cargo.lock" && f.Fields["advisory"] == "RUSTSEC-2026-0002" && f.Fields["version"] == version + }) { + t.Fatalf("pinned Cargo scanner lost maintenance classification for %s: %+v", version, findings) + } + } } } @@ -76,6 +87,20 @@ func cargoOSVDatabase(t *testing.T) string { "published":"2026-01-01T00:00:00Z","summary":"Offline scanner fixture", "affected":[{"package":{"ecosystem":"crates.io","name":"registry-crate"}, "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}]}] +}`)) + if err != nil { + t.Fatal(err) + } + file, err = archive.Create("RUSTSEC-2026-0002.json") + if err != nil { + t.Fatal(err) + } + _, err = file.Write([]byte(`{ + "schema_version":"1.7.0","id":"RUSTSEC-2026-0002","modified":"2026-01-01T00:00:00Z", + "published":"2026-01-01T00:00:00Z","summary":"Offline maintenance fixture", + "affected":[{"package":{"ecosystem":"crates.io","name":"registry-crate"}, + "ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}], + "database_specific":{"informational":"unmaintained","cvss":null}}] }`)) if err != nil { t.Fatal(err) diff --git a/internal/supplychain/osv_informational.go b/internal/supplychain/osv_informational.go new file mode 100644 index 0000000..013a100 --- /dev/null +++ b/internal/supplychain/osv_informational.go @@ -0,0 +1,74 @@ +package supplychain + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/riteofstring/code-polishy/internal/policy" +) + +type osvAffectedRecord struct { + Package struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` + } `json:"package"` + Severity []json.RawMessage `json:"severity"` + DatabaseSpecific struct { + Informational string `json:"informational"` + CVSS json.RawMessage `json:"cvss"` + } `json:"database_specific"` +} + +func osvUnmaintainedGroup(group osvGroup, observed osvPackageResult, identifiers []string) bool { + if group.MaxSeverity != "" || observed.Package.Ecosystem != "crates.io" || len(identifiers) == 0 { + return false + } + covered := map[string]bool{} + for _, advisory := range observed.Vulnerabilities { + aliases := append([]string{advisory.ID}, advisory.Aliases...) + if !slices.ContainsFunc(aliases, func(id string) bool { return slices.Contains(identifiers, id) }) { + continue + } + if !osvUnmaintainedAdvisory(advisory, observed.Package.Name) { + return false + } + for _, id := range aliases { + covered[id] = true + } + } + return !slices.ContainsFunc(identifiers, func(id string) bool { return !covered[id] }) +} + +func osvUnmaintainedAdvisory(advisory osvVulnerability, name string) bool { + if !strings.HasPrefix(advisory.ID, "RUSTSEC-") || len(advisory.Severity) != 0 { + return false + } + matched := false + for _, affected := range advisory.Affected { + if affected.Package.Ecosystem != "crates.io" || affected.Package.Name != name { + continue + } + cvss := strings.TrimSpace(string(affected.DatabaseSpecific.CVSS)) + if affected.DatabaseSpecific.Informational != "unmaintained" || len(affected.Severity) != 0 || cvss != "" && cvss != "null" { + return false + } + matched = true + } + return matched +} + +func osvUnmaintainedFinding(scope string, observed osvPackageResult, identifiers []string) policy.Finding { + advisory := preferredAdvisory(identifiers) + return policy.Finding{ + Check: "supplyChain.osvUnmaintained", Path: scope, Severity: policy.FindingWarning, + Subject: advisory + ":" + observed.Package.Name + "@" + observed.Package.Version, + Fields: map[string]string{ + "ecosystem": "crates.io", "advisory": advisory, "aliases": strings.Join(identifiers, ","), + "package": observed.Package.Name, "version": observed.Package.Version, "informational": "unmaintained", + }, + Message: fmt.Sprintf("RustSec reports %s: %s@%s is unmaintained; this notice does not identify a security vulnerability", advisory, observed.Package.Name, observed.Package.Version), + Remediation: policy.FindingRemediation{Summary: "Assess the maintenance risk and plan a reviewed migration; this notice does not establish that the dependency is free of vulnerabilities."}, + } +} diff --git a/internal/supplychain/osv_informational_test.go b/internal/supplychain/osv_informational_test.go new file mode 100644 index 0000000..51e3daa --- /dev/null +++ b/internal/supplychain/osv_informational_test.go @@ -0,0 +1,100 @@ +package supplychain + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/runner" +) + +const osvUnmaintainedReport = `{"results":[{"source":{"path":"Cargo.lock"},"packages":[{ + "package":{"name":"zvariant","version":"5.15.0","ecosystem":"crates.io"}, + "groups":[{"ids":["RUSTSEC-2026-0370"],"max_severity":""}], + "vulnerabilities":[{"id":"RUSTSEC-2026-0370","affected":[{ + "package":{"name":"zvariant","ecosystem":"crates.io"}, + "database_specific":{"informational":"unmaintained","cvss":null} + }]}] +}]}]}` + +func TestOSVUnmaintainedNoticeRetainsIdentityAndReportBearingExit(t *testing.T) { + t.Parallel() + for name, payload := range map[string]string{ + "grouped": osvUnmaintainedReport, + "ungrouped": strings.ReplaceAll(osvUnmaintainedReport, `"groups":[{"ids":["RUSTSEC-2026-0370"],"max_severity":""}],`, ""), + "alias": strings.ReplaceAll(osvUnmaintainedReport, `"id":"RUSTSEC-2026-0370",`, `"id":"RUSTSEC-2026-0370","aliases":["GHSA-test-maintenance"],`), + } { + t.Run(name, func(t *testing.T) { + repo := supplyRepository(t) + findings, err := parseOSVReport(repo, ".", []byte(payload)) + if err != nil || len(findings) != 1 { + t.Fatalf("maintenance report = %+v, %v", findings, err) + } + finding := findings[0] + if finding.Check != "supplyChain.osvUnmaintained" || finding.Severity != policy.FindingWarning || finding.Vulnerability != nil || finding.Path != "Cargo.lock" { + t.Fatalf("maintenance notice became a vulnerability or disappeared: %+v", finding) + } + if finding.Fields["package"] != "zvariant" || finding.Fields["version"] != "5.15.0" || !strings.Contains(finding.Fields["aliases"], "RUSTSEC-2026-0370") { + t.Fatalf("maintenance identity was lost: %+v", finding) + } + result := runner.Result{ExitStatus: 1, FailureCategory: runner.FailureCommandExit} + if !acceptedOSVReportExit(t.Context(), repo, osvScan{Root: "."}, result, runner.Output{Stdout: []byte(payload)}, errors.New("scanner findings")) { + t.Fatal("complete maintenance report was treated as scanner failure") + } + }) + } +} + +func TestOSVUnmaintainedClassificationPreservesUncertainAndSecurityFindings(t *testing.T) { + t.Parallel() + for name, change := range map[string]func(*osvPackageResult){ + "missing metadata": func(p *osvPackageResult) { p.Vulnerabilities[0].Affected = nil }, + "missing advisory": func(p *osvPackageResult) { p.Vulnerabilities = nil }, + "unsound": func(p *osvPackageResult) { p.Vulnerabilities[0].Affected[0].DatabaseSpecific.Informational = "unsound" }, + "other notice": func(p *osvPackageResult) { p.Vulnerabilities[0].Affected[0].DatabaseSpecific.Informational = "notice" }, + "wrong affected package": func(p *osvPackageResult) { p.Vulnerabilities[0].Affected[0].Package.Name = "other" }, + "wrong affected ecosystem": func(p *osvPackageResult) { p.Vulnerabilities[0].Affected[0].Package.Ecosystem = "npm" }, + "wrong observed ecosystem": func(p *osvPackageResult) { p.Package.Ecosystem = "npm" }, + "group severity": func(p *osvPackageResult) { p.Groups[0].MaxSeverity = "high" }, + "advisory severity": func(p *osvPackageResult) { + p.Vulnerabilities[0].Severity = []json.RawMessage{json.RawMessage(`{"type":"CVSS_V3","score":"CVSS:3.1/AV:N"}`)} + }, + "affected severity": func(p *osvPackageResult) { + p.Vulnerabilities[0].Affected[0].Severity = []json.RawMessage{json.RawMessage(`{"type":"CVSS_V3","score":"CVSS:3.1/AV:N"}`)} + }, + "RustSec CVSS": func(p *osvPackageResult) { + p.Vulnerabilities[0].Affected[0].DatabaseSpecific.CVSS = json.RawMessage(`"CVSS:3.1/AV:N"`) + }, + "unrepresented alias": func(p *osvPackageResult) { p.Groups[0].Aliases = []string{"CVE-2026-1234"} }, + "mixed group": func(p *osvPackageResult) { + p.Groups[0].IDs = append(p.Groups[0].IDs, "RUSTSEC-2026-0001") + p.Vulnerabilities = append(p.Vulnerabilities, osvVulnerability{ID: "RUSTSEC-2026-0001"}) + }, + "conflicting affected entries": func(p *osvPackageResult) { + other := p.Vulnerabilities[0].Affected[0] + other.DatabaseSpecific.Informational = "unsound" + p.Vulnerabilities[0].Affected = append(p.Vulnerabilities[0].Affected, other) + }, + "alias with security record": func(p *osvPackageResult) { + p.Vulnerabilities = append(p.Vulnerabilities, osvVulnerability{ID: "GHSA-security", Aliases: []string{"RUSTSEC-2026-0370"}}) + }, + } { + t.Run(name, func(t *testing.T) { + var report osvReport + if err := json.Unmarshal([]byte(osvUnmaintainedReport), &report); err != nil { + t.Fatal(err) + } + change(&report.Results[0].Packages[0]) + payload, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + findings, err := parseOSVReport(supplyRepository(t), ".", payload) + if err != nil || len(findings) != 1 || findings[0].Check != "supplyChain.osvVulnerability" || findings[0].Vulnerability == nil { + t.Fatalf("security finding was downgraded: %+v, %v", findings, err) + } + }) + } +} From b076d7f4dd3de90785b9cb2da1c8706453137e6f Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:29:12 -0400 Subject: [PATCH 4/8] Govern Cargo replacement sources through their consumer lock --- .code-polishy.json | 3 + docs/design/cargo-facts.md | 13 ++ docs/design/rust-inputs.md | 29 ++++- docs/policies/supply-chain.md | 10 ++ internal/policy/cargo_override_test.go | 33 ++++++ internal/policy/supplychain_config.go | 6 + internal/repository/cargo_manifest.go | 10 ++ internal/repository/cargo_patches.go | 27 +++++ internal/rustfacts/cargo_patches.go | 98 ++++++++++++++++ internal/rustfacts/cargo_patches_test.go | 111 ++++++++++++++++++ internal/rustfacts/resolution.go | 18 ++- internal/supplychain/cargo.go | 42 ++++--- internal/supplychain/cargo_patches.go | 85 ++++++++++++++ internal/supplychain/cargo_patches_test.go | 100 ++++++++++++++++ internal/supplychain/node_governance.go | 11 +- internal/supplychain/osv_informational.go | 3 + .../supplychain/osv_informational_test.go | 1 + internal/supplychain/supplychain.go | 5 +- schema/code-polishy-supply-chain.schema.json | 4 +- 19 files changed, 582 insertions(+), 27 deletions(-) create mode 100644 internal/policy/cargo_override_test.go create mode 100644 internal/repository/cargo_patches.go create mode 100644 internal/rustfacts/cargo_patches.go create mode 100644 internal/rustfacts/cargo_patches_test.go create mode 100644 internal/supplychain/cargo_patches.go create mode 100644 internal/supplychain/cargo_patches_test.go diff --git a/.code-polishy.json b/.code-polishy.json index 7dcd5d6..a708521 100644 --- a/.code-polishy.json +++ b/.code-polishy.json @@ -112,6 +112,7 @@ "internal/policy/cargo_release_age_test.go", "internal/policy/cargo_vulnerability_test.go", "internal/repository/cargo_manifest.go", + "internal/repository/cargo_patches.go", "internal/repository/cargo_manifest_test.go", "internal/repository/cargo_dependencies.go", "internal/repository/cargo_dependencies_test.go", @@ -133,6 +134,8 @@ "internal/quality/comments.go", "internal/quality/wgsl_test.go", "internal/supplychain/cargo.go", + "internal/supplychain/cargo_patches.go", + "internal/supplychain/cargo_patches_test.go", "internal/supplychain/cargo_test.go", "internal/supplychain/cargo_lock.go", "internal/supplychain/cargo_lock_test.go", diff --git a/docs/design/cargo-facts.md b/docs/design/cargo-facts.md index c156b02..7e64dd0 100644 --- a/docs/design/cargo-facts.md +++ b/docs/design/cargo-facts.md @@ -51,6 +51,19 @@ Offline verification uses `--frozen` and requires that cache to be prepared. Both modes require a contained, parsed workspace lock before resolution runs. Neither repairs a missing or stale lockfile. +Explicitly governed `patch.crates-io` sources resolve through their consumer's +workspace lock. Selecting a contained replacement manifest adds its declared +consumer rather than inventing a dependency-owned lock. Independent projects +retain their own locks. For these consumers, data-only metadata enables all +consumer features so optional replacements have actual source evidence; this +does not compile those features or establish that every feature combination can +execute together. Every declared patch must occur in the locked graph with its +exact local manifest or Git repository and commit, outside workspace members. +Unused substitutions, missing locks and stale resolution remain findings. +Nested patch owners are rejected because Cargo applies only workspace-root +patches. No dependency development target or build script executes in this +operation, and the original manifests and locks remain untouched. + Resolved facts preserve opaque package IDs, source identities, renamed edges, dependency kinds, target predicates and enabled features. Every graph node and edge must refer to a real package, and resolved package coordinates must occur diff --git a/docs/design/rust-inputs.md b/docs/design/rust-inputs.md index 34c4397..0f6af75 100644 --- a/docs/design/rust-inputs.md +++ b/docs/design/rust-inputs.md @@ -50,6 +50,33 @@ explicit source governance. Static manifest checks neither install dependencies nor run build scripts. They do not establish lock agreement, vulnerability, license or release-age coverage; those remain separate dependency-policy operations. +The existing `dependencyOverridePolicies` contract governs Cargo's root +`patch.crates-io` table with ecosystem `cargo`. Its canonical JSON identity is +computed from the parsed TOML table, retaining every declaration field. Changed, +missing, expired and unused ownership remains visible. Supported substitutions +select contained local package manifests or exact Git revisions; other patch +registries and deprecated `replace` declarations remain unsupported. + +A governed local replacement is consumed as a dependency of the declaring +application. Its registry requirements are transitive constraints resolved by +that application's lock, so copying a dependency into the repository does not +require rewriting its upstream constraints or creating a standalone lock. +Consumer declarations retain exact-version policy, and replacement Git and path +sources retain containment and immutable-pin checks. Cargo must prove each +replacement's actual source and package identity in the consumer graph; a +workspace member cannot obtain transitive treatment through a patch declaration. +This does not authorize the replacement's own development targets or establish +an independently buildable library project. + +Local replacement source remains governed repository code. A visible source +coverage warning distinguishes review of its provenance and changes from the +public registry's age, advisory and license evidence. The override owner must +record that review and its maintenance plan; a matching table digest neither +authenticates the copied source nor proves it unaffected. Registry dependencies +in the complete consumer lock retain their ordinary age and security checks. +The [consumer resolution](cargo-facts.md#locked-resolution) supplies the actual +source binding without compiling the replacement or executing its build scripts. + Lock inventory supports Cargo's version-3 and version-4 records. A package identity retains its name, complete version and exact source; different versions or sources are not collapsed. Git-index and sparse registry records must carry @@ -141,7 +168,7 @@ aliases and original lock scope. Only RustSec records explicitly classifying the matching crates.io package as unmaintained, without group severity, advisory/affected severity or a CVSS score, qualify. Every grouped identifier must be covered by such a record. Missing metadata, contradictory matching -records, security aliases, other ecosystems and `unsound` or other notice +records, CVE identities, security aliases, other ecosystems and `unsound` or other notice categories remain blocking vulnerability findings. A maintenance notice cannot mask a separate vulnerability for the same package or require an invented non-exposure assessment. Complete warning-bearing scanner exits retain the same diff --git a/docs/policies/supply-chain.md b/docs/policies/supply-chain.md index 8fdc187..5de38e8 100644 --- a/docs/policies/supply-chain.md +++ b/docs/policies/supply-chain.md @@ -474,6 +474,16 @@ canonical JSON override object plus reason, owner, review date, and expiry. Run the static check once after introducing an ungoverned override to obtain the observed canonical digest; adding governance does not suppress a vulnerability. +Cargo `patch.crates-io` uses the same ownership record with ecosystem `cargo`, +the exact `Cargo.toml` path and field `patch.crates-io`. The digest covers the +parsed table encoded as canonical JSON. Contained local replacements and exact +Git revisions require actual source binding through the consumer's frozen lock. +Replacement registry requirements remain transitive constraints; the consumer's +direct requirements stay exactly pinned. Local source provenance and changes +need review under the override owner and remain a visible coverage warning. +Other patch registries, `replace`, nested patch owners, unresolved replacements +and substitutions for workspace members are not admitted by this contract. + ```json { "supplyChain": { diff --git a/internal/policy/cargo_override_test.go b/internal/policy/cargo_override_test.go new file mode 100644 index 0000000..7b047c3 --- /dev/null +++ b/internal/policy/cargo_override_test.go @@ -0,0 +1,33 @@ +package policy + +import ( + "strings" + "testing" + "time" +) + +func TestCargoOverrideRequiresExactManifestAndSourceTable(t *testing.T) { + t.Parallel() + today := time.Now().UTC().Format("2006-01-02") + expiry := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02") + baseline := `{"id":"cargo-source","ecosystem":"cargo","path":"crate/Cargo.toml","field":"patch.crates-io","contentSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","reason":"reviewed replacement","owner":"fixture","reviewed":"` + today + `","expires":"` + expiry + `"}` + for name, test := range map[string]struct { + declaration string + valid bool + }{ + "governed Cargo replacement": {baseline, true}, + "wrong field": {strings.Replace(baseline, "patch.crates-io", "overrides", 1), false}, + "wrong ecosystem": {strings.Replace(baseline, `"cargo"`, `"npm"`, 1), false}, + "wrong manifest": {strings.Replace(baseline, "crate/Cargo.toml", "package.json", 1), false}, + "other registry": {strings.Replace(baseline, "patch.crates-io", "patch.private", 1), false}, + "absent digest": {strings.Replace(baseline, `"contentSha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",`, "", 1), false}, + } { + t.Run(name, func(t *testing.T) { + configuration := strings.Replace(minimalConfig(), `"supplyChain":{}`, `"supplyChain":{"dependencyOverridePolicies":[`+test.declaration+`]}`, 1) + _, err := Parse([]byte(configuration), ConfigFilename) + if (err == nil) != test.valid { + t.Fatalf("Cargo override validity = %v, want %v: %v", err == nil, test.valid, err) + } + }) + } +} diff --git a/internal/policy/supplychain_config.go b/internal/policy/supplychain_config.go index b76888d..6089fd4 100644 --- a/internal/policy/supplychain_config.go +++ b/internal/policy/supplychain_config.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/url" + "path/filepath" "regexp" "strings" "time" @@ -318,6 +319,11 @@ func validateDependencyOverridePolicies(policies []DependencyOverridePolicy) err } func validateDependencyOverridePolicy(override DependencyOverridePolicy, label string, seenIDs, seenOwners map[string]bool, now time.Time) error { + if override.Ecosystem == "cargo" || override.Field == "patch.crates-io" { + if override.Ecosystem != "cargo" || override.Field != "patch.crates-io" || filepath.Base(override.Path) != "Cargo.toml" { + return fmt.Errorf("%s Cargo overrides must own patch.crates-io in a Cargo.toml manifest", label) + } + } if seenIDs[override.ID] { return fmt.Errorf("duplicate dependency override policy id %q", override.ID) } diff --git a/internal/repository/cargo_manifest.go b/internal/repository/cargo_manifest.go index fdf55fc..10a68c1 100644 --- a/internal/repository/cargo_manifest.go +++ b/internal/repository/cargo_manifest.go @@ -1,6 +1,7 @@ package repository import ( + "encoding/json" "errors" "os" "path/filepath" @@ -15,6 +16,7 @@ type CargoManifest struct { Package *CargoPackageManifest Workspace *CargoWorkspaceManifest Dependencies []CargoDependencyDeclaration + Patches map[string]json.RawMessage } type CargoPackageManifest struct { @@ -95,6 +97,14 @@ func ParseCargoManifest(path string, data []byte) (CargoManifest, error) { return CargoManifest{}, err } manifest := CargoManifest{Path: path, Root: filepath.ToSlash(filepath.Dir(path)), Package: document.Package, Dependencies: dependencies} + manifest.Patches = map[string]json.RawMessage{} + for registry, declarations := range document.Patch { + encoded, err := json.Marshal(declarations) + if err != nil { + return CargoManifest{}, err + } + manifest.Patches["patch."+registry] = encoded + } if document.Workspace != nil { manifest.Workspace = &document.Workspace.CargoWorkspaceManifest } diff --git a/internal/repository/cargo_patches.go b/internal/repository/cargo_patches.go new file mode 100644 index 0000000..b6d79a3 --- /dev/null +++ b/internal/repository/cargo_patches.go @@ -0,0 +1,27 @@ +package repository + +import ( + "path/filepath" + "strings" +) + +func (repo Repository) GovernedCargoPatchManifests() CargoManifestInventory { + paths := []string{} + for _, owner := range repo.Config.SupplyChain.DependencyOverridePolicies { + if owner.Ecosystem == "cargo" && owner.Field == "patch.crates-io" { + paths = append(paths, owner.Path) + } + } + return repo.InspectCargoManifests(paths) +} + +func CargoPatchManifest(owner CargoManifest, dependency CargoDependencyDeclaration) string { + if dependency.Table != "patch.crates-io" || dependency.Path == "" || filepath.IsAbs(dependency.Path) || strings.ContainsAny(dependency.Path, "\\\x00:") { + return "" + } + path := filepath.ToSlash(filepath.Join(owner.Root, dependency.Path, "Cargo.toml")) + if !filepath.IsLocal(path) { + return "" + } + return path +} diff --git a/internal/rustfacts/cargo_patches.go b/internal/rustfacts/cargo_patches.go new file mode 100644 index 0000000..c4c3f0c --- /dev/null +++ b/internal/rustfacts/cargo_patches.go @@ -0,0 +1,98 @@ +package rustfacts + +import ( + "fmt" + "maps" + "path/filepath" + "slices" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/repository" +) + +func cargoResolutionRoots(repo repository.Repository, selected []string) ([]string, map[string]repository.CargoManifest, []policy.Finding) { + owners := map[string]repository.CargoManifest{} + consumers := map[string][]string{} + for _, owner := range repo.GovernedCargoPatchManifests().Manifests { + if len(owner.Patches["patch.crates-io"]) == 0 { + continue + } + owners[owner.Path] = owner + for _, dependency := range owner.Dependencies { + if path := repository.CargoPatchManifest(owner, dependency); path != "" { + consumers[path] = append(consumers[path], owner.Path) + } + } + } + roots := map[string]bool{} + findings := []policy.Finding{} + for _, manifest := range selected { + if err := addCargoResolutionRoot(manifest, consumers, owners, roots, map[string]bool{}); err != nil { + findings = append(findings, resolutionFinding(manifest, err.Error())) + } + } + patches := map[string]repository.CargoManifest{} + for root := range roots { + if owner, present := owners[root]; present { + patches[root] = owner + } + } + return slices.Sorted(maps.Keys(roots)), patches, findings +} + +func addCargoResolutionRoot(manifest string, consumers map[string][]string, owners map[string]repository.CargoManifest, roots, visiting map[string]bool) error { + if visiting[manifest] { + return fmt.Errorf("cargo replacement ownership is cyclic at %s", manifest) + } + if len(consumers[manifest]) == 0 { + roots[manifest] = true + return nil + } + if _, owner := owners[manifest]; owner { + return fmt.Errorf("cargo patch owner %s is itself a consumed replacement; only workspace-root patches apply", manifest) + } + visiting[manifest] = true + defer delete(visiting, manifest) + for _, consumer := range consumers[manifest] { + if err := addCargoResolutionRoot(consumer, consumers, owners, roots, visiting); err != nil { + return err + } + } + return nil +} + +func cargoResolvedPatchFindings(owner repository.CargoManifest, resolution Resolution) []policy.Finding { + if owner.Path != filepath.ToSlash(filepath.Join(resolution.Workspace.Root, "Cargo.toml")) { + return []policy.Finding{resolutionFinding(owner.Path, "governed Cargo patches must be declared in the actual workspace-root manifest")} + } + findings := []policy.Finding{} + for _, dependency := range owner.Dependencies { + if dependency.Table != "patch.crates-io" { + continue + } + if !slices.ContainsFunc(resolution.Packages, func(pkg Package) bool { + return cargoPatchMatchesPackage(owner, dependency, pkg) && !slices.Contains(resolution.Workspace.Members, pkg.ID) + }) { + findings = append(findings, policy.Finding{ + Check: "supplyChain.cargoSource", Path: owner.Path, Subject: dependency.Table + ":" + dependency.Alias, + Message: "Cargo replacement must resolve to the declared source as a dependency in the consumer's locked all-feature graph, outside its workspace members", + }) + } + } + return findings +} + +func cargoPatchMatchesPackage(owner repository.CargoManifest, dependency repository.CargoDependencyDeclaration, pkg Package) bool { + if pkg.Name != dependency.Package { + return false + } + if path := repository.CargoPatchManifest(owner, dependency); path != "" { + return pkg.Source == nil && pkg.Manifest == path + } + if dependency.Git == "" || pkg.Source == nil { + return false + } + declared, declarationErr := repository.ParseCargoGitDeclaration(dependency) + resolved, resolutionErr := repository.ParseCargoGitLockSource(*pkg.Source) + return declarationErr == nil && resolutionErr == nil && declared.Commit != "" && declared.RepositoryIdentity() == resolved.RepositoryIdentity() && declared.Commit == resolved.Commit +} diff --git a/internal/rustfacts/cargo_patches_test.go b/internal/rustfacts/cargo_patches_test.go new file mode 100644 index 0000000..e44c348 --- /dev/null +++ b/internal/rustfacts/cargo_patches_test.go @@ -0,0 +1,111 @@ +package rustfacts + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/repository" + "github.com/riteofstring/code-polishy/internal/runner" +) + +func TestCargoReplacementUsesConsumerLockWithoutCompilingOrStandaloneLocks(t *testing.T) { + t.Parallel() + for name, selected := range map[string][]string{ + "whole project": {"Cargo.toml", "vendor/patch-source/Cargo.toml", "vendor/leaf/Cargo.toml"}, + "dependency only": {"vendor/patch-source/Cargo.toml"}, + } { + t.Run(name, func(t *testing.T) { + repo := cargoPatchRepository(t) + plan := PlanResolution(repo, selected, true) + result := Resolve(t.Context(), repo, plan, runner.OSRunner{}) + if len(result.Findings) != 0 || len(result.Resolutions) != 1 || result.Resolutions[0].Lock.Path != "Cargo.lock" { + t.Fatalf("consumer resolution = %+v", result) + } + if len(result.Resolutions[0].Packages) != 3 { + t.Fatalf("optional replacement graph was not examined: %+v", result.Resolutions[0].Packages) + } + for _, path := range []string{"vendor/patch-source/Cargo.lock", "vendor/leaf/Cargo.lock", "executed", ".tools/rust-target"} { + if _, err := os.Stat(filepath.Join(repo.Root, filepath.FromSlash(path))); !os.IsNotExist(err) { + t.Errorf("resolution created dependency build input or executed code at %s: %v", path, err) + } + } + }) + } +} + +func TestCargoReplacementRejectsStaleConsumerAndUnusedSource(t *testing.T) { + t.Parallel() + for _, version := range []string{"1.0.1", "2.0.0"} { + t.Run(version, func(t *testing.T) { + repo := cargoPatchRepository(t) + write(t, repo.Root, "vendor/leaf/Cargo.toml", "[package]\nname='patch-leaf'\nversion='"+version+"'\nedition='2021'\n") + result := Resolve(t.Context(), repo, PlanResolution(repo, []string{"Cargo.toml", "vendor/leaf/Cargo.toml"}, true), runner.OSRunner{}) + if len(result.Findings) == 0 || len(result.Resolutions) != 0 { + t.Fatalf("changed replacement passed old lock: %+v", result) + } + }) + } +} + +func TestCargoReplacementDoesNotClaimIndependentPackageOrWorkspaceMember(t *testing.T) { + t.Parallel() + repo := cargoPatchRepository(t) + write(t, repo.Root, "other/Cargo.toml", "[package]\nname='other'\nversion='1.0.0'\nedition='2021'\n[workspace]\n") + write(t, repo.Root, "other/src/lib.rs", "pub fn value() {}\n") + result := Resolve(t.Context(), repo, PlanResolution(repo, []string{"Cargo.toml", "other/Cargo.toml"}, true), runner.OSRunner{}) + if len(result.Resolutions) != 1 || !slices.ContainsFunc(result.Findings, func(f policy.Finding) bool { return f.Path == "other/Cargo.lock" }) { + t.Fatalf("independent missing lock was hidden: %+v", result) + } + owner := repo.GovernedCargoPatchManifests().Manifests[0] + resolved := result.Resolutions[0] + for _, pkg := range resolved.Packages { + if pkg.Name == "patch-leaf" { + resolved.Workspace.Members = append(resolved.Workspace.Members, pkg.ID) + } + } + if findings := cargoResolvedPatchFindings(owner, resolved); len(findings) != 1 || !strings.Contains(findings[0].Subject, "patch-leaf") { + t.Fatalf("workspace member received replacement dependency treatment: %+v", findings) + } +} + +func TestCargoReplacementRequiresActualConsumerUse(t *testing.T) { + t.Parallel() + repo := cargoPatchRepository(t) + manifest, err := repo.Read("Cargo.toml") + if err != nil { + t.Fatal(err) + } + write(t, repo.Root, "Cargo.toml", string(manifest)+"unused={path='unused'}\n") + write(t, repo.Root, "unused/Cargo.toml", "[package]\nname='unused'\nversion='1.0.0'\nedition='2021'\n[workspace]\n") + write(t, repo.Root, "unused/src/lib.rs", "pub fn unused() {}\n") + lock, err := repo.Read("Cargo.lock") + if err != nil { + t.Fatal(err) + } + write(t, repo.Root, "Cargo.lock", string(lock)+"[[patch.unused]]\nname='unused'\nversion='1.0.0'\n") + result := Resolve(t.Context(), repo, PlanResolution(repo, []string{"Cargo.toml", "unused/Cargo.toml"}, true), runner.OSRunner{}) + if !slices.ContainsFunc(result.Findings, func(f policy.Finding) bool { + return f.Check == "supplyChain.cargoSource" && f.Subject == "patch.crates-io:unused" + }) { + t.Fatalf("unused substitution claimed source evidence: %+v", result.Findings) + } +} + +func cargoPatchRepository(t *testing.T) repository.Repository { + t.Helper() + repo := rustRepository(t) + repo.Config.SupplyChain.DependencyOverridePolicies = []policy.DependencyOverridePolicy{{Ecosystem: "cargo", Path: "Cargo.toml", Field: "patch.crates-io"}} + write(t, repo.Root, "Cargo.toml", "[package]\nname='app'\nversion='1.0.0'\nedition='2021'\n[workspace]\nexclude=['vendor/patch-source','vendor/leaf']\n[features]\ndefault=[]\ndesktop=['dep:patch-source']\n[dependencies]\npatch-source={version='=1.0.0',optional=true}\n[patch.crates-io]\npatch-source={path='vendor/patch-source'}\npatch-leaf={path='vendor/leaf'}\n") + write(t, repo.Root, "src/lib.rs", "compile_error!(\"must not compile\");\n") + write(t, repo.Root, "vendor/patch-source/Cargo.toml", "[package]\nname='patch-source'\nversion='1.0.0'\nedition='2021'\n[dependencies]\npatch-leaf='1'\n[dev-dependencies]\nnot-consumed='9'\n") + write(t, repo.Root, "vendor/patch-source/src/lib.rs", "compile_error!(\"must not compile\");\n") + write(t, repo.Root, "vendor/patch-source/build.rs", "fn main() { std::fs::write(\"executed\", \"bad\").unwrap(); panic!(\"must not execute\"); }\n") + write(t, repo.Root, "vendor/leaf/Cargo.toml", "[package]\nname='patch-leaf'\nversion='1.0.0'\nedition='2021'\n") + write(t, repo.Root, "vendor/leaf/src/lib.rs", "pub fn value() {}\n") + write(t, repo.Root, "Cargo.lock", "version=4\n[[package]]\nname='app'\nversion='1.0.0'\ndependencies=['patch-source']\n[[package]]\nname='patch-source'\nversion='1.0.0'\ndependencies=['patch-leaf']\n[[package]]\nname='patch-leaf'\nversion='1.0.0'\n") + return repo +} diff --git a/internal/rustfacts/resolution.go b/internal/rustfacts/resolution.go index b6ffe30..d55cb69 100644 --- a/internal/rustfacts/resolution.go +++ b/internal/rustfacts/resolution.go @@ -14,6 +14,7 @@ import ( type ResolutionPlan struct { Discovery DiscoveryPlan Commands []policy.Command + Patches map[string]repository.CargoManifest } type ResolutionResult struct { @@ -22,13 +23,19 @@ type ResolutionResult struct { } func PlanResolution(repo repository.Repository, files []string, offline bool) ResolutionPlan { - discovery := PlanDiscovery(repo, files, files) - plan := ResolutionPlan{Discovery: discovery, Commands: slices.Clone(discovery.Commands)} + manifests, patches, findings := cargoResolutionRoots(repo, selectedManifests(files, files)) + discovery := PlanDiscovery(repo, manifests, manifests) + discovery.Findings = append(discovery.Findings, findings...) + plan := ResolutionPlan{Discovery: discovery, Commands: slices.Clone(discovery.Commands), Patches: patches} if len(plan.Commands) == 0 { return plan } for _, manifest := range discovery.Manifests { - plan.Commands = append(plan.Commands, resolutionCommand(repo, manifest, offline)) + command := resolutionCommand(repo, manifest, offline) + if _, present := patches[manifest]; present { + command.Argv = append(command.Argv, "--all-features") + } + plan.Commands = append(plan.Commands, command) } return plan } @@ -57,6 +64,11 @@ func Resolve(ctx context.Context, repo repository.Repository, plan ResolutionPla continue } resolution, findings := resolveManifest(ctx, repo, manifest, *workspace, command, commandRunner) + if len(findings) == 0 { + if owner, present := plan.Patches[manifest]; present { + findings = append(findings, cargoResolvedPatchFindings(owner, resolution)...) + } + } result.Findings = append(result.Findings, findings...) if len(findings) == 0 { result.Resolutions = append(result.Resolutions, resolution) diff --git a/internal/supplychain/cargo.go b/internal/supplychain/cargo.go index 060576e..c423be5 100644 --- a/internal/supplychain/cargo.go +++ b/internal/supplychain/cargo.go @@ -12,22 +12,12 @@ import ( ) func checkCargoManifest(repo repository.Repository, path string) []policy.Finding { - inventory := repo.InspectCargoManifests([]string{path}) - findings := []policy.Finding{} - for _, problem := range inventory.Problems { - findings = append(findings, policy.Finding{Check: "supplyChain.cargoManifest", Path: problem.Path, Line: problem.Line, Subject: "manifest", Message: problem.Message}) - } - for _, manifest := range inventory.Manifests { - for _, dependency := range manifest.Dependencies { - findings = append(findings, cargoDeclarationFindings(repo, manifest, dependency)...) - } - } - return findings + return cargoStaticFindings(repo, []string{path}) } -func cargoDeclarationFindings(repo repository.Repository, manifest repository.CargoManifest, dependency repository.CargoDependencyDeclaration) []policy.Finding { - if dependency.Table == "replace" || strings.HasPrefix(dependency.Table, "patch.") { - return []policy.Finding{cargoDependencyFinding(manifest.Path, dependency, "supplyChain.cargoSource", "Cargo patch/replace declarations require governed override and resolved-source evidence")} +func cargoDeclarationFindings(repo repository.Repository, manifest repository.CargoManifest, dependency repository.CargoDependencyDeclaration, replacement, governedPatch bool) []policy.Finding { + if err := cargoPatchDeclarationProblem(dependency, governedPatch); err != nil { + return []policy.Finding{cargoDependencyFinding(manifest.Path, dependency, "supplyChain.cargoSource", err.Error())} } if err := cargoDeclarationFields(dependency); err != nil { return []policy.Finding{cargoDependencyFinding(manifest.Path, dependency, "supplyChain.cargoManifest", err.Error())} @@ -36,10 +26,8 @@ func cargoDeclarationFindings(repo repository.Repository, manifest repository.Ca return nil } findings := []policy.Finding{} - if dependency.Version != "" || dependency.Path == "" && dependency.Git == "" { - if !exactCargoRequirement(dependency.Version) { - findings = append(findings, cargoDependencyFinding(manifest.Path, dependency, "supplyChain.exactVersion", "Cargo dependencies must use an exact =major.minor.patch requirement without build metadata; bare versions and ranges permit updates")) - } + if cargoRequiresExactRequirement(dependency, replacement) && !exactCargoRequirement(dependency.Version) { + findings = append(findings, cargoDependencyFinding(manifest.Path, dependency, "supplyChain.exactVersion", "Cargo dependencies must use an exact =major.minor.patch requirement without build metadata; bare versions and ranges permit updates")) } if err := cargoDeclarationSource(repo, manifest, dependency); err != nil { findings = append(findings, cargoDependencyFinding(manifest.Path, dependency, "supplyChain.cargoSource", err.Error())) @@ -47,6 +35,24 @@ func cargoDeclarationFindings(repo repository.Repository, manifest repository.Ca return findings } +func cargoPatchDeclarationProblem(dependency repository.CargoDependencyDeclaration, governed bool) error { + if dependency.Table == "replace" || strings.HasPrefix(dependency.Table, "patch.") && (dependency.Table != "patch.crates-io" || !governed) { + return fmt.Errorf("cargo patch/replace declarations require governed override and resolved-source evidence") + } + if dependency.Table == "patch.crates-io" && dependency.Path == "" && dependency.Git == "" { + return fmt.Errorf("cargo registry patches must select a contained path or immutable Git source") + } + return nil +} + +func cargoRequiresExactRequirement(dependency repository.CargoDependencyDeclaration, replacement bool) bool { + registry := dependency.Path == "" && dependency.Git == "" + if replacement && registry && dependency.Version != "" { + return false + } + return dependency.Version != "" || registry +} + func cargoDeclarationFields(dependency repository.CargoDependencyDeclaration) error { allowed := []string{"package", "version", "path", "git", "rev", "branch", "tag", "registry", "workspace", "optional", "features", "default-features"} if dependency.Workspace { diff --git a/internal/supplychain/cargo_patches.go b/internal/supplychain/cargo_patches.go new file mode 100644 index 0000000..86528b0 --- /dev/null +++ b/internal/supplychain/cargo_patches.go @@ -0,0 +1,85 @@ +package supplychain + +import ( + "slices" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/repository" +) + +func cargoStaticFindings(repo repository.Repository, files []string) []policy.Finding { + inventory := repo.InspectCargoManifests(files) + if len(inventory.Manifests) == 0 && len(inventory.Problems) == 0 { + return nil + } + patches, replacements, findings := cargoPatchGovernance(repo, &inventory) + for _, problem := range inventory.Problems { + findings = append(findings, policy.Finding{Check: "supplyChain.cargoManifest", Path: problem.Path, Line: problem.Line, Subject: "manifest", Message: problem.Message}) + } + for _, manifest := range inventory.Manifests { + for _, dependency := range manifest.Dependencies { + findings = append(findings, cargoDeclarationFindings(repo, manifest, dependency, replacements[manifest.Path], patches[manifest.Path])...) + } + if replacements[manifest.Path] { + findings = append(findings, policy.Finding{ + Check: "supplyChain.cargoPatchSourceCoverage", Path: manifest.Path, Subject: "local replacement", Severity: policy.FindingWarning, + Message: "Local Cargo replacement is governed as repository source; public registry evidence does not authenticate its modified contents or establish their advisory, license or release-age coverage", + Remediation: policy.FindingRemediation{Summary: "Review the replacement's provenance and complete source delta under its exact override owner; retain the consumer's locked dependency checks."}, + }) + } + } + return findings +} + +func cargoPatchGovernance(repo repository.Repository, selected *repository.CargoManifestInventory) (map[string]bool, map[string]bool, []policy.Finding) { + patches, replacements := map[string]bool{}, map[string]bool{} + findings := []policy.Finding{} + for _, owner := range cargoPatchOwners(repo, selected.Manifests) { + if !cargoPatchOwnerSelected(owner, selected.Manifests) { + continue + } + if !slices.ContainsFunc(selected.Manifests, func(m repository.CargoManifest) bool { return m.Path == owner.Path }) { + selected.Manifests = append(selected.Manifests, owner) + } + if !nonemptyJSONObject(owner.Patches["patch.crates-io"]) { + continue + } + problems := dependencyOverrideBlockFindings(repo, owner.Path, "cargo", "patch.crates-io", owner.Patches["patch.crates-io"]) + findings = append(findings, problems...) + if len(problems) != 0 { + continue + } + patches[owner.Path] = true + for _, dependency := range owner.Dependencies { + path := repository.CargoPatchManifest(owner, dependency) + if path != "" && cargoDeclarationSource(repo, owner, dependency) == nil { + replacements[path] = true + } + } + } + return patches, replacements, findings +} + +func cargoPatchOwners(repo repository.Repository, selected []repository.CargoManifest) []repository.CargoManifest { + owners := repo.GovernedCargoPatchManifests().Manifests + for _, manifest := range selected { + if nonemptyJSONObject(manifest.Patches["patch.crates-io"]) && !slices.ContainsFunc(owners, func(owner repository.CargoManifest) bool { return owner.Path == manifest.Path }) { + owners = append(owners, manifest) + } + } + return owners +} + +func cargoPatchOwnerSelected(owner repository.CargoManifest, selected []repository.CargoManifest) bool { + for _, manifest := range selected { + if manifest.Path == owner.Path { + return true + } + for _, dependency := range owner.Dependencies { + if repository.CargoPatchManifest(owner, dependency) == manifest.Path { + return true + } + } + } + return false +} diff --git a/internal/supplychain/cargo_patches_test.go b/internal/supplychain/cargo_patches_test.go new file mode 100644 index 0000000..13eff63 --- /dev/null +++ b/internal/supplychain/cargo_patches_test.go @@ -0,0 +1,100 @@ +package supplychain + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/repository" +) + +func TestCargoGovernedReplacementRetainsTransitiveRangesAndSourceCoverage(t *testing.T) { + t.Parallel() + repo := governedCargoPatchRepository(t) + for _, selected := range [][]string{{"Cargo.toml", "vendor/replacement/Cargo.toml"}, {"vendor/replacement/Cargo.toml"}} { + findings := Static(t.Context(), repo, selected) + if len(findings) != 1 || findings[0].Check != "supplyChain.cargoPatchSourceCoverage" || findings[0].Severity != policy.FindingWarning { + t.Fatalf("governed replacement findings = %+v", findings) + } + } + if findings := DependencyOverrideGovernanceFindings(repo, []string{"Cargo.toml"}); len(findings) != 0 { + t.Fatalf("used Cargo override was classified as unused: %+v", findings) + } +} + +func TestCargoReplacementGovernanceDoesNotHideChangedOrUnownedInputs(t *testing.T) { + t.Parallel() + for name, change := range map[string]func(repository.Repository){ + "unowned": func(repo repository.Repository) { + repo.Config.SupplyChain.DependencyOverridePolicies[0].Path = "other/Cargo.toml" + }, + "changed owner": func(repo repository.Repository) { + repo.Config.SupplyChain.DependencyOverridePolicies[0].ContentSHA256 = strings.Repeat("0", 64) + }, + "wrong ecosystem": func(repo repository.Repository) { + repo.Config.SupplyChain.DependencyOverridePolicies[0].Ecosystem = "npm" + }, + } { + t.Run(name, func(t *testing.T) { + repo := governedCargoPatchRepository(t) + change(repo) + findings := Static(t.Context(), repo, []string{"Cargo.toml", "vendor/replacement/Cargo.toml"}) + if !supplyChecks(findings)["supplyChain.exactVersion"] || !supplyChecks(findings)["supplyChain.cargoSource"] { + t.Fatalf("unreviewed replacement bypassed native policy: %+v", findings) + } + }) + } +} + +func TestCargoReplacementStillPinsConsumerAndGitDependencies(t *testing.T) { + t.Parallel() + repo := governedCargoPatchRepository(t) + data, err := repo.Read("Cargo.toml") + if err != nil { + t.Fatal(err) + } + writeSupplyFile(t, repo.Root, "Cargo.toml", strings.Replace(string(data), "'=1.0.0'", "'1.0'", 1)) + writeSupplyFile(t, repo.Root, "vendor/replacement/Cargo.toml", "[package]\nname='replacement'\n[dependencies]\ntransitive='1.2'\nmutable={git='https://github.com/example/crate',branch='main'}\n") + findings := Static(t.Context(), repo, []string{"Cargo.toml", "vendor/replacement/Cargo.toml"}) + if !slices.ContainsFunc(findings, func(f policy.Finding) bool { return f.Path == "Cargo.toml" && f.Check == "supplyChain.exactVersion" }) { + t.Fatalf("consumer range passed as a transitive dependency: %+v", findings) + } + if !slices.ContainsFunc(findings, func(f policy.Finding) bool { + return f.Path == "vendor/replacement/Cargo.toml" && f.Check == "supplyChain.cargoSource" + }) { + t.Fatalf("moving Git source passed in replacement: %+v", findings) + } +} + +func TestCargoOverrideExpiryAndRemovalRemainVisible(t *testing.T) { + t.Parallel() + repo := governedCargoPatchRepository(t) + repo.Config.SupplyChain.DependencyOverridePolicies[0].Expires = policy.Date{Time: time.Now().UTC().AddDate(0, 0, -1)} + if findings := DependencyOverrideGovernanceFindings(repo, []string{"Cargo.toml"}); !supplyChecks(findings)["policy.dependencyOverridePolicyExpired"] { + t.Fatalf("expired Cargo owner passed: %+v", findings) + } + repo.Config.SupplyChain.DependencyOverridePolicies[0].Expires = policy.Date{Time: time.Now().UTC().AddDate(0, 0, 1)} + writeSupplyFile(t, repo.Root, "Cargo.toml", "[package]\nname='app'\n") + if findings := DependencyOverrideGovernanceFindings(repo, []string{"Cargo.toml"}); !supplyChecks(findings)["policy.dependencyOverridePolicyUnused"] { + t.Fatalf("removed Cargo patch retained stale governance: %+v", findings) + } +} + +func governedCargoPatchRepository(t *testing.T) repository.Repository { + t.Helper() + repo := supplyRepository(t) + writeSupplyFile(t, repo.Root, "Cargo.toml", "[package]\nname='app'\n[dependencies]\nreplacement='=1.0.0'\n[patch.crates-io]\nreplacement={path='vendor/replacement'}\n") + writeSupplyFile(t, repo.Root, "vendor/replacement/Cargo.toml", "[package]\nname='replacement'\n[dependencies]\ntransitive='1.2'\n[dev-dependencies]\nnot-consumed='9'\n") + manifest := repo.InspectCargoManifests([]string{"Cargo.toml"}).Manifests[0] + digest, err := canonicalJSONSHA256(manifest.Patches["patch.crates-io"]) + if err != nil { + t.Fatal(err) + } + repo.Config.SupplyChain.DependencyOverridePolicies = []policy.DependencyOverridePolicy{{ + ID: "replacement", Ecosystem: "cargo", Path: "Cargo.toml", Field: "patch.crates-io", ContentSHA256: digest, + Reason: "Reviewed contained replacement", Owner: "fixture", Reviewed: policy.Date{Time: time.Now().UTC()}, Expires: policy.Date{Time: time.Now().UTC().AddDate(0, 0, 1)}, + }} + return repo +} diff --git a/internal/supplychain/node_governance.go b/internal/supplychain/node_governance.go index 279f73e..9eda42c 100644 --- a/internal/supplychain/node_governance.go +++ b/internal/supplychain/node_governance.go @@ -23,7 +23,7 @@ func nodeManifestGovernanceFindings(repo repository.Repository, path, manager st if !nonemptyJSONObject(block.raw) { continue } - findings = append(findings, nodeOverrideBlockFindings(repo, path, manager, block.field, block.raw)...) + findings = append(findings, dependencyOverrideBlockFindings(repo, path, manager, block.field, block.raw)...) } return findings } @@ -52,7 +52,7 @@ func nodeOverrideBlocks(payload packageJSON) []nodeOverrideBlock { } } -func nodeOverrideBlockFindings(repo repository.Repository, path, manager, field string, raw json.RawMessage) []policy.Finding { +func dependencyOverrideBlockFindings(repo repository.Repository, path, manager, field string, raw json.RawMessage) []policy.Finding { digest, err := canonicalJSONSHA256(raw) if err != nil { return []policy.Finding{{Check: "supplyChain.dependencyOverride", Path: path, Subject: field, Message: err.Error()}} @@ -90,6 +90,13 @@ func dependencyOverrideGovernanceFinding(repo repository.Repository, files []str if !slices.Contains(files, governance.Path) { return unusedDependencyOverridePolicy(governance), true } + if governance.Ecosystem == "cargo" { + inventory := repo.InspectCargoManifests([]string{governance.Path}) + if len(inventory.Manifests) == 1 && nonemptyJSONObject(inventory.Manifests[0].Patches[governance.Field]) { + return policy.Finding{}, false + } + return unusedDependencyOverridePolicy(governance), true + } payload, ok := readPackageJSON(repo, governance.Path) if !ok { return unusedDependencyOverridePolicy(governance), true diff --git a/internal/supplychain/osv_informational.go b/internal/supplychain/osv_informational.go index 013a100..7bb3dc2 100644 --- a/internal/supplychain/osv_informational.go +++ b/internal/supplychain/osv_informational.go @@ -25,6 +25,9 @@ func osvUnmaintainedGroup(group osvGroup, observed osvPackageResult, identifiers if group.MaxSeverity != "" || observed.Package.Ecosystem != "crates.io" || len(identifiers) == 0 { return false } + if slices.ContainsFunc(identifiers, func(id string) bool { return strings.HasPrefix(id, "CVE-") }) { + return false + } covered := map[string]bool{} for _, advisory := range observed.Vulnerabilities { aliases := append([]string{advisory.ID}, advisory.Aliases...) diff --git a/internal/supplychain/osv_informational_test.go b/internal/supplychain/osv_informational_test.go index 51e3daa..dfcce13 100644 --- a/internal/supplychain/osv_informational_test.go +++ b/internal/supplychain/osv_informational_test.go @@ -68,6 +68,7 @@ func TestOSVUnmaintainedClassificationPreservesUncertainAndSecurityFindings(t *t p.Vulnerabilities[0].Affected[0].DatabaseSpecific.CVSS = json.RawMessage(`"CVSS:3.1/AV:N"`) }, "unrepresented alias": func(p *osvPackageResult) { p.Groups[0].Aliases = []string{"CVE-2026-1234"} }, + "CVE alias": func(p *osvPackageResult) { p.Vulnerabilities[0].Aliases = []string{"CVE-2026-1234"} }, "mixed group": func(p *osvPackageResult) { p.Groups[0].IDs = append(p.Groups[0].IDs, "RUSTSEC-2026-0001") p.Vulnerabilities = append(p.Vulnerabilities, osvVulnerability{ID: "RUSTSEC-2026-0001"}) diff --git a/internal/supplychain/supplychain.go b/internal/supplychain/supplychain.go index d9a3198..bd057f6 100644 --- a/internal/supplychain/supplychain.go +++ b/internal/supplychain/supplychain.go @@ -64,8 +64,11 @@ func DependencyReviewStatic(ctx context.Context, repo repository.Repository, fil } func static(ctx context.Context, repo repository.Repository, files []string, includeInstalledLicenses bool) []policy.Finding { - findings := []policy.Finding{} + findings := cargoStaticFindings(repo, files) for _, path := range files { + if filepath.Base(path) == "Cargo.toml" { + continue + } findings = append(findings, staticPathFindings(repo, path)...) } findings = append(findings, pnpmGovernanceFindings(ctx, repo, files)...) diff --git a/schema/code-polishy-supply-chain.schema.json b/schema/code-polishy-supply-chain.schema.json index 3a4a0b4..e0dee0c 100644 --- a/schema/code-polishy-supply-chain.schema.json +++ b/schema/code-polishy-supply-chain.schema.json @@ -227,9 +227,9 @@ "required": ["id", "ecosystem", "path", "field"], "properties": { "id": { "$ref": "code-polishy.schema.json#/$defs/identifier" }, - "ecosystem": { "enum": ["npm", "pnpm"] }, + "ecosystem": { "enum": ["npm", "pnpm", "cargo"] }, "path": { "$ref": "code-polishy.schema.json#/$defs/concretePath" }, - "field": { "enum": ["overrides", "pnpm.overrides"] }, + "field": { "enum": ["overrides", "pnpm.overrides", "patch.crates-io"] }, "contentSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "reason": { "$ref": "code-polishy.schema.json#/$defs/nonemptyString" }, "owner": { "$ref": "code-polishy.schema.json#/$defs/nonemptyString" }, From 2e3222d5c48ff9ddf4f9802d1510984d4d2b14cc Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:30:42 -0400 Subject: [PATCH 5/8] Keep scanner runtime assertions within the test complexity boundary --- .../supplychain/osv_cargo_runtime_test.go | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/supplychain/osv_cargo_runtime_test.go b/internal/supplychain/osv_cargo_runtime_test.go index 0195a53..e6ab4bc 100644 --- a/internal/supplychain/osv_cargo_runtime_test.go +++ b/internal/supplychain/osv_cargo_runtime_test.go @@ -59,17 +59,22 @@ func testOSVCargoPinnedRuntimeProjection(t *testing.T, aliased bool) { } continue } - if len(findings) != 3 || !slices.ContainsFunc(findings, func(f policy.Finding) bool { - return f.Vulnerability != nil && f.Vulnerability.Advisory == "RUSTSEC-2026-0001" && f.Vulnerability.Scope == "Cargo.lock" + assertCargoOSVFindings(t, findings) + } +} + +func assertCargoOSVFindings(t *testing.T, findings []policy.Finding) { + t.Helper() + if len(findings) != 3 || !slices.ContainsFunc(findings, func(f policy.Finding) bool { + return f.Vulnerability != nil && f.Vulnerability.Advisory == "RUSTSEC-2026-0001" && f.Vulnerability.Scope == "Cargo.lock" + }) { + t.Fatalf("pinned Cargo scanner lost complete package or vulnerability evidence: %+v", findings) + } + for _, version := range []string{"1.0.0", "2.0.0"} { + if !slices.ContainsFunc(findings, func(f policy.Finding) bool { + return f.Check == "supplyChain.osvUnmaintained" && f.Severity == policy.FindingWarning && f.Path == "Cargo.lock" && f.Fields["advisory"] == "RUSTSEC-2026-0002" && f.Fields["version"] == version }) { - t.Fatalf("pinned Cargo scanner lost complete package or vulnerability evidence: %+v", findings) - } - for _, version := range []string{"1.0.0", "2.0.0"} { - if !slices.ContainsFunc(findings, func(f policy.Finding) bool { - return f.Check == "supplyChain.osvUnmaintained" && f.Severity == policy.FindingWarning && f.Path == "Cargo.lock" && f.Fields["advisory"] == "RUSTSEC-2026-0002" && f.Fields["version"] == version - }) { - t.Fatalf("pinned Cargo scanner lost maintenance classification for %s: %+v", version, findings) - } + t.Fatalf("pinned Cargo scanner lost maintenance classification for %s: %+v", version, findings) } } } From 8747d857f24a83fcab1869f939d7040e11fbc2df Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:48:53 -0400 Subject: [PATCH 6/8] Bind container scan reports to saved image configuration identity --- .code-polishy.json | 9 ++ CHANGELOG.md | 16 +++ VERSION | 2 +- docs/artifact-security.md | 5 + docs/design/artifact-identity.md | 21 +++ internal/artifactsecurity/archive.go | 86 +++++++++++++ internal/artifactsecurity/archive_test.go | 121 ++++++++++++++++++ .../artifactsecurity/artifactsecurity_test.go | 19 ++- internal/artifactsecurity/run.go | 15 +-- .../testdata/docker29-image.tar | Bin 0 -> 7168 bytes 10 files changed, 273 insertions(+), 21 deletions(-) create mode 100644 docs/design/artifact-identity.md create mode 100644 internal/artifactsecurity/archive.go create mode 100644 internal/artifactsecurity/archive_test.go create mode 100644 internal/artifactsecurity/testdata/docker29-image.tar diff --git a/.code-polishy.json b/.code-polishy.json index a708521..38bfb37 100644 --- a/.code-polishy.json +++ b/.code-polishy.json @@ -24,6 +24,15 @@ }, "documentation": { "design": [ + { + "path": "docs/design/artifact-identity.md", + "sourcePaths": [ + "internal/artifactsecurity/archive.go", + "internal/artifactsecurity/archive_test.go", + "internal/artifactsecurity/artifactsecurity_test.go", + "internal/artifactsecurity/run.go" + ] + }, { "path": "docs/design/trivy-scanner-assessments.md", "module": "artifactsecurity" diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f7414..75c637c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## 0.29.3 - 2026-10-18 + +- Resolve cyclic Rust glob exports and root `extern crate` aliases without + losing valid imports, and use bounded, distinct Cargo command identities in + gates while preserving their original manifest arguments. +- Govern explicit Cargo replacement sources through the actual consumer lock. + Verify exact source bindings and that every replacement is consumed, retain + root dependency pins, and expose local-source provenance limits as findings. +- Preserve explicitly classified RustSec unmaintained notices as warnings. + Vulnerabilities, severity-bearing records and incomplete classifications + remain subject to the existing security checks. +- Save built container images by immutable Docker identity and compare Trivy + reports with the saved configuration digest. Support Docker's legacy and + containerd image stores while rejecting missing or ambiguous archive identity + and mismatched scanner reports. + ## 0.29.2 - 2026-10-18 - Preserve governed source counts and line-budget headroom when workspace-only diff --git a/VERSION b/VERSION index 20f0687..5540b6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.2 +0.29.3 diff --git a/docs/artifact-security.md b/docs/artifact-security.md index 02297e7..e943bfa 100644 --- a/docs/artifact-security.md +++ b/docs/artifact-security.md @@ -38,6 +38,10 @@ HIGH and CRITICAL vulnerabilities, secrets, image-config misconfigurations, and end-of-life operating systems are blocking. Secret matches are never copied into normalized findings or reports. +Built images are saved by immutable Docker identity. Trivy reports must match +the configuration digest from that exact saved archive; see +[Container Archive Identity](design/artifact-identity.md). + ## Target modes Use `dockerfile` when the shared engine can build the product directly: @@ -99,6 +103,7 @@ beneath the supplied directory: The producer owns only product construction. It must not run Trivy, download a database, apply OpenVEX, or publish security reports. +Its optional `imageId` denotes the configuration digest reported by Trivy. ## OpenVEX diff --git a/docs/design/artifact-identity.md b/docs/design/artifact-identity.md new file mode 100644 index 0000000..5ba008a --- /dev/null +++ b/docs/design/artifact-identity.md @@ -0,0 +1,21 @@ +# Container Archive Identity + +For images built by the engine, Docker's inspected ID selects the immutable +image passed to `image save`. Docker's containerd image store may use a manifest +digest for this ID; Trivy's `Metadata.ImageID` identifies the image configuration. +They are different objects and cannot be compared directly. + +The engine therefore reads the single configuration named by the saved Docker +archive's `manifest.json` and derives its content digest for the Trivy comparison. +Both legacy Docker and containerd exports provide this manifest. The archive +and metadata retain their release-owned size limits, and missing, duplicate, +linked or ambiguous identity records fail before scanning. The saved image is +never selected again through a mutable tag. A Trivy configuration mismatch still +fails, including when OpenVEX is applied. + +The Docker 29 regression archive in `internal/artifactsecurity/testdata` was +exported from a dependency-free scratch image with one purpose label. Its +configuration ID is +`sha256:0cf74d99b5a97c3748ef2e9f91839752b96b00d2427cb57f18919e8ed7d01ad6`; +the distinct Docker manifest ID is +`sha256:13c9308faace219384c34d1108b928face27e7e1881b1d9b0dd0ec1e84569c68`. diff --git a/internal/artifactsecurity/archive.go b/internal/artifactsecurity/archive.go new file mode 100644 index 0000000..dff3c55 --- /dev/null +++ b/internal/artifactsecurity/archive.go @@ -0,0 +1,86 @@ +package artifactsecurity + +import ( + "archive/tar" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +func saveDockerImage(ctx context.Context, client dockerClient, operation, image, archive string, maximum, metadataMaximum int64) (string, error) { + identity, err := inspectImageID(ctx, client, operation+"-inspect", image) + if err != nil { + return "", err + } + if _, err := client.run(ctx, operation+"-save", "image", "save", "--output", archive, identity); err != nil { + return "", err + } + return dockerArchiveImageID(archive, maximum, metadataMaximum) +} + +func dockerArchiveImageID(archive string, maximum, metadataMaximum int64) (string, error) { + payload, err := dockerArchiveEntry(archive, "manifest.json", maximum, metadataMaximum) + if err != nil { + return "", err + } + var manifest []struct { + Config string `json:"Config"` + } + if err := json.Unmarshal(payload, &manifest); err != nil { + return "", fmt.Errorf("decode Docker archive manifest: %w", err) + } + if len(manifest) != 1 || manifest[0].Config == "" { + return "", errors.New("Docker archive must identify exactly one image configuration") + } + config, err := dockerArchiveEntry(archive, manifest[0].Config, maximum, metadataMaximum) + if err != nil { + return "", err + } + return "sha256:" + bytesSHA256(config), nil +} + +func dockerArchiveEntry(archive, name string, maximum, entryMaximum int64) ([]byte, error) { + info, err := os.Lstat(archive) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > maximum { + return nil, fmt.Errorf("Docker archive must be a regular file no larger than %d bytes", maximum) + } + file, err := os.Open(archive) + if err != nil { + return nil, err + } + defer file.Close() + return readDockerArchiveEntry(tar.NewReader(file), name, entryMaximum) +} + +func readDockerArchiveEntry(reader *tar.Reader, name string, maximum int64) ([]byte, error) { + var payload []byte + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + if header.Name != name { + continue + } + if payload != nil || header.Typeflag != tar.TypeReg || header.Size > maximum { + return nil, fmt.Errorf("Docker archive entry %q must be one bounded regular file", name) + } + payload, err = io.ReadAll(reader) + if err != nil { + return nil, err + } + } + if payload == nil { + return nil, fmt.Errorf("Docker archive omits %q", name) + } + return payload, nil +} diff --git a/internal/artifactsecurity/archive_test.go b/internal/artifactsecurity/archive_test.go new file mode 100644 index 0000000..94bf2fd --- /dev/null +++ b/internal/artifactsecurity/archive_test.go @@ -0,0 +1,121 @@ +package artifactsecurity + +import ( + "archive/tar" + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/riteofstring/code-polishy/internal/policy" + "github.com/riteofstring/code-polishy/internal/repository" +) + +const archiveFixtureConfig = `{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}` + +func TestDockerArchiveConfigurationIdentity(t *testing.T) { + actual, err := dockerArchiveImageID(filepath.Join("testdata", "docker29-image.tar"), 1024*1024, 1024*1024) + const expected = "sha256:0cf74d99b5a97c3748ef2e9f91839752b96b00d2427cb57f18919e8ed7d01ad6" + if err != nil || actual != expected { + t.Fatalf("Docker 29 archive configuration identity = %q, error = %v", actual, err) + } + archive := filepath.Join(t.TempDir(), "legacy.tar") + payload := fixtureDockerArchive(t, []archiveFixtureEntry{ + {name: "manifest.json", body: `[{"Config":"image.json","Layers":[]}]`}, + {name: "image.json", body: archiveFixtureConfig}, + }) + if err := os.WriteFile(archive, payload, 0o600); err != nil { + t.Fatal(err) + } + actual, err = dockerArchiveImageID(archive, 1024*1024, 1024*1024) + if err != nil || actual != "sha256:c5b1d63604f273462ef36fadac3182d43ae6a6138731cf594b314835cf1c034f" { + t.Fatalf("Docker legacy archive configuration identity = %q, error = %v", actual, err) + } +} + +func TestDockerArchiveRejectsMissingAmbiguousAndUnboundedIdentity(t *testing.T) { + cases := map[string][]archiveFixtureEntry{ + "missing manifest": {{name: "image.json", body: archiveFixtureConfig}}, + "invalid manifest": {{name: "manifest.json", body: "["}}, + "missing configuration": {{name: "manifest.json", body: `[{"Config":"missing.json"}]`}}, + "empty configuration reference": {{name: "manifest.json", body: `[{}]`}}, + "multiple images": {{name: "manifest.json", body: `[{"Config":"one.json"},{"Config":"two.json"}]`}}, + "duplicate manifest": {{name: "manifest.json", body: "[]"}, {name: "manifest.json", body: "[]"}}, + "linked manifest": {{name: "manifest.json", kind: tar.TypeSymlink}}, + "oversized manifest": {{name: "manifest.json", body: strings.Repeat(" ", 64*1024+1)}}, + "duplicate configuration": { + {name: "manifest.json", body: `[{"Config":"image.json"}]`}, + {name: "image.json", body: archiveFixtureConfig}, {name: "image.json", body: archiveFixtureConfig}, + }, + "linked configuration": { + {name: "manifest.json", body: `[{"Config":"image.json"}]`}, {name: "image.json", kind: tar.TypeLink}, + }, + "oversized configuration": { + {name: "manifest.json", body: `[{"Config":"image.json"}]`}, {name: "image.json", body: strings.Repeat(" ", 1024*1024+1)}, + }, + } + for name, entries := range cases { + t.Run(name, func(t *testing.T) { + archive := filepath.Join(t.TempDir(), "image.tar") + if err := os.WriteFile(archive, fixtureDockerArchive(t, entries), 0o600); err != nil { + t.Fatal(err) + } + if identity, err := dockerArchiveImageID(archive, 2*1024*1024, 64*1024); err == nil { + t.Fatalf("malformed archive accepted with identity %q", identity) + } + }) + } + if _, err := dockerArchiveImageID(filepath.Join("testdata", "docker29-image.tar"), 1, 1024*1024); err == nil { + t.Fatal("oversized archive was accepted") + } +} + +func TestRunRejectsScannerConfigurationMismatch(t *testing.T) { + root := t.TempDir() + policyRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + repo := repository.Repository{ + Root: root, PolicyRoot: policyRoot, + Config: policy.Config{SupplyChain: policy.SupplyChain{ArtifactSecurity: policy.ArtifactSecurity{ + Targets: []policy.ArtifactTarget{{Name: "agent", Mode: "archive", Archive: "agent.tar"}}, + }}}, + } + boundary := newScannerBoundary(t, policyRoot) + boundary.imageID = "sha256:" + strings.Repeat("c", 64) + result := Run(t.Context(), repo, boundary) + if len(result.Findings) != 1 || result.Findings[0].Subject != "code-polishy-scanner:self-scan" || + !strings.Contains(result.Findings[0].Message, "does not match") { + t.Fatalf("wrong scanner configuration identity was not refused: %+v", result) + } +} + +type archiveFixtureEntry struct { + name string + body string + kind byte +} + +func fixtureDockerArchive(t *testing.T, entries []archiveFixtureEntry) []byte { + t.Helper() + var buffer bytes.Buffer + writer := tar.NewWriter(&buffer) + for _, entry := range entries { + header := &tar.Header{Name: entry.name, Mode: 0o600, Size: int64(len(entry.body)), Typeflag: entry.kind} + if entry.kind == tar.TypeSymlink || entry.kind == tar.TypeLink { + header.Linkname = "image.json" + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(entry.body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/internal/artifactsecurity/artifactsecurity_test.go b/internal/artifactsecurity/artifactsecurity_test.go index b4a60ce..d1734c3 100644 --- a/internal/artifactsecurity/artifactsecurity_test.go +++ b/internal/artifactsecurity/artifactsecurity_test.go @@ -434,7 +434,7 @@ func newScannerBoundary(t *testing.T, policyRoot string) *scannerBoundary { } return &scannerBoundary{ t: t, containers: map[string][]string{}, findings: findings, - imageID: "sha256:" + strings.Repeat("a", 64), + imageID: "sha256:" + bytesSHA256([]byte(archiveFixtureConfig)), version: strings.TrimSpace(string(version)), } } @@ -502,7 +502,7 @@ func (boundary *scannerBoundary) imageCommand(arguments []string) (runner.Output if strings.Contains(format, "RepoDigests") { return runner.Output{Stdout: []byte(`["` + arguments[len(arguments)-1] + `"]` + "\n")}, nil } - return runner.Output{Stdout: []byte(boundary.imageID + "\n")}, nil + return runner.Output{Stdout: []byte("sha256:" + strings.Repeat("b", 64) + "\n")}, nil case "save": path, ok := flagValue(arguments, "--output") if !ok { @@ -511,10 +511,13 @@ func (boundary *scannerBoundary) imageCommand(arguments []string) (runner.Output if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return runner.Output{}, err } - payload := []byte("target image") - if strings.Contains(arguments[len(arguments)-1], "artifact-scanner") { - payload = []byte("scanner image") + if arguments[len(arguments)-1] != "sha256:"+strings.Repeat("b", 64) { + return runner.Output{}, fmt.Errorf("image save did not use the inspected immutable identity") } + payload := fixtureDockerArchive(boundary.t, []archiveFixtureEntry{ + {name: "manifest.json", body: `[{"Config":"image.json","Layers":[]}]`}, + {name: "image.json", body: archiveFixtureConfig}, + }) return runner.Output{}, os.WriteFile(path, payload, 0o600) default: return runner.Output{}, fmt.Errorf("unsupported image operation %v", arguments) @@ -599,11 +602,7 @@ func (boundary *scannerBoundary) writeVulnerabilityReport(arguments []string) er findings := []map[string]any{} archive, archiveFound := mountSource(arguments, "/input/image.tar") if archiveFound && !slices.Contains(arguments, "--vex") { - payload, err := os.ReadFile(archive) - if err != nil { - return err - } - if string(payload) == "scanner image" { + if filepath.Base(filepath.Dir(archive)) == "code-polishy-scanner" { findings = boundary.findings } } diff --git a/internal/artifactsecurity/run.go b/internal/artifactsecurity/run.go index cb1374a..c84e054 100644 --- a/internal/artifactsecurity/run.go +++ b/internal/artifactsecurity/run.go @@ -123,10 +123,8 @@ func scanScannerRuntime(ctx context.Context, repo repository.Repository, state * return err } archive := filepath.Join(directory, "image.tar") - if _, err := state.client.run(ctx, governedDockerCommandPrefix+"save-scanner", "image", "save", "--output", archive, state.image); err != nil { - return err - } - imageID, err := inspectImageID(ctx, state.client, governedDockerCommandPrefix+"inspect-scanner", state.image) + imageID, err := saveDockerImage(ctx, state.client, governedDockerCommandPrefix+"scanner", state.image, archive, + state.policy.MaximumArchiveBytes, state.policy.MaximumReportBytes) if err != nil { return err } @@ -379,18 +377,15 @@ func prepareDockerfileArtifact(ctx context.Context, repo repository.Repository, _, err := state.client.cleanup(cleanupContext, governedDockerCommandPrefix+"target-"+target.Name+"-remove", "image", "rm", "--force", tag) return err } - imageID, err := inspectImageID(ctx, state.client, governedDockerCommandPrefix+"target-"+target.Name+"-inspect", tag) - if err != nil { - _ = cleanup() - return preparedArtifact{}, err - } directory := filepath.Join(state.root, "targets", target.Name) if err := os.MkdirAll(directory, 0o700); err != nil { _ = cleanup() return preparedArtifact{}, err } archive := filepath.Join(directory, "image.tar") - if _, err := state.client.run(ctx, governedDockerCommandPrefix+"target-"+target.Name+"-save", "image", "save", "--output", archive, tag); err != nil { + imageID, err := saveDockerImage(ctx, state.client, governedDockerCommandPrefix+"target-"+target.Name, tag, archive, + state.policy.MaximumArchiveBytes, state.policy.MaximumReportBytes) + if err != nil { _ = cleanup() return preparedArtifact{}, err } diff --git a/internal/artifactsecurity/testdata/docker29-image.tar b/internal/artifactsecurity/testdata/docker29-image.tar new file mode 100644 index 0000000000000000000000000000000000000000..9bb2727dce58ce2cf14e9b1ea10d39f75fbd72fb GIT binary patch literal 7168 zcmeHKOK;mS49Aci=J?NcuU>Hdh_1qK2!{>e3>Yd;K$2~7PDXO*drnbxgq+@M4 z0D2N3_G zIk^YVj`%<4{{k_?$I41>8za#Wv5AO7)JI%bg&LvZ0)47xX!rwtIlugTHfU-yC^cv0 zpw3k_yf@!Y?-p9oY6`FDs*k>FbEB%!M`gg&0L)ylT$Z&nqs_?YTF7@(U8;IK_ot1S z>bk|`AkL(xgsiNxbI-_;k#((fw$tu)}C=i}v_-K6OWJH`lNtR8d?j0c-9s%jM3ceZ9#J|4G2J0H+0GTvCz+ zX~=_65Q5V@qaX%pA`?l{l;lKYITiv-PGlNJamM3x4=hLar}!Vnce!BAe?p^<|9jl| zs^Y~P&qFmI%L)4?jbjuVdbIDP6DbtCY_Fw<*mPPd&Ma&g%qp?I>b?Mq8I?Cq?oU{y z3Rzo!M&JIdK~t+{lSn*g>JQbzHaZEtH$&YWvUYL)_2i5I{wE`)c@#i&28;rLMWjf z{}1!>%XTjwlAreP6aNwKHCJm+u5QkjnP|g)s8~>M??2lH+P3+uEx)TQ1(;TuWn=T`#Ijso{nI&P6SY&Ofamy661v;}6Oy?9|E~Xk z8K)2V3aI*F-RYy9UhwFf-$!5NR9~{ux`lPUJ~YCchW6?Zl-=G_c>wbHJ7{rqV^3wF z*K+%*{@?xVyBx6|UHv~@C?08Epti#XYQh!R{sAE3V;`e`x^g`_N+;+%(0QQqK<9x2 GJn$Q7fIC(I literal 0 HcmV?d00001 From 73e1f7bf2c8f3b858d08a10baade52a601b62301 Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:50:04 -0400 Subject: [PATCH 7/8] Use conventional casing in archive identity errors --- internal/artifactsecurity/archive.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/artifactsecurity/archive.go b/internal/artifactsecurity/archive.go index dff3c55..6116433 100644 --- a/internal/artifactsecurity/archive.go +++ b/internal/artifactsecurity/archive.go @@ -33,7 +33,7 @@ func dockerArchiveImageID(archive string, maximum, metadataMaximum int64) (strin return "", fmt.Errorf("decode Docker archive manifest: %w", err) } if len(manifest) != 1 || manifest[0].Config == "" { - return "", errors.New("Docker archive must identify exactly one image configuration") + return "", errors.New("saved Docker archive must identify exactly one image configuration") } config, err := dockerArchiveEntry(archive, manifest[0].Config, maximum, metadataMaximum) if err != nil { @@ -48,7 +48,7 @@ func dockerArchiveEntry(archive, name string, maximum, entryMaximum int64) ([]by return nil, err } if !info.Mode().IsRegular() || info.Size() > maximum { - return nil, fmt.Errorf("Docker archive must be a regular file no larger than %d bytes", maximum) + return nil, fmt.Errorf("saved Docker archive must be a regular file no larger than %d bytes", maximum) } file, err := os.Open(archive) if err != nil { @@ -72,7 +72,7 @@ func readDockerArchiveEntry(reader *tar.Reader, name string, maximum int64) ([]b continue } if payload != nil || header.Typeflag != tar.TypeReg || header.Size > maximum { - return nil, fmt.Errorf("Docker archive entry %q must be one bounded regular file", name) + return nil, fmt.Errorf("saved Docker archive entry %q must be one bounded regular file", name) } payload, err = io.ReadAll(reader) if err != nil { @@ -80,7 +80,7 @@ func readDockerArchiveEntry(reader *tar.Reader, name string, maximum int64) ([]b } } if payload == nil { - return nil, fmt.Errorf("Docker archive omits %q", name) + return nil, fmt.Errorf("saved Docker archive omits %q", name) } return payload, nil } From 62d164d3e1795643eca645894966b2beead082e5 Mon Sep 17 00:00:00 2001 From: Jeff Musgrave <1578618+JeffMusgrave@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:00:30 -0400 Subject: [PATCH 8/8] Catalog the container archive identity design --- docs/catalog.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/catalog.json b/docs/catalog.json index dc6d100..599d21a 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -65,6 +65,14 @@ "aliases": ["artifacts"], "public": true }, + { + "id": "artifact-security-identity", + "path": "docs/design/artifact-identity.md", + "title": "Container Archive Identity", + "summary": "Bind scanner reports to the configuration in the immutable Docker image archive.", + "aliases": ["container-identity"], + "public": true + }, { "id": "artifact-security-scanner", "path": "docs/design/trivy-scanner-assessments.md",