From 1533b4279e0b3c1083e026bc813e4a53a4b1b539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdulvahap=20=C3=96=C4=9F=C3=BCt?= <110431024+vahapogut@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:36:32 +0300 Subject: [PATCH 1/6] docs: record M5 verification and policy decisions --- docs/adr/0007-lazy-download-counts.md | 54 +++++++++++++++++++ ...0009-local-npm-attestation-verification.md | 42 +++++++++++++++ docs/adr/0010-reviewed-policy-exceptions.md | 16 ++++++ 3 files changed, 112 insertions(+) create mode 100644 docs/adr/0007-lazy-download-counts.md create mode 100644 docs/adr/0009-local-npm-attestation-verification.md create mode 100644 docs/adr/0010-reviewed-policy-exceptions.md diff --git a/docs/adr/0007-lazy-download-counts.md b/docs/adr/0007-lazy-download-counts.md new file mode 100644 index 0000000..4d54d17 --- /dev/null +++ b/docs/adr/0007-lazy-download-counts.md @@ -0,0 +1,54 @@ +# ADR 0007: Fetch download counts when an enabled check needs them + +Date: 2026-09-29 +Status: accepted + +## Context + +The loader currently prefetches downloads for every resolved package and the +runner reads them into every subject. Most checks do not use counts. npm supports +at most 128 unscoped names per bulk request, but scoped names need separate +requests. The official [download count documentation](https://github.com/npm/registry/blob/main/docs/download-counts.md) +was rechecked on 2026-09-29. A scan dominated by scoped packages pays for these +requests even when low-usage is disabled and typosquat-suspect needs no counts. + +Counts are evidence, so leaving a check enabled while quietly omitting a count +it needs would change the security result. TD012 gives registry counts precedence +over deps.dev LOW_USAGE, including when the numeric threshold is zero. TD008 can +use counts to identify a non-list neighbor and to decide whether an old candidate +has enough users to lower the finding's level. TD007 also uses counts on demand +for a newly introduced dependency. + +## Decision + +Remove download counts from the generic prefetch and subject-loading phases. +Offer an optional PrefetchDownloads operation on the loader, forwarded by the +baseline wrapper. The runner selects only resolved subjects with an enabled, +applicable, non-allowlisted low-usage check for that batch. The existing npm bulk +partitioning, memoization, partial-result handling and failure handling remain. +Loaders without the optional operation still work through their Downloads method. + +TD012 obtains its count through the loader when it runs. TD008 obtains the +candidate count only when a non-list similarly named package needs comparison, +or after a real candidate's age permits the established-package demotion. +TD007 keeps its existing on-demand lookups. Checks use a private copy of the +subject's download state so a timed-out check cannot race with a later check. +Transport failures keep their unavailable status; an unrequested count is neither +a fabricated zero nor an outage. Active allow entries for TD008 and TD012 produce +an explicit policy skip before those checks request data. + +Always loading counts was rejected because disabled and allowed findings cannot +use them. Disabling the default low-usage check or substituting deps.dev for an +unrequested registry count was rejected because either changes the findings. +Moving all requests into checks without batching was rejected because it would +replace efficient unscoped npm batches with individual requests. + +## Consequences + +Scans that disable or allow low-usage avoid counts unless TD008 or TD007 needs +them. Default low-usage still requires counts for every evaluated package, so this +change does not promise fewer scoped npm requests under the default policy. +Counts shared by checks and package versions remain memoized for the run. +Regression tests count real httptest requests as well as fake loader calls, and +assert findings, levels, fallback behavior, outages and allow expiry. No module, +report schema or policy schema is added. diff --git a/docs/adr/0009-local-npm-attestation-verification.md b/docs/adr/0009-local-npm-attestation-verification.md new file mode 100644 index 0000000..d5da830 --- /dev/null +++ b/docs/adr/0009-local-npm-attestation-verification.md @@ -0,0 +1,42 @@ +# 0009: Local npm attestations through an explicit Cosign verifier + +Status: accepted, 2026-09-29. + +## Context + +M5 requires a verification path independent of deps.dev indexing. Implementing +Sigstore certificate, SCT, transparency-log and DSSE verification ourselves would +create a new cryptographic implementation to maintain. Importing Cosign's Go +module graph would exceed the existing direct-dependency and binary budgets. + +## Decision + +`--verify-npm-attestations` explicitly enables a local Cosign 3.1.3 adapter. +The ordinary binary retains its current metadata-only behavior and dependencies. +The adapter fetches public npm bundles through trustdiff's bounded HTTP cache, +selects SLSA provenance, and binds its signed subject to the exact npm package +name, version and SHA512 registry integrity. It passes that digest and algorithm +to `cosign verify-blob-attestation`, retaining certificate, SCT and transparency +log checks. No tarball is downloaded or executed. Only GitHub Actions and GitLab +CI issuers are supported. The verified certificate identity is reported; a valid +attestation does not establish that a repository or its source code is benign. + +An executable path and trusted-root file can be selected explicitly. Without a +root file Cosign uses its upstream TUF trust distribution. Offline verification +requires a local root and a cached bundle; it never launches a network-capable +default-root lookup. The adapter bounds runtime and output and invokes no shell. +Verification failure leaves provenance explicitly unavailable, never rescued by +deps.dev in this mode. Local success is attributed to `cosign`, not the registry. + +## Consequences + +This feature is optional local verification, not an embedded Sigstore verifier. +Cosign is an additional installation only for users selecting this mode. The +six-module, CGO0 and 15MB default binary constraints remain in force. A pinned +real npm fixture plus tamper tests exercise the adapter and actual Cosign in +opt-in integration coverage; ordinary tests remain offline. + +Sources checked 2026-09-29: +- https://github.com/sigstore/cosign/blob/v3.1.3/doc/cosign_verify-blob-attestation.md +- https://docs.sigstore.dev/cosign/system_config/custom_components/ +- https://docs.npmjs.com/generating-provenance-statements/ diff --git a/docs/adr/0010-reviewed-policy-exceptions.md b/docs/adr/0010-reviewed-policy-exceptions.md new file mode 100644 index 0000000..a0dd0ef --- /dev/null +++ b/docs/adr/0010-reviewed-policy-exceptions.md @@ -0,0 +1,16 @@ +# 0010: Narrow, reviewed policy exceptions + +Status: accepted, 2026-09-29. + +`policy allow --reason ... --expires YYYY-MM-DD` builds an +entry in the existing policy schema. Check IDs and names are accepted, while +wildcards, missing ecosystems, empty reasons and expired dates are refused. +Versioned references restrict the exception to that version; an unversioned +reference explicitly selects all versions of that one package. + +The default is a unified-diff preview. `--write` opts into a timestamped backup +and atomic replacement, preserving the original file's comments and all lines +outside the allow sequence. Ambiguous anchors or flow-style containers are +refused rather than rewritten. Existing identical entries are a no-op; existing +entries selecting the same check/package with a different review are not silently +replaced. A strict schema parse validates both before and after the edit. From 14d79ec02b6bc5efa460fbe85447e2ec7e41aa3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdulvahap=20=C3=96=C4=9F=C3=BCt?= <110431024+vahapogut@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:36:46 +0300 Subject: [PATCH 2/6] docs: define watch observations and GuardDog handoff --- docs/adr/0006-watch-observations.md | 49 +++++++++++++++++++++++++++++ docs/adr/0008-guarddog-handoff.md | 26 +++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 docs/adr/0006-watch-observations.md create mode 100644 docs/adr/0008-guarddog-handoff.md diff --git a/docs/adr/0006-watch-observations.md b/docs/adr/0006-watch-observations.md new file mode 100644 index 0000000..de0c6ac --- /dev/null +++ b/docs/adr/0006-watch-observations.md @@ -0,0 +1,49 @@ +# 0006: Watch reviewed baseline observations without approving changes + +Status: accepted + +## Context + +A pinned version can gain an advisory or its package can change owners without a +lockfile edit. `diff` cannot notice that event. A baseline already records exact +package versions and observed trust signals, but updating that file would approve +the very changes a monitor should report. + +## Decision + +`watch [path]` loads an existing, nonempty baseline and the policy once. It +evaluates those pinned versions immediately and then sequentially, waiting the +configured interval after each completed evaluation. The interval defaults to +one hour and accepts one minute through 24 hours. `--once` performs one evaluation +for an external scheduler. Neither form writes the baseline or persistent watch +state, follows lockfile edits, or sends messages to external services. + +Each online cycle uses a fresh loader and bypasses the HTTP cache, so a polling +interval is not silently extended by registry or advisory cache TTLs. Offline +runs use existing caches and explicitly cannot observe new remote information. +The current-owner comparison replaces TD003's usual npm per-release preference: +watch compares current owners with the reviewed baseline even when the pinned +version and its publication-time maintainer list have not changed. Other checks +and the existing policy, failure thresholds and unavailable-data rules apply. + +The first observation is always emitted. Subsequent output contains only changes +in check coverage, skip reasons or finding facts. Elapsed age text alone does not +trigger an event; a cooldown ending does. A missing finding is called cleared +only when the check completed on both observations. Coverage loss is never a +claim that a finding was resolved. Events are sorted by package and check. + +Human output includes the current ordinary report. JSON is newline-delimited +`trustdiff.watch/1` events embedding the unchanged `trustdiff.report/1` document. +SARIF and Markdown are rejected because concatenating those document formats +would produce an ambiguous stream. `--once` returns the report's exit code; +continuous runs keep monitoring findings and source failures. Cancellation stops +the loop and retains the existing exit-3 contract. + +## Consequences + +Online polling has the cost of a fresh audit, so requests retain the normal +per-host rate limits and runs never overlap. Restarting emits a new initial +observation. Users explicitly review and refresh a baseline outside this command. +Baseline observations prove what the registry reported at observation time; they +do not reconstruct crates.io ownership at a release date. An event timestamp is +the time of evaluation, not the time an upstream incident happened. diff --git a/docs/adr/0008-guarddog-handoff.md b/docs/adr/0008-guarddog-handoff.md new file mode 100644 index 0000000..8e2d591 --- /dev/null +++ b/docs/adr/0008-guarddog-handoff.md @@ -0,0 +1,26 @@ +# ADR 0008: Opt-in GuardDog handoff for suspicious registry releases + +Date: 2026-09-29 +Status: accepted + +## Context + +The M5 proposal asks trustdiff to pass releases with existing trust findings to a source analyzer. The normal run must retain its small Go dependency set, metadata-only behavior and offline guarantees. GuardDog is a separately installed Python tool; silently installing it, scanning local project paths, accepting a different package version, or treating its download errors as a clean scan would break those guarantees. + +The upstream CLI and JSON contract were verified against GuardDog v3.2.0, commit `3da172679cb58b1c9a780f9f5d640f855be016dc`, on 2026-09-29. Its default branch has changed since older integrations were written. This release requires a kernel sandbox for extraction and source analysis; its own documentation supports Linux/macOS scanning and Docker for Windows. Its JSON can contain `issues: 0` together with `errors`, so process success alone proves nothing about coverage. + +## Decision + +Add an explicit `--guarddog` handoff after trustdiff evaluates packages. Only registry releases with an existing warn or block finding qualify. Use a separately configured executable, require exactly the reviewed version 3.2.0, and invoke its scan command with a fixed argument vector, an exact validated version, JSON output, `--sandbox`, and the argument terminator `--`. Never invoke a shell, install the tool, install the target package, or run a package lifecycle command. + +Run the executable in an owned empty temporary directory so GuardDog cannot interpret a registry name as a same-named directory in the user's project. Support npm, PyPI and Cargo (`crates` in GuardDog). Refuse offline mode and native Windows before process creation. On supported POSIX hosts, put the process in its own group and terminate that group on cancellation, timeout or output overflow. Limit a client to twenty scans, bound each scan to two minutes by default, and retain at most 4 MiB of JSON and 64 KiB of diagnostics per process. + +Keep results as an attributed report supplement. They do not erase, weaken or promote trustdiff findings. Parse the package and exact version identity, counts, rule results and risk records; do not infer success from an exit code. Distinguish a completed scan, a partial scan with upstream errors, and an unavailable scan. Empty findings mean only that the selected GuardDog version reported none. Preserve useful partial evidence when a rule fails. + +## Consequences + +The default trustdiff run acquires no new dependency, network request or process. Users who opt in must install the reviewed GuardDog release and have a supported sandbox. Linux/macOS subprocess handling is tested with a deterministic helper rather than downloaded suspect packages; native Windows receives a clear unsupported message and can use trustdiff inside a supported Linux environment instead. + +GuardDog's download and metadata phases require network access and run before its analysis sandbox is applied. The adapter does not claim to cap the external tool's download bytes, memory or disk usage; the limits here cover wall time, process output and package count. Operators should run source scanning in their normal isolated analysis environment. New GuardDog releases require a contract review and tests before widening the version check. + +Primary sources: [CLI](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/cli.py), [JSON reporter](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/reporters/json.py), [package scanner](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/scanners/scanner.py), and [sandbox description](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/README.md#sandboxed-scanning). From c0a75d296ca2f92ab9a6b890fe0961cb977c298d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdulvahap=20=C3=96=C4=9F=C3=BCt?= <110431024+vahapogut@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:59:30 +0300 Subject: [PATCH 3/6] feat: monitor baseline trust and add reviewed verification tools --- .github/workflows/ci.yml | 13 + CHANGELOG.md | 12 + README.md | 10 +- docs/PLAN.md | 13 + docs/guarddog.md | 62 +++ docs/local-attestations.md | 43 ++ docs/policy-allow.md | 23 ++ docs/roadmap.md | 23 +- docs/watch.md | 102 +++++ internal/attestation/cosign.go | 125 ++++++ internal/attestation/integration_test.go | 50 +++ internal/attestation/npm.go | 210 ++++++++++ internal/attestation/npm_test.go | 144 +++++++ internal/attestation/testdata/README.md | 13 + .../testdata/sigstore-bundle-5.0.0.json | 148 +++++++ internal/baseline/baseline.go | 5 +- internal/baseline/baseline.v1.json | 7 +- internal/baseline/observe.go | 5 +- internal/baseline/observe_test.go | 51 +++ internal/checks/check.go | 29 +- internal/checks/lazy_downloads_test.go | 325 +++++++++++++++ internal/checks/loader.go | 20 +- internal/checks/loader_test.go | 14 +- internal/checks/runner.go | 43 +- internal/checks/runner_test.go | 28 +- internal/checks/td004.go | 59 ++- internal/checks/td004_local_test.go | 85 ++++ internal/checks/td008.go | 5 + internal/checks/td008_test.go | 4 +- internal/checks/td012.go | 11 +- internal/checks/td012_test.go | 3 + internal/cli/baseline.go | 10 + internal/cli/baseline_downloads_test.go | 47 +++ internal/cli/check.go | 13 + internal/cli/cli_test.go | 2 + internal/cli/guarddog.go | 101 +++++ internal/cli/guarddog_test.go | 113 ++++++ internal/cli/policy.go | 2 +- internal/cli/policy_allow.go | 95 +++++ internal/cli/policy_allow_test.go | 74 ++++ internal/cli/root.go | 48 ++- internal/cli/watch.go | 202 ++++++++++ internal/cli/watch_test.go | 376 ++++++++++++++++++ internal/guarddog/guarddog.go | 183 +++++++++ internal/guarddog/guarddog_test.go | 133 +++++++ internal/guarddog/integration_test.go | 57 +++ internal/guarddog/process.go | 111 ++++++ internal/guarddog/process_other.go | 12 + internal/guarddog/process_other_test.go | 15 + internal/guarddog/process_posix.go | 29 ++ internal/guarddog/process_posix_test.go | 222 +++++++++++ internal/guarddog/report.go | 134 +++++++ internal/model/analysis.go | 28 ++ internal/model/versioninfo.go | 4 +- internal/policy/allow.go | 150 +++++++ internal/policy/allow_test.go | 53 +++ .../dumpindex/historical_owners_test.go | 67 ++++ internal/report/analysis.go | 37 ++ internal/report/analysis_test.go | 45 +++ internal/report/human.go | 1 + internal/report/markdown.go | 1 + internal/report/report.go | 3 + internal/report/report.v1.json | 24 ++ internal/report/sarif.go | 6 +- internal/watch/schema.go | 10 + internal/watch/schema_test.go | 143 +++++++ internal/watch/watch.go | 261 ++++++++++++ internal/watch/watch.v1.json | 49 +++ internal/watch/watch_test.go | 236 +++++++++++ schema/baseline.v1.json | 7 +- schema/report.v1.json | 24 ++ schema/watch.v1.json | 49 +++ 72 files changed, 4789 insertions(+), 73 deletions(-) create mode 100644 docs/guarddog.md create mode 100644 docs/local-attestations.md create mode 100644 docs/policy-allow.md create mode 100644 docs/watch.md create mode 100644 internal/attestation/cosign.go create mode 100644 internal/attestation/integration_test.go create mode 100644 internal/attestation/npm.go create mode 100644 internal/attestation/npm_test.go create mode 100644 internal/attestation/testdata/README.md create mode 100644 internal/attestation/testdata/sigstore-bundle-5.0.0.json create mode 100644 internal/checks/lazy_downloads_test.go create mode 100644 internal/checks/td004_local_test.go create mode 100644 internal/cli/baseline_downloads_test.go create mode 100644 internal/cli/guarddog.go create mode 100644 internal/cli/guarddog_test.go create mode 100644 internal/cli/policy_allow.go create mode 100644 internal/cli/policy_allow_test.go create mode 100644 internal/cli/watch.go create mode 100644 internal/cli/watch_test.go create mode 100644 internal/guarddog/guarddog.go create mode 100644 internal/guarddog/guarddog_test.go create mode 100644 internal/guarddog/integration_test.go create mode 100644 internal/guarddog/process.go create mode 100644 internal/guarddog/process_other.go create mode 100644 internal/guarddog/process_other_test.go create mode 100644 internal/guarddog/process_posix.go create mode 100644 internal/guarddog/process_posix_test.go create mode 100644 internal/guarddog/report.go create mode 100644 internal/model/analysis.go create mode 100644 internal/policy/allow.go create mode 100644 internal/policy/allow_test.go create mode 100644 internal/registry/crates/dumpindex/historical_owners_test.go create mode 100644 internal/report/analysis.go create mode 100644 internal/report/analysis_test.go create mode 100644 internal/watch/schema.go create mode 100644 internal/watch/schema_test.go create mode 100644 internal/watch/watch.go create mode 100644 internal/watch/watch.v1.json create mode 100644 internal/watch/watch_test.go create mode 100644 schema/watch.v1.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2698ad8..28036d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,5 +240,18 @@ jobs: # directive version (observed 2026-09-09) because GOTOOLCHAIN is local here. go-version: "1.26.x" check-latest: true + - name: Install local attestation verifier + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: v3.1.3 + - name: Install pinned source analyzer in an isolated environment + run: | + python3 -m venv "$RUNNER_TEMP/guarddog-venv" + "$RUNNER_TEMP/guarddog-venv/bin/python" -m pip install 'guarddog==3.2.0' - name: go test -tags integration + # The attestation integration test runs real Cosign on a recorded npm + # bundle and a tampered signature; the installer verifies its release. + env: + TRUSTDIFF_GUARDDOG_INTEGRATION: "1" + TRUSTDIFF_GUARDDOG_BIN: ${{ runner.temp }}/guarddog-venv/bin/guarddog run: go test -tags integration ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d89ed1..b0be9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `watch` evaluates the exact versions in an existing baseline once or at a bounded interval, reports changes in findings and coverage, and compares current owners even when a locked release is unchanged. Online cycles bypass stale HTTP caches; offline cycles explicitly use recorded data. JSON events use the published `trustdiff.watch/1` schema and never approve or rewrite the baseline. +- Optional local npm SLSA verification with `--verify-npm-attestations` and Cosign 3.1.3. Exact package/version/SHA512 binding, certificate identity and normal Sigstore transparency verification are required; verification errors remain unavailable rather than falling back to deps.dev. Default builds keep six direct Go dependencies. +- Optional `--guarddog` source-analysis supplements for suspicious, identified public registry releases. The reviewed GuardDog 3.2.0 runs with its sandbox on Linux/macOS, bounded time/output/package count, exact versions, and no shell. Partial and failed analyses are explicit and prevent exit0; metadata findings remain unchanged. Native Windows, private mirrors and ambiguous locked sources are reported unsupported rather than scanning a substitute package. +- `policy allow ` previews one reviewed exception with required reason and expiry. `--write` preserves unrelated policy text, creates a timestamped backup and replaces the file atomically. Broad patterns, expired dates and ambiguous edits are refused. + + + - `cache refresh --crates-dump` builds a bounded, hash-checked plain-file index of the public crates.io database for bulk `scan` runs. Scans preload only requested crates, grouped by shard, and retain the normal API path for single-package `check`. `cache status` reports snapshot age; stale metadata falls back with a diagnostic, while a damaged selected shard or a package newer than the snapshot stays unavailable. Publication times, available publishers, yank flags, checksums and current owners are indexed; scripts, dependency rows, downloads, trusted-publisher evidence and historical ownership are not invented from missing data. Refreshes preserve the previous index on failure, and clearing shares the refresh lock. Ctrl+C and supported termination signals cancel the command with exit 3 and let refresh cleanup run. (#2) - A dependency-free pnpm 10 install hook that invokes trustdiff for resolved registry packages, including repeat and frozen-lockfile installs. It preserves alias/scope/peer identities, refuses unchecked or malformed reports, and stops on blocking findings, missing binaries, timeouts and output-limit violations. Documentation identifies local exclusions and unsupported dependency sources; real pnpm lifecycle tests run against a synthetic local registry on Linux and Windows. (#3) @@ -16,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A partial Turkish README covering installation and the three main workflows, linked to its exact English source revision. A dedicated CI check compares the recorded README content hash with the current English document, so translation drift is visible even after a squash merge or a shallow checkout. (#4) +### Changed + +- TD008 asks for popularity only when it can change a name-collision result. TD012 prefetches counts only for enabled, non-allowlisted subjects; active exceptions are explicit policy skips. The default low-usage check still needs scoped npm counts, preserving its findings and outage behavior. Introduced dependencies retain their on-demand TD007 count checks. +- Current crates.io owners are explicitly kept separate from historical release maintainers. An end-to-end dump regression verifies that current ownership never demotes a historical publisher change. Public data cannot fulfill the original M5.5 ownership-at-release proposal. + ### Fixed - Update the pinned artifact downloader to v8.0.1 and CodeQL SARIF uploader to v4.38.1. The downloader's stricter artifact digest validation is retained; both workflow references and the composite action use verified release commit SHAs. (#7, #8) diff --git a/README.md b/README.md index c0c36a3..2fffb00 100644 --- a/README.md +++ b/README.md @@ -486,10 +486,18 @@ release. Installing the latest release still installs the versions above. | Bulk Cargo metadata (issue #2) | An explicit crates.io dump refresh builds a bounded local index for `scan`; see [dump usage and data limits](docs/crates-dump.md). | | pnpm install gate (issue #3) | A copyable `.pnpmfile.cjs` checks resolved packages and frozen-lockfile installs; see [setup and supported pnpm versions](integrations/pnpm-hook/README.md). | | Translated introduction (issue #4) | [Turkish](docs/readme/tr.md) covers installation and the three main workflows, with its English source revision and a freshness check. | +| Monitor existing dependencies | [`watch`](docs/watch.md) compares pinned baseline packages on a schedule; `--once` supports an external scheduler. Current-owner changes, new advisories and lost coverage are reported without approving a new baseline. | +| Verify npm bundles locally | [`--verify-npm-attestations`](docs/local-attestations.md) uses separately installed Cosign 3.1.3, binds the exact npm subject and SHA512 checksum, and reports failed verification as unavailable. | +| Avoid irrelevant count requests | Download counts are lazy for TD008 and batched only where enabled TD012 needs them. Active exceptions avoid requests; default TD012 still requires counts for scoped npm packages. | +| Inspect suspicious package code | [`--guarddog`](docs/guarddog.md) invokes separately installed GuardDog 3.2.0 in its sandbox on Linux/macOS and reports bounded, attributed supplemental results. | +| Review narrow exceptions | [`policy allow`](docs/policy-allow.md) previews a per-package/check exception with a reason and expiry; `--write` saves it atomically with a backup. | [docs/roadmap.md](docs/roadmap.md) maps these milestones to code and verification. [docs/PLAN.md](docs/PLAN.md) preserves the original estimates and later proposals; -its proposed 0.6 work is not a claim that those features have shipped. +its historical estimates are preserved. Five M5 items are implemented above. +The remaining historical Cargo ownership premise is unavailable from public +registry data: current owners cannot prove who owned a crate at an earlier +release. Baselines and `watch` record observed changes without inventing that past. ## Principles diff --git a/docs/PLAN.md b/docs/PLAN.md index 0990ebf..d21623b 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -295,3 +295,16 @@ current owners but no complete ownership-event history. Current rows do not prove ownership at the time of an earlier release. The bulk metadata index must not invent that history; see [ADR 0005](adr/0005-crates-database-dump.md) and [the supported dump fields](crates-dump.md). + +## 18. M5 implementation follow-up (2026-09-29) + +The remaining work was subsequently approved. Items 5.1, 5.2, 5.3, 5.4 and 5.6 +are implemented: baseline watch, opt-in local Cosign verification, lazy counts, +opt-in GuardDog handoff and reviewed policy exceptions. Their exact contracts, +platform requirements and tests are linked from [roadmap.md](roadmap.md). + +Item 5.5 cannot reconstruct historical ownership from the public dump. The +implementation deliberately keeps current-owner rows out of per-release +maintainer sets, with an end-to-end regression proving that boundary. Baseline +observations and watch compare evidence collected from observation time onward; +they do not claim ownership at an unobserved publication time. diff --git a/docs/guarddog.md b/docs/guarddog.md new file mode 100644 index 0000000..72bb1f0 --- /dev/null +++ b/docs/guarddog.md @@ -0,0 +1,62 @@ +# Optional GuardDog source analysis + +`--guarddog` asks a separately installed [DataDog GuardDog](https://github.com/DataDog/guarddog) executable to analyze registry releases that already have a trustdiff `warn` or `block` verdict. The default trustdiff run does not start GuardDog, download package source, or add a Python dependency. + +The supported executable is **GuardDog 3.2.0** on Linux or macOS with its kernel sandbox available. Install that version separately using the [upstream installation instructions](https://github.com/DataDog/guarddog/tree/v3.2.0#installation), then check `guarddog --version`. trustdiff does not install or upgrade it. Native Windows is refused; run both tools inside a supported Linux environment such as WSL. A missing sandbox is a scan failure, with no retry that disables isolation. + +```sh +trustdiff check npm:example@1.2.3 --guarddog +trustdiff scan package-lock.json --guarddog --format json +trustdiff diff --base-file package-lock.json.before package-lock.json --guarddog +trustdiff watch . --guarddog +``` + +Use `--guarddog-bin /absolute/path/to/guarddog` for an executable outside `PATH`. `--guarddog-timeout 90s` changes the per-release deadline; the default is two minutes, and the CLI accepts one second through ten minutes. The version probe counts against the first release's deadline and has its own ten-second maximum. + +`watch` reads an existing reviewed baseline; see [watch](watch.md) for creating that baseline and choosing an interval. + +## Selection and results + +The handoff supports npm, PyPI and Cargo releases with an exact version. Cargo maps to GuardDog's `crates` command. Local directories, Git dependencies, archive URLs, bundled packages and unsupported ecosystems do not become same-named registry scans. Missing coverage is reported explicitly. Releases with no existing warning or block finding are not selected. + +For npm lock entries, the public tarball URL must name the same package and exact version, including its scope. A tarball repointed to another package on the same registry host is unavailable. Private mirrors are not replaced by public packages. For PyPI, GuardDog selects the first supported source/archive distribution listed for the release; the result is release-level analysis, not proof that every platform wheel or the particular locked artifact was scanned. GuardDog does not compare a scanned distribution against the lockfile checksum. + +GuardDog's npm `risky_new_dependency` metadata rule is excluded. That rule resolves additional dependency ranges and starts further scans, which would expand this handoff beyond the exact releases trustdiff selected. Each npm result states the exclusion. Other applicable GuardDog rules retain their upstream behavior. + +The report includes an attributed `guarddog` supplement; it does not change a trustdiff finding's level or erase a finding. JSON keeps the package reference, source URL, tool version, upstream issue count, nonempty rule matches, compact risks, rule errors and status. Source locations are relative to the scanned package, and temporary directory prefixes are normalized so repeated watch runs do not invent changes. + +| Status | Meaning | +|---|---| +| `completed` | The reviewed executable returned the requested package and exact version with a complete report and no reported rule errors. | +| `partial` | The requested release was identified and available evidence is retained, but some GuardDog rules failed. | +| `unavailable` | The scan could not be completed or verified, for example a missing executable, unsupported platform/version, download failure, malformed report, timeout or output overflow. | + +Zero issues means this GuardDog run reported no matches. It does not prove a package is safe. A download error with zero issues is unavailable, and a report for another release is rejected. An incomplete requested handoff cannot make an otherwise successful run succeed; existing warning/block exit behavior retains priority. The usual trustdiff metadata evidence remains present even when the external analysis fails. + +## Execution limits + +- At most twenty release scan attempts per client/run, including failed attempts. Additional selections are reported unavailable. +- At most 4 MiB of standard output and 64 KiB of diagnostics per process. Overflow stops the process; truncated JSON is never interpreted as a clean scan. +- A fresh private working directory and temporary root for each process. Package names cannot select a same-named path in the user's project. +- Fixed argument vectors with an explicit `--sandbox` and `--` terminator. No shell, target package installation or package lifecycle command is invoked. +- The scanner and its process group are terminated on cancellation, timeout or output overflow; owned temporary files are removed afterward. + +`--offline --guarddog` is rejected before starting the scanner. GuardDog's registry download and metadata phases need network access before it applies its analysis sandbox. The extraction and source-analysis phases use GuardDog's sandbox with network blocked. The adapter bounds process time, output and selected releases; it does not impose additional download-size, memory or disk quotas on the external tool. Use your isolated analysis environment for source scanning. + +## Contract and verification + +The CLI, JSON reporter, sandbox and archive extraction paths were reviewed on 2026-09-29 at GuardDog v3.2.0, commit [`3da172679cb58b1c9a780f9f5d640f855be016dc`](https://github.com/DataDog/guarddog/tree/3da172679cb58b1c9a780f9f5d640f855be016dc). A newer version requires another contract review before trustdiff accepts it. GuardDog is an external runtime prerequisite and is not bundled into trustdiff. + +Tests use synthetic upstream-shaped reports and inert executable helpers. Linux subprocess tests exercise exact arguments, sandbox flags, version rejection, private directories, failure reports, output bounds, timeouts and child-process cancellation; Windows tests verify parsing and refusal before process creation. These tests do not download or install suspect packages, and do not claim a live GuardDog scan was performed. macOS uses the same POSIX adapter and runs its subprocess tests in CI. + +The separate live integration test requires an explicit environment flag and an installed GuardDog 3.2.0. It downloads the small established release `npm:is-number@7.0.0`, exercises the real sandbox, and requires a complete report for that exact identity with no rule errors. It does not assume that a benign package will always produce zero heuristic matches. On a supported Linux/macOS host: + +```sh +TRUSTDIFF_GUARDDOG_INTEGRATION=1 \ +TRUSTDIFF_GUARDDOG_BIN=/absolute/path/to/guarddog \ +go test -tags integration ./internal/guarddog -run TestIntegrationRealGuardDogSandbox -count=1 -v +``` + +Without the environment flag the live test skips, even when `-tags integration` is set. `TRUSTDIFF_INTEGRATION_OFFLINE` also disables it. Once enabled on Linux/macOS, a missing executable, unavailable sandbox or incomplete scan is a test failure. Upstream GuardDog 3.2.0 requires Python 3.10 or newer; an isolated virtual environment with `python -m pip install 'guarddog==3.2.0'` supplies the external executable without changing trustdiff's dependencies. + +See [ADR 0008](adr/0008-guarddog-handoff.md) for the design decision. Primary sources are the pinned [CLI](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/cli.py), [JSON reporter](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/reporters/json.py), [sandbox](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/sandbox.py), [archive handling](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/utils/archives.py) and [additional-dependency rule](https://github.com/DataDog/guarddog/blob/3da172679cb58b1c9a780f9f5d640f855be016dc/guarddog/analyzer/metadata/npm/risky_new_dependency.py). diff --git a/docs/local-attestations.md b/docs/local-attestations.md new file mode 100644 index 0000000..509f611 --- /dev/null +++ b/docs/local-attestations.md @@ -0,0 +1,43 @@ +# Local npm provenance verification + +The opt-in local verifier removes deps.dev from the decision about whether an +npm build attestation verifies. It requires the separately installed +[Cosign v3.1.3](https://github.com/sigstore/cosign/releases/tag/v3.1.3). + +```sh +trustdiff check npm:@sigstore/bundle@5.0.0 --verify-npm-attestations +trustdiff scan . --verify-npm-attestations --cosign-bin /opt/tools/cosign +``` + +The adapter fetches the public npm attestation endpoint, selects SLSA provenance +v0.2 or v1, and checks the signed npm PURL, exact version and SHA512 integrity. +Cosign then verifies the DSSE signature, certificate, certificate-transparency +evidence and transparency-log evidence. GitHub Actions and GitLab CI identities +are supported. The certificate identity is recorded, and TD004 attributes local +verification to `cosign`. Neither an attestation nor a successful scan proves +that source code is benign. This command verifies metadata binding; it does not +download or execute the package archive. + +Cosign normally obtains authenticated trust material through its TUF client. +`--sigstore-root ` selects a local Sigstore trusted-root file. +Obtain that file through a trusted channel such as a successfully updated Cosign +TUF cache; a root downloaded from an arbitrary source is not a trust anchor. +Offline mode requires this file and a previously cached npm response: + +```sh +trustdiff check npm:@sigstore/bundle@5.0.0 --offline \ + --verify-npm-attestations --sigstore-root /trusted/trusted_root.json +``` + +Every subprocess has a 45-second timeout and 64 KiB output limits; invocations +are serialized and use fixed arguments without a shell. A bundle response is +limited to 2 MiB before parsing. Missing, unsupported, mismatched or invalid +evidence is reported as unavailable provenance, never a successful local +verification and never silently replaced by deps.dev. Set +`on_data_unavailable: fail` in the policy to require complete evidence (exit3). +Without the opt-in flag, the existing registry/deps.dev behavior is unchanged. + +Ordinary tests use a recorded response and a fake process. The `integration` +test uses real Cosign on that response and proves a modified signature fails. +The CI live-integration job installs the same pinned verifier. See +[ADR0009](adr/0009-local-npm-attestation-verification.md). diff --git a/docs/policy-allow.md b/docs/policy-allow.md new file mode 100644 index 0000000..67513c6 --- /dev/null +++ b/docs/policy-allow.md @@ -0,0 +1,23 @@ +# Review one policy exception + +```sh +trustdiff policy allow TD013 npm:vendored-lib@1.2.3 \ + --reason 'Reviewed pinned source commit and its changes' --expires 2026-12-31 +``` + +This prints a unified diff. Add `--write` to save it with a timestamped backup +and atomic replacement. Check names such as `exotic-source` also work. The +policy is found using the normal discovery rules, or selected with `--policy`. +Create a new policy first with `trustdiff policy init` if none exists. + +The helper accepts one exact package and check. A version restricts the exception +to that version; omitting it explicitly covers all versions of that package. +Wildcards and missing ecosystem prefixes are rejected. The reason and a +non-expired UTC date are required. An exception is valid through its expiry day; +after that normal policy processing reports TD000 and the check applies again. + +Comments and unrelated settings are preserved. Ambiguous anchors, multiline +allow values and populated flow-style allow sequences must be edited manually. +An identical existing exception makes no change. A conflicting review is refused +instead of silently extending its lifetime. `--format json` reports the path, +diff, whether anything changed, whether it was written and the backup path. diff --git a/docs/roadmap.md b/docs/roadmap.md index f6ef798..f6e8528 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,19 +42,30 @@ not evidence that every later idea had been implemented: These changes remain **Unreleased** until included in a published version. -## Proposals still in the detailed plan +## M5 implementation -[PLAN section 16](PLAN.md#16-proposed-m5-v060) proposes `watch`, local Sigstore -verification, lazy download-count fetching, a GuardDog handoff, historical Cargo -owner comparison and a `policy allow` helper. That section calls itself a -proposal; the released milestones above do not imply those commands exist. +[PLAN section 16](PLAN.md#16-proposed-m5-v060) is the historical proposal. +The subsequently approved implementation is included in the next release: -One premise needs correcting: the public crates.io dump does not provide a +| Item | Implementation and verification | +|---|---| +| 5.1 watch | [Contract](watch.md), `internal/watch`, CLI offline/current-owner/cancellation/coverage tests, versioned event schema | +| 5.2 local Sigstore verification | [Contract](local-attestations.md), optional Cosign3.1.3 adapter, exact npm subject/checksum binding, real valid/tampered-signature integration test | +| 5.3 lazy counts | Policy-selected TD012 batching and on-demand TD008 counts; synthetic8-version test:3 requests by default,0 with low-usage off and no typo candidate,1 with scoped exceptions | +| 5.4 GuardDog | [Contract](guarddog.md), optional GuardDog3.2.0 sandbox on Linux/macOS, strict source identity, subprocess cancellation/output/partial-result tests | +| 5.6 policy allow | [Contract](policy-allow.md), exact exceptions, reason/expiry, preview/backup/atomic write, comment and expiry regressions | + +These features remain **Unreleased** until a tag is published. Optional external +verifiers do not add a runtime requirement to the normal metadata-only command. + +Item5.5 remains unavailable from the source: the public crates.io dump does not provide a complete ownership-event history. Current owner rows and their creation times cannot establish who owned a crate at every past release. The dump implementation therefore reports unavailable historical facts instead of reconstructing them from current owners. Baselines can record observations from the time they are created, but cannot recover observations never made. See [the dump ADR](adr/0005-crates-database-dump.md). +`internal/registry/crates/dumpindex/historical_owners_test.go` verifies that a +current-owner row never turns into a per-release maintainer or a TD002 demotion. ## Verification diff --git a/docs/watch.md b/docs/watch.md new file mode 100644 index 0000000..cae1043 --- /dev/null +++ b/docs/watch.md @@ -0,0 +1,102 @@ +# Monitoring a reviewed baseline + +`watch` re-evaluates packages a project already uses, even when no lockfile +changes. It can notice a newly published advisory, a changed current owner set, +or a change in which checks have enough data to run. + +Create a baseline, review its observations, and commit the reviewed file first: + +```sh +trustdiff baseline . +git diff -- .trustdiff/baseline.json +trustdiff watch . --interval 1h +``` + +The path must be a directory. The command finds `.trustdiff/baseline.json` upward +from that directory, like `baseline`. A missing, empty or malformed baseline is +an error. Policy discovery starts from the working directory; pass `--policy` +when watching a project from elsewhere. + +The baseline and policy are loaded once. Each cycle evaluates the exact versions +recorded in that file. It never overwrites the baseline, approves new owners, +follows lockfile changes, writes persistent watch state, or opens GitHub issues. +Restart after intentionally reviewing a different baseline or changing policy. +`baseline` records one version per package, so watch cannot cover additional +locked versions absent from that record; use `scan` to evaluate every lock entry. + +## Scheduling and freshness + +The first evaluation starts immediately. Subsequent evaluations start after the +preceding evaluation finishes plus `--interval`. The default is one hour; valid +values are Go durations from `1m` through `24h` (for example `90m` or `2h30m`). +Slow evaluations never overlap or queue missed intervals. + +Online cycles request fresh registry and advisory data using a new loader and +bypass the persistent HTTP cache. They neither read nor update that cache, so +normal cache TTLs do not delay detection beyond the polling schedule. Registry +rate limits, upstream publication/indexing delays and the time a full evaluation +takes still apply. Cargo uses the regular API, not the bulk snapshot. Short +intervals can be expensive for a large baseline. + +`--offline` reads existing caches and the installed OSV index, even if they are +old. It cannot discover changes that have not reached those local sources. +Missing data appears as skipped checks; `on_data_unavailable: fail` can make a +one-shot run exit 3. `--offline` and `--no-cache` cannot be combined. + +For an external scheduler, run one evaluation and let the scheduler retain the +output and decide how to notify you: + +```sh +trustdiff watch . --once --format json > observation.jsonl +trustdiff watch . --once --offline --format json +``` + +`--once` returns the ordinary report status: 0 for no findings at the configured +failure threshold, 1 for findings at that threshold, 2 for invalid input, or 3 +for required unavailable data. Continuous mode keeps running through findings +and source outages; inspect each emitted report's `summary.exit_code`. Ctrl+C +cancels requests or waiting and exits 3. Completed earlier events remain valid; +an interrupted output write may leave a partial final line. + +## Reading events + +Human output prints the initial report, then only changed observations. JSON is +one complete [`trustdiff.watch/1`](../schema/watch.v1.json) event per line, with +these fields (its schema references the sibling report schema): + +| Field | Meaning | +| --- | --- | +| `schema` | Always `trustdiff.watch/1`. | +| `kind` | `initial` for the first observation, otherwise `changed`. | +| `observed_at` | UTC time when the evaluation completed, not the upstream incident time. | +| `changes` | Sorted by package reference and check ID; empty for the initial event. | +| `report` | A complete ordinary [`trustdiff.report/1`](../schema/report.v1.json) report. | + +Each change has `ref`, `check`, and `kind`. Trust checks use `findings_added`, +`findings_changed`, `findings_cleared`, `coverage_lost`, `coverage_restored`, or +`skip_changed`. A disappeared finding is called cleared only if that check ran +successfully on both adjacent observations. Losing data is never called a fix. +Recovery after an outage emits `coverage_restored`; inspect its current report. +Elapsed age text alone produces no event, but a cooldown ending does. + +`--guarddog` adds the optional external analysis to each cycle before comparison. +Its change records use `check: "guarddog"`, with `analysis_added`, +`analysis_changed`, `analysis_not_requested`, or coverage lost/restored. A package +that no longer has a warn/block metadata finding is no longer selected for that +analysis; this is not a source-code clearance. Normal GuardDog availability, +offline, timeout and exit rules still apply. + +JSON notes go to stderr. SARIF and Markdown are rejected because this command +produces an event stream. Restarting always emits a fresh initial report; events +are not deduplicated across process restarts. There is no heartbeat output for +unchanged observations. + +TD003 in watch compares the recorded maintainers with **current** registry +owners, including npm releases whose publication metadata still names the old +owners. Evidence retains the baseline's observation date and pinned version. +That comparison cannot establish who owned a crate at an earlier release date: +the registry and public dump do not provide complete historical ownership. The +other checks retain their usual evidence and limitations; lockfile-only checks +are skipped because a baseline contains no lockfile entries. + +See [ADR 0006](adr/0006-watch-observations.md) for the design decision. diff --git a/internal/attestation/cosign.go b/internal/attestation/cosign.go new file mode 100644 index 0000000..0f4f690 --- /dev/null +++ b/internal/attestation/cosign.go @@ -0,0 +1,125 @@ +package attestation + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" +) + +// Options selects an installed verifier and, optionally, an explicit trust root. +type Options struct { + Binary, TrustedRoot string + Offline bool +} + +// Verifier uses the reviewed Cosign release with bounded execution and output. +type Verifier struct { + binary, root string + gate chan struct{} +} + +// New resolves and validates explicit local configuration before a registry run. +func New(ctx context.Context, opts Options) (*Verifier, error) { + if opts.Binary == "" { + opts.Binary = "cosign" + } + binary, err := exec.LookPath(opts.Binary) + if err != nil { + return nil, fmt.Errorf("install cosign v3.1.3 or set --cosign-bin: %w", err) + } + binary, err = filepath.Abs(binary) + if err != nil { + return nil, err + } + if opts.Offline && opts.TrustedRoot == "" { + return nil, fmt.Errorf("offline local verification requires --sigstore-root") + } + if opts.TrustedRoot != "" { + opts.TrustedRoot, err = filepath.Abs(opts.TrustedRoot) + if err != nil { + return nil, err + } + st, err := os.Stat(opts.TrustedRoot) + if err != nil || !st.Mode().IsRegular() || st.Size() > maxBundleBytes { + return nil, fmt.Errorf("trusted root must be a readable regular file at most 2 MiB") + } + } + v := &Verifier{binary: binary, root: opts.TrustedRoot, gate: make(chan struct{}, 1)} + output, err := v.run(ctx, "version", "--json") + if err != nil { + return nil, fmt.Errorf("cosign version: %w", err) + } + var version struct{ GitVersion string } + if err := json.Unmarshal(output, &version); err != nil || version.GitVersion != "v3.1.3" { + return nil, fmt.Errorf("local verification requires the reviewed cosign v3.1.3") + } + return v, nil +} + +// Verify authenticates the complete bundle; no insecure skip flags are accepted. +func (v *Verifier) Verify(ctx context.Context, c *Claim) error { + // Serialize invocations so concurrent subjects cannot race Cosign's TUF cache. + select { + case v.gate <- struct{}{}: + defer func() { <-v.gate }() + case <-ctx.Done(): + return ctx.Err() + } + if err := ctx.Err(); err != nil { + return err + } + dir, err := os.MkdirTemp("", "trustdiff-sigstore-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "bundle.json") + if err := os.WriteFile(path, c.Bundle, 0600); err != nil { + return err + } + args := []string{"verify-blob-attestation", "--bundle", path, "--certificate-identity", c.Identity, "--certificate-oidc-issuer", c.Issuer, "--type", c.Predicate, "--digest", c.Digest, "--digestAlg", "sha512", "--timeout", "45s"} + if v.root != "" { + args = append(args, "--trusted-root", v.root) + } + _, err = v.run(ctx, args...) + return err +} + +type cappedOutput struct { + data []byte + exceeded bool +} + +func (b *cappedOutput) Write(p []byte) (int, error) { + if len(b.data)+len(p) > 64<<10 { + b.exceeded = true + return 0, fmt.Errorf("verifier output exceeds 64 KiB") + } + b.data = append(b.data, p...) + return len(p), nil +} + +func (v *Verifier) run(ctx context.Context, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, v.binary, args...) // #nosec G204 -- explicit user-selected verifier, fixed argument vector and no shell + cmd.WaitDelay = time.Second + var out, diagnostic cappedOutput + cmd.Stdout = &out + cmd.Stderr = &diagnostic + err := cmd.Run() + if ctx.Err() != nil { + return nil, ctx.Err() + } + if out.exceeded || diagnostic.exceeded { + return nil, fmt.Errorf("cosign output limit exceeded") + } + if err != nil { + return nil, fmt.Errorf("cosign verification failed: %w", err) + } + return out.data, nil +} diff --git a/internal/attestation/integration_test.go b/internal/attestation/integration_test.go new file mode 100644 index 0000000..560a960 --- /dev/null +++ b/internal/attestation/integration_test.go @@ -0,0 +1,50 @@ +//go:build integration + +package attestation + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "testing" +) + +// This test uses a real installed Cosign and its TUF trust roots. No package code +// is downloaded or executed. Set TRUSTDIFF_COSIGN_BIN for a non-PATH executable. +func TestIntegrationRealCosignAndTamperedSignature(t *testing.T) { + if os.Getenv("TRUSTDIFF_INTEGRATION_OFFLINE") != "" { + t.Skip("live integration disabled") + } + v, err := New(context.Background(), Options{Binary: os.Getenv("TRUSTDIFF_COSIGN_BIN"), TrustedRoot: os.Getenv("TRUSTDIFF_SIGSTORE_ROOT")}) + if err != nil { + t.Fatal(err) + } + b, ref, integrity := fixture(t) + c, err := Parse(b, ref, integrity) + if err != nil { + t.Fatal(err) + } + if err := v.Verify(context.Background(), c); err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(c.Bundle, &raw); err != nil { + t.Fatal(err) + } + envelope := raw["dsseEnvelope"].(map[string]any) + sig := envelope["signatures"].([]any)[0].(map[string]any) + decoded, err := base64.StdEncoding.DecodeString(sig["sig"].(string)) + if err != nil { + t.Fatal(err) + } + decoded[len(decoded)-1] ^= 1 + sig["sig"] = base64.StdEncoding.EncodeToString(decoded) + c.Bundle, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if err := v.Verify(context.Background(), c); err == nil { + t.Fatal("Cosign accepted a tampered DSSE signature") + } +} diff --git a/internal/attestation/npm.go b/internal/attestation/npm.go new file mode 100644 index 0000000..7ed436e --- /dev/null +++ b/internal/attestation/npm.go @@ -0,0 +1,210 @@ +// Package attestation binds npm provenance to a package and invokes an explicitly +// selected local Sigstore verifier. Cryptographic verification stays in Cosign. +package attestation + +import ( + "context" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/vahapogut/trustdiff/internal/httpcache" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/registry/npm" +) + +const maxBundleBytes = 2 << 20 + +// NPM wraps npm metadata while preserving its optional bulk-download method. +type NPM struct { + *npm.Client + HTTP *httpcache.Client + Verifier *Verifier +} + +// VersionInfo verifies advertised build provenance locally. A failed verification +// makes that facet unavailable; callers must not use deps.dev to erase the gap. +func (n *NPM) VersionInfo(ctx context.Context, ref model.PackageRef) (*model.VersionInfo, error) { + info, err := n.Client.VersionInfo(ctx, ref) + if err != nil || info.Provenance.Kind != model.ProvenanceAttestation { + return info, err + } + endpoint := npm.DefaultRegistryURL + "/-/npm/v1/attestations/" + url.PathEscape(info.Ref.Name+"@"+info.Ref.Version) + resp, err := n.HTTP.Get(ctx, endpoint, httpcache.Request{Accept: "application/json"}) + if err == nil && resp.StatusCode != 200 { + err = fmt.Errorf("attestation endpoint returned HTTP %d", resp.StatusCode) + } + if err == nil { + var claim *Claim + claim, err = Parse(resp.Body, info.Ref, info.Integrity) + if err == nil { + err = n.Verifier.Verify(ctx, claim) + } + if err == nil { + info.Provenance.Verified = true + info.Provenance.VerifiedBy = "cosign" + info.Provenance.Identity = claim.Identity + } + } + if err != nil { + info.SetUnknown(model.FacetProvenance, "local Sigstore verification unavailable: "+err.Error()) + } + return info, nil +} + +// Claim is a validated binding passed unchanged to the cryptographic verifier. +type Claim struct { + Bundle json.RawMessage + Identity, Issuer, Predicate, Digest string +} + +type bundle struct { + MediaType string `json:"mediaType"` + Envelope struct{ Payload, PayloadType string } `json:"dsseEnvelope"` + Material struct { + Certificate struct{ RawBytes string } `json:"certificate"` + Chain struct{ Certificates []struct{ RawBytes string } } `json:"x509CertificateChain"` + } `json:"verificationMaterial"` +} + +// Parse requires an exact npm PURL and SHA512 registry checksum in the signed +// subject. The certificate is parsed only to select an explicit identity/issuer; +// Cosign subsequently authenticates it, its signature and transparency evidence. +func Parse(data []byte, ref model.PackageRef, integrity string) (*Claim, error) { + if len(data) > maxBundleBytes { + return nil, fmt.Errorf("attestation response exceeds 2 MiB") + } + if ref.Ecosystem != model.NPM || ref.Version == "" || model.NPMNameProblem(ref.Name) != "" { + return nil, fmt.Errorf("exact npm reference required") + } + var digest string + for _, part := range strings.Fields(integrity) { + if !strings.HasPrefix(part, "sha512-") { + continue + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(part, "sha512-")) + if err != nil || len(decoded) != 64 { + return nil, fmt.Errorf("invalid SHA512 registry integrity") + } + candidate := hex.EncodeToString(decoded) + if digest != "" && digest != candidate { + return nil, fmt.Errorf("ambiguous SHA512 registry integrity") + } + digest = candidate + } + if digest == "" { + return nil, fmt.Errorf("SHA512 registry integrity is required") + } + var response struct { + Attestations []struct { + PredicateType string + Bundle json.RawMessage + } + } + if err := json.Unmarshal(data, &response); err != nil { + return nil, fmt.Errorf("attestation JSON: %w", err) + } + var selected json.RawMessage + var predicate string + for _, a := range response.Attestations { + if a.PredicateType != "https://slsa.dev/provenance/v1" && a.PredicateType != "https://slsa.dev/provenance/v0.2" { + continue + } + if selected != nil { + return nil, fmt.Errorf("multiple build provenance bundles are ambiguous") + } + selected, predicate = a.Bundle, a.PredicateType + } + if selected == nil { + return nil, fmt.Errorf("no supported SLSA build provenance bundle") + } + var b bundle + if err := json.Unmarshal(selected, &b); err != nil { + return nil, err + } + switch b.MediaType { + case "application/vnd.dev.sigstore.bundle+json;version=0.1", "application/vnd.dev.sigstore.bundle+json;version=0.2", "application/vnd.dev.sigstore.bundle.v0.3+json": + default: + return nil, fmt.Errorf("unsupported Sigstore bundle media type %q", b.MediaType) + } + if b.Envelope.PayloadType != "application/vnd.in-toto+json" { + return nil, fmt.Errorf("unexpected DSSE payload type") + } + payload, err := base64.StdEncoding.DecodeString(b.Envelope.Payload) + if err != nil { + return nil, err + } + var statement struct { + Type string `json:"_type"` + PredicateType string + Subject []struct { + Name string + Digest map[string]string + } + } + if err := json.Unmarshal(payload, &statement); err != nil { + return nil, err + } + if statement.Type != "https://in-toto.io/Statement/v1" && statement.Type != "https://in-toto.io/Statement/v0.1" { + return nil, fmt.Errorf("unsupported in-toto statement") + } + if statement.PredicateType != predicate || len(statement.Subject) != 1 { + return nil, fmt.Errorf("provenance must describe exactly one matching subject") + } + name, err := url.PathUnescape(statement.Subject[0].Name) + if err != nil || name != "pkg:npm/"+ref.Name+"@"+ref.Version || statement.Subject[0].Digest["sha512"] != digest { + return nil, fmt.Errorf("signed subject does not match npm package, version and registry checksum") + } + raw := b.Material.Certificate.RawBytes + if raw == "" && len(b.Material.Chain.Certificates) == 1 { + raw = b.Material.Chain.Certificates[0].RawBytes + } + der, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("bundle certificate: %w", err) + } + if len(cert.URIs) != 1 { + return nil, fmt.Errorf("certificate must name exactly one URI identity") + } + identity := cert.URIs[0].String() + issuer, err := certificateIssuer(cert) + if err != nil { + return nil, err + } + if (issuer != "https://token.actions.githubusercontent.com" || !strings.HasPrefix(identity, "https://github.com/")) && (issuer != "https://gitlab.com" || !strings.HasPrefix(identity, "https://gitlab.com/")) { + return nil, fmt.Errorf("unsupported CI certificate issuer or identity") + } + return &Claim{Bundle: selected, Identity: identity, Issuer: issuer, Predicate: predicate, Digest: digest}, nil +} + +func certificateIssuer(cert *x509.Certificate) (string, error) { + issuer := "" + for _, ext := range cert.Extensions { + candidate := "" + if ext.Id.Equal(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}) { + candidate = string(ext.Value) + } + if ext.Id.Equal(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}) { + if rest, err := asn1.Unmarshal(ext.Value, &candidate); err != nil || len(rest) != 0 { + return "", fmt.Errorf("invalid certificate issuer extension") + } + } + if candidate == "" { + continue + } + if issuer != "" && issuer != candidate { + return "", fmt.Errorf("conflicting certificate issuers") + } + issuer = candidate + } + return issuer, nil +} diff --git a/internal/attestation/npm_test.go b/internal/attestation/npm_test.go new file mode 100644 index 0000000..73b927b --- /dev/null +++ b/internal/attestation/npm_test.go @@ -0,0 +1,144 @@ +package attestation + +import ( + "context" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/model" +) + +const fixtureDigest = "c1e7e3ca0b9d10d6f36d0324b35b79bb7e043f47c5a03d17bda10fec33943ffb172afa20cc4258170e44ea97a01b2a77a27196cd51182956b76cd656c93617f8" + +func fixture(t *testing.T) ([]byte, model.PackageRef, string) { + t.Helper() + b, err := os.ReadFile("testdata/sigstore-bundle-5.0.0.json") + if err != nil { + t.Fatal(err) + } + hash, err := hex.DecodeString(fixtureDigest) + if err != nil { + t.Fatal(err) + } + return b, model.PackageRef{Ecosystem: model.NPM, Name: "@sigstore/bundle", Version: "5.0.0"}, "sha512-" + base64.StdEncoding.EncodeToString(hash) +} + +func TestParseExactProvenanceBinding(t *testing.T) { + b, ref, integrity := fixture(t) + c, err := Parse(b, ref, integrity) + if err != nil { + t.Fatal(err) + } + if c.Digest != fixtureDigest || c.Issuer != "https://token.actions.githubusercontent.com" || !strings.Contains(c.Identity, "/sigstore/sigstore-js/") { + t.Fatalf("wrong claim: %+v", c) + } + ref.Version = "4.0.0" + if _, err := Parse(b, ref, integrity); err == nil { + t.Fatal("cross-version replay accepted") + } + ref.Version = "5.0.0" + ref.Name = "other" + if _, err := Parse(b, ref, integrity); err == nil { + t.Fatal("cross-package replay accepted") + } + _, ref, _ = fixture(t) + if _, err := Parse(b, ref, "sha512-"+base64.StdEncoding.EncodeToString(make([]byte, 64))); err == nil { + t.Fatal("wrong checksum accepted") + } + if _, err := Parse(b, ref, "sha1-aaaa"); err == nil { + t.Fatal("weak checksum accepted") + } +} + +func TestParseRejectsAmbiguousAndWrongStatements(t *testing.T) { + b, ref, integrity := fixture(t) + var response map[string]any + if err := json.Unmarshal(b, &response); err != nil { + t.Fatal(err) + } + items := response["attestations"].([]any) + response["attestations"] = append(items, items[1]) + duplicate, _ := json.Marshal(response) + if _, err := Parse(duplicate, ref, integrity); err == nil { + t.Fatal("ambiguous bundles accepted") + } + response["attestations"] = items + bundle := items[1].(map[string]any)["bundle"].(map[string]any) + bundle["dsseEnvelope"].(map[string]any)["payloadType"] = "text/plain" + wrong, _ := json.Marshal(response) + if _, err := Parse(wrong, ref, integrity); err == nil { + t.Fatal("wrong payload type accepted") + } +} + +func TestConflictingIssuerExtensionsRejectedInEitherOrder(t *testing.T) { + modern, err := asn1.Marshal("https://gitlab.com") + if err != nil { + t.Fatal(err) + } + a := pkix.Extension{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, Value: []byte("https://token.actions.githubusercontent.com")} + b := pkix.Extension{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, Value: modern} + for _, extensions := range [][]pkix.Extension{{a, b}, {b, a}} { + if _, err := certificateIssuer(&x509.Certificate{Extensions: extensions}); err == nil { + t.Fatal("conflicting issuers accepted") + } + } +} + +// The test executable acts as a verifier without a shell or a platform script. +func TestMain(m *testing.M) { + if os.Getenv("TRUSTDIFF_FAKE_COSIGN") == "1" && len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "verify-blob-attestation") { + if os.Args[1] == "version" { + fmt.Println(`{"gitVersion":"v3.1.3"}`) + os.Exit(0) + } + if os.Getenv("TRUSTDIFF_FAKE_COSIGN_WAIT") == "1" { + time.Sleep(time.Minute) + } + joined := strings.Join(os.Args, " ") + if !strings.Contains(joined, "--digestAlg sha512") || strings.Contains(joined, "insecure") || !strings.Contains(joined, "--digest "+fixtureDigest) { + os.Exit(7) + } + fmt.Println("Verified OK") + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestCosignFixedArgumentsAndCancellation(t *testing.T) { + t.Setenv("TRUSTDIFF_FAKE_COSIGN", "1") + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + v, err := New(context.Background(), Options{Binary: exe}) + if err != nil { + t.Fatal(err) + } + b, ref, integrity := fixture(t) + c, err := Parse(b, ref, integrity) + if err != nil { + t.Fatal(err) + } + if err := v.Verify(context.Background(), c); err != nil { + t.Fatal(err) + } + t.Setenv("TRUSTDIFF_FAKE_COSIGN_WAIT", "1") + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := v.Verify(ctx, c); err == nil { + t.Fatal("cancellation ignored") + } + if _, err := New(context.Background(), Options{Binary: exe, Offline: true}); err == nil { + t.Fatal("offline verification without local root accepted") + } +} diff --git a/internal/attestation/testdata/README.md b/internal/attestation/testdata/README.md new file mode 100644 index 0000000..d3b8ea5 --- /dev/null +++ b/internal/attestation/testdata/README.md @@ -0,0 +1,13 @@ +# npm attestation fixture + +`sigstore-bundle-5.0.0.json` is the public registry response for +`@sigstore/bundle@5.0.0`, recorded on 2026-09-29 from +https://registry.npmjs.org/-/npm/v1/attestations/@sigstore%2fbundle@5.0.0 +and pretty-printed without changing the encoded signed material. + +SHA256 of this fixture: +`b6db2dcd0d9fe5abb5ae62b5409d3d153396b94eac3264ea36500588606ef17d`. +It is public factual attestation metadata; no package source is included. +The referenced sigstore-js project is Apache-2.0 licensed. +Ordinary tests only parse or replay this local response. The integration test +uses real Cosign to verify it and reject a deliberately modified signature. diff --git a/internal/attestation/testdata/sigstore-bundle-5.0.0.json b/internal/attestation/testdata/sigstore-bundle-5.0.0.json new file mode 100644 index 0000000..2de40f9 --- /dev/null +++ b/internal/attestation/testdata/sigstore-bundle-5.0.0.json @@ -0,0 +1,148 @@ +{ + "attestations": [ + { + "predicateType": "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + "bundle": { + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.2", + "verificationMaterial": { + "publicKey": { + "hint": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U" + }, + "tlogEntries": [ + { + "logIndex": "1697020176", + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1780347773", + "inclusionPromise": { + "signedEntryTimestamp": "MEUCIFtJlJRdz1UAmqSV+wLJ6pJ4h8CVs0cT6M7jPjAPj5wVAiEAhzQvL7f4UoAniw5fYM0l7WvRy7NzYa9V6wZwFZ3RVdM=" + }, + "inclusionProof": { + "logIndex": "1575115914", + "rootHash": "diKCtR/W4pzRkxoqZb1fm2IF4/Bg14Dc19GQIW8Hmdc=", + "treeSize": "1575115942", + "hashes": [ + "n2Wl03Y7mUQkmuYuAOOORPbPjWj5kKkU4zHd88v8Ksk=", + "uToJYTaRPe/mWePIFNLUN9NOF8NovUguhXttpvjb/vQ=", + "w4u+DO64akAbxcVGz6NocYTwgJbNnZBGsHkjck7K3jU=", + "7dw8+Iof/FRDjc7v02cL6frk3qmvxLq8DERria0fijc=", + "U9X/LFkjBRbzEvIfN3jygsJuJ1yXAtCHG2i44KWs6Y0=", + "k2/HtYkKgVmt0N8UAiEV6jcEvzT2PBmfoC0mFlPh8Bk=", + "ZeFxm0hhfb0O5Gm7TfYj2qfHg9FASmCCQYZUKK/qGXc=", + "rT35wQ5Moat8BiHMuheHQI16ENylyCbuoSeDOcHrhi4=", + "oIbGldzRs4Cm7kX3T4L7zHRKAevjG6cLmK1I/MWzl6A=", + "OLEh/KmxOb6G9ie6Gt04mJ1Q1kv2KXD99n2qg5JERBw=", + "uUalPMuHel2OrfCc+v/gF43MhrBlZo6VX2LVKy8oZZ0=", + "iRzCjdExvDokoKopJ6kWAkibOeERrsIoNe37amVMaw0=", + "c/VhvcqSr+wLh593N35Xg+0/dkL+nxxHGlXX0wkf4mk=", + "m1C80UYOuuRJM8Fi8OjFNLpWjlAWtO7mxH1q4m7pmtU=", + "HFVlvVZ05vzILwkKMUxo7PURUqLin1FJtKlrf0POzSI=", + "LjVtMakaffwcoZfuLfHLX7E1aYzFqtl7SoAdRGN4jsk=", + "hn+ppu7vwlSCi7W1L5Z750bpAdr+WUAJLSUnb8qLy3k=", + "r5nn9+mbgUQL4m8neTg/XjYkdf2hD/cvZMsNR/v0cBU=", + "eT+F471g2HJfd43U4j4L1PIBkt4rLbHQd/pOR/rllO0=", + "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU=" + ], + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n1575115942\ndiKCtR/W4pzRkxoqZb1fm2IF4/Bg14Dc19GQIW8Hmdc=\n\n— rekor.sigstore.dev wNI9ajBFAiAk4HaAwGpkYRGDqmf4BiFK1LZ/CZSRk2uSRdD6y58urwIhAIsoJv7pHwWRBJ4Ku70fY8PYmQynolWvCfYD1Jl/ZL0K\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYWNkNjhkZjcyYjgxYjdhNDI4MjY2NzUwMDk5YTY2MWNmODNjOTcwNWY2Yzk2N2ZjYTk5NTdhZjU5MWI5MjFiNSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6Ijk2MWViZmZhNDE0YWY1MmZmNTZlMDM1MTYxMmNkMjI2OGQ2NTU4MWVkYjhiYmQxZTU0NDUxNTBlODllODMyOWQifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lGMFlFa3NvZmVMaUVwMm1peXpxRTl4NndOd1daOEpYK3BDZlJUdTNzQ2JLQWlBWlVhc01vV0trUURZWHhPVkRrVkJFQzU0WnE1aVA4S1h1SktOTSt6ZlBhUT09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJRVlVKTVNVTWdTMFZaTFMwdExTMEtUVVpyZDBWM1dVaExiMXBKZW1vd1EwRlJXVWxMYjFwSmVtb3dSRUZSWTBSUlowRkZXVFpaWVRkWEt5czNZVlZRZW5aTlZISmxla2cyV1dONE0yTXJTQXBQUzFsRFkwNUhlV0pLV2xORFNuRXZabVEzVVdFNGRYVkJTM1JrU1d0VlVYUlJhVVZMUlZKb1FXMUZOV3hOVFVwb1VEaFBhMFJQWVRKblBUMEtMUzB0TFMxRlRrUWdVRlZDVEVsRElFdEZXUzB0TFMwdENnPT0ifV19fQ==" + } + ], + "timestampVerificationData": { + "rfc3161Timestamps": [] + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjAuMSIsInN1YmplY3QiOlt7Im5hbWUiOiJwa2c6bnBtLyU0MHNpZ3N0b3JlL2J1bmRsZUA1LjAuMCIsImRpZ2VzdCI6eyJzaGE1MTIiOiJjMWU3ZTNjYTBiOWQxMGQ2ZjM2ZDAzMjRiMzViNzliYjdlMDQzZjQ3YzVhMDNkMTdiZGExMGZlYzMzOTQzZmZiMTcyYWZhMjBjYzQyNTgxNzBlNDRlYTk3YTAxYjJhNzdhMjcxOTZjZDUxMTgyOTU2Yjc2Y2Q2NTZjOTM2MTdmOCJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2dpdGh1Yi5jb20vbnBtL2F0dGVzdGF0aW9uL3RyZWUvbWFpbi9zcGVjcy9wdWJsaXNoL3YwLjEiLCJwcmVkaWNhdGUiOnsibmFtZSI6IkBzaWdzdG9yZS9idW5kbGUiLCJ2ZXJzaW9uIjoiNS4wLjAiLCJyZWdpc3RyeSI6Imh0dHBzOi8vcmVnaXN0cnkubnBtanMub3JnIn19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIF0YEksofeLiEp2miyzqE9x6wNwWZ8JX+pCfRTu3sCbKAiAZUasMoWKkQDYXxOVDkVBEC54Zq5iP8KXuJKNM+zfPaQ==", + "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U" + } + ] + } + }, + "signedAccessSignatureUrl": "" + }, + { + "predicateType": "https://slsa.dev/provenance/v1", + "bundle": { + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "certificate": { + "rawBytes": "MIIG9DCCBnugAwIBAgIURXZBeuBsfRx46Z3Dg6AwlGdixhcwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNjAxMjEwMjUwWhcNMjYwNjAxMjExMjUwWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBSKe34lX62X8h9GDtNj/zQkYAzJcleTF1E1iliuFo/YfrId9PfB0G2BWENZ+joKLyHWUb3MQqu/DFILBRIBJWKOCBZowggWWMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUl9hEKUJboVsMFKsPaguTmkecvZgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wYwYDVR0RAQH/BFkwV4ZVaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlL3NpZ3N0b3JlLWpzLy5naXRodWIvd29ya2Zsb3dzL3JlbGVhc2UueW1sQHJlZnMvaGVhZHMvbWFpbjA5BgorBgEEAYO/MAEBBCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMBIGCisGAQQBg78wAQIEBHB1c2gwNgYKKwYBBAGDvzABAwQoN2QyOTAwZWNhMWMyMmIzZjg3YzEzOTg3YzhkNGI3YzlhMjliNzMzYTAVBgorBgEEAYO/MAEEBAdSZWxlYXNlMCIGCisGAQQBg78wAQUEFHNpZ3N0b3JlL3NpZ3N0b3JlLWpzMB0GCisGAQQBg78wAQYED3JlZnMvaGVhZHMvbWFpbjA7BgorBgEEAYO/MAEIBC0MK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wZQYKKwYBBAGDvzABCQRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoN2QyOTAwZWNhMWMyMmIzZjg3YzEzOTg3YzhkNGI3YzlhMjliNzMzYTAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwNwYKKwYBBAGDvzABDAQpDCdodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMwOAYKKwYBBAGDvzABDQQqDCg3ZDI5MDBlY2ExYzIyYjNmODdjMTM5ODdjOGQ0YjdjOWEyOWI3MzNhMB8GCisGAQQBg78wAQ4EEQwPcmVmcy9oZWFkcy9tYWluMBkGCisGAQQBg78wAQ8ECwwJNDk1NTc0NTU1MCsGCisGAQQBg78wARAEHQwbaHR0cHM6Ly9naXRodWIuY29tL3NpZ3N0b3JlMBgGCisGAQQBg78wAREECgwINzEwOTYzNTMwZQYKKwYBBAGDvzABEgRXDFVodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wARMEKgwoN2QyOTAwZWNhMWMyMmIzZjg3YzEzOTg3YzhkNGI3YzlhMjliNzMzYTAUBgorBgEEAYO/MAEUBAYMBHB1c2gwWwYKKwYBBAGDvzABFQRNDEtodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvYWN0aW9ucy9ydW5zLzI2NzgxODY2NjE4L2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwPQYKKwYBBAGDvzABGAQvDC1yZXBvOnNpZ3N0b3JlL3NpZ3N0b3JlLWpzOnJlZjpyZWZzL2hlYWRzL21haW4wgYoGCisGAQQB1nkCBAIEfAR6AHgAdgDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ6E/xbMAAAEAwBHMEUCIQCjjptu5d45umEgmuao+5nPXQX1tLXzEaOnrQgqsOL86gIgPhDfOf2ZhAM2FHP8SFywzbxvQOUL/+Mk7euHdBVJtQMwCgYIKoZIzj0EAwMDZwAwZAIwERS9t4H8JB0PIhfw9BTj97FVDQdcQ80oQvXZFyeLUUUWDIfqqFp5DztfhX2UMTeIAjAWmdIU8LG7iG6WPHQGqRj6s/WTJIxenvmfaZ1zNmdqTbG4ikQbk+0lz7G8nKKDh5I=" + }, + "tlogEntries": [ + { + "logIndex": "1697019799", + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + }, + "kindVersion": { + "kind": "dsse", + "version": "0.0.1" + }, + "integratedTime": "1780347770", + "inclusionPromise": { + "signedEntryTimestamp": "MEUCIG8FUnl6a/neKmOne3f5v0t0fplns3XLOCTsZqyi1CKsAiEA4oYL1Jo7v2Fyxbtea/F6zid/1pLSMKneB6CmYLmF7DA=" + }, + "inclusionProof": { + "logIndex": "1575115537", + "rootHash": "NJ7Pxk0P9oc+2grpyBCAZ0dR6Z9oLVpomv8a3AeEbWM=", + "treeSize": "1575115572", + "hashes": [ + "CA85GexTb9Mg1aEvx4NTGAN/6a8vq3WsUXkQxRyFGU0=", + "McJU3E325KjePJ52s1c4y2Q6JSU5OBZj4GGdgC02bWM=", + "7r9FBjYm/8xRaZWfmrjrSZiZcS3Z6oag5c2ZaRRupik=", + "B7TR779pRuxXb2PysGwa7HhP34QQKXASd+I6Veqxuhc=", + "F7MSq2U1EQTG6aZoXk3cST+NRPzsNbokMXRkWb0pNuM=", + "BHmfprGy+egKt2uqd6N1vKgHBUz5soh8KPR7G+ADYYI=", + "aLrz1uryqg2Umqm2fScMwqdXT7tjkedKCX+To/94RQM=", + "VI5llsvIarrbkj3k+GM1R1/c782GhR5qlPLX0mJKzQ8=", + "oIbGldzRs4Cm7kX3T4L7zHRKAevjG6cLmK1I/MWzl6A=", + "OLEh/KmxOb6G9ie6Gt04mJ1Q1kv2KXD99n2qg5JERBw=", + "uUalPMuHel2OrfCc+v/gF43MhrBlZo6VX2LVKy8oZZ0=", + "iRzCjdExvDokoKopJ6kWAkibOeERrsIoNe37amVMaw0=", + "c/VhvcqSr+wLh593N35Xg+0/dkL+nxxHGlXX0wkf4mk=", + "m1C80UYOuuRJM8Fi8OjFNLpWjlAWtO7mxH1q4m7pmtU=", + "HFVlvVZ05vzILwkKMUxo7PURUqLin1FJtKlrf0POzSI=", + "LjVtMakaffwcoZfuLfHLX7E1aYzFqtl7SoAdRGN4jsk=", + "hn+ppu7vwlSCi7W1L5Z750bpAdr+WUAJLSUnb8qLy3k=", + "r5nn9+mbgUQL4m8neTg/XjYkdf2hD/cvZMsNR/v0cBU=", + "eT+F471g2HJfd43U4j4L1PIBkt4rLbHQd/pOR/rllO0=", + "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU=" + ], + "checkpoint": { + "envelope": "rekor.sigstore.dev - 1193050959916656506\n1575115572\nNJ7Pxk0P9oc+2grpyBCAZ0dR6Z9oLVpomv8a3AeEbWM=\n\n— rekor.sigstore.dev wNI9ajBEAiBIVkVIwtSJ/4RdVBveb8tec9F1i3N0ox5phf/D7E5mzwIgZIgEnV61H47mRcPt0P1PK7JLZ3H35ztU9Xr+YuGcBMk=\n" + } + }, + "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiMDA1ZWIyZWI3NGE1YmE4N2ZmNjdlOWViNjMzYTExYTc1ZGQ4NmY2MTNjYjNjMjEwNWZlMzU3M2QxMmNkYTdkMCJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjViZTQxYjBiYzExZDk1NTc2NWEwNWIwZjk2NzVkMWQwM2YwODBmYzgxMThkNWE4N2Q0MTNjYTM3ZGNjNzAyZjYifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lHRmZkanNSbTMyaTlvQlFWaHRLV1VLSHRPb3kwVk5vMDFlMVNhb00yQVJTQWlCWmRYb0pJRGVjbFkvdkNES3E3cnE2b2pJYTU2dzFmNEQrem9OZ0cxZjRyZz09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VjNVJFTkRRbTUxWjBGM1NVSkJaMGxWVWxoYVFtVjFRbk5tVW5nME5sb3pSR2MyUVhkc1IyUnBlR2hqZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwNXFRWGhOYWtWM1RXcFZkMWRvWTA1TmFsbDNUbXBCZUUxcVJYaE5hbFYzVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVkNVMHRsTXpSc1dEWXlXRGhvT1VkRWRFNXFMM3BSYTFsQmVrcGpiR1ZVUmpGRk1Xa0tiR2wxUm04dldXWnlTV1E1VUdaQ01FY3lRbGRGVGxvcmFtOUxUSGxJVjFWaU0wMVJjWFV2UkVaSlRFSlNTVUpLVjB0UFEwSmFiM2RuWjFkWFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVnNPV2hGQ2t0VlNtSnZWbk5OUmt0elVHRm5kVlJ0YTJWamRscG5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMWwzV1VSV1VqQlNRVkZJTDBKR2EzZFdORnBXWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBURE5PY0ZvelRqQmlNMHBzVEROT2NBcGFNMDR3WWpOS2JFeFhjSHBNZVRWdVlWaFNiMlJYU1haa01qbDVZVEphYzJJelpIcE1NMHBzWWtkV2FHTXlWWFZsVnpGelVVaEtiRnB1VFhaaFIxWm9DbHBJVFhaaVYwWndZbXBCTlVKbmIzSkNaMFZGUVZsUEwwMUJSVUpDUTNSdlpFaFNkMk42YjNaTU0xSjJZVEpXZFV4dFJtcGtSMngyWW01TmRWb3liREFLWVVoV2FXUllUbXhqYlU1MlltNVNiR0p1VVhWWk1qbDBUVUpKUjBOcGMwZEJVVkZDWnpjNGQwRlJTVVZDU0VJeFl6Sm5kMDVuV1V0TGQxbENRa0ZIUkFwMmVrRkNRWGRSYjA0eVVYbFBWRUYzV2xkT2FFMVhUWGxOYlVsNldtcG5NMWw2UlhwUFZHY3pXWHBvYTA1SFNUTlplbXhvVFdwc2FVNTZUWHBaVkVGV0NrSm5iM0pDWjBWRlFWbFBMMDFCUlVWQ1FXUlRXbGQ0YkZsWVRteE5RMGxIUTJselIwRlJVVUpuTnpoM1FWRlZSVVpJVG5CYU0wNHdZak5LYkV3elRuQUtXak5PTUdJelNteE1WM0I2VFVJd1IwTnBjMGRCVVZGQ1p6YzRkMEZSV1VWRU0wcHNXbTVOZG1GSFZtaGFTRTEyWWxkR2NHSnFRVGRDWjI5eVFtZEZSUXBCV1U4dlRVRkZTVUpETUUxTE1tZ3daRWhDZWs5cE9IWmtSemx5V2xjMGRWbFhUakJoVnpsMVkzazFibUZZVW05a1Ywb3hZekpXZVZreU9YVmtSMVoxQ21SRE5XcGlNakIzV2xGWlMwdDNXVUpDUVVkRWRucEJRa05SVWxoRVJsWnZaRWhTZDJONmIzWk1NbVJ3WkVkb01WbHBOV3BpTWpCMll6SnNibU16VW5ZS1kyMVZkbU15Ykc1ak0xSjJZMjFWZEdGdVRYWk1iV1J3WkVkb01WbHBPVE5pTTBweVdtMTRkbVF6VFhaamJWWnpXbGRHZWxwVE5UVmlWM2hCWTIxV2JRcGplVGx2V2xkR2EyTjVPWFJaVjJ4MVRVUm5SME5wYzBkQlVWRkNaemM0ZDBGUmIwVkxaM2R2VGpKUmVVOVVRWGRhVjA1b1RWZE5lVTF0U1hwYWFtY3pDbGw2UlhwUFZHY3pXWHBvYTA1SFNUTlplbXhvVFdwc2FVNTZUWHBaVkVGa1FtZHZja0puUlVWQldVOHZUVUZGVEVKQk9FMUVWMlJ3WkVkb01WbHBNVzhLWWpOT01GcFhVWGRPZDFsTFMzZFpRa0pCUjBSMmVrRkNSRUZSY0VSRFpHOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhaak1teHVZek5TZGdwamJWVjJZekpzYm1NelVuWmpiVlYwWVc1TmQwOUJXVXRMZDFsQ1FrRkhSSFo2UVVKRVVWRnhSRU5uTTFwRVNUVk5SRUpzV1RKRmVGbDZTWGxaYWs1dENrOUVaR3BOVkUwMVQwUmthazlIVVRCWmFtUnFUMWRGZVU5WFNUTk5lazVvVFVJNFIwTnBjMGRCVVZGQ1p6YzRkMEZSTkVWRlVYZFFZMjFXYldONU9XOEtXbGRHYTJONU9YUlpWMngxVFVKclIwTnBjMGRCVVZGQ1p6YzRkMEZST0VWRGQzZEtUa1JyTVU1VVl6Qk9WRlV4VFVOelIwTnBjMGRCVVZGQ1p6YzRkd3BCVWtGRlNGRjNZbUZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3pUbkJhTTA0d1lqTktiRTFDWjBkRGFYTkhRVkZSUW1jM09IZEJVa1ZGQ2tObmQwbE9la1YzVDFSWmVrNVVUWGRhVVZsTFMzZFpRa0pCUjBSMmVrRkNSV2RTV0VSR1ZtOWtTRkozWTNwdmRrd3laSEJrUjJneFdXazFhbUl5TUhZS1l6SnNibU16VW5aamJWVjJZekpzYm1NelVuWmpiVlYwWVc1TmRreHRaSEJrUjJneFdXazVNMkl6U25KYWJYaDJaRE5OZG1OdFZuTmFWMFo2V2xNMU5RcGlWM2hCWTIxV2JXTjVPVzlhVjBaclkzazVkRmxYYkhWTlJHZEhRMmx6UjBGUlVVSm5OemgzUVZKTlJVdG5kMjlPTWxGNVQxUkJkMXBYVG1oTlYwMTVDazF0U1hwYWFtY3pXWHBGZWs5VVp6TlplbWhyVGtkSk0xbDZiR2hOYW14cFRucE5lbGxVUVZWQ1oyOXlRbWRGUlVGWlR5OU5RVVZWUWtGWlRVSklRakVLWXpKbmQxZDNXVXRMZDFsQ1FrRkhSSFo2UVVKR1VWSk9SRVYwYjJSSVVuZGplbTkyVERKa2NHUkhhREZaYVRWcVlqSXdkbU15Ykc1ak0xSjJZMjFWZGdwak1teHVZek5TZG1OdFZYUmhiazEyV1ZkT01HRlhPWFZqZVRsNVpGYzFla3g2U1RKT2VtZDRUMFJaTWs1cVJUUk1Na1l3WkVkV2RHTklVbnBNZWtWM0NrWm5XVXRMZDFsQ1FrRkhSSFo2UVVKR1oxRkpSRUZhZDJSWFNuTmhWMDEzVUZGWlMwdDNXVUpDUVVkRWRucEJRa2RCVVhaRVF6RjVXbGhDZGs5dVRuQUtXak5PTUdJelNteE1NMDV3V2pOT01HSXpTbXhNVjNCNlQyNUtiRnBxY0hsYVYxcDZUREpvYkZsWFVucE1NakZvWVZjMGQyZFpiMGREYVhOSFFWRlJRZ294Ym10RFFrRkpSV1pCVWpaQlNHZEJaR2RFWkZCVVFuRjRjMk5TVFcxTldraG9lVnBhZW1ORGIydHdaWFZPTkRoeVppdElhVzVMUVV4NWJuVnFaMEZCQ2tGYU5rVXZlR0pOUVVGQlJVRjNRa2hOUlZWRFNWRkRhbXB3ZEhVMVpEUTFkVzFGWjIxMVlXOHJOVzVRV0ZGWU1YUk1XSHBGWVU5dWNsRm5jWE5QVERnS05tZEpaMUJvUkdaUFpqSmFhRUZOTWtaSVVEaFRSbmwzZW1KNGRsRlBWVXd2SzAxck4yVjFTR1JDVmtwMFVVMTNRMmRaU1V0dldrbDZhakJGUVhkTlJBcGFkMEYzV2tGSmQwVlNVemwwTkVnNFNrSXdVRWxvWm5jNVFsUnFPVGRHVmtSUlpHTlJPREJ2VVhaWVdrWjVaVXhWVlZWWFJFbG1jWEZHY0RWRWVuUm1DbWhZTWxWTlZHVkpRV3BCVjIxa1NWVTRURWMzYVVjMlYxQklVVWR4VW1vMmN5OVhWRXBKZUdWdWRtMW1ZVm94ZWs1dFpIRlVZa2MwYVd0Ulltc3JNR3dLZWpkSE9HNUxTMFJvTlVrOUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwSyJ9XX19" + } + ], + "timestampVerificationData": { + "rfc3161Timestamps": [] + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoicGtnOm5wbS8lNDBzaWdzdG9yZS9idW5kbGVANS4wLjAiLCJkaWdlc3QiOnsic2hhNTEyIjoiYzFlN2UzY2EwYjlkMTBkNmYzNmQwMzI0YjM1Yjc5YmI3ZTA0M2Y0N2M1YTAzZDE3YmRhMTBmZWMzMzk0M2ZmYjE3MmFmYTIwY2M0MjU4MTcwZTQ0ZWE5N2EwMWIyYTc3YTI3MTk2Y2Q1MTE4Mjk1NmI3NmNkNjU2YzkzNjE3ZjgifX1dLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi9wcm92ZW5hbmNlL3YxIiwicHJlZGljYXRlIjp7ImJ1aWxkRGVmaW5pdGlvbiI6eyJidWlsZFR5cGUiOiJodHRwczovL3Nsc2EtZnJhbWV3b3JrLmdpdGh1Yi5pby9naXRodWItYWN0aW9ucy1idWlsZHR5cGVzL3dvcmtmbG93L3YxIiwiZXh0ZXJuYWxQYXJhbWV0ZXJzIjp7IndvcmtmbG93Ijp7InJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInJlcG9zaXRvcnkiOiJodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMiLCJwYXRoIjoiLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55bWwifX0sImludGVybmFsUGFyYW1ldGVycyI6eyJnaXRodWIiOnsiZXZlbnRfbmFtZSI6InB1c2giLCJyZXBvc2l0b3J5X2lkIjoiNDk1NTc0NTU1IiwicmVwb3NpdG9yeV9vd25lcl9pZCI6IjcxMDk2MzUzIn19LCJyZXNvbHZlZERlcGVuZGVuY2llcyI6W3sidXJpIjoiZ2l0K2h0dHBzOi8vZ2l0aHViLmNvbS9zaWdzdG9yZS9zaWdzdG9yZS1qc0ByZWZzL2hlYWRzL21haW4iLCJkaWdlc3QiOnsiZ2l0Q29tbWl0IjoiN2QyOTAwZWNhMWMyMmIzZjg3YzEzOTg3YzhkNGI3YzlhMjliNzMzYSJ9fV19LCJydW5EZXRhaWxzIjp7ImJ1aWxkZXIiOnsiaWQiOiJodHRwczovL2dpdGh1Yi5jb20vYWN0aW9ucy9ydW5uZXIvZ2l0aHViLWhvc3RlZCJ9LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSWQiOiJodHRwczovL2dpdGh1Yi5jb20vc2lnc3RvcmUvc2lnc3RvcmUtanMvYWN0aW9ucy9ydW5zLzI2NzgxODY2NjE4L2F0dGVtcHRzLzEifX19fQ==", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIGFfdjsRm32i9oBQVhtKWUKHtOoy0VNo01e1SaoM2ARSAiBZdXoJIDeclY/vCDKq7rq6ojIa56w1f4D+zoNgG1f4rg==", + "keyid": "" + } + ] + } + }, + "signedAccessSignatureUrl": "" + } + ] +} diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index 0000edb..4abc3e9 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -81,8 +81,11 @@ const ( type Provenance struct { // Kind is the strongest kind of evidence, in the spelling of model.ProvenanceKind. Kind model.ProvenanceKind `json:"kind"` - // Verified is true when the registry or deps.dev verified the evidence. + // Verified is true when the registry, deps.dev or a local verifier verified the evidence. Verified bool `json:"verified"` + // VerifiedBy retains an explicitly selected verifier, such as cosign. Absent + // in older observations and where only the registry/deps.dev signal was used. + VerifiedBy string `json:"verified_by,omitempty"` // Identity is the workflow or repository the evidence names, when it names one. Identity string `json:"identity,omitempty"` } diff --git a/internal/baseline/baseline.v1.json b/internal/baseline/baseline.v1.json index 00e4c43..27d8a8a 100644 --- a/internal/baseline/baseline.v1.json +++ b/internal/baseline/baseline.v1.json @@ -88,9 +88,14 @@ "enum": ["none", "signature", "attestation", "trusted-publisher"] }, "verified": { - "description": "True when the registry or deps.dev verified the evidence rather than only carrying it.", + "description": "True when the registry, deps.dev or an explicitly selected local verifier verified the evidence rather than only carrying it.", "type": "boolean" }, + "verified_by": { + "description": "The explicitly selected verifier, such as cosign. Absent in older observations and where only the registry/deps.dev signal was used.", + "type": "string", + "minLength": 1 + }, "identity": { "description": "The workflow or repository the evidence names, for example github:pypa/sampleproject/release.yml. Absent when the evidence names none.", "type": "string", diff --git a/internal/baseline/observe.go b/internal/baseline/observe.go index dde756f..386cde4 100644 --- a/internal/baseline/observe.go +++ b/internal/baseline/observe.go @@ -138,7 +138,10 @@ func observe(ctx context.Context, src Signals, ref model.PackageRef, now time.Ti e.outage |= signalProvenance problems = append(problems, fmt.Sprintf("%s: provenance was not read (%s), so what the baseline already holds was kept", ref, reason)) } else if info.Provenance.Kind != "" { - e.Provenance = &Provenance{Kind: info.Provenance.Kind, Verified: info.Provenance.Verified, Identity: info.Provenance.Identity} + e.Provenance = &Provenance{ + Kind: info.Provenance.Kind, Verified: info.Provenance.Verified, + VerifiedBy: info.Provenance.VerifiedBy, Identity: info.Provenance.Identity, + } } e.Publisher, e.PublisherSource = PublisherOf(info) } diff --git a/internal/baseline/observe_test.go b/internal/baseline/observe_test.go index ca8cb04..b1118a7 100644 --- a/internal/baseline/observe_test.go +++ b/internal/baseline/observe_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/vahapogut/trustdiff/internal/jsonschema" "github.com/vahapogut/trustdiff/internal/model" ) @@ -20,6 +21,56 @@ type signals struct { fail map[string]error } +func TestObservePersistsLocalVerifierAndRetainsItAcrossOutage(t *testing.T) { + ref := model.MustParseRef("npm:example-lib@1.0.0") + info := &model.VersionInfo{Ref: ref, Provenance: model.Provenance{ + Kind: model.ProvenanceAttestation, Verified: true, VerifiedBy: "cosign", + Identity: "https://github.com/example/lib/.github/workflows/release.yml@refs/heads/main", + }} + src := &signals{releases: map[string]*model.VersionInfo{ref.String(): info}, + owners: map[string][]model.Publisher{ref.Package().String(): publishers("alice")}} + observed, problems, unavailable := Observe(context.Background(), src, []model.PackageRef{ref}, now, 1) + if len(problems) != 0 || len(unavailable) != 0 || len(observed) != 1 { + t.Fatalf("observed=%+v problems=%v unavailable=%v", observed, problems, unavailable) + } + path := Path(t.TempDir()) + if _, err := Update(path, observed, nil, now); err != nil { + t.Fatal(err) + } + assertAttribution := func() { + t.Helper() + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + entry, ok := loaded.Lookup(ref) + if !ok || entry.Provenance == nil || !entry.Provenance.Verified || entry.Provenance.VerifiedBy != "cosign" { + t.Fatalf("local verifier lost in baseline round trip: %+v", entry) + } + data, err := Bytes(loaded) + if err != nil { + t.Fatal(err) + } + schema, err := jsonschema.Compile(SchemaJSON) + if err != nil { + t.Fatal(err) + } + if err := schema.Validate(data); err != nil { + t.Fatalf("local attribution violates baseline schema: %v", err) + } + } + assertAttribution() + info.SetUnknown(model.FacetProvenance, "local Sigstore verification unavailable") + observed, _, unavailable = Observe(context.Background(), src, []model.PackageRef{ref}, now.Add(day), 1) + if len(unavailable) != 1 || observed[0].Provenance != nil { + t.Fatal("failed local verification was recorded as fresh evidence") + } + if _, err := Update(path, observed, nil, now.Add(day)); err != nil { + t.Fatal(err) + } + assertAttribution() +} + var errUnknown = errors.New("not found") func (s *signals) VersionInfo(_ context.Context, ref model.PackageRef) (*model.VersionInfo, error) { diff --git a/internal/checks/check.go b/internal/checks/check.go index 8d9d7df..bc61fb6 100644 --- a/internal/checks/check.go +++ b/internal/checks/check.go @@ -11,6 +11,7 @@ import ( "context" "errors" "fmt" + "maps" "sort" "strings" "sync" @@ -124,14 +125,38 @@ type Subject struct { DepsDev *depsdev.VersionFacts // DepsDevFindings are the deps.dev findings for the version (MALICIOUS, LOW_USAGE and so on). DepsDevFindings []depsdev.Finding - // Downloads is the weekly download count, -1 when unknown. + // Downloads is the weekly download count, -1 when unknown or not yet requested. + // Checks that need it use withDownloads; the runner does not load it eagerly. Downloads int64 // Unavailable records, per data source name, why it could not be fetched. Unavailable map[string]error - // Loader lets a check look up other packages (TD007 inspects introduced dependencies). + // Loader lets a check request counts or look up other packages (TD007 inspects + // introduced dependencies). Those requests are memoized for the run. Loader Loader } +// withDownloads obtains the subject's count only when a check needs it. A private +// copy holds the answer: a timed-out check may still be finishing while the next +// check reads the original subject. Loader memoization shares the actual request +// without mutating that subject or its Unavailable map. +func withDownloads(ctx context.Context, s *Subject) *Subject { + if s.Downloads >= 0 || s.Unavailable[SourceDownloads] != nil || s.Loader == nil { + return s + } + copied := *s + count, err := s.Loader.Downloads(ctx, s.Ref.Ecosystem, s.Ref.Name) + if err == nil { + copied.Downloads = count + } else { + copied.Unavailable = maps.Clone(s.Unavailable) + if copied.Unavailable == nil { + copied.Unavailable = map[string]error{} + } + copied.Unavailable[SourceDownloads] = err + } + return &copied +} + // Setting returns the effective policy setting of a check for this subject. func (s *Subject) Setting(name string) policy.CheckSetting { if cfg, ok := s.Settings.Check(name); ok { diff --git a/internal/checks/lazy_downloads_test.go b/internal/checks/lazy_downloads_test.go new file mode 100644 index 0000000..cd79d35 --- /dev/null +++ b/internal/checks/lazy_downloads_test.go @@ -0,0 +1,325 @@ +package checks + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/advisory/depsdev" + "github.com/vahapogut/trustdiff/internal/httpcache" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/policy" + "github.com/vahapogut/trustdiff/internal/registry" + "github.com/vahapogut/trustdiff/internal/registry/npm" +) + +// npmCountsSource uses a real npm counts client and fake package metadata, so +// request assertions cover the runner, loader and npm batching without fixtures +// for unrelated registry fields. The HTTP answers below are synthetic. +type npmCountsSource struct { + *fakeSourceR + counts *npm.Client +} + +func (s npmCountsSource) Downloads(ctx context.Context, name string) (int64, error) { + return s.counts.Downloads(ctx, name) +} + +func (s npmCountsSource) BulkDownloads(ctx context.Context, names []string) (map[string]int64, error) { + return s.counts.BulkDownloads(ctx, names) +} + +func TestLazyDownloadsRequestOnlyWhatThePolicyUses(t *testing.T) { + off := model.LevelOff + zero := int64(0) + allow := policy.AllowEntry{Check: "low-usage", Package: policy.MustParsePattern("npm:@scope/*"), Reason: "reviewed internal packages"} + expired := allow + expired.Expires = policy.Date{Year: 2025, Month: time.January, Day: 1} + tests := []struct { + name string + pol *policy.Policy + wantRequests int + wantLow bool + wantAllowed bool + }{ + {name: "default low usage retains the scoped requests", wantRequests: 3, wantLow: true}, + {name: "off needs no counts for names without candidates", pol: &policy.Policy{Checks: map[string]policy.CheckConfig{"low-usage": {Level: &off}}}}, + {name: "active scoped allows retain the unscoped batch", pol: &policy.Policy{Allow: []policy.AllowEntry{allow}}, wantRequests: 1, wantAllowed: true}, + {name: "expired scoped allows request counts again", pol: &policy.Policy{Allow: []policy.AllowEntry{expired}}, wantRequests: 3, wantLow: true}, + {name: "zero threshold preserves registry precedence over fallback", pol: &policy.Policy{Checks: map[string]policy.CheckConfig{"low-usage": {MinWeeklyDownloads: &zero}}}, wantRequests: 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mu sync.Mutex + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.URL.Path) + mu.Unlock() + names := strings.Split(strings.TrimPrefix(r.URL.Path, "/downloads/point/last-week/"), ",") + count := func(name string) map[string]any { + n := 1000 + if strings.HasPrefix(name, "@") { + n = 42 + } + return map[string]any{"package": name, "downloads": n} + } + var body any + if len(names) == 1 { + body = count(names[0]) + } else { + bulk := map[string]any{} + for _, name := range names { + bulk[name] = count(name) + } + body = bulk + } + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Error(err) + } + })) + defer srv.Close() + h, err := httpcache.New(httpcache.Options{NoCache: true, UserAgent: "trustdiff-test", Retries: -1}) + if err != nil { + t.Fatal(err) + } + source := newFakeSourceR(model.NPM) + names := []string{"@scope/quartz", "@scope/velocity", "unrelated-alpha", "unrelated-bravo"} + dd := newFakeDepsDevR() + var inputs []Input + for _, name := range names { + list := stableListR(model.NPM, name, "1.0.0", "2.0.0") + source.add(list) + for _, version := range list.Versions { + inputs = append(inputs, Input{Ref: version.Ref}) + dd.findings[version.Ref] = []depsdev.Finding{{Type: "LOW_USAGE"}} + } + } + loader := newDataLoader(registry.Registry{model.NPM: npmCountsSource{source, npm.New(h, npm.WithDownloadsURL(srv.URL))}}, nil, dd, nil) + r := newRunnerR(loader, newTyposquatT(), lowUsage{}) + r.Policy = tt.pol + out := r.Evaluate(t.Context(), inputs) + if len(requests) != tt.wantRequests { + t.Fatalf("counts requests = %v, want %d", requests, tt.wantRequests) + } + if tt.wantRequests > 0 && !slices.Contains(requests, "/downloads/point/last-week/unrelated-alpha,unrelated-bravo") { + t.Errorf("unscoped names were not batched: %v", requests) + } + for i := range out { + s := &out[i].Subject + scoped := strings.HasPrefix(s.Ref.Name, "@") + wantLow := tt.wantLow && scoped + if got := slices.Contains(findingIDsR(s), "TD012"); got != wantLow { + t.Errorf("%s findings = %v, want low-usage %v", s.Ref, findingIDsR(s), wantLow) + } + if tt.wantAllowed && scoped { + if !strings.Contains(skippedReasonsR(s)["TD012"], "allow entry") || slices.Contains(s.Evaluated, "TD012") || out[i].Unavailable { + t.Errorf("allowed check must be a policy skip: %+v", out[i]) + } + } + } + }) + } +} + +func TestGenericPrefetchDoesNotRequestDownloads(t *testing.T) { + source := &bulkSourceR{fakeSourceR: newFakeSourceR(model.NPM)} + loader := newDataLoader(registry.Registry{model.NPM: source}, nil, nil, nil) + loader.Prefetch(t.Context(), []model.PackageRef{model.MustParseRef("npm:lib@1.0.0")}) + if source.bulkCalls != 0 || source.count("downloads") != 0 { + t.Fatal("generic prefetch asked for download counts") + } +} + +func TestLazyDownloadsOutageRemainsUnavailable(t *testing.T) { + loader := libLoaderR() + loader.fail[SourceDownloads] = errors.New("counts service unreachable") + r := newRunnerR(loader, lowUsage{}) + out := r.Evaluate(t.Context(), inputsR("npm:lib@1.0.0")) + if !out[0].Unavailable || !strings.Contains(skippedReasonsR(&out[0].Subject)["TD012"], "counts service unreachable") { + t.Fatalf("download outage lost its unavailable status: %+v", out[0]) + } +} + +func TestLazyDownloadsFailedBatchDoesNotRetryPerPackage(t *testing.T) { + source := newFakeSourceR(model.NPM) + source.add(stableListR(model.NPM, "lib", "1.0.0", "2.0.0")) + bulk := &bulkSourceR{fakeSourceR: source, bulkErr: errors.New("counts service rejected the batch")} + loader := newDataLoader(registry.Registry{model.NPM: bulk}, nil, nil, nil) + out := newRunnerR(loader, lowUsage{}).Evaluate(t.Context(), inputsR("npm:lib@1.0.0", "npm:lib@2.0.0")) + if bulk.bulkCalls != 1 || source.count("downloads") != 0 { + t.Fatalf("batch calls %d, individual calls %d", bulk.bulkCalls, source.count("downloads")) + } + for _, got := range out { + if !got.Unavailable || !strings.Contains(skippedReasonsR(&got.Subject)["TD012"], "rejected the batch") { + t.Errorf("failed count was not reported as unavailable: %+v", got) + } + } +} + +func TestLazyTyposquatCountsAreSharedWithLowUsage(t *testing.T) { + source := newFakeSourceR(model.NPM) + list := stableListR(model.NPM, "crossenv", "1.0.0", "2.0.0") + for i := range list.Versions { + list.Versions[i].PublishedAt = nowR.AddDate(-2, 0, i) + } + source.add(list) + source.downloads["crossenv"] = 1000 + source.downloads["cross-env"] = 10000 + bulk := &bulkSourceR{fakeSourceR: source} + loader := newDataLoader(registry.Registry{model.NPM: bulk}, nil, nil, nil) + out := newRunnerR(loader, newTyposquatT(), lowUsage{}).Evaluate(t.Context(), inputsR("npm:crossenv@1.0.0", "npm:crossenv@2.0.0")) + if bulk.bulkCalls != 1 || source.count("downloads") != 1 { + t.Fatalf("want one candidate batch and one neighbor request shared across versions, got %d and %d", bulk.bulkCalls, source.count("downloads")) + } + for _, got := range out { + if len(got.Subject.Findings) != 1 || got.Subject.Findings[0].ID != "TD008" || got.Subject.Findings[0].Level != model.LevelWarn { + t.Errorf("count sharing changed the findings: %+v", got) + } + } +} + +func TestLazyTyposquatActiveAllowNeedsNoDownloads(t *testing.T) { + loader := libLoaderR() + list := stableListR(model.NPM, "crossenv", "1.0.0") + list.Versions[0].PublishedAt = nowR.AddDate(-2, 0, 0) + loader.add(list) + r := newRunnerR(loader, newTyposquatT()) + r.Policy = &policy.Policy{Allow: []policy.AllowEntry{{Check: "typosquat-suspect", Package: policy.MustParsePattern("npm:crossenv"), Reason: "reviewed look-alike"}}} + out := r.Evaluate(t.Context(), inputsR("npm:crossenv@1.0.0")) + if loader.count("downloads") != 0 || len(out[0].Subject.Findings) != 0 || !strings.Contains(skippedReasonsR(&out[0].Subject)["TD008"], "allow entry") { + t.Fatalf("allowed typo check fetched or evaluated: %+v, calls %d", out[0], loader.count("downloads")) + } +} + +func TestLazyTyposquatNonListNeighborStillComparesCounts(t *testing.T) { + loader := &fakeLoaderT{similar: []depsdev.Similar{{Name: "left-pad"}}, downloads: map[string]int64{"leftpad-fork": 10, "left-pad": 2000000}} + s := subjectT("npm:leftpad-fork@1.0.0", loader, -1) + result := newTyposquatT().Run(t.Context(), s) + if len(result.Findings) != 1 || result.Findings[0].Evidence["deps_dev_neighbor"] != "left-pad" { + t.Fatalf("lazy counts lost the non-list neighbor finding: %+v", result) + } + if want := []string{"similar npm:leftpad-fork", "downloads npm:leftpad-fork", "downloads npm:left-pad"}; !slices.Equal(loader.calls, want) { + t.Errorf("calls = %v, want %v", loader.calls, want) + } +} + +func TestLazyDownloadsErrorDoesNotMutateSubject(t *testing.T) { + loader := libLoaderR() + loader.fail[SourceDownloads] = errors.New("counts down") + s := subjectT("npm:lib@1.0.0", loader, -1) + s.Unavailable = map[string]error{SourceOwners: errors.New("owners down")} + result := lowUsage{}.Run(t.Context(), s) + if !result.Unavailable || s.Downloads != -1 || len(s.Unavailable) != 1 || s.Unavailable[SourceDownloads] != nil { + t.Fatalf("lazy failure mutated the subject or lost its outage: %+v, %+v", s, result) + } +} + +type cancelCountsSource struct { + *fakeSourceR + started chan struct{} + calls atomic.Int32 +} + +func (s *cancelCountsSource) Downloads(ctx context.Context, _ string) (int64, error) { + if s.calls.Add(1) == 1 { + close(s.started) + <-ctx.Done() + return -1, ctx.Err() + } + return 1000, nil +} + +func TestLazyDownloadsCancellationLeavesNoPoisonedMemo(t *testing.T) { + source := &cancelCountsSource{fakeSourceR: newFakeSourceR(model.NPM), started: make(chan struct{})} + loader := newDataLoader(registry.Registry{model.NPM: source}, nil, nil, nil) + s := subjectT("npm:lib@1.0.0", loader, -1) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + finished := make(chan Result, 1) + go func() { finished <- (lowUsage{}).Run(ctx, s) }() + select { + case <-source.started: + case <-time.After(time.Second): + t.Fatal("download did not start") + } + cancel() + select { + case result := <-finished: + if !result.Unavailable || result.Skipped == nil { + t.Fatalf("canceled count passed: %+v", result) + } + case <-time.After(time.Second): + t.Fatal("download ignored cancellation") + } + result := lowUsage{}.Run(t.Context(), s) + if result.Skipped != nil || len(result.Findings) != 0 || source.calls.Load() != 2 || s.Downloads != -1 { + t.Fatalf("canceled count poisoned the next check: %+v, calls %d", result, source.calls.Load()) + } +} + +func TestLazyDownloadsKeepIntroducedDependencyEscalationWithLowUsageOff(t *testing.T) { + source := newFakeSourceR(model.NPM) + parent := stableListR(model.NPM, "lib", "1.0.0", "2.0.0") + parent.Versions[1].Dependencies = map[string]string{"plain-crypto-js": "^1.0.0"} + source.add(parent) + dep := stableListR(model.NPM, "plain-crypto-js", "1.0.0") + dep.Versions[0].Publisher = &model.Publisher{Name: "different-author"} + source.add(dep) + source.downloads["plain-crypto-js"] = 12 + bulk := &bulkSourceR{fakeSourceR: source} + loader := newDataLoader(registry.Registry{model.NPM: bulk}, nil, newFakeDepsDevR(), nil) + r := newRunnerR(loader, td007{}, lowUsage{}) + off := model.LevelOff + r.Policy = &policy.Policy{Checks: map[string]policy.CheckConfig{"low-usage": {Level: &off}}} + out := r.Evaluate(t.Context(), inputsR("npm:lib@2.0.0")) + if bulk.bulkCalls != 0 || source.count("downloads") != 1 { + t.Fatalf("want only the introduced dependency's count, got %d batches and %d point requests", bulk.bulkCalls, source.count("downloads")) + } + if len(out[0].Subject.Findings) != 1 || out[0].Subject.Findings[0].ID != "TD007" || out[0].Subject.Findings[0].Level != model.LevelBlock { + t.Fatalf("lazy counts lost introduced dependency escalation: %+v", out[0]) + } +} + +func TestLazyTyposquatDownloadsKeepTheStandingDecision(t *testing.T) { + tests := []struct { + name string + days int + wantCalls int + wantLevel model.Level + }{ + {name: "young candidate needs no count", days: 1, wantLevel: model.LevelBlock}, + {name: "old candidate needs counts to demote", days: 400, wantCalls: 2, wantLevel: model.LevelWarn}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loader := &fakeLoaderT{downloads: map[string]int64{"crossenv": 1000, "cross-env": 10000}} + s := subjectT("npm:crossenv@1.0.0", loader, -1) + s.Package = ®istry.VersionList{Versions: []model.VersionInfo{{Ref: s.Ref, PublishedAt: s.Now.AddDate(0, 0, -tt.days)}}} + res := newTyposquatT().Run(t.Context(), s) + if len(res.Findings) != 1 || res.Findings[0].Level != tt.wantLevel { + t.Fatalf("result = %+v", res) + } + calls := 0 + for _, call := range loader.calls { + if strings.HasPrefix(call, "downloads ") { + calls++ + } + } + if calls != tt.wantCalls { + t.Errorf("downloads calls = %v, want %d", loader.calls, tt.wantCalls) + } + if s.Downloads != -1 || len(s.Unavailable) != 0 { + t.Fatal("lazy loading mutated the shared subject") + } + }) + } +} diff --git a/internal/checks/loader.go b/internal/checks/loader.go index 2dcca5e..656a645 100644 --- a/internal/checks/loader.go +++ b/internal/checks/loader.go @@ -99,12 +99,12 @@ func newDataLoader(reg registry.Registry, adv advisory.Source, dd depsDevSource, return &DataLoader{reg: reg, adv: adv, dd: dd, log: log} } -// Prefetch warms the OSV, deps.dev and download count batches for every ref that +// Prefetch warms the OSV and deps.dev batches for every ref that // carries a version. Refs without a version are skipped: the advisory endpoints // answer per version, and the runner resolves bare refs before it calls Prefetch. // Refs of an ecosystem a source does not index are left out of its batch, as the // source itself would leave them out of its answer; the per-ref methods report them -// as unsupported without a request. The four batches run concurrently. A batch that +// as unsupported without a request. The three batches run concurrently. A batch that // fails stores its error for every ref it covered, so the source is not asked again // for them; a batch that lost only some of its refs stores the error for those and // the answers for the rest; a batch that failed because ctx ended stores nothing. @@ -114,7 +114,7 @@ func (l *DataLoader) Prefetch(ctx context.Context, refs []model.PackageRef) { return } var wg sync.WaitGroup - wg.Add(4) + wg.Add(3) go func() { defer wg.Done() l.prefetchAdvisories(ctx, versioned) @@ -127,10 +127,6 @@ func (l *DataLoader) Prefetch(ctx context.Context, refs []model.PackageRef) { defer wg.Done() l.prefetchFindings(ctx, versioned) }() - go func() { - defer wg.Done() - l.prefetchDownloads(ctx, versioned) - }() wg.Wait() } @@ -268,9 +264,11 @@ type bulkDownloader interface { BulkDownloads(ctx context.Context, names []string) (map[string]int64, error) } -// prefetchDownloads warms the download counts of every name of the run through the -// sources that can answer many at once. A name the batch could not carry, such as a -// scoped npm name, or did not know is not stored, so the per name path asks for it +// PrefetchDownloads warms the counts of names selected by the runner's effective +// policy through sources that answer many at once. It is deliberately separate +// from Prefetch: most checks need no count. The npm source batches unscoped names +// and requests scoped names individually. A name the source did not know is not +// stored, so the per name path asks for it // and reports what it gets. Storing a zero for an unknown name would read as a // package nobody installs, which is the thing the low usage check is about. // @@ -280,7 +278,7 @@ type bulkDownloader interface { // large lockfile drew 2,406 answers of 429 and then a block of the address itself, // measured on 2026-09-12. The checks that read counts report themselves as skipped // instead, which is what a policy with on_data_unavailable can act on. -func (l *DataLoader) prefetchDownloads(ctx context.Context, refs []model.PackageRef) { +func (l *DataLoader) PrefetchDownloads(ctx context.Context, refs []model.PackageRef) { byEco := map[model.Ecosystem][]string{} seen := map[model.PackageRef]bool{} for _, ref := range refs { diff --git a/internal/checks/loader_test.go b/internal/checks/loader_test.go index 25eed1b..a8ca901 100644 --- a/internal/checks/loader_test.go +++ b/internal/checks/loader_test.go @@ -629,9 +629,9 @@ func (b *bulkSourceR) BulkDownloads(_ context.Context, names []string) (map[stri // 1201 entry package-lock.json, evaluated live on 2026-09-12, api.npmjs.org // answered 1684 of those requests with 429 and the client spent the run backing // off and retrying. The counts API takes up to 128 names in one request, which is -// what Prefetch now uses, so the per name path is left with the names a batch +// what PrefetchDownloads uses, so the per name path is left with the names a batch // cannot carry and the ones it did not know. -func TestPrefetchAsksTheCountsApiOnceForTheWholeRun(t *testing.T) { +func TestPrefetchDownloadsAsksTheCountsAPIOnceForSelectedNames(t *testing.T) { src := newFakeSourceR(model.NPM) src.add(stableListR(model.NPM, "lib", "1.0.0")) src.add(stableListR(model.NPM, "other", "2.0.0")) @@ -646,7 +646,7 @@ func TestPrefetchAsksTheCountsApiOnceForTheWholeRun(t *testing.T) { model.MustParseRef("npm:lib@1.0.0"), model.MustParseRef("npm:unknown@3.0.0"), } - l.Prefetch(t.Context(), refs) + l.PrefetchDownloads(t.Context(), refs) if bulk.bulkCalls != 1 { t.Fatalf("the counts API was asked %d times, want once for the whole run", bulk.bulkCalls) @@ -687,14 +687,14 @@ func TestPrefetchAsksTheCountsApiOnceForTheWholeRun(t *testing.T) { // the batch form itself were refused. So a failed batch is an answer about every // name it carried: the checks that read counts report themselves as skipped, which // a policy can fail the run on, and nothing asks again. -func TestPrefetchDoesNotFallBackToOneRequestPerPackage(t *testing.T) { +func TestPrefetchDownloadsDoesNotFallBackToOneRequestPerPackage(t *testing.T) { src := newFakeSourceR(model.NPM) src.add(stableListR(model.NPM, "lib", "1.0.0")) src.downloads["lib"] = 42 bulk := &bulkSourceR{fakeSourceR: src, bulkErr: errors.New("429 Too Many Requests")} l := newDataLoader(registry.Registry{model.NPM: bulk}, nil, nil, nil) - l.Prefetch(t.Context(), []model.PackageRef{model.MustParseRef("npm:lib@1.0.0")}) + l.PrefetchDownloads(t.Context(), []model.PackageRef{model.MustParseRef("npm:lib@1.0.0")}) if bulk.bulkCalls != 1 { t.Fatalf("the batch was asked %d times, want once", bulk.bulkCalls) } @@ -715,7 +715,7 @@ func TestPrefetchDoesNotFallBackToOneRequestPerPackage(t *testing.T) { // lockfile lost the counts of 1,583 packages because api.npmjs.org refused the // request for @babel/plugin-syntax-flow, and every check that reads counts // reported itself skipped for the whole run. -func TestPrefetchKeepsTheCountsABatchDidRead(t *testing.T) { +func TestPrefetchDownloadsKeepsTheCountsABatchDidRead(t *testing.T) { src := newFakeSourceR(model.NPM) src.add(stableListR(model.NPM, "lib", "1.0.0")) src.add(stableListR(model.NPM, "@scope/other", "2.0.0")) @@ -723,7 +723,7 @@ func TestPrefetchKeepsTheCountsABatchDidRead(t *testing.T) { bulk := &bulkSourceR{fakeSourceR: src, bulkErr: errors.New("429 Too Many Requests"), partial: true} l := newDataLoader(registry.Registry{model.NPM: bulk}, nil, nil, nil) - l.Prefetch(t.Context(), []model.PackageRef{ + l.PrefetchDownloads(t.Context(), []model.PackageRef{ model.MustParseRef("npm:lib@1.0.0"), model.MustParseRef("npm:@scope/other@2.0.0"), }) diff --git a/internal/checks/runner.go b/internal/checks/runner.go index 54939c6..1ce60a2 100644 --- a/internal/checks/runner.go +++ b/internal/checks/runner.go @@ -234,6 +234,7 @@ func (r *Runner) Evaluate(ctx context.Context, inputs []Input) []Outcome { } } rn.loader.Prefetch(ctx, refs) + rn.prefetchDownloads(ctx, refs) out := make([]Outcome, len(inputs)) rn.forEach(len(inputs), func(i int) { @@ -242,6 +243,34 @@ func (r *Runner) Evaluate(ctx context.Context, inputs []Input) []Outcome { return out } +// prefetchDownloads preserves efficient bulk requests for low-usage, the only +// check that needs every candidate's count. Typosquat and introduced-dependency +// counts stay on demand, after those checks find something to compare. +func (rn *run) prefetchDownloads(ctx context.Context, refs []model.PackageRef) { + bulk, ok := rn.loader.(interface { + PrefetchDownloads(context.Context, []model.PackageRef) + }) + if !ok { + return + } + var selected []model.PackageRef + for _, ref := range refs { + s := &Subject{Ref: ref, Settings: rn.policy.Effective(ref.Ecosystem)} + for _, c := range rn.checks { + if c.ID() != "TD012" || !AppliesTo(c, ref.Ecosystem) || s.Setting(c.Name()).Level == model.LevelOff { + continue + } + if _, allowed := rn.policy.Allowed(c.Name(), ref, rn.now); !allowed { + selected = append(selected, ref) + } + break + } + } + if len(selected) > 0 { + bulk.PrefetchDownloads(ctx, selected) + } +} + // Run is Evaluate for a caller that wants the report subjects only. func (r *Runner) Run(ctx context.Context, inputs []Input) []report.Subject { return Subjects(r.Evaluate(ctx, inputs)) @@ -409,6 +438,15 @@ func (rn *run) evaluate(ctx context.Context, in *Input, res *resolution) Outcome // body of that loop because the skip path runs the lockfile checks through it too, // and a check has to be filed the same way wherever it ran. func (rn *run) runOne(ctx context.Context, out *Outcome, s *Subject, c Check, outages []string) { + // These checks may request counts while running. An active allow already + // settles their findings, so report a policy skip without requesting evidence + // the report cannot use. Expired entries still run and fetch normally. + if c.ID() == "TD008" || c.ID() == "TD012" { + if entry, allowed := rn.policy.Allowed(c.Name(), s.Ref, rn.now); allowed { + out.Subject.Skipped = append(out.Subject.Skipped, model.Skipped{Check: c.ID(), Reason: "excluded by allow entry: " + entry.Reason}) + return + } + } result, unfinished := rn.runCheck(ctx, c, s) if result.Skipped != nil { skipped := *result.Skipped @@ -564,11 +602,6 @@ func (rn *run) load(ctx context.Context, s *Subject, list *registry.VersionList) } else { rn.log.Debug("loader has no deps.dev findings; leaving them empty", "ref", ref.String()) } - if count, err := rn.loader.Downloads(ctx, ref.Ecosystem, ref.Name); err != nil { - s.Unavailable[SourceDownloads] = err - } else { - s.Downloads = count - } for source, err := range s.Unavailable { rn.log.Debug("source unavailable", "source", source, "ref", ref.String(), "error", err) } diff --git a/internal/checks/runner_test.go b/internal/checks/runner_test.go index d664cc0..9084887 100644 --- a/internal/checks/runner_test.go +++ b/internal/checks/runner_test.go @@ -291,8 +291,15 @@ func TestRunAllowEntrySuppressesFindings(t *testing.T) { if got := findingIDsR(&out[0]); !slices.Equal(got, tt.want) { t.Errorf("findings = %v, want %v", got, tt.want) } - if !slices.Equal(out[0].Evaluated, []string{"TD006", "TD010", "TD012"}) { - t.Errorf("evaluated = %v, want every check (a suppressed check still ran)", out[0].Evaluated) + wantEvaluated := []string{"TD006", "TD010", "TD012"} + if tt.ref == "npm:lib@1.0.0" { + wantEvaluated = []string{"TD006", "TD010"} + if !strings.Contains(skippedReasonsR(&out[0])["TD012"], "allow entry") { + t.Error("allowed low-usage must skip before requesting counts") + } + } + if !slices.Equal(out[0].Evaluated, wantEvaluated) { + t.Errorf("evaluated = %v, want %v", out[0].Evaluated, wantEvaluated) } }) } @@ -437,7 +444,10 @@ func TestRunUnavailableSourceIsSkippedNotPass(t *testing.T) { loader := libLoaderR() loader.fail[tt.source] = boom var downloads int64 - needs := fakeCheckR{id: "TD001", name: "young-version", run: func(_ context.Context, s *Subject) Result { + needs := fakeCheckR{id: "TD001", name: "young-version", run: func(ctx context.Context, s *Subject) Result { + if tt.source == SourceDownloads { + s = withDownloads(ctx, s) + } downloads = s.Downloads if reason, skip := s.Skipped(tt.source); skip { return Skip("TD001", reason) @@ -467,7 +477,8 @@ func TestRunUnsupportedDownloadsIsRecorded(t *testing.T) { loader := libLoaderR() var reason string var ok bool - check := fakeCheckR{id: "TD012", name: "low-usage", run: func(_ context.Context, s *Subject) Result { + check := fakeCheckR{id: "TD012", name: "low-usage", run: func(ctx context.Context, s *Subject) Result { + s = withDownloads(ctx, s) reason, ok = s.Skipped(SourceDownloads) return Result{} }} @@ -759,8 +770,8 @@ func TestRunAssemblesSubject(t *testing.T) { if len(got.DepsDevFindings) != 1 || got.DepsDevFindings[0].Type != "LOW_USAGE" { t.Errorf("DepsDevFindings = %v, want LOW_USAGE", got.DepsDevFindings) } - if got.Downloads != 1234 { - t.Errorf("Downloads = %d, want 1234", got.Downloads) + if got.Downloads != -1 || base.count("downloads") != 0 { + t.Errorf("Downloads = %d with %d calls, want unrequested", got.Downloads, base.count("downloads")) } if len(got.Unavailable) != 0 { t.Errorf("Unavailable = %v, want empty", got.Unavailable) @@ -839,7 +850,10 @@ func TestRunLeavesProvenanceVerificationToTD004(t *testing.T) { // skipsOn returns a check that skips with the joined reasons of the sources that // failed, the way the checks in this package do. func skipsOn(sources ...string) fakeCheckR { - return fakeCheckR{id: "TD001", name: "young-version", run: func(_ context.Context, s *Subject) Result { + return fakeCheckR{id: "TD001", name: "young-version", run: func(ctx context.Context, s *Subject) Result { + if slices.Contains(sources, SourceDownloads) { + s = withDownloads(ctx, s) + } var reasons []string for _, source := range sources { if reason, down := s.Skipped(source); down { diff --git a/internal/checks/td004.go b/internal/checks/td004.go index 77b5cd5..49c6d57 100644 --- a/internal/checks/td004.go +++ b/internal/checks/td004.go @@ -17,14 +17,15 @@ import ( // trusted-publisher and ranks a verified record above an unverified one of the same // kind. // -// Who verifies. The npm and PyPI clients store the attestation bundle a version -// carries but never verify it, so an attestation counts as verified only when -// deps.dev verified it (attestations[].verified or slsaProvenances[].verified). +// Who verifies. Ordinary npm and PyPI metadata carries attestation presence; +// deps.dev can supply verification (attestations[].verified or +// slsaProvenances[].verified). Explicit local npm verification supplies its own +// VerifiedBy attribution, which takes precedence over deps.dev. A failed local +// verification marks provenance unknown and cannot be rescued by deps.dev. // The check consults the deps.dev facts of both versions: the evaluated version's // from the Subject, the previous version's through the Loader when its -// attestation is not verified by the registry. verified_by names the verifier -// for each side, deps.dev whenever deps.dev verified an attestation, whatever the -// runner wrote into the Provenance beforehand. The previous version's deps.dev +// attestation has no verified result. verified_by names the actual verifier +// for each side. The previous version's deps.dev // verification is applied only when deps.dev has indexed the evaluated version // too, and only when it has read that version's attestations: a fresh release // deps.dev has not seen, and one it has seen and not yet opened, both compare by @@ -40,8 +41,8 @@ import ( // evidence the project is losing. The version compared with is whichever of the two // carried the most, because the finding is about how much of it this release gives // up, and it degenerates to the previous release when the two are equally strong. -// The base version is ignored when the registry client could not gather its -// provenance. +// Unavailable base-version provenance is reported as skipped when it could hide +// a downgrade and no readable predecessor already proves one. // // A base version nobody could reach is not ignored quietly: where it would have // decided the answer the check reports itself as skipped naming it. A base version @@ -54,19 +55,19 @@ import ( // the finding names // previous_kind its provenance kind: none, signature, attestation or trusted-publisher // previous_verified whether that evidence was verified -// previous_verified_by registry or deps.dev, when verified +// previous_verified_by registry, deps.dev or cosign, when verified // previous_identity the workflow or repository it names, when known // compared_version the version the finding names: the stronger of the two // base_version the version the base lockfile locked, when diff knows one // and it is not the previous release (diff only) // base_kind its provenance kind (diff only) // base_verified whether that evidence was verified (diff only) -// base_verified_by registry or deps.dev, when verified (diff only) +// base_verified_by registry, deps.dev or cosign, when verified (diff only) // downgraded_since_base whether that version's evidence was stronger than this // one's (diff only) // kind the evaluated version's provenance kind // verified whether its evidence was verified -// verified_by registry or deps.dev, when verified +// verified_by registry, deps.dev or cosign, when verified // identity the workflow or repository it names, when known type td004 struct{} @@ -132,6 +133,9 @@ func (c td004) Run(ctx context.Context, s *Subject) Result { compared, comparedBy, with = baseProvenance, baseBy, base } if compared.Strength() <= current.Strength() { + if res := c.unknownBaseProvenance(s, current); res.Skipped != nil { + return res + } // No predecessor that was read carried more. Two things could still have // been true and were not read: a base version nobody could reach, and a // predecessor's attestation only deps.dev could have verified. @@ -215,6 +219,26 @@ func (c td004) Run(ctx context.Context, s *Subject) Result { return Result{Findings: []model.Finding{finding}} } +// unknownBaseProvenance covers a distinct base release whose version metadata +// was fetched but whose provenance could not be read or locally verified. That +// gap is not SourceBase (the fetch succeeded), and comparableBase excludes it. +// It cannot hide a downgrade only when the current evidence is already strongest. +func (c td004) unknownBaseProvenance(s *Subject, current model.Provenance) Result { + base := s.PreviousInBase + if base == nil || base.Ref.Version == s.Ref.Version || + (s.Previous != nil && base.Ref.Version == s.Previous.Ref.Version) { + return Result{} + } + strongest := model.Provenance{Kind: model.ProvenanceTrustedPublisher, Verified: true} + if current.Strength() >= strongest.Strength() { + return Result{} + } + if reason, unknown := unknownFacet(base, model.FacetProvenance); unknown { + return skipOutage(c, fmt.Sprintf("provenance of the base version %s unavailable: %s", base.Ref.Version, reason)) + } + return Result{} +} + // predecessorProvenance reads what an earlier version was published with, and who // vouched for it. It reads deliberately differently from verification, which judges // the evaluated version: here a registry that verified the record settles it, and @@ -225,6 +249,9 @@ func (c td004) Run(ctx context.Context, s *Subject) Result { func predecessorProvenance(ctx context.Context, s *Subject, v *model.VersionInfo) (model.Provenance, string) { p := v.Provenance if p.Verified { + if p.VerifiedBy != "" { + return p, p.VerifiedBy + } return p, SourceRegistry } if p.Kind == model.ProvenanceAttestation && s.Loader != nil && depsDevMaySpeakForAPredecessor(s) { @@ -289,11 +316,13 @@ func (c td004) unverifiedByOutage(s *Subject, current model.Provenance, predeces } // verification decides whether provenance counts as verified and by whom. An -// attestation that deps.dev verified is credited to deps.dev even when the -// Provenance already says verified: no registry client verifies attestations, -// so that flag came from deps.dev through the runner. Anything else verified is -// the registry's own word (a trusted publishing record, a registry signature). +// explicit local verifier keeps its attribution even when deps.dev also verified +// that release. Without local attribution, a verified attestation is credited to +// deps.dev when its facts say so; other verified evidence is the registry's word. func verification(p model.Provenance, facts *depsdev.VersionFacts) (verified bool, by string) { + if p.Verified && p.VerifiedBy != "" { + return true, p.VerifiedBy + } if p.Kind == model.ProvenanceAttestation && depsDevVerified(facts) { return true, SourceDepsDev } diff --git a/internal/checks/td004_local_test.go b/internal/checks/td004_local_test.go new file mode 100644 index 0000000..4da187c --- /dev/null +++ b/internal/checks/td004_local_test.go @@ -0,0 +1,85 @@ +package checks + +import ( + "testing" + + "github.com/vahapogut/trustdiff/internal/advisory/depsdev" + "github.com/vahapogut/trustdiff/internal/model" +) + +func TestTD004UnknownBaseProvenanceCannotHideDowngrade(t *testing.T) { + for _, tc := range []struct { + name string + previous model.Provenance + current model.Provenance + findings int + skipped bool + }{ + {"unverified base could be stronger", model.Provenance{}, model.Provenance{}, 0, true}, + {"readable predecessor already proves downgrade", model.Provenance{Kind: model.ProvenanceAttestation, Verified: true, VerifiedBy: "cosign"}, model.Provenance{}, 1, false}, + {"nothing can be stronger than verified trusted publishing", model.Provenance{}, model.Provenance{Kind: model.ProvenanceTrustedPublisher, Verified: true}, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := subjectA(model.NPM, "lib", "1.3.0") + s.Version.Provenance = tc.current + withPreviousA(s, "1.2.0").Provenance = tc.previous + base := withBaseA(s, "1.0.0") + base.Provenance = model.Provenance{Kind: model.ProvenanceAttestation} + base.SetUnknown(model.FacetProvenance, "local Sigstore verification unavailable: invalid signature") + s.DepsDev = &depsdev.VersionFacts{Found: true, AttestationVerified: true, AttestationsListed: true} + want := outcomeA{findings: tc.findings} + if tc.skipped { + want.skip = "base version 1.0.0 unavailable" + } + res := runA(t, "TD004", s, want) + if tc.skipped && !res.Unavailable { + t.Fatal("base verification failure does not reach on_data_unavailable") + } + }) + } +} + +func TestTD004LocalVerifierKeepsAttributionWhenDepsDevAlsoVerified(t *testing.T) { + local := model.Provenance{Kind: model.ProvenanceAttestation, Verified: true, VerifiedBy: "cosign"} + trusted := model.Provenance{Kind: model.ProvenanceTrustedPublisher, Verified: true} + for _, side := range []string{"current", "previous", "base"} { + t.Run(side, func(t *testing.T) { + s := subjectA(model.NPM, "lib", "1.3.0") + previous := withPreviousA(s, "1.2.0") + key := "verified_by" + switch side { + case "current": + s.Version.Provenance, previous.Provenance = local, trusted + case "previous": + previous.Provenance = local + key = "previous_verified_by" + case "base": + withBaseA(s, "1.0.0").Provenance = local + key = "base_verified_by" + } + s.DepsDev = &depsdev.VersionFacts{Found: true, AttestationVerified: true, AttestationsListed: true} + f := runA(t, "TD004", s, outcomeA{findings: 1}).Findings[0] + if f.Evidence[key] != "cosign" { + t.Fatalf("lost local attribution for %s: %+v", side, f.Evidence) + } + }) + } +} + +func TestTD004LocalVerificationFailureIsNotRescuedByDepsDev(t *testing.T) { + for _, side := range []string{"current", "previous"} { + t.Run(side, func(t *testing.T) { + s := subjectA(model.NPM, "lib", "1.3.0") + previous := withPreviousA(s, "1.2.0") + previous.Provenance = model.Provenance{Kind: model.ProvenanceAttestation, Verified: true, VerifiedBy: "cosign"} + s.Version.Provenance = model.Provenance{Kind: model.ProvenanceAttestation} + broken := s.Version + if side == "previous" { + broken = previous + } + broken.SetUnknown(model.FacetProvenance, "local Sigstore verification unavailable: invalid signature") + s.DepsDev = &depsdev.VersionFacts{Found: true, AttestationVerified: true, AttestationsListed: true} + runA(t, "TD004", s, outcomeA{skip: "local Sigstore verification unavailable"}) + }) + } +} diff --git a/internal/checks/td008.go b/internal/checks/td008.go index fc934eb..6515fc6 100644 --- a/internal/checks/td008.go +++ b/internal/checks/td008.go @@ -283,6 +283,7 @@ func (c *typosquatSuspect) standing(ctx context.Context, s *Subject, resembles [ first.UTC().Format(time.RFC3339), durationText(age)) } + s = withDownloads(ctx, s) threshold := s.Setting("low-usage").MinWeeklyDownloads if s.Downloads >= 0 { if s.Downloads < threshold { @@ -449,6 +450,10 @@ func (c *typosquatSuspect) crossCheck(ctx context.Context, s *Subject, candidate } neighbors = append(neighbors, n) } + if len(neighbors) == 0 { + return "", "" + } + s = withDownloads(ctx, s) if s.Downloads < 0 { return "", "" } diff --git a/internal/checks/td008_test.go b/internal/checks/td008_test.go index dfd8f11..5c6b843 100644 --- a/internal/checks/td008_test.go +++ b/internal/checks/td008_test.go @@ -210,7 +210,7 @@ func TestTyposquatSuspect(t *testing.T) { calls: []string{"similar npm:leftpad-fork", "downloads npm:leftpad"}, }, { - name: "unknown own downloads skip the download comparison", + name: "missing own count is requested before the download comparison", ref: "npm:leftpad-fork@1.0.0", loader: &fakeLoaderT{ similar: []depsdev.Similar{{Name: "left-pad"}}, @@ -218,7 +218,7 @@ func TestTyposquatSuspect(t *testing.T) { }, down: -1, want: 0, - calls: []string{"similar npm:leftpad-fork"}, + calls: []string{"similar npm:leftpad-fork", "downloads npm:leftpad-fork"}, }, { name: "deps.dev error is no cross-check", diff --git a/internal/checks/td012.go b/internal/checks/td012.go index 1b0254f..93836cf 100644 --- a/internal/checks/td012.go +++ b/internal/checks/td012.go @@ -58,7 +58,16 @@ func (lowUsage) Name() string { return "low-usage" } func (lowUsage) Ecosystems() []model.Ecosystem { return nil } // Run implements Check. -func (c lowUsage) Run(_ context.Context, s *Subject) Result { +func (c lowUsage) Run(ctx context.Context, s *Subject) Result { + s = withDownloads(ctx, s) + result := c.evaluate(s) + if result.Skipped != nil && namesOutage(result.Skipped.Reason, s.outageReasons()) { + return skipOutage(c, result.Skipped.Reason) + } + return result +} + +func (c lowUsage) evaluate(s *Subject) Result { if s.Downloads >= 0 { return c.fromRegistry(s) } diff --git a/internal/checks/td012_test.go b/internal/checks/td012_test.go index 2d95f72..fe8d738 100644 --- a/internal/checks/td012_test.go +++ b/internal/checks/td012_test.go @@ -190,6 +190,9 @@ func TestLowUsage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := subjectB(t, tt.ref, tt.opts...) + // These cases exercise preloaded counts and fallback facts. Lazy loader + // requests and outage propagation are covered in lazy_downloads_test.go. + s.Loader = nil res := resultB(t, c, s) if tt.skipped != "" { assertSkippedB(t, c, res, tt.skipped) diff --git a/internal/cli/baseline.go b/internal/cli/baseline.go index b07660c..7cd3062 100644 --- a/internal/cli/baseline.go +++ b/internal/cli/baseline.go @@ -190,6 +190,7 @@ func (a *App) evaluateWithBaseline(ctx context.Context, cmd *cobra.Command, st * if rep.Summary.ExitCode == ExitOK && (dataUnavailableFails(st.pol, outcomes) || unreadFails(st.pol, incomplete)) { rep.SetExitCode(ExitUnavailable) } + a.addGuardDog(ctx, rep, inputs) if err := st.writer.Write(a.Stdout, rep); err != nil { return fmt.Errorf("write report: %w", err) } @@ -423,6 +424,15 @@ func (l baselineLoader) PrefetchVersions(ctx context.Context, refs []model.Packa } } +// PrefetchDownloads preserves policy-selected batching through the wrapper. +func (l baselineLoader) PrefetchDownloads(ctx context.Context, refs []model.PackageRef) { + if bulk, ok := l.Loader.(interface { + PrefetchDownloads(context.Context, []model.PackageRef) + }); ok { + bulk.PrefetchDownloads(ctx, refs) + } +} + // baselineFindingsLoader is a baselineLoader over a loader that serves the // deps.dev findings, forwarding them unchanged. type baselineFindingsLoader struct { diff --git a/internal/cli/baseline_downloads_test.go b/internal/cli/baseline_downloads_test.go new file mode 100644 index 0000000..0a81328 --- /dev/null +++ b/internal/cli/baseline_downloads_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/baseline" + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/model" +) + +type baselineCountsLoader struct { + *fakeLoader + selected []model.PackageRef + readBeforeBatch bool +} + +func (l *baselineCountsLoader) PrefetchDownloads(_ context.Context, refs []model.PackageRef) { + l.selected = slices.Clone(refs) +} + +func (l *baselineCountsLoader) Downloads(context.Context, model.Ecosystem, string) (int64, error) { + if len(l.selected) == 0 { + l.readBeforeBatch = true + } + return 42, nil +} + +func TestBaselineForwardsPolicySelectedCountsBeforeLowUsage(t *testing.T) { + now := time.Date(2026, time.September, 29, 0, 0, 0, 0, time.UTC) + loader := &baselineCountsLoader{fakeLoader: &fakeLoader{now: now}} + ref := model.MustParseRef("npm:trustdiff-fixture-lib@2.0.0") + check, ok := checks.Lookup("TD012") + if !ok { + t.Fatal("TD012 not registered") + } + r := checks.Runner{Loader: withBaseline(loader, &baseline.Set{}), Checks: []checks.Check{check}, Now: now} + out := r.Evaluate(t.Context(), []checks.Input{{Ref: ref}}) + if loader.readBeforeBatch || !slices.Equal(loader.selected, []model.PackageRef{ref}) { + t.Fatalf("baseline lost counts batching: selected %v, read first %v", loader.selected, loader.readBeforeBatch) + } + if len(out[0].Subject.Findings) != 1 || out[0].Subject.Findings[0].ID != "TD012" { + t.Fatalf("low-usage lost the count through baseline: %+v", out[0]) + } +} diff --git a/internal/cli/check.go b/internal/cli/check.go index b3ab19f..e1fceba 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -15,6 +15,7 @@ import ( "github.com/vahapogut/trustdiff/internal/advisory/depsdev" "github.com/vahapogut/trustdiff/internal/advisory/osv" "github.com/vahapogut/trustdiff/internal/advisory/osvindex" + "github.com/vahapogut/trustdiff/internal/attestation" "github.com/vahapogut/trustdiff/internal/checks" "github.com/vahapogut/trustdiff/internal/httpcache" "github.com/vahapogut/trustdiff/internal/manifest" @@ -133,6 +134,7 @@ func (a *App) runCheck(cmd *cobra.Command, args []string) error { rep.SetExitCode(ExitUnavailable) } + a.addGuardDog(cmd.Context(), rep, inputs) if err := writer.Write(a.Stdout, rep); err != nil { return fmt.Errorf("write report: %w", err) } @@ -468,6 +470,17 @@ func (a *App) defaultLoader(now time.Time) (checks.Loader, error) { model.Cargo: crates.New(hc, crates.WithLogger(log)), model.JSR: jsr.New(hc, jsrOptions(log, now)...), } + if a.Opts.VerifyNPM { + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + verifier, err := attestation.New(ctx, attestation.Options{Binary: a.Opts.CosignBinary, TrustedRoot: a.Opts.SigstoreRoot, Offline: a.Opts.Offline}) + if err != nil { + return nil, err + } + reg[model.NPM] = &attestation.NPM{Client: npm.New(hc, npm.WithLogger(log)), HTTP: hc, Verifier: verifier} + } if a.bulkScan && !a.Opts.NoCache { reg[model.Cargo] = a.bulkCratesSource(hc.Dir(), reg[model.Cargo], now) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 357a4ca..7d7eb75 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -100,10 +100,12 @@ func TestCommandTreeMatchesTheBrief(t *testing.T) { "doctor", "hook install", "hook uninstall", + "policy allow", "policy init", "policy validate", "scan", "version", + "watch", } app := &App{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} var got []string diff --git a/internal/cli/guarddog.go b/internal/cli/guarddog.go new file mode 100644 index 0000000..83cc6cf --- /dev/null +++ b/internal/cli/guarddog.go @@ -0,0 +1,101 @@ +package cli + +import ( + "context" + "net/url" + "strings" + + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/guarddog" + "github.com/vahapogut/trustdiff/internal/lockfile" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/report" +) + +type codeScanner interface { + Scan(context.Context, model.PackageRef) (guarddog.Result, error) +} + +var guardDogFactory = func(a *App) (codeScanner, error) { + return guarddog.New(guarddog.Options{Binary: a.Opts.GuardDogBinary, Timeout: a.Opts.GuardDogTimeout, Offline: a.Opts.Offline}) +} + +// addGuardDog appends attributed evidence without changing any trustdiff finding. +// A requested scan that did not finish cannot turn into a successful empty result. +func (a *App) addGuardDog(ctx context.Context, rep *report.Report, inputs []checks.Input) { + if !a.Opts.GuardDog { + return + } + rep.GuardDogRequested = true + var scanner codeScanner + var setupErr error + initialized := false + seen := map[model.PackageRef]bool{} + for i := range rep.Subjects { + s := &rep.Subjects[i] + if s.Verdict != report.VerdictWarn && s.Verdict != report.VerdictBlock { + continue + } + // A Git/path/archive entry is not the registry release that happens to have + // its name. Keep that lack of coverage explicit in the supplement. + nonRegistry := i < len(inputs) && inputs[i].Lock != nil && !publicRegistryEntry(inputs[i].Lock) + if nonRegistry { + rep.GuardDog = append(rep.GuardDog, model.Analysis{Ref: s.Ref, Source: guarddog.SourceURL, Status: "unavailable", Message: "locked source is not an identified public registry release; no substitute package was scanned"}) + continue + } + if seen[s.Ref] { + continue + } + seen[s.Ref] = true + if !initialized { + scanner, setupErr = guardDogFactory(a) + initialized = true + } + var result model.Analysis + if setupErr != nil { + result = model.Analysis{Ref: s.Ref, Source: guarddog.SourceURL, Status: "unavailable", Message: setupErr.Error()} + } else { + result, _ = scanner.Scan(ctx, s.Ref) + } + rep.GuardDog = append(rep.GuardDog, result) + } + for i := range rep.GuardDog { + r := &rep.GuardDog[i] + if r.Status != "completed" && rep.Summary.ExitCode == ExitOK { + rep.SetExitCode(ExitUnavailable) + } + } +} + +func publicRegistryEntry(entry *lockfile.Entry) bool { + if entry.Source != lockfile.SourceRegistry || entry.Bundled { + return false + } + if entry.Ref.Ecosystem == model.Cargo { + return entry.Resolved == "registry+https://github.com/rust-lang/crates.io-index" || entry.Resolved == "sparse+https://index.crates.io/" + } + u, err := url.Parse(entry.Resolved) + if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.ForceQuery || strings.Contains(entry.Resolved, "#") { + return false + } + switch entry.Ref.Ecosystem { + case model.NPM: + if !strings.EqualFold(u.Host, "registry.npmjs.org") || model.NPMNameProblem(entry.Ref.Name) != "" || entry.Ref.Version == "" { + return false + } + // npm's public tarball path identifies both the package and exact version. + // Verified 2026-09-29 against is-number@7.0.0 and @sigstore/bundle@5.0.0 + // version documents from https://registry.npmjs.org. + // URL.Path is decoded once, so equivalent percent-encoding is accepted but + // another package/version on the same registry host is not substituted. + name := entry.Ref.Name + if _, base, scoped := strings.Cut(name, "/"); scoped { + name = base + } + return u.Path == "/"+entry.Ref.Name+"/-/"+name+"-"+entry.Ref.Version+".tgz" + case model.PyPI: + return strings.EqualFold(u.Host, "files.pythonhosted.org") || strings.EqualFold(u.Host, "pypi.org") + default: + return false + } +} diff --git a/internal/cli/guarddog_test.go b/internal/cli/guarddog_test.go new file mode 100644 index 0000000..8bd34e9 --- /dev/null +++ b/internal/cli/guarddog_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "context" + "testing" + + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/guarddog" + "github.com/vahapogut/trustdiff/internal/lockfile" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/report" +) + +type fakeCodeScanner struct { + refs []model.PackageRef + partial bool +} + +func (s *fakeCodeScanner) Scan(_ context.Context, ref model.PackageRef) (guarddog.Result, error) { + s.refs = append(s.refs, ref) + status := "completed" + if s.partial { + status = "partial" + } + return guarddog.Result{Ref: ref, Source: guarddog.SourceURL, Status: status, Issues: 1}, nil +} + +func TestGuardDogOnlySelectedRegistryReleasesAndPartialExit(t *testing.T) { + original := guardDogFactory + t.Cleanup(func() { guardDogFactory = original }) + fake := &fakeCodeScanner{} + guardDogFactory = func(*App) (codeScanner, error) { return fake, nil } + ref := model.PackageRef{Ecosystem: model.NPM, Name: "suspect", Version: "1.0.0"} + inputs := []checks.Input{{Lock: &lockfile.Entry{Source: lockfile.SourceGit}}, {}, {}, {}} + rep := &report.Report{Subjects: []report.Subject{ + {Ref: ref, Verdict: report.VerdictBlock}, + {Ref: ref, Verdict: report.VerdictWarn}, + {Ref: ref, Verdict: report.VerdictWarn}, + {Ref: model.PackageRef{Ecosystem: model.NPM, Name: "clean", Version: "1.0.0"}, Verdict: report.VerdictOK}, + }} + a := &App{Opts: Options{GuardDog: true}} + a.addGuardDog(context.Background(), rep, inputs) + if len(fake.refs) != 1 || len(rep.GuardDog) != 2 || rep.GuardDog[0].Status != "unavailable" { + t.Fatalf("wrong selection: %+v %+v", fake.refs, rep.GuardDog) + } + if rep.Subjects[0].Verdict != report.VerdictBlock || rep.Summary.ExitCode != ExitUnavailable { + t.Fatalf("wrong verdict/exit: %+v", rep) + } + fake.partial = true + rep = &report.Report{Subjects: []report.Subject{{Ref: ref, Verdict: report.VerdictWarn}}} + a.addGuardDog(context.Background(), rep, nil) + if rep.Summary.ExitCode != ExitUnavailable { + t.Fatal("partial scan reported successful exit") + } +} + +func TestGuardDogRejectsOfflineBeforeAnyAnalysis(t *testing.T) { + code, _, stderr := run(t, "check", "npm:foo@1.0.0", "--guarddog", "--offline") + if code != ExitUsage || stderr == "" { + t.Fatalf("offline accepted: %d %s", code, stderr) + } +} + +func TestGuardDogDoesNotSubstitutePublicPackageForPrivateMirror(t *testing.T) { + original := guardDogFactory + t.Cleanup(func() { guardDogFactory = original }) + fake := &fakeCodeScanner{} + guardDogFactory = func(*App) (codeScanner, error) { return fake, nil } + ref := model.PackageRef{Ecosystem: model.NPM, Name: "suspect", Version: "1.0.0"} + rep := &report.Report{Subjects: []report.Subject{{Ref: ref, Verdict: report.VerdictWarn}, {Ref: ref, Verdict: report.VerdictWarn}}} + inputs := []checks.Input{ + {Lock: &lockfile.Entry{Ref: ref, Source: lockfile.SourceRegistry, Resolved: "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz"}}, + {Lock: &lockfile.Entry{Ref: ref, Source: lockfile.SourceRegistry, Resolved: "https://artifacts.example.com/suspect/-/suspect-1.0.0.tgz"}}, + } + a := &App{Opts: Options{GuardDog: true}} + a.addGuardDog(context.Background(), rep, inputs) + if len(fake.refs) != 1 || len(rep.GuardDog) != 2 || rep.GuardDog[1].Status != "unavailable" { + t.Fatalf("private artifact substitution: %+v", rep.GuardDog) + } +} + +func TestGuardDogNPMLockedArtifactIdentity(t *testing.T) { + for _, tt := range []struct { + name, resolved string + want bool + }{ + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz", true}, + {"suspect", "https://REGISTRY.NPMJS.ORG/%73uspect/-/suspect-1.0.0.tgz", true}, + {"@scope/suspect", "https://registry.npmjs.org/@scope/suspect/-/suspect-1.0.0.tgz", true}, + {"@scope/suspect", "https://registry.npmjs.org/%40scope%2fsuspect/-/suspect-1.0.0.tgz", true}, + {"@scope/suspect", "https://registry.npmjs.org/@scope%2Fsuspect/-/suspect-1.0.0.tgz", true}, + {"suspect", "https://registry.npmjs.org/other/-/other-1.0.0.tgz", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.1.tgz", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/other-1.0.0.tgz", false}, + {"@scope/suspect", "https://registry.npmjs.org/@other/suspect/-/suspect-1.0.0.tgz", false}, + {"@scope/suspect", "https://registry.npmjs.org/@scope%252fsuspect/-/suspect-1.0.0.tgz", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz?alternate=1", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz?", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz#alternate", false}, + {"suspect", "https://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz#", false}, + {"suspect", "https://someone@registry.npmjs.org/suspect/-/suspect-1.0.0.tgz", false}, + {"suspect", "https://registry.npmjs.org:443/suspect/-/suspect-1.0.0.tgz", false}, + {"suspect", "http://registry.npmjs.org/suspect/-/suspect-1.0.0.tgz", false}, + {"suspect", "https://registry.npmjs.org.example.com/suspect/-/suspect-1.0.0.tgz", false}, + } { + t.Run(tt.resolved, func(t *testing.T) { + entry := &lockfile.Entry{Ref: model.PackageRef{Ecosystem: model.NPM, Name: tt.name, Version: "1.0.0"}, Source: lockfile.SourceRegistry, Resolved: tt.resolved} + if got := publicRegistryEntry(entry); got != tt.want { + t.Fatalf("publicRegistryEntry=%v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cli/policy.go b/internal/cli/policy.go index 81a4107..dcc43da 100644 --- a/internal/cli/policy.go +++ b/internal/cli/policy.go @@ -81,7 +81,7 @@ when the file is invalid or none is found.`, }, } - cmd.AddCommand(initCmd, validateCmd) + cmd.AddCommand(initCmd, validateCmd, a.newPolicyAllowCommand()) return cmd } diff --git a/internal/cli/policy_allow.go b/internal/cli/policy_allow.go new file mode 100644 index 0000000..0dd6365 --- /dev/null +++ b/internal/cli/policy_allow.go @@ -0,0 +1,95 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/policy" + "github.com/vahapogut/trustdiff/internal/textdiff" +) + +func (a *App) newPolicyAllowCommand() *cobra.Command { + var reason, expires string + var write bool + cmd := &cobra.Command{ + Use: "allow ", + Short: "Preview a reviewed exception with a reason and expiry; --write saves it", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + check, ok := checks.Lookup(args[0]) + if !ok { + return Usagef("unknown check %q", args[0]) + } + path, err := a.policyPath() + if err != nil { + return err + } + stat, err := os.Lstat(path) + if err != nil { + return Usagef("policy: %v", err) + } + if !stat.Mode().IsRegular() || stat.Size() > 1<<20 { + return Usagef("policy must be a regular file at most 1 MiB") + } + before, err := os.ReadFile(path) // #nosec G304 -- user-selected policy path, regular file checked above + if err != nil { + return Usagef("policy: %v", err) + } + now, err := runClock(os.Getenv(nowEnv)) + if err != nil { + return err + } + // runClock leaves the default for Runner to resolve. This command has + // no runner, so resolve it before checking expiry or naming a backup. + if now.IsZero() { + now = time.Now() + } + after, err := policy.AddAllow(before, check.Name(), args[1], reason, expires, now) + if err != nil { + return Usagef("policy allow: %v", err) + } + changed := !bytes.Equal(before, after) + backup := "" + if write && changed { + current, err := os.ReadFile(path) // #nosec G304 -- same user-selected policy path + if err != nil || !bytes.Equal(current, before) { + return Usagef("policy changed during review; run the command again") + } + backup, err = textdiff.Backup(path, now) + if err != nil { + return Usagef("backup policy: %v", err) + } + if err := textdiff.Write(path, after, stat.Mode().Perm()); err != nil { + return Usagef("write policy: %v", err) + } + } + diff := string(textdiff.Unified(path, path, before, after)) + if a.Opts.Format == "json" { + return json.NewEncoder(a.Stdout).Encode(map[string]any{"path": path, "changed": changed, "written": write && changed, "backup": backup, "diff": diff}) + } + if !changed { + _, err = fmt.Fprintln(a.Stdout, "exception already present; no change") + return err + } + if _, err := fmt.Fprint(a.Stdout, diff); err != nil { + return err + } + if write { + _, err = fmt.Fprintf(a.Stdout, "wrote %s (backup: %s)\n", path, backup) + } else { + _, err = fmt.Fprintln(a.Stdout, "preview only; pass --write to save with a backup") + } + return err + }, + } + cmd.Flags().StringVar(&reason, "reason", "", "why this specific finding was reviewed and accepted (required)") + cmd.Flags().StringVar(&expires, "expires", "", "last valid UTC date, YYYY-MM-DD (required)") + cmd.Flags().BoolVar(&write, "write", false, "save the exception atomically after creating a backup") + return cmd +} diff --git a/internal/cli/policy_allow_test.go b/internal/cli/policy_allow_test.go new file mode 100644 index 0000000..ec086db --- /dev/null +++ b/internal/cli/policy_allow_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPolicyAllowPreviewWriteAndBackup(t *testing.T) { + path := filepath.Join(t.TempDir(), "policy.yaml") + original := []byte("# reviewed project\nversion: 1\n") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + t.Setenv(nowEnv, "2026-09-29T12:00:00Z") + args := []string{"policy", "allow", "TD014", "npm:vendored@1.2.3", "--policy", path, "--reason", "verified source", "--expires", "2026-10-31"} + var out, stderr bytes.Buffer + if code := Main(args, &out, &stderr); code != 0 { + t.Fatalf("preview exit %d: %s", code, &stderr) + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, original) { + t.Fatal("preview changed policy") + } + if code := Main(append(args, "--write"), &out, &stderr); code != 0 { + t.Fatalf("write exit %d: %s", code, &stderr) + } + backups, err := filepath.Glob(path + ".trustdiff-backup-*") + if err != nil || len(backups) != 1 { + t.Fatalf("missing backup: %v", err) + } + got, err = os.ReadFile(backups[0]) + if err != nil || !bytes.Equal(got, original) { + t.Fatal("bad backup") + } + if code := Main(append(args, "--write"), &out, &stderr); code != 0 { + t.Fatalf("idempotent exit %d: %s", code, &stderr) + } +} + +func TestPolicyAllowDefaultClockRejectsPastExpiryAndDatesBackup(t *testing.T) { + t.Setenv(nowEnv, "") + path := filepath.Join(t.TempDir(), "policy.yaml") + original := []byte("version: 1\n") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + args := []string{"policy", "allow", "TD014", "npm:vendored@1.2.3", "--policy", path, "--reason", "verified source", "--write", "--expires"} + var out, stderr bytes.Buffer + if code := Main(append(args, "2000-01-01"), &out, &stderr); code != ExitUsage || !strings.Contains(stderr.String(), "already past") { + t.Fatalf("default clock accepted expired exception: exit %d, stderr %s", code, &stderr) + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, original) { + t.Fatal("expired exception changed the policy") + } + now := time.Now().UTC() + expires := now.AddDate(1, 0, 0).Format("2006-01-02") + if code := Main(append(args, expires), &out, &stderr); code != ExitOK { + t.Fatalf("future exception failed: exit %d, stderr %s", code, &stderr) + } + backups, err := filepath.Glob(path + ".trustdiff-backup-*") + if err != nil || len(backups) != 1 { + t.Fatalf("backups %v, error %v", backups, err) + } + stamp := strings.TrimPrefix(backups[0], path+".trustdiff-backup-") + created, err := time.Parse("20060102T150405Z", stamp) + if err != nil || created.Before(now.Add(-time.Second)) || created.After(time.Now().UTC()) { + t.Fatalf("backup did not use the wall clock: %q, %v", stamp, err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index dff80cf..6f31741 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( "os" "slices" "strings" + "time" "github.com/spf13/cobra" @@ -28,15 +29,21 @@ var failOnLevels = []string{"block", "warn", "never"} // also exist in the policy file: command line flag, then the ecosystem override in // the policy, then the policy value, then the built-in default. type Options struct { - Format string - Policy string - Offline bool - NoCache bool - Cooldown string - FailOn string - Jobs int - NoColor bool - Verbose bool + Format string + Policy string + Offline bool + NoCache bool + Cooldown string + FailOn string + Jobs int + NoColor bool + Verbose bool + VerifyNPM bool + CosignBinary string + SigstoreRoot string + GuardDog bool + GuardDogBinary string + GuardDogTimeout time.Duration // Derived at startup. Color bool @@ -51,6 +58,7 @@ type App struct { Opts Options // bulkScan prefers an explicitly refreshed crates.io dump for this scan. bulkScan bool + ctx context.Context } // Main runs the CLI with the given arguments and returns the process exit code. @@ -124,6 +132,12 @@ or the run was canceled.`, f.IntVar(&a.Opts.Jobs, "jobs", 8, "maximum packages worked on at once; the request rate is bounded per registry host, not by this") f.BoolVar(&a.Opts.NoColor, "no-color", false, "disable colored output (NO_COLOR and non-terminal output also disable it)") f.BoolVarP(&a.Opts.Verbose, "verbose", "v", false, "log progress and diagnostics to stderr") + f.BoolVar(&a.Opts.VerifyNPM, "verify-npm-attestations", false, "verify npm build bundles locally with cosign v3.1.3") + f.StringVar(&a.Opts.CosignBinary, "cosign-bin", "cosign", "local Cosign executable for --verify-npm-attestations") + f.StringVar(&a.Opts.SigstoreRoot, "sigstore-root", "", "local Sigstore trusted-root JSON (required for offline local verification)") + f.BoolVar(&a.Opts.GuardDog, "guarddog", false, "run GuardDog 3.2.0 source analysis for up to 20 suspicious registry releases") + f.StringVar(&a.Opts.GuardDogBinary, "guarddog-bin", "guarddog", "installed GuardDog executable") + f.DurationVar(&a.Opts.GuardDogTimeout, "guarddog-timeout", 2*time.Minute, "timeout per external source scan (1s to 10m)") root.AddCommand( a.newCheckCommand(), @@ -131,6 +145,7 @@ or the run was canceled.`, a.newScanCommand(), a.newDoctorCommand(), a.newBaselineCommand(), + a.newWatchCommand(), a.newHookCommand(), a.newCacheCommand(), a.newPolicyCommand(), @@ -141,7 +156,22 @@ or the run was canceled.`, // prepare validates the global flags and derives the runtime settings. func (a *App) prepare(cmd *cobra.Command) error { + a.ctx = cmd.Context() o := &a.Opts + if o.GuardDog { + if !slices.Contains([]string{"check", "scan", "diff", "watch"}, cmd.Name()) { + return Usagef("--guarddog applies to check, scan, diff or watch") + } + if o.Offline { + return Usagef("--guarddog needs network access and cannot be combined with --offline") + } + if o.GuardDogTimeout < time.Second || o.GuardDogTimeout > 10*time.Minute { + return Usagef("--guarddog-timeout must be between 1s and 10m") + } + } + if o.VerifyNPM && o.Offline && o.SigstoreRoot == "" { + return Usagef("offline local verification requires --sigstore-root") + } if !slices.Contains(formats, o.Format) { return Usagef("--format must be one of %s, got %q", strings.Join(formats, ", "), o.Format) } diff --git a/internal/cli/watch.go b/internal/cli/watch.go new file mode 100644 index 0000000..c81d8bc --- /dev/null +++ b/internal/cli/watch.go @@ -0,0 +1,202 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/vahapogut/trustdiff/internal/baseline" + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/report" + "github.com/vahapogut/trustdiff/internal/watch" +) + +func (a *App) newWatchCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "watch []", + Short: "Re-evaluate a reviewed baseline and report trust changes", + Long: `Load the existing .trustdiff/baseline.json found upward from a directory, +the working directory by default. Evaluate its pinned versions immediately and +then after each interval. The first report is always printed; subsequent reports +are printed only when findings or check coverage change. Nothing is written to +the baseline, and changes to it or to lockfiles require restarting the command. + +Online evaluations bypass the HTTP cache to request current registry and advisory +data on every cycle. Offline mode uses existing caches and cannot discover new +remote data. Evaluations never overlap: the interval starts after a run completes. + +Use --once with an external scheduler. It returns the ordinary report exit code. +Continuous mode keeps monitoring findings and outages until canceled (exit 3). +Formats are human or newline-delimited JSON events (trustdiff.watch/1).`, + Args: cobra.MaximumNArgs(1), + RunE: a.runWatch, + } + cmd.Flags().Bool("once", false, "evaluate the baseline once and exit with the report status") + cmd.Flags().Duration("interval", watch.DefaultInterval, "delay between completed evaluations (1m to 24h)") + return cmd +} + +func (a *App) runWatch(cmd *cobra.Command, args []string) error { + if a.Opts.Format != "human" && a.Opts.Format != "json" { + return Usagef("watch supports --format human or json (newline-delimited events)") + } + interval, _ := cmd.Flags().GetDuration("interval") + if interval < watch.MinInterval || interval > watch.MaxInterval { + return Usagef("watch --interval must be between 1m and 24h") + } + st, err := a.settle() + if err != nil { + return err + } + dir := "." + if len(args) == 1 { + dir = args[0] + } + info, err := os.Stat(dir) + if err != nil { + return Usagef("watch directory %q: %v", dir, err) + } + if !info.IsDir() { + return Usagef("watch path %q must be a directory", dir) + } + path, err := a.baselinePath(dir) + if err != nil { + return err + } + recorded, err := a.readBaseline(path) + if err != nil { + return err + } + if recorded == nil || len(recorded.Packages) == 0 { + return Usagef("watch requires an existing, nonempty baseline; run trustdiff baseline and review it first") + } + inputs := make([]checks.Input, 0, len(recorded.Packages)) + for i := range recorded.Packages { + ref := recorded.Packages[i].Ref() + if _, err := model.ParseRef(ref.String()); err != nil { + return Usagef("watch baseline: %v", err) + } + inputs = append(inputs, checks.Input{Ref: ref}) + } + note := fmt.Sprintf("watching %s from %s; baseline and policy are fixed for this session", + countOf(len(inputs), "package", "packages"), path) + if a.Opts.Offline { + note += "; offline caches cannot reveal new remote data" + } else { + note += "; each cycle requests fresh data without the HTTP cache" + } + if err := a.writeNotes([]string{note}); err != nil { + return err + } + once, _ := cmd.Flags().GetBool("once") + loop := watch.Loop{Interval: interval, Once: once, Now: func() time.Time { return baselineClock(st) }} + code, err := loop.Run(cmd.Context(), func(ctx context.Context) (*report.Report, error) { + return a.evaluateWatch(ctx, st, inputs, recorded) + }, func(event watch.Event) error { return a.writeWatchEvent(st, &event) }) + if err != nil { + return err + } + if code != ExitOK { + return Exit(code, nil) + } + return nil +} + +func (a *App) evaluateWatch(ctx context.Context, st *settings, inputs []checks.Input, recorded *baseline.File) (*report.Report, error) { + // A fresh memo alone is not enough: advisory responses otherwise remain fresh + // in the HTTP cache for hours. Keep ordinary offline cache semantics while + // online watch makes a fresh request on each cycle without changing the cache. + cycle := *a + if !cycle.Opts.Offline { + cycle.Opts.NoCache = true + } + loader, err := loaderFactory(&cycle, st.now) + if err != nil { + return nil, Usagef("%v", err) + } + selected := checks.All() + for i := range selected { + if selected[i].ID() == baselineCheckID { + selected[i] = watchMaintainers{recorded: recorded} + } + } + runner := &checks.Runner{ + Loader: withBaseline(loader, &baseline.Set{Head: recorded}), Policy: st.pol, + Checks: selected, Jobs: a.Opts.Jobs, Timeout: checkTimeout, + Now: st.now, Log: a.Opts.Log, + } + outcomes := runner.Evaluate(ctx, inputs) + rep := report.Build(checks.Subjects(outcomes), report.CurrentTool(), report.Policy{ + Path: st.policyPath, Cooldown: st.cooldown, FailOn: a.Opts.FailOn, + }, st.failOn) + if rep.Summary.ExitCode == ExitOK && dataUnavailableFails(st.pol, outcomes) { + rep.SetExitCode(ExitUnavailable) + } + a.addGuardDog(ctx, rep, inputs) + return rep, nil +} + +func (a *App) writeWatchEvent(st *settings, event *watch.Event) error { + if a.Opts.Format == "json" { + if err := json.NewEncoder(a.Stdout).Encode(event); err != nil { + return fmt.Errorf("write watch event: %w", err) + } + return nil + } + if _, err := fmt.Fprintf(a.Stdout, "watch %s at %s\n", event.Kind, event.ObservedAt.Format(time.RFC3339)); err != nil { + return fmt.Errorf("write watch event: %w", err) + } + for _, change := range event.Changes { + if _, err := fmt.Fprintf(a.Stdout, " %s %s: %s\n", change.Ref, change.Check, change.Kind); err != nil { + return fmt.Errorf("write watch change: %w", err) + } + } + if err := st.writer.Write(a.Stdout, event.Report); err != nil { + return fmt.Errorf("write watch report: %w", err) + } + return nil +} + +// watchMaintainers compares owners now with the reviewed observation. The usual +// TD003 prefers npm's release maintainer lists, which cannot reveal a takeover +// that happened after the same pinned release was published. +type watchMaintainers struct{ recorded *baseline.File } + +func (watchMaintainers) ID() string { return baselineCheckID } +func (watchMaintainers) Name() string { return baselineCheck } +func (watchMaintainers) Ecosystems() []model.Ecosystem { return nil } + +func (w watchMaintainers) Run(_ context.Context, s *checks.Subject) checks.Result { + was, ok := w.recorded.Lookup(s.Ref) + if !ok || len(was.Maintainers) == 0 { + return checks.Skip(baselineCheckID, "the baseline records no maintainer set for this package") + } + if reason, unavailable := s.Skipped(checks.SourceOwners); unavailable { + return checks.Skip(baselineCheckID, reason) + } + if len(s.Owners) == 0 { + return checks.Skip(baselineCheckID, "the registry lists no maintainer for this package now") + } + fresh := baseline.Entry{ + Ecosystem: s.Ref.Ecosystem, Name: s.Ref.Name, Version: s.Ref.Version, ObservedAt: s.Now, + } + for _, owner := range s.Owners { + fresh.Maintainers = append(fresh.Maintainers, owner.Name) + } + // Put normalizes the in-memory observation's owner set, not the baseline. + observed := baseline.New(s.Now) + observed.Put(&fresh) + if len(observed.Packages[0].Maintainers) == 0 { + return checks.Skip(baselineCheckID, "the registry lists no named maintainer for this package now") + } + changes := baseline.Compare(w.recorded, observed.Packages) + if len(changes) == 0 { + return checks.Result{} + } + return checks.Result{Findings: []model.Finding{driftFinding(&changes[0], s.Setting(baselineCheck).Level, s.Now)}} +} diff --git a/internal/cli/watch_test.go b/internal/cli/watch_test.go new file mode 100644 index 0000000..9927248 --- /dev/null +++ b/internal/cli/watch_test.go @@ -0,0 +1,376 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/advisory" + "github.com/vahapogut/trustdiff/internal/baseline" + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/guarddog" + "github.com/vahapogut/trustdiff/internal/httpcache" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/report" + "github.com/vahapogut/trustdiff/internal/watch" +) + +// Both releases retain alice in their publication metadata even after current +// ownership has changed. A comparison of releases would miss this takeover. +type watchTestLoader struct { + *fakeLoader + owners []model.Publisher + ownerErr error + malware bool +} + +func (w *watchTestLoader) VersionInfo(ctx context.Context, ref model.PackageRef) (*model.VersionInfo, error) { + v, err := w.fakeLoader.VersionInfo(ctx, ref) + if err == nil { + v.Maintainers = []model.Publisher{{Name: "alice"}} + } + return v, err +} + +func (w *watchTestLoader) Owners(context.Context, model.Ecosystem, string) ([]model.Publisher, error) { + return w.owners, w.ownerErr +} + +func (w *watchTestLoader) Advisories(context.Context, model.PackageRef) ([]advisory.Advisory, error) { + if w.malware { + return []advisory.Advisory{{ID: "MAL-2026-9001", Malicious: true, Severity: advisory.SeverityCritical}}, nil + } + return nil, nil +} + +func installWatchLoader(t *testing.T, factory func(*App, time.Time) (checks.Loader, error)) { + t.Helper() + old := loaderFactory + loaderFactory = factory + t.Cleanup(func() { loaderFactory = old }) +} + +func watchFixture(t *testing.T, ref string) (string, []byte) { + t.Helper() + fixtureClock(t) + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + writeBaselineFile(t, dir, recorded(ref, "alice")) + data, err := os.ReadFile(baseline.Path(dir)) + if err != nil { + t.Fatal(err) + } + return dir, data +} + +func decodeWatchEvents(t *testing.T, output string) []watch.Event { + t.Helper() + var events []watch.Event + decoder := json.NewDecoder(strings.NewReader(output)) + for { + var raw json.RawMessage + if err := decoder.Decode(&raw); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatal(err) + } + var e watch.Event + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatal(err) + } + var envelope struct { + Report json.RawMessage `json:"report"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + decodeReport(t, string(envelope.Report)) + if e.Schema != watch.SchemaID || e.Changes == nil || e.ObservedAt.IsZero() { + t.Fatalf("bad event: %s", raw) + } + events = append(events, e) + } + return events +} + +func assertWatchBaselineUnchanged(t *testing.T, dir string, before []byte) { + t.Helper() + after, err := os.ReadFile(baseline.Path(dir)) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("watch changed its baseline: err=%v\nbefore=%s\nafter=%s", err, before, after) + } + entries, err := os.ReadDir(filepath.Join(dir, baseline.DirName)) + if err != nil || len(entries) != 1 || entries[0].Name() != baseline.FileName { + t.Fatalf("watch wrote unexpected state: entries=%v err=%v", entries, err) + } +} + +func TestWatchOnceDetectsCurrentOwnersOfUnchangedRelease(t *testing.T) { + dir, before := watchFixture(t, "npm:trustdiff-fixture-lib@2.0.0") + // Keep the test focused on owner changes; both releases' npm maintainer + // lists remain alice, while current ownership is mallory. + writePolicy(t, "version: 1\nchecks:\n publisher-changed: off\n young-version: off\n") + installWatchLoader(t, func(a *App, now time.Time) (checks.Loader, error) { + if !a.Opts.NoCache || a.Opts.Offline { + t.Fatal("online watch reused persistent cache") + } + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: []model.Publisher{{Name: "mallory"}}}, nil + }) + code, stdout, stderr := run(t, "watch", "--once", "--format", "json", "--fail-on", "warn") + if code != ExitFindings { + t.Fatalf("exit=%d stdout=%s stderr=%s", code, stdout, stderr) + } + events := decodeWatchEvents(t, stdout) + if len(events) != 1 || events[0].Kind != "initial" || len(events[0].Changes) != 0 { + t.Fatalf("events=%+v", events) + } + found := false + for _, f := range events[0].Report.Subjects[0].Findings { + if f.ID == "TD003" { + found = true + if f.Evidence["baseline_version"] != "2.0.0" || !strings.Contains(f.Explanation, "locked version did not move") { + t.Fatalf("not compared with the baseline: %+v", f) + } + } + } + if !found { + t.Fatal("current-owner takeover was hidden by unchanged release metadata") + } + assertWatchBaselineUnchanged(t, dir, before) +} + +func TestWatchHonorsPolicyAndFailureThreshold(t *testing.T) { + for _, tc := range []struct { + name, policy, threshold string + code, count int + }{ + {"warn", "version: 1\n", "warn", ExitFindings, 1}, + {"block threshold", "version: 1\n", "block", ExitOK, 1}, + {"off", "version: 1\nchecks:\n maintainers-changed: off\n", "warn", ExitOK, 0}, + {"allow", "version: 1\nallow:\n - check: maintainers-changed\n package: npm:trustdiff-fixture-lib\n reason: reviewed owner transfer\n", "warn", ExitOK, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + dir, before := watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + writePolicy(t, tc.policy) + installWatchLoader(t, func(_ *App, now time.Time) (checks.Loader, error) { + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: []model.Publisher{{Name: "bob"}}}, nil + }) + code, stdout, stderr := run(t, "watch", "--once", "--format", "json", "--fail-on", tc.threshold) + if code != tc.code { + t.Fatalf("code=%d want=%d stderr=%s stdout=%s", code, tc.code, stderr, stdout) + } + count := 0 + for _, f := range decodeWatchEvents(t, stdout)[0].Report.Subjects[0].Findings { + if f.ID == "TD003" { + count++ + } + } + if count != tc.count { + t.Fatalf("TD003 findings=%d want=%d", count, tc.count) + } + assertWatchBaselineUnchanged(t, dir, before) + }) + } +} + +func TestWatchOfflineUsesRealEmptyCacheAndReportsUnavailable(t *testing.T) { + dir, before := watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + t.Setenv(httpcache.EnvDir, t.TempDir()) + writePolicy(t, "version: 1\non_data_unavailable: fail\n") + code, stdout, stderr := run(t, "watch", "--once", "--offline", "--format", "json") + if code != ExitUnavailable || !strings.Contains(stderr, "offline caches cannot reveal new remote data") { + t.Fatalf("exit=%d stderr=%s stdout=%s", code, stderr, stdout) + } + events := decodeWatchEvents(t, stdout) + if len(events) != 1 || events[0].Report.Summary.ExitCode != ExitUnavailable || len(events[0].Report.Subjects[0].Skipped) == 0 { + t.Fatalf("offline miss was not reported: %+v", events) + } + assertWatchBaselineUnchanged(t, dir, before) +} + +func TestWatchOwnerOutageHonorsUnavailablePolicy(t *testing.T) { + watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + writePolicy(t, "version: 1\non_data_unavailable: fail\n") + installWatchLoader(t, func(_ *App, now time.Time) (checks.Loader, error) { + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, ownerErr: errRegistryDown}, nil + }) + code, stdout, stderr := run(t, "watch", "--once", "--format", "json") + if code != ExitUnavailable { + t.Fatalf("owner outage exit=%d stderr=%s stdout=%s", code, stderr, stdout) + } + subject := decodeWatchEvents(t, stdout)[0].Report.Subjects[0] + if !slices.ContainsFunc(subject.Skipped, func(s model.Skipped) bool { return s.Check == "TD003" && strings.Contains(s.Reason, "owners") }) { + t.Fatalf("owner outage hidden: %+v", subject) + } +} + +func TestWatchRejectsMissingEmptyMalformedBaselineAndBadFlags(t *testing.T) { + for _, tc := range []struct { + name string + file string + args []string + }{ + {"missing", "", nil}, + {"empty", `{"schema":"trustdiff.baseline/1","updated_at":"2026-09-09T12:00:00Z","packages":[]}`, nil}, + {"malformed", `{`, nil}, + {"interval too short", "", []string{"--interval", "59s"}}, + {"interval too long", "", []string{"--interval", "24h1s"}}, + {"sarif", "", []string{"--format", "sarif"}}, + {"markdown", "", []string{"--format", "markdown"}}, + {"no approval flag", "", []string{"--update-baseline"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fixtureClock(t) + if tc.file != "" { + writeFile(t, ".", filepath.Join(baseline.DirName, baseline.FileName), tc.file) + } + installWatchLoader(t, func(*App, time.Time) (checks.Loader, error) { + t.Fatal("invalid watch invocation reached a data source") + return nil, errors.New("unexpected loader") + }) + code, stdout, stderr := run(t, append([]string{"watch", "--once"}, tc.args...)...) + if code != ExitUsage || stdout != "" || stderr == "" { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + }) + } +} + +func TestWatchRepeatedEvaluationsUseFreshLoaderAndFrozenBaseline(t *testing.T) { + dir, _ := watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + recordedFile := readBaselineFile(t, dir) + app, _, _ := baselineApp(t, "json") + st, err := app.settle() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cycles := 0 + installWatchLoader(t, func(a *App, now time.Time) (checks.Loader, error) { + cycles++ + if !a.Opts.NoCache { + t.Fatal("online cycle did not request fresh data") + } + owners := []model.Publisher{{Name: "alice"}} + if cycles >= 2 { + owners = []model.Publisher{{Name: "mallory"}} + } + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: owners, malware: cycles >= 3}, nil + }) + var events []watch.Event + var externalEdit []byte + loop := watch.Loop{Interval: time.Minute, Wait: func(ctx context.Context, _ time.Duration) error { + if cycles == 1 { + // An unrelated process approves a different version while watch runs. + // The session must retain the original reviewed version and owners. + writeBaselineFile(t, dir, recorded("npm:trustdiff-fixture-lib@2.0.0", "mallory")) + var readErr error + externalEdit, readErr = os.ReadFile(baseline.Path(dir)) + if readErr != nil { + t.Fatal(readErr) + } + } + if cycles == 4 { + cancel() + return ctx.Err() + } + return nil + }} + inputs := []checks.Input{{Ref: recordedFile.Packages[0].Ref()}} + _, err = loop.Run(ctx, func(ctx context.Context) (*report.Report, error) { + return app.evaluateWatch(ctx, st, inputs, recordedFile) + }, func(e watch.Event) error { events = append(events, e); return nil }) + if !errors.Is(err, context.Canceled) || cycles != 4 || len(events) != 3 { + t.Fatalf("err=%v cycles=%d events=%+v", err, cycles, events) + } + for _, event := range events { + if event.Report.Subjects[0].Ref.Version != "1.0.0" { + t.Fatal("watch followed an unreviewed baseline edit") + } + } + if events[1].Changes[0].Check != "TD003" || !slices.ContainsFunc(events[2].Changes, func(c watch.Change) bool { return c.Check == "TD009" }) { + t.Fatalf("owner/advisory changes missing: %+v", events) + } + if app.Opts.NoCache { + t.Fatal("watch changed the caller's global cache setting") + } + assertWatchBaselineUnchanged(t, dir, externalEdit) +} + +type watchCancelWriter struct { + bytes.Buffer + cancel context.CancelFunc +} + +func (w *watchCancelWriter) Write(p []byte) (int, error) { + n, err := w.Buffer.Write(p) + w.cancel() + return n, err +} + +func TestWatchCancellationAfterFirstEventExitsThree(t *testing.T) { + dir, before := watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + installWatchLoader(t, func(_ *App, now time.Time) (checks.Loader, error) { + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: []model.Publisher{{Name: "alice"}}}, nil + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + out := &watchCancelWriter{cancel: cancel} + var stderr bytes.Buffer + code := MainContext(ctx, []string{"watch", "--format", "json", "--interval", "24h"}, out, &stderr) + if code != ExitUnavailable || !strings.Contains(stderr.String(), "context canceled") || len(decodeWatchEvents(t, out.String())) != 1 { + t.Fatalf("code=%d stderr=%s stdout=%s", code, &stderr, out.String()) + } + assertWatchBaselineUnchanged(t, dir, before) +} + +func TestWatchFindsBaselineUpwardAndRendersHumanReport(t *testing.T) { + dir, before := watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + child := filepath.Join(dir, "nested") + if err := os.Mkdir(child, 0o755); err != nil { + t.Fatal(err) + } + installWatchLoader(t, func(_ *App, now time.Time) (checks.Loader, error) { + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: []model.Publisher{{Name: "alice"}}}, nil + }) + code, stdout, stderr := run(t, "watch", child, "--once", "--no-color") + if code != ExitOK || !strings.Contains(stdout, "watch initial at "+fixtureNow.Format(time.RFC3339)) || !strings.Contains(stdout, "npm:trustdiff-fixture-lib@1.0.0") { + t.Fatalf("code=%d stdout=%s stderr=%s", code, stdout, stderr) + } + assertWatchBaselineUnchanged(t, dir, before) +} + +type watchUnavailableScanner struct{} + +func (watchUnavailableScanner) Scan(_ context.Context, ref model.PackageRef) (guarddog.Result, error) { + return model.Analysis{Ref: ref, Source: guarddog.SourceURL, Status: "unavailable", Message: "offline test scanner"}, errors.New("offline test scanner") +} + +func TestWatchGuardDogSupplementIsIncludedBeforeEventAndExit(t *testing.T) { + watchFixture(t, "npm:trustdiff-fixture-lib@1.0.0") + installWatchLoader(t, func(_ *App, now time.Time) (checks.Loader, error) { + return &watchTestLoader{fakeLoader: &fakeLoader{now: now}, owners: []model.Publisher{{Name: "bob"}}}, nil + }) + old := guardDogFactory + guardDogFactory = func(*App) (codeScanner, error) { return watchUnavailableScanner{}, nil } + t.Cleanup(func() { guardDogFactory = old }) + code, stdout, stderr := run(t, "watch", "--once", "--guarddog", "--fail-on", "never", "--format", "json") + if code != ExitUnavailable { + t.Fatalf("incomplete requested analysis exit=%d stdout=%s stderr=%s", code, stdout, stderr) + } + events := decodeWatchEvents(t, stdout) + if len(events) != 1 || !events[0].Report.GuardDogRequested || len(events[0].Report.GuardDog) != 1 || events[0].Report.Summary.ExitCode != ExitUnavailable { + t.Fatalf("analysis supplement omitted: %+v", events) + } +} diff --git a/internal/guarddog/guarddog.go b/internal/guarddog/guarddog.go new file mode 100644 index 0000000..f1dcbc3 --- /dev/null +++ b/internal/guarddog/guarddog.go @@ -0,0 +1,183 @@ +// Package guarddog runs an explicitly enabled, separately installed GuardDog +// scanner for exact registry releases and returns attributed supplemental results. +// It never installs a tool or package, invokes a shell, or weakens trust findings. +package guarddog + +import ( + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/model/version" +) + +const ( + // SourceURL identifies the external scanner, separately from trustdiff checks. + SourceURL = "https://github.com/DataDog/guarddog" + // SupportedVersion pins the reviewed sandbox and JSON contract. Verified + // 2026-09-29 at upstream commit 3da172679cb58b1c9a780f9f5d640f855be016dc. + SupportedVersion = "3.2.0" + // DefaultTimeout includes the version probe and scan for one package. + DefaultTimeout = 2 * time.Minute + // MaxPackages caps external scans per client, including failed attempts. + MaxPackages = 20 +) + +// Options enables the external executable without downloading or installing it. +type Options struct { + Binary string + Timeout time.Duration + Offline bool +} + +// Result is an attributed supplement, not a trustdiff finding. +type Result = model.Analysis + +// Risk retains an upstream risk description without duplicating source snippets. +type Risk = model.AnalysisRisk + +// Client serializes scans, checks the executable version once, and enforces a +// total package limit. Its public methods are safe for concurrent callers. +type Client struct { + binary string + timeout time.Duration + gate chan struct{} + version string + scans int +} + +// New validates configuration without starting a process. Offline and unsupported +// platforms fail before executable lookup. Binary defaults to guarddog on PATH. +func New(opts Options) (*Client, error) { + if opts.Offline { + return nil, errors.New("GuardDog requires network access and cannot run in offline mode") + } + if opts.Timeout < 0 || opts.Timeout > 30*time.Minute { + return nil, errors.New("GuardDog timeout must be positive and at most 30 minutes") + } + if !supportedHost() { + return nil, errors.New("GuardDog handoff requires Linux or macOS with sandbox support; on Windows run trustdiff and GuardDog inside a supported Linux environment") + } + if opts.Timeout == 0 { + opts.Timeout = DefaultTimeout + } + if opts.Binary == "" { + opts.Binary = "guarddog" + } + binary, err := exec.LookPath(opts.Binary) + if err != nil { + return nil, fmt.Errorf("find GuardDog executable: install GuardDog %s separately or set --guarddog-bin: %w", SupportedVersion, err) + } + binary, err = filepath.Abs(binary) + if err != nil { + return nil, fmt.Errorf("resolve GuardDog executable: %w", err) + } + return &Client{binary: binary, timeout: opts.Timeout, gate: make(chan struct{}, 1)}, nil +} + +// Scan checks one exact npm, PyPI or Cargo registry release. The caller selects +// releases already carrying trust findings and excludes local/git/URL sources. +// Any incomplete scan returns a non-nil error together with its attributed result. +func (c *Client) Scan(ctx context.Context, ref model.PackageRef) (Result, error) { + result := Result{Ref: ref, Source: SourceURL, Status: "unavailable"} + fail := func(err error) (Result, error) { + result.Message = err.Error() + return result, err + } + if err := validateRef(ref); err != nil { + return fail(err) + } + select { + case c.gate <- struct{}{}: + defer func() { <-c.gate }() + case <-ctx.Done(): + return fail(ctx.Err()) + } + result.ToolVersion = c.version + if err := ctx.Err(); err != nil { + return fail(err) + } + if c.scans >= MaxPackages { + return fail(fmt.Errorf("GuardDog scan limit reached (%d packages)", MaxPackages)) + } + c.scans++ + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + if c.version == "" { + probeCtx, stopProbe := context.WithTimeout(ctx, 10*time.Second) + output, err := c.run(probeCtx, "--version") + stopProbe() + if err != nil { + return fail(fmt.Errorf("GuardDog version probe: %w", err)) + } + if strings.TrimSpace(string(output)) != SupportedVersion { + return fail(fmt.Errorf("GuardDog version must be %s; install the reviewed version separately", SupportedVersion)) + } + c.version = SupportedVersion + result.ToolVersion = c.version + } + ecosystem := string(ref.Ecosystem) + if ref.Ecosystem == model.Cargo { + ecosystem = "crates" + } + // Fixed argv follows the pinned CLI above. --sandbox is explicit: a missing + // kernel sandbox is an error, never permission to retry without isolation. + args := []string{ecosystem, "scan", "--version", ref.Version, "--output-format", "json", "--sandbox"} + if ref.Ecosystem == model.NPM { + // This upstream metadata rule resolves ranges and starts additional scans. + // Keep this handoff scoped to the exact releases selected by trustdiff. + args = append(args, "--exclude-rules", "risky_new_dependency") + } + args = append(args, "--", ref.Name) + output, err := c.run(ctx, args...) + if err != nil { + return fail(fmt.Errorf("GuardDog scan %s: %w", ref, err)) + } + result, err = parseReport(ref, output) + if ref.Ecosystem == model.NPM { + if result.Message != "" { + result.Message += "; " + } + result.Message += "risky_new_dependency excluded: the handoff scans only the selected exact release" + } + return result, err +} + +var ( + pypiName = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`) + crateName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`) + exactSemver = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) +) + +func validateRef(ref model.PackageRef) error { + validName := false + switch ref.Ecosystem { + case model.NPM: + validName = model.NPMNameProblem(ref.Name) == "" + case model.PyPI: + validName = len(ref.Name) <= 214 && pypiName.MatchString(ref.Name) + case model.Cargo: + validName = crateName.MatchString(ref.Name) + default: + return fmt.Errorf("GuardDog handoff does not support ecosystem %q", ref.Ecosystem) + } + if !validName { + return errors.New("GuardDog requires a valid registry package name; paths, URLs and options are not accepted") + } + if len(ref.Version) > 256 || strings.TrimSpace(ref.Version) != ref.Version { + return errors.New("GuardDog requires an exact registry release version") + } + if ref.Ecosystem != model.PyPI && !exactSemver.MatchString(ref.Version) { + return errors.New("GuardDog requires a complete exact semantic version, not a tag or range") + } + if _, err := version.Parse(ref.Ecosystem, ref.Version); err != nil { + return fmt.Errorf("GuardDog requires an exact registry release version: %w", err) + } + return nil +} diff --git a/internal/guarddog/guarddog_test.go b/internal/guarddog/guarddog_test.go new file mode 100644 index 0000000..e16c3c9 --- /dev/null +++ b/internal/guarddog/guarddog_test.go @@ -0,0 +1,133 @@ +package guarddog + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/model" +) + +// Synthetic reports follow the Apache-2.0 upstream CLI/JSON reporter at +// DataDog/guarddog 3da172679cb58b1c9a780f9f5d640f855be016dc, verified 2026-09-29. +const cleanReport = `{"package":"example","package_version":"1.2.3","issues":0,"errors":{},"results":{"metadata-rule":null,"source-rule":{}},"risks":[]}` + +func TestParseReport(t *testing.T) { + ref := model.PackageRef{Ecosystem: model.NPM, Name: "example", Version: "1.2.3"} + for _, tt := range []struct { + name, body, status string + wantErr bool + }{ + {"clean", cleanReport, "completed", false}, + {"findings", strings.Replace(cleanReport, `"metadata-rule":null`, `"metadata-rule":"suspicious release"`, 1), "completed", false}, + {"partial", strings.Replace(cleanReport, `"errors":{}`, `"errors":{"metadata-rule":"timeout"}`, 1), "partial", true}, + {"download failure", `{"package":"example","issues":0,"errors":{"download-package":"not found"}}`, "unavailable", true}, + {"wrong name", strings.Replace(cleanReport, `"example"`, `"other"`, 1), "unavailable", true}, + {"wrong version", strings.Replace(cleanReport, `"1.2.3"`, `"1.2.4"`, 1), "unavailable", true}, + {"missing issues", strings.Replace(cleanReport, `"issues":0,`, "", 1), "unavailable", true}, + {"negative issues", strings.Replace(cleanReport, `"issues":0`, `"issues":-1`, 1), "unavailable", true}, + {"missing results", strings.Replace(cleanReport, `"results":{"metadata-rule":null,"source-rule":{}},`, "", 1), "unavailable", true}, + {"missing errors", strings.Replace(cleanReport, `"errors":{},`, "", 1), "unavailable", true}, + {"null errors", strings.Replace(cleanReport, `"errors":{}`, `"errors":null`, 1), "unavailable", true}, + {"missing risks", strings.Replace(cleanReport, `,"risks":[]`, "", 1), "unavailable", true}, + {"null risk", strings.Replace(cleanReport, `"risks":[]`, `"risks":[null]`, 1), "unavailable", true}, + {"empty risk", strings.Replace(cleanReport, `"risks":[]`, `"risks":[{}]`, 1), "unavailable", true}, + {"mistyped errors", strings.Replace(cleanReport, `"errors":{}`, `"errors":{"rule":false}`, 1), "unavailable", true}, + {"mistyped rule", strings.Replace(cleanReport, `"metadata-rule":null`, `"metadata-rule":true`, 1), "unavailable", true}, + {"malformed", `{`, "unavailable", true}, + {"trailing report", cleanReport + cleanReport, "unavailable", true}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := parseReport(ref, []byte(tt.body)) + if (err != nil) != tt.wantErr || got.Status != tt.status { + t.Fatalf("status=%q, err=%v; want status=%q, error=%v", got.Status, err, tt.status, tt.wantErr) + } + if got.Ref != ref || got.Source != SourceURL || got.ToolVersion != SupportedVersion { + t.Fatalf("missing attribution: %+v", got) + } + }) + } +} + +func TestReportPreservesFindingsAndPartialErrors(t *testing.T) { + ref := model.PackageRef{Ecosystem: model.PyPI, Name: "example", Version: "1.2.3"} + body := `{"package":"example","package_version":"1.2.3","issues":2,"errors":{"unavailable-rule":"network unavailable"},"results":{"empty":{},"null":null,"empty-list":[],"empty-message":"","source-rule":[{"code":"eval(data)","location":"example.py:3"}],"metadata-rule":"suspicious"},"risks":[{"name":"Suspicious execution","category":"threat","severity":"high","threat_rule":"source-rule","threat_description":"Executes data","file_path":"example.py","threat_code":"excluded duplicated code"}]}` + got, err := parseReport(ref, []byte(body)) + if err == nil || got.Status != "partial" || got.Issues != 2 || len(got.Results) != 2 || len(got.Risks) != 1 || got.Errors["unavailable-rule"] != "network unavailable" { + t.Fatalf("partial evidence lost: %+v, %v", got, err) + } + encoded, err := json.Marshal(got) + if err != nil || !strings.Contains(string(encoded), "eval(data)") || strings.Contains(string(encoded), "excluded duplicated code") { + t.Fatalf("unexpected evidence: %s, %v", encoded, err) + } +} + +func TestValidateRef(t *testing.T) { + for _, tt := range []struct { + ref model.PackageRef + want bool + }{ + {model.PackageRef{Ecosystem: model.NPM, Name: "@scope/example", Version: "1.2.3-beta.1+build"}, true}, + {model.PackageRef{Ecosystem: model.NPM, Name: "JSONStream", Version: "1.2.3"}, true}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "example-package", Version: "1!2.3rc1.post2+local"}, true}, + {model.PackageRef{Ecosystem: model.Cargo, Name: "example_crate", Version: "1.2.3"}, true}, + {model.PackageRef{Ecosystem: model.NPM, Name: "example", Version: "latest"}, false}, + {model.PackageRef{Ecosystem: model.NPM, Name: "example", Version: "1.2"}, false}, + {model.PackageRef{Ecosystem: model.NPM, Name: "example", Version: "^1.2.3"}, false}, + {model.PackageRef{Ecosystem: model.NPM, Name: "example", Version: "1.2.3\n"}, false}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "--help", Version: "1.0"}, false}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "../example", Version: "1.0"}, false}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "https://example.test", Version: "1.0"}, false}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "example;echo", Version: "1.0"}, false}, + {model.PackageRef{Ecosystem: model.PyPI, Name: "example", Version: ""}, false}, + {model.PackageRef{Ecosystem: model.Cargo, Name: "./example", Version: "1.2.3"}, false}, + {model.PackageRef{Ecosystem: model.JSR, Name: "@scope/example", Version: "1.2.3"}, false}, + } { + t.Run(tt.ref.String(), func(t *testing.T) { + if err := validateRef(tt.ref); (err == nil) != tt.want { + t.Fatalf("validateRef(%+v)=%v, want valid=%v", tt.ref, err, tt.want) + } + }) + } +} + +func TestOfflineRefusesBeforeExecutableLookup(t *testing.T) { + _, err := New(Options{Offline: true, Binary: "does-not-exist"}) + if err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("offline error=%v", err) + } +} + +func TestInvalidOptions(t *testing.T) { + for _, timeout := range []time.Duration{-time.Second, 31 * time.Minute} { + if _, err := New(Options{Timeout: timeout}); err == nil { + t.Fatal("accepted invalid timeout", timeout) + } + } +} + +func TestTemporaryPathsRemainStableAcrossScans(t *testing.T) { + ref := model.MustParseRef("npm:example@1.2.3") + var previous string + for _, root := range []string{"/tmp/trustdiff-first", "/tmp/trustdiff-second"} { + path := root + "/scratch/tmp" + strings.TrimPrefix(root, "/tmp/trustdiff-") + "/package" + body := `{"package":"example","package_version":"1.2.3","issues":1,"errors":{"rule":"failed to read ` + path + `/example.js"},"path":"` + path + `","results":{"source-rule":[{"location":"` + path + `/example.js:3","code":"original code"}]},"risks":[]}` + got, err := parseReport(ref, []byte(normalizeTempPaths(body, root))) + if err == nil || got.Status != "partial" || got.Errors["rule"] != "failed to read example.js" { + t.Fatalf("path normalization: %+v, %v", got, err) + } + encoded, err := json.Marshal(got) + if err != nil || !strings.Contains(string(encoded), `"location":"example.js:3"`) || !strings.Contains(string(encoded), "original code") { + t.Fatalf("relative source evidence: %s, %v", encoded, err) + } + if previous != "" && previous != string(encoded) { + t.Fatalf("scan paths changed fingerprint:\n%s\n%s", previous, encoded) + } + previous = string(encoded) + message := normalizeTempPaths("download failed in "+path, root) + if message != "download failed in /scratch//package" { + t.Fatalf("download diagnostic remains volatile: %q", message) + } + } +} diff --git a/internal/guarddog/integration_test.go b/internal/guarddog/integration_test.go new file mode 100644 index 0000000..e58e9c3 --- /dev/null +++ b/internal/guarddog/integration_test.go @@ -0,0 +1,57 @@ +//go:build integration + +package guarddog + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/model" +) + +// This is an opt-in real sandboxed scan of a small, established npm release. +// It downloads package source and metadata, but never installs the target or +// executes its code. The enabled test must fail if the actual sandbox is absent. +func TestIntegrationRealGuardDogSandbox(t *testing.T) { + if os.Getenv("TRUSTDIFF_GUARDDOG_INTEGRATION") != "1" { + t.Skip("set TRUSTDIFF_GUARDDOG_INTEGRATION=1 to run the real sandboxed scanner") + } + if !supportedHost() { + t.Skip("real GuardDog handoff requires Linux or macOS") + } + if os.Getenv("TRUSTDIFF_INTEGRATION_OFFLINE") != "" { + t.Skip("live registry integration is disabled") + } + client, err := New(Options{Binary: os.Getenv("TRUSTDIFF_GUARDDOG_BIN")}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), DefaultTimeout+5*time.Second) + defer cancel() + ref := model.MustParseRef("npm:is-number@7.0.0") + start := time.Now() + got, err := client.Scan(ctx, ref) + if err != nil { + t.Fatalf("real GuardDog scan: %v; status=%s, reported errors=%v", err, got.Status, got.Errors) + } + if got.Status != "completed" || got.Ref != ref || got.ToolVersion != SupportedVersion || got.Source != SourceURL || len(got.Errors) != 0 { + t.Fatalf("real GuardDog identity or coverage mismatch: %+v", got) + } + if time.Since(start) > DefaultTimeout+5*time.Second { + t.Fatal("real scanner exceeded the configured deadline and cleanup allowance") + } + data, err := json.Marshal(got) + if err != nil || len(data) > maxStdout+4096 { + t.Fatalf("invalid or unbounded result: bytes=%d, error=%v", len(data), err) + } + if strings.Contains(string(data), "trustdiff-guarddog-") { + t.Fatal("real scanner result leaked a volatile working-directory prefix") + } + // Rule findings can change with metadata and installed rule data. Assert the + // contract and coverage rather than assuming even a benign package has zero. + t.Logf("GuardDog %s completed %s with %d reported issue(s), %d matched rules and %d risks", got.ToolVersion, ref, got.Issues, len(got.Results), len(got.Risks)) +} diff --git a/internal/guarddog/process.go b/internal/guarddog/process.go new file mode 100644 index 0000000..0ea5169 --- /dev/null +++ b/internal/guarddog/process.go @@ -0,0 +1,111 @@ +package guarddog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" +) + +const ( + maxStdout = 4 << 20 + maxStderr = 64 << 10 +) + +var errOutputLimit = errors.New("GuardDog process output exceeded its limit") + +type boundedOutput struct { + buf bytes.Buffer + limit int + exceeded bool + cancel context.CancelFunc +} + +func (w *boundedOutput) Write(p []byte) (int, error) { + if len(p) > w.limit-w.buf.Len() { + w.exceeded = true + w.cancel() + return 0, errOutputLimit + } + return w.buf.Write(p) +} + +func (c *Client) run(ctx context.Context, args ...string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "trustdiff-guarddog-") + if err != nil { + return nil, fmt.Errorf("create GuardDog working directory: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + work, scratch := filepath.Join(dir, "work"), filepath.Join(dir, "scratch") + for _, path := range []string{work, scratch} { + if err := os.Mkdir(path, 0o700); err != nil { + return nil, fmt.Errorf("create GuardDog directory: %w", err) + } + } + processCtx, cancel := context.WithCancel(ctx) + defer cancel() + stdout := &boundedOutput{limit: maxStdout, cancel: cancel} + stderr := &boundedOutput{limit: maxStderr, cancel: cancel} + // The executable is explicitly configured and resolved by New. Every argument + // is a fixed flag or a validated registry identity; no shell is involved. + cmd := exec.CommandContext(processCtx, c.binary, args...) // #nosec G204 -- explicit external scanner, validated argv + cmd.Dir = work + cmd.Env = append(os.Environ(), "TMPDIR="+scratch, "TMP="+scratch, "TEMP="+scratch) + cmd.Stdout, cmd.Stderr = stdout, stderr + cmd.WaitDelay = time.Second + configureProcess(cmd) + err = cmd.Run() + // Include helpers still running after the scanner exits, not just its parent. + stopProcessGroup(cmd) + if stdout.exceeded || stderr.exceeded { + return nil, errOutputLimit + } + if ctx.Err() != nil { + return nil, fmt.Errorf("GuardDog process interrupted: %w", ctx.Err()) + } + if err != nil { + diagnostic := safeDiagnostic(normalizeTempPaths(stderr.buf.String(), dir)) + if diagnostic != "" { + return nil, fmt.Errorf("GuardDog process failed: %w: %s", err, diagnostic) + } + return nil, fmt.Errorf("GuardDog process failed: %w", err) + } + return []byte(normalizeTempPaths(stdout.buf.String(), dir)), nil +} + +// GuardDog creates another tempfile directory beneath the supplied private temp +// root. Normalize both owned paths before cleanup so repeated watch scans retain +// stable diagnostics, including download errors without a top-level report.path. +// Do not touch arbitrary user paths or package code unrelated to these roots. +func normalizeTempPaths(value, root string) string { + encoded, _ := json.Marshal(root) + for _, prefix := range []string{root, string(encoded[1 : len(encoded)-1])} { + temporary := regexp.MustCompile(regexp.QuoteMeta(prefix) + `/scratch/tmp[A-Za-z0-9_-]+`) + value = temporary.ReplaceAllString(value, "/scratch/") + value = strings.ReplaceAll(value, prefix, "") + } + return value +} + +func safeDiagnostic(value string) string { + if len(value) > 4096 { + value = value[:4096] + " (truncated)" + } + return strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, value)) +} diff --git a/internal/guarddog/process_other.go b/internal/guarddog/process_other.go new file mode 100644 index 0000000..06b34d6 --- /dev/null +++ b/internal/guarddog/process_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin + +package guarddog + +import "os/exec" + +func supportedHost() bool { return false } + +// New refuses these hosts before process creation; these functions only keep +// report types and validation available to all trustdiff build targets. +func configureProcess(_ *exec.Cmd) {} +func stopProcessGroup(_ *exec.Cmd) {} diff --git a/internal/guarddog/process_other_test.go b/internal/guarddog/process_other_test.go new file mode 100644 index 0000000..d50978b --- /dev/null +++ b/internal/guarddog/process_other_test.go @@ -0,0 +1,15 @@ +//go:build !linux && !darwin + +package guarddog + +import ( + "strings" + "testing" +) + +func TestUnsupportedHostRefusesBeforeExecutableLookup(t *testing.T) { + _, err := New(Options{Binary: "does-not-exist"}) + if err == nil || !strings.Contains(err.Error(), "requires Linux or macOS") { + t.Fatalf("unsupported platform: %v", err) + } +} diff --git a/internal/guarddog/process_posix.go b/internal/guarddog/process_posix.go new file mode 100644 index 0000000..9466012 --- /dev/null +++ b/internal/guarddog/process_posix.go @@ -0,0 +1,29 @@ +//go:build linux || darwin + +package guarddog + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func supportedHost() bool { return true } + +func configureProcess(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } +} + +func stopProcessGroup(cmd *exec.Cmd) { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } +} diff --git a/internal/guarddog/process_posix_test.go b/internal/guarddog/process_posix_test.go new file mode 100644 index 0000000..81f1747 --- /dev/null +++ b/internal/guarddog/process_posix_test.go @@ -0,0 +1,222 @@ +//go:build linux || darwin + +package guarddog + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/vahapogut/trustdiff/internal/model" +) + +// Helpers are inert executable fixtures. Their interpreter is selected by the +// kernel's shebang handling; production always executes its configured binary +// directly, with no shell command string. No helper downloads or installs data. +func helperBinary(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "guarddog fixture") + if err := os.WriteFile(path, []byte("#!/bin/sh\nset -eu\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + return path +} + +func helperClient(t *testing.T, body string, timeout time.Duration) *Client { + t.Helper() + binary := helperBinary(t, `if [ "$1" = "--version" ]; then printf '3.2.0\n'; exit 0; fi`+"\n"+body) + client, err := New(Options{Binary: binary, Timeout: timeout}) + if err != nil { + t.Fatal(err) + } + return client +} + +func TestExecutableArgumentsIdentityAndIsolation(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "calls") + t.Setenv("GUARDDOG_TEST_LOG", logPath) + for _, tt := range []struct { + ecosystem model.Ecosystem + command string + name string + }{ + {model.NPM, "npm", "@scope/example"}, + {model.PyPI, "pypi", "example"}, + {model.Cargo, "crates", "example_crate"}, + } { + t.Run(string(tt.ecosystem), func(t *testing.T) { + body := `printf '%s\n' "$PWD" "$TMPDIR" "$TMP" "$TEMP" "$@" >> "$GUARDDOG_TEST_LOG" +if [ "$1" = "--version" ]; then printf '3.2.0\n'; exit 0; fi +[ "$(find . -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" = '0' ] +for arg in "$@"; do target="$arg"; done +printf '{"package":"%s","package_version":"%s","issues":0,"errors":{},"results":{},"risks":[]}' "$target" "$4"` + binary := helperBinary(t, body) + client, err := New(Options{Binary: binary}) + if err != nil { + t.Fatal(err) + } + ref := model.PackageRef{Ecosystem: tt.ecosystem, Name: tt.name, Version: "1.2.3"} + for range 2 { + got, scanErr := client.Scan(t.Context(), ref) + if scanErr != nil || got.Status != "completed" { + t.Fatalf("scan: %+v, %v", got, scanErr) + } + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + want := []string{tt.command, "scan", "--version", "1.2.3", "--output-format", "json", "--sandbox"} + if tt.ecosystem == model.NPM { + want = append(want, "--exclude-rules", "risky_new_dependency") + } + want = append(want, "--", tt.name) + if len(lines) != 5+2*(4+len(want)) { + t.Fatalf("want one probe and two scans; got %d lines: %q", len(lines), lines) + } + for _, offset := range []int{0, 5, 9 + len(want)} { + work, scratch := lines[offset], lines[offset+1] + if filepath.Dir(work) != filepath.Dir(scratch) || filepath.Base(work) != "work" || filepath.Base(scratch) != "scratch" || lines[offset+2] != scratch || lines[offset+3] != scratch { + t.Fatalf("temporary isolation missing: %q", lines[offset:offset+4]) + } + if _, statErr := os.Stat(filepath.Dir(work)); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("temporary directory not removed: %v", statErr) + } + } + if !reflect.DeepEqual(lines[9:9+len(want)], want) || !reflect.DeepEqual(lines[13+len(want):], want) { + t.Fatalf("unexpected scan argv: %q", lines) + } + if err := os.Remove(logPath); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestWrongVersionNeverScans(t *testing.T) { + marker := filepath.Join(t.TempDir(), "unexpected-scan") + t.Setenv("GUARDDOG_TEST_MARKER", marker) + binary := helperBinary(t, `if [ "$1" = "--version" ]; then printf '3.1.0\n'; exit 0; fi +touch "$GUARDDOG_TEST_MARKER"`) + client, err := New(Options{Binary: binary}) + if err != nil { + t.Fatal(err) + } + got, err := client.Scan(t.Context(), model.MustParseRef("npm:example@1.2.3")) + if err == nil || got.Status != "unavailable" || !strings.Contains(err.Error(), SupportedVersion) { + t.Fatalf("wrong-version scan: %+v, %v", got, err) + } + if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("scan executed for unreviewed tool: %v", err) + } +} + +func TestProcessFailuresAreNotCleanScans(t *testing.T) { + for _, tt := range []struct{ name, body, want string }{ + {"exit", `printf 'sandbox unavailable\n' >&2; exit 1`, "sandbox unavailable"}, + {"malformed", `printf 'not json'`, "invalid JSON"}, + {"download", `printf '%s' '{"package":"example","issues":0,"errors":{"download-package":"unavailable"}}'`, "did not confirm"}, + {"stdout limit", `dd if=/dev/zero bs=65536 count=65 2>/dev/null`, "output exceeded"}, + {"stderr limit", `dd if=/dev/zero bs=65536 count=2 >&2 2>/dev/null`, "output exceeded"}, + {"timeout", `exec sleep 30`, "deadline exceeded"}, + } { + t.Run(tt.name, func(t *testing.T) { + client := helperClient(t, tt.body, 300*time.Millisecond) + start := time.Now() + got, err := client.Scan(t.Context(), model.MustParseRef("npm:example@1.2.3")) + if err == nil || got.Status != "unavailable" || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("scan failure: %+v, %v; want %s", got, err, tt.want) + } + if time.Since(start) > 3*time.Second { + t.Fatal("scan exceeded its timeout and pipe drain allowance") + } + }) + } +} + +func TestCancelTerminatesChildProcess(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "child.pid") + t.Setenv("GUARDDOG_TEST_PID", pidFile) + client := helperClient(t, `sleep 30 & +printf '%s' "$!" > "$GUARDDOG_TEST_PID" +wait`, 5*time.Second) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := client.Scan(ctx, model.MustParseRef("npm:example@1.2.3")) + done <- err + }() + var pid int + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(pidFile) + if err == nil { + pid, err = strconv.Atoi(string(data)) + if err == nil { + break + } + } + time.Sleep(10 * time.Millisecond) + } + if pid <= 0 { + t.Fatal("helper child did not start") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("cancellation did not terminate process group") + } + // A killed child can remain a zombie until init reaps it in a container. + // ESRCH or Linux's zombie state both prove it cannot continue running. + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { + return + } + if status, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil && strings.Contains(string(status), ") Z ") { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("child %d still exists after cancellation", pid) +} + +func TestPackageLimitAndCanceledContext(t *testing.T) { + client := helperClient(t, `printf '%s' '`+cleanReport+`'`, time.Second) + ref := model.MustParseRef("npm:example@1.2.3") + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := client.Scan(ctx, ref); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled context: %v", err) + } + for range MaxPackages { + if _, err := client.Scan(t.Context(), ref); err != nil { + t.Fatal(err) + } + } + got, err := client.Scan(t.Context(), ref) + if err == nil || got.Status != "unavailable" || !strings.Contains(err.Error(), "scan limit") { + t.Fatalf("scan limit ignored: %+v, %v", got, err) + } +} + +func TestMissingBinary(t *testing.T) { + _, err := New(Options{Binary: filepath.Join(t.TempDir(), "missing")}) + if err == nil || !strings.Contains(err.Error(), "install GuardDog "+SupportedVersion+" separately") { + t.Fatalf("missing executable error: %v", err) + } +} diff --git a/internal/guarddog/report.go b/internal/guarddog/report.go new file mode 100644 index 0000000..fe8cd88 --- /dev/null +++ b/internal/guarddog/report.go @@ -0,0 +1,134 @@ +package guarddog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + + "github.com/vahapogut/trustdiff/internal/model" +) + +// The pinned JSON reporter serializes the CLI result directly. Errors may coexist +// with issues=0, including a download failure with no version/results/risks fields. +// See upstream cli.py and analyzer/analyzer.py, verified 2026-09-29. +func parseReport(ref model.PackageRef, body []byte) (Result, error) { + result := Result{Ref: ref, Source: SourceURL, ToolVersion: SupportedVersion, Status: "unavailable"} + fail := func(message string) (Result, error) { + result.Message = message + return result, errors.New(message) + } + var report struct { + Package string `json:"package"` + Version string `json:"package_version"` + Issues *int `json:"issues"` + Errors map[string]string `json:"errors"` + Results map[string]json.RawMessage `json:"results"` + Risks []Risk `json:"risks"` + Path string `json:"path"` + } + if err := json.Unmarshal(body, &report); err != nil { + return fail("GuardDog returned invalid JSON or an incompatible report shape") + } + if report.Package != ref.Name { + return fail("GuardDog report package does not match the requested release") + } + if report.Issues == nil || *report.Issues < 0 || report.Errors == nil { + return fail("GuardDog report is missing valid issue counts or rule errors") + } + result.Errors = report.Errors + if report.Version != ref.Version { + return fail("GuardDog did not confirm the exact requested version; download or identity verification failed") + } + if report.Results == nil || report.Risks == nil { + return fail("GuardDog report is missing rule results or risk records") + } + prefix := "" + if (path.IsAbs(report.Path) && path.Clean(report.Path) != "/") || strings.HasPrefix(report.Path, "/") { + prefix = strings.TrimSuffix(report.Path, "/") + "/" + } + for rule, message := range report.Errors { + report.Errors[rule] = relativePath(message, prefix) + } + for _, risk := range report.Risks { + if risk.Name == "" || risk.Category == "" || risk.ThreatRule == "" || + (risk.Severity != "low" && risk.Severity != "medium" && risk.Severity != "high") { + return fail("GuardDog report contains an invalid risk record") + } + } + results := make(map[string]json.RawMessage) + for name, value := range report.Results { + value = bytes.TrimSpace(value) + if name == "" || len(value) == 0 { + return fail("GuardDog returned an invalid rule result") + } + // Metadata rules return null/string; source rules return {} or a list of + // match objects. Reject unrelated types rather than treating them as clean. + switch value[0] { + case 'n': + continue + case '"': + var message string + if err := json.Unmarshal(value, &message); err != nil { + return fail("GuardDog returned an invalid metadata result") + } + if message == "" { + continue + } + value, _ = json.Marshal(relativePath(message, prefix)) + case '{': + var empty map[string]json.RawMessage + if err := json.Unmarshal(value, &empty); err != nil || len(empty) != 0 { + return fail("GuardDog returned an incompatible source rule result") + } + continue + case '[': + var matches []map[string]json.RawMessage + if err := json.Unmarshal(value, &matches); err != nil { + return fail("GuardDog returned invalid source matches") + } + if len(matches) == 0 { + continue + } + for _, match := range matches { + if len(match) == 0 { + return fail("GuardDog returned an empty source match") + } + for _, key := range []string{"location", "file_path"} { + if raw, ok := match[key]; ok { + var location string + if err := json.Unmarshal(raw, &location); err != nil { + return fail("GuardDog returned an invalid source location") + } + match[key], _ = json.Marshal(relativePath(location, prefix)) + } + } + } + value, _ = json.Marshal(matches) + default: + return fail("GuardDog returned an incompatible rule result type") + } + results[name] = value + } + result.Issues = *report.Issues + result.Results = results + result.Risks = report.Risks + for i := range result.Risks { + result.Risks[i].FilePath = relativePath(result.Risks[i].FilePath, prefix) + } + result.Status = "completed" + if len(report.Errors) != 0 { + result.Status = "partial" + return fail(fmt.Sprintf("GuardDog could not complete %d rule(s); available evidence is retained", len(report.Errors))) + } + return result, nil +} + +func relativePath(value, prefix string) string { + if prefix == "" { + return value + } + return strings.ReplaceAll(value, prefix, "") +} diff --git a/internal/model/analysis.go b/internal/model/analysis.go new file mode 100644 index 0000000..0fcb5d3 --- /dev/null +++ b/internal/model/analysis.go @@ -0,0 +1,28 @@ +package model + +import "encoding/json" + +// Analysis is an attributed external scanner supplement, not a trustdiff finding +// or a malware-free assertion. Status is completed, partial or unavailable. +type Analysis struct { + Ref PackageRef `json:"ref"` + Source string `json:"source"` + ToolVersion string `json:"tool_version,omitempty"` + Status string `json:"status"` + Issues int `json:"issues"` + Results map[string]json.RawMessage `json:"results,omitempty"` + Risks []AnalysisRisk `json:"risks,omitempty"` + Errors map[string]string `json:"errors,omitempty"` + Message string `json:"message,omitempty"` +} + +// AnalysisRisk retains an external risk description. The scanner's rule results +// carry source snippets, so they are not duplicated in this compact summary. +type AnalysisRisk struct { + Name string `json:"name"` + Category string `json:"category"` + Severity string `json:"severity"` + ThreatRule string `json:"threat_rule"` + ThreatDescription string `json:"threat_description"` + FilePath string `json:"file_path"` +} diff --git a/internal/model/versioninfo.go b/internal/model/versioninfo.go index 5056cc7..f8cafd7 100644 --- a/internal/model/versioninfo.go +++ b/internal/model/versioninfo.go @@ -27,8 +27,10 @@ const ( // Provenance describes how a version was published and whether that evidence was verified. type Provenance struct { Kind ProvenanceKind `json:"kind"` - // Verified is true when the registry or deps.dev verified the evidence. + // Verified is true when the registry, deps.dev or the selected local verifier verified the evidence. Verified bool `json:"verified"` + // VerifiedBy names a local verifier; empty retains the registry/deps.dev behavior. + VerifiedBy string `json:"verified_by,omitempty"` // Identity is the workflow or repository the attestation names, when known. Identity string `json:"identity,omitempty"` } diff --git a/internal/policy/allow.go b/internal/policy/allow.go new file mode 100644 index 0000000..0d28186 --- /dev/null +++ b/internal/policy/allow.go @@ -0,0 +1,150 @@ +package policy + +import ( + "bytes" + "fmt" + "strings" + "time" + + "go.yaml.in/yaml/v3" + + "github.com/vahapogut/trustdiff/internal/model" +) + +// AddAllow previews one exact package exception without rewriting other policy +// text. The caller resolves check IDs to policy names before calling this method. +func AddAllow(data []byte, check, packageRef, reason, expiry string, now time.Time) ([]byte, error) { + p, err := Parse(data) + if err != nil { + return nil, err + } + if strings.ContainsAny(packageRef, "*?[]\\") { + return nil, fmt.Errorf("package must be an exact reference without wildcards") + } + ref, err := model.ParseRef(packageRef) + if err != nil { + return nil, err + } + pat, err := ParsePattern(ref.String()) + if err != nil { + return nil, err + } + date, err := ParseDate(expiry) + if err != nil { + return nil, err + } + e := AllowEntry{Check: check, Package: pat, Reason: strings.TrimSpace(reason), Expires: date} + if e.Reason == "" || strings.ContainsAny(e.Reason, "\r\n") { + return nil, fmt.Errorf("reason must be a nonempty single line") + } + if e.Expired(now) { + return nil, fmt.Errorf("expiry %s is already past", expiry) + } + for _, old := range p.Allow { + if old.Check != check || old.Package.String() != pat.String() { + continue + } + if old.Reason == e.Reason && old.Expires == e.Expires { + return bytes.Clone(data), nil + } + return nil, fmt.Errorf("an exception for %s and %s already exists; review that entry before replacing it", check, pat) + } + p.Allow = append(p.Allow, e) + if err := p.Validate(); err != nil { + return nil, err + } + var tree yaml.Node + if err := yaml.Unmarshal(data, &tree); err != nil { + return nil, err + } + root := tree.Content[0] + if root.Kind != yaml.MappingNode || root.Style&yaml.FlowStyle != 0 { + return nil, fmt.Errorf("policy root must be a block mapping to edit safely") + } + newline := "\n" + if bytes.Contains(data, []byte("\r\n")) { + newline = "\r\n" + } + text := strings.ReplaceAll(string(data), "\r\n", "\n") + lines := strings.Split(strings.TrimSuffix(text, "\n"), "\n") + at := len(lines) + for i, line := range lines { + if strings.TrimSpace(line) == "..." { + at = i + break + } + } + indent := 2 + found := false + for i := 0; i < len(root.Content); i += 2 { + key, value := root.Content[i], root.Content[i+1] + if key.Value != "allow" { + continue + } + found = true + if value.Kind != yaml.SequenceNode || value.Anchor != "" { + return nil, fmt.Errorf("allow must be an unanchored sequence to edit safely") + } + if len(value.Content) == 0 { + line := lines[value.Line-1] + prefix := value.Column - 1 + if value.Style&yaml.FlowStyle == 0 || prefix+2 > len(line) || line[prefix:prefix+2] != "[]" { + return nil, fmt.Errorf("empty allow sequence cannot be edited safely") + } + lines[value.Line-1] = line[:prefix] + line[prefix+2:] + at = value.Line + } else { + if value.Style&yaml.FlowStyle != 0 { + return nil, fmt.Errorf("flow-style allow sequence cannot be edited safely") + } + indent = value.Content[0].Column - 3 + if indent < 0 { + return nil, fmt.Errorf("invalid allow indentation") + } + last, err := editableEnd(value) + if err != nil { + return nil, err + } + at = last + } + } + var encoded bytes.Buffer + enc := yaml.NewEncoder(&encoded) + enc.SetIndent(2) + if err := enc.Encode([]AllowEntry{e}); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + added := strings.Split(strings.TrimSuffix(encoded.String(), "\n"), "\n") + for i := range added { + added[i] = strings.Repeat(" ", indent) + added[i] + } + if !found { + added = append([]string{"allow:"}, added...) + } + out := append([]string{}, lines[:at]...) + out = append(out, added...) + out = append(out, lines[at:]...) + result := []byte(strings.Join(out, newline) + newline) + if _, err := Parse(result); err != nil { + return nil, fmt.Errorf("refusing invalid edited policy: %w", err) + } + return result, nil +} + +func editableEnd(n *yaml.Node) (int, error) { + if n.Anchor != "" || n.Kind == yaml.AliasNode || n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0 || strings.ContainsAny(n.Value, "\r\n") { + return 0, fmt.Errorf("allow contains anchors or multiline values; edit it manually") + } + last := n.Line + for _, child := range n.Content { + end, err := editableEnd(child) + if err != nil { + return 0, err + } + last = max(last, end) + } + return last, nil +} diff --git a/internal/policy/allow_test.go b/internal/policy/allow_test.go new file mode 100644 index 0000000..73fecd0 --- /dev/null +++ b/internal/policy/allow_test.go @@ -0,0 +1,53 @@ +package policy + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestAddAllowPreservesPolicyAndRestrictsException(t *testing.T) { + now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + for _, source := range []string{ + "# keep\nversion: 1\nchecks:\n low-usage: off # retain\n", + "version: 1\nallow: [] # retain\nchecks:\n low-usage: off\n", + "version: 1\nallow:\n - check: low-usage\n package: npm:first\n reason: reviewed\nchecks:\n low-usage: off # retain\n", + "version: 1\nallow:\n- check: low-usage\n package: npm:first\n reason: reviewed\n...\n", + } { + for _, nl := range []string{"\n", "\r\n"} { + src := []byte(strings.ReplaceAll(source, "\n", nl)) + out, err := AddAllow(src, "exotic-source", "npm:vendored@1.2.3", "reviewed # repo: abc", "2026-10-10", now) + if err != nil { + t.Fatal(err) + } + p, err := Parse(out) + if err != nil { + t.Fatal(err) + } + entry := p.Allow[len(p.Allow)-1] + if entry.Reason != "reviewed # repo: abc" || entry.Package.String() != "npm:vendored@1.2.3" { + t.Fatalf("bad entry: %+v", entry) + } + if strings.Contains(source, "# retain") && !bytes.Contains(out, []byte("# retain")) { + t.Fatal("comment lost") + } + again, err := AddAllow(out, "exotic-source", "npm:vendored@1.2.3", "reviewed # repo: abc", "2026-10-10", now) + if err != nil || !bytes.Equal(out, again) { + t.Fatalf("non-idempotent: %v", err) + } + } + } +} + +func TestAddAllowRefusesBroadOrExpiredReview(t *testing.T) { + for _, tc := range []struct{ ref, reason, expiry string }{ + {"npm:*", "reviewed", "2030-01-01"}, {"npm:foo", "", "2030-01-01"}, + {"npm:foo", "reviewed", "2020-01-01"}, {"foo", "reviewed", "2030-01-01"}, + {"npm:foo", "reviewed", ""}, + } { + if _, err := AddAllow([]byte("version: 1\n"), "exotic-source", tc.ref, tc.reason, tc.expiry, time.Now()); err == nil { + t.Fatalf("accepted %+v", tc) + } + } +} diff --git a/internal/registry/crates/dumpindex/historical_owners_test.go b/internal/registry/crates/dumpindex/historical_owners_test.go new file mode 100644 index 0000000..e3d9091 --- /dev/null +++ b/internal/registry/crates/dumpindex/historical_owners_test.go @@ -0,0 +1,67 @@ +package dumpindex + +import ( + "strings" + "testing" + + "github.com/vahapogut/trustdiff/internal/checks" + "github.com/vahapogut/trustdiff/internal/model" + "github.com/vahapogut/trustdiff/internal/registry" +) + +// The public owner rows describe current membership. Even an old created_at +// cannot establish continuous membership at publication: deleted owner rows and +// owner actions are omitted from the public dump. Those rows must never become +// historical maintainers that would demote a publisher-change finding. +func TestCurrentDumpOwnersNeverBecomeHistoricalMaintainers(t *testing.T) { + tables := fixtureTables() + tables["data/users.csv"] = "id,gh_login,username\n2,alice,alice\n3,bob,bob\n" + tables["data/crate_owners.csv"] = "crate_id,owner_kind,owner_id,created_at\n1,0,3,2020-01-01T00:00:00Z\n" + tables["data/versions.csv"] = "crate_id,num,created_at,published_by,yanked,tar_sha256\n" + + "1,1.0.0,2026-09-25T00:00:00Z,2,f," + strings.Repeat("a", 64) + "\n" + + "1,2.0.0,2026-09-27T00:00:00Z,3,f," + strings.Repeat("b", 64) + "\n" + dir := t.TempDir() + if _, err := refreshFixture(t, dir, archiveFixture(t, tables), nil); err != nil { + t.Fatal(err) + } + index, err := Open(dir, fixtureTime) + if err != nil { + t.Fatal(err) + } + owners, err := index.Owners(t.Context(), "test_crate") + if err != nil || len(owners) != 1 || owners[0].Name != "bob" { + t.Fatalf("current owners = %+v, %v", owners, err) + } + list, err := index.Versions(t.Context(), "test_crate") + if err != nil { + t.Fatal(err) + } + for _, version := range list.Versions { + if len(version.Maintainers) != 0 { + t.Fatalf("current owners were assigned to historical release %s: %+v", version.Ref, version.Maintainers) + } + } + selected := make([]checks.Check, 0, 2) + for _, id := range []string{"TD002", "TD003"} { + check, ok := checks.Lookup(id) + if !ok { + t.Fatalf("%s not registered", id) + } + selected = append(selected, check) + } + loader := checks.NewLoader(registry.Registry{model.Cargo: index}, nil, nil, nil) + runner := &checks.Runner{Loader: loader, Checks: selected, Now: fixtureTime} + results := runner.Evaluate(t.Context(), []checks.Input{{Ref: model.MustParseRef("cargo:test_crate@2.0.0")}}) + subject := results[0].Subject + if len(subject.Findings) != 1 || subject.Findings[0].ID != "TD002" || subject.Findings[0].Level != model.LevelBlock { + t.Fatalf("current owner wrongly cleared or demoted a new publisher: %+v", subject) + } + if _, claimed := subject.Findings[0].Evidence["publisher_is_maintainer"]; claimed { + t.Fatal("finding claims a historical maintainer identity from current owner rows") + } + if len(subject.Skipped) != 1 || subject.Skipped[0].Check != "TD003" || + !strings.Contains(subject.Skipped[0].Reason, "no maintainer set per version") || + !strings.Contains(subject.Skipped[0].Reason, "no baseline") { + t.Fatalf("missing historical membership was not explicit: %+v", subject.Skipped) + } +} diff --git a/internal/report/analysis.go b/internal/report/analysis.go new file mode 100644 index 0000000..bea8047 --- /dev/null +++ b/internal/report/analysis.go @@ -0,0 +1,37 @@ +package report + +import ( + "encoding/json" + "fmt" + "strings" +) + +// External findings are escaped as JSON rather than rendered as terminal control +// codes or active Markdown. Full structured evidence remains available in JSON. +func writeAnalysis(b *strings.Builder, r *Report, markdown bool) { + if !r.GuardDogRequested { + return + } + if markdown { + b.WriteString("\n### GuardDog source analysis\n\n") + } else { + b.WriteString("\nGuardDog source analysis (supplement)\n") + } + if len(r.GuardDog) == 0 { + b.WriteString("No warn/block registry releases selected.\n") + return + } + for i := range r.GuardDog { + a := &r.GuardDog[i] + data, _ := json.Marshal(a) + if markdown { + // JSON escapes source newlines, so data cannot close this fence on a + // separate line and introduce active Markdown. + b.WriteString("~~~~json\n") + b.Write(data) + b.WriteString("\n~~~~\n\n") + } else { + fmt.Fprintf(b, "%s\n", data) + } + } +} diff --git a/internal/report/analysis_test.go b/internal/report/analysis_test.go new file mode 100644 index 0000000..26b143b --- /dev/null +++ b/internal/report/analysis_test.go @@ -0,0 +1,45 @@ +package report + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/vahapogut/trustdiff/internal/jsonschema" + "github.com/vahapogut/trustdiff/internal/model" +) + +func TestExternalAnalysisAllFormatsAndSchema(t *testing.T) { + r := Build(nil, CurrentTool(), Policy{Cooldown: "3d", FailOn: "block"}, model.LevelBlock) + r.GuardDogRequested = true + r.GuardDog = []model.Analysis{{Ref: model.PackageRef{Ecosystem: model.NPM, Name: "foo", Version: "1.0.0"}, Source: "https://github.com/DataDog/guarddog", Status: "partial", Issues: 1, Message: "bad\x1b[31m\n~~~~\n