diff --git a/.code-polishy.json b/.code-polishy.json index cb75ecd..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" @@ -112,6 +121,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 +143,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", @@ -150,6 +162,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/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/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", 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/docs/design/cargo-facts.md b/docs/design/cargo-facts.md index 725483c..7e64dd0 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 @@ -44,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 @@ -190,6 +210,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/docs/design/rust-inputs.md b/docs/design/rust-inputs.md index ea84508..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 @@ -133,6 +160,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, 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 +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..5de38e8 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. @@ -468,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/artifactsecurity/archive.go b/internal/artifactsecurity/archive.go new file mode 100644 index 0000000..6116433 --- /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("saved 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("saved 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("saved 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("saved 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 0000000..9bb2727 Binary files /dev/null and b/internal/artifactsecurity/testdata/docker29-image.tar differ 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/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/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/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/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/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) +} diff --git a/internal/rustfacts/resolution.go b/internal/rustfacts/resolution.go index 0729a2a..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 } @@ -38,7 +45,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" @@ -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.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..e6ab4bc 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,8 +59,22 @@ 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" { - t.Fatalf("pinned Cargo scanner lost complete package or vulnerability evidence: %+v", findings) + 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 maintenance classification for %s: %+v", version, findings) } } } @@ -76,6 +92,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..7bb3dc2 --- /dev/null +++ b/internal/supplychain/osv_informational.go @@ -0,0 +1,77 @@ +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 + } + 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...) + 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..dfcce13 --- /dev/null +++ b/internal/supplychain/osv_informational_test.go @@ -0,0 +1,101 @@ +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"} }, + "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"}) + }, + "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) + } + }) + } +} 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" },