From 6613a034e3eaa04a66acfbba4234d8aaeefdbf58 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 00:32:57 -0700 Subject: [PATCH 1/2] docs(spec): design v0.6 adoption controls Capture the competitor-informed feature bundle before implementation: repo config, init scaffolding, policy budgets, license-change gating, and findings-only markdown mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...026-04-29-v0.6-adoption-controls-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-29-v0.6-adoption-controls-design.md diff --git a/docs/superpowers/specs/2026-04-29-v0.6-adoption-controls-design.md b/docs/superpowers/specs/2026-04-29-v0.6-adoption-controls-design.md new file mode 100644 index 0000000..9ba46bb --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-v0.6-adoption-controls-design.md @@ -0,0 +1,161 @@ +# bomdrift v0.6 adoption controls design + +## Context + +v0.5 shipped the adoption baseline: zero-config GitHub Action, cleaner PR +comments, in-comment suppression, contributor surfaces, and a signed v0.5.0 +release. The next useful release should not add a dashboard or duplicate SCA +scanners. It should make bomdrift easier to adopt in real repositories and +easier to tune once it starts commenting on every PR. + +Competitor and blocker review highlighted five recurring needs: + +1. Teams want repo-level policy instead of long workflow inputs. +2. First-time users want a scaffolded setup, not a docs hunt. +3. Security teams need numeric gates for dependency churn, not only "any CVE". +4. License-change governance should be gateable without blocking all findings. +5. PR comments need a reviewer-focused mode that hides raw dependency churn and + keeps risk signals visible. + +The user asked to work autonomously while unavailable, so this spec records the +chosen approach and assumptions before implementation. + +## Approaches considered + +### Recommended: adoption-controls bundle + +Implement five tightly related features in one release: + +- `.bomdrift.toml` repo policy config. +- `bomdrift init` scaffolding. +- numeric diff-budget gates for added/removed/version-changed dependencies. +- `--fail-on license-change`. +- `--findings-only` markdown mode. + +This bundle is practical in the current architecture: the CLI already has a +small `DiffArgs` surface, all gating happens after one `ChangeSet` is computed, +and markdown rendering already has option flags. + +### Alternative: new detection enrichers + +Add suspicious install scripts, package age, registry freshness, or dependency +confusion detection. These are valuable but require new network integrations +and ecosystem-specific package metadata. They are better after bomdrift has +policy/config surfaces to keep false positives manageable. + +### Alternative: external integrations + +Build GitLab comments, Slack summaries, or Marketplace polish first. These help +distribution but do not improve the core review loop for current GitHub users. +The v0.6 release should improve the product before expanding channels. + +## Feature design + +### 1. Repo policy config + +Add a `config` module that loads `.bomdrift.toml` when present, or a path from +`bomdrift diff --config `. A missing default config is ignored. A missing +explicit config is an error. + +Initial TOML shape: + +```toml +[diff] +fail_on = "critical-cve" +baseline = ".bomdrift/baseline.json" +no_osv = false +no_maintainer_age = false +summary_only = false +findings_only = false +include_file_components = false +repo_url = "https://github.com/owner/repo" +max_added = 25 +max_removed = 50 +max_version_changed = 10 +``` + +CLI flags remain authoritative where present. For positive boolean flags, a +CLI flag can turn behavior on; the config supplies defaults. This keeps the +implementation simple and avoids adding negative flags in v0.6. + +### 2. `bomdrift init` + +Add `bomdrift init` to create starter files: + +- `.bomdrift.toml` +- `.github/workflows/sbom-diff.yml` +- `.github/workflows/bomdrift-suppress.yml` + +The command fails rather than overwriting existing files unless `--force` is +provided. It also supports `--config-only` for users who only want the TOML. + +### 3. Diff-budget gates + +Add `--max-added `, `--max-removed `, and +`--max-version-changed `. When a limit is exceeded, bomdrift exits with the +same `FAIL_ON_EXIT_CODE` (`2`) after writing output, so the Action still posts +the comment before failing. These gates are intentionally numeric and separate +from `--fail-on`; teams can use them for dependency-review hygiene without +turning every advisory into a blocker. + +### 4. License-change gate + +Add `license-change` as a `--fail-on` threshold. Today `any` includes +license-changed-without-version-bump, but a legal/compliance team may want to +gate only that signal. This is a small enum addition plus tests. + +### 5. Findings-only markdown mode + +Add `--findings-only` for markdown output. The summary table still shows all +change counts, but the detailed Added / Removed / Version changed sections are +omitted. Risk-bearing sections remain visible: + +- License changed. +- Vulnerabilities. +- Possible typosquats. +- Multi-major version jumps. +- Young maintainers. + +This gives reviewers a Socket/Snyk-like triage view while preserving the full +diff via JSON/SARIF or a normal markdown run. + +## Action integration + +Add inputs mirroring the new CLI controls: + +- `config` +- `findings-only` +- `max-added` +- `max-removed` +- `max-version-changed` + +The entrypoint passes non-empty inputs through to `bomdrift diff`. Existing +behavior remains unchanged when none are set. + +## Error handling + +- Default `.bomdrift.toml` absence is not an error. +- Explicit `--config ` absence is an error with the path included. +- Invalid TOML or unknown enum values are errors with context. +- `bomdrift init` refuses to overwrite files unless `--force` is set. +- Policy gates exit with code 2, not code 1, because they are user-configured + review outcomes rather than tool failures. + +## Testing plan + +- Unit tests for config parsing and merge behavior. +- Unit tests for diff-budget and license-change trip logic. +- Markdown renderer tests for `findings_only`. +- CLI tests for config loading, explicit missing config failure, init file + creation, and policy exit code 2. +- Shell syntax checks for `entrypoint.sh`. +- Full release-style validation: `cargo fmt`, `cargo clippy`, `cargo test + --release`, `bash examples/run-all.sh`, and `mdbook build docs`. + +## Out of scope + +- New network enrichers. +- SaaS/dashboard/telemetry. +- Negative boolean flags for every config boolean. +- GitLab/Bitbucket comments. +- Organization-level policy distribution. From 80919517f49809480492f8b98cc861345570d16f Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 00:43:53 -0700 Subject: [PATCH 2/2] feat: add v0.6 adoption controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 29 +++++ Cargo.lock | 60 +++++++++ Cargo.toml | 1 + README.md | 21 ++- action.yml | 31 ++++- docs/src/cli-reference.md | 73 ++++++++++- docs/src/enrichers/osv-cve.md | 2 + docs/src/github-action.md | 50 +++++-- docs/src/quickstart.md | 5 + docs/src/roadmap.md | 7 +- entrypoint.sh | 40 ++++-- src/cli.rs | 63 +++++++-- src/config.rs | 185 ++++++++++++++++++++++++++ src/lib.rs | 192 ++++++++++++++++++++++++++- src/render/markdown.rs | 67 +++++++++- tests/cli.rs | 236 ++++++++++++++++++++++++++++++++++ 16 files changed, 1013 insertions(+), 49 deletions(-) create mode 100644 src/config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e4630..8f79a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Repository policy config (`.bomdrift.toml`).** `bomdrift diff` + auto-loads `.bomdrift.toml` from the current working directory when + present, or an explicit file via `--config`. Config can set defaults + for output format, fail thresholds, baseline path, markdown focus + mode, and dependency-churn budgets while leaving CLI flags as the + one-off override path. + +- **`bomdrift init` scaffolding.** `bomdrift init` writes a starter + `.bomdrift.toml`, SBOM-diff workflow, and comment-suppression workflow. + `--config-only` writes just the policy file; `--force` overwrites + existing generated files. + +- **Diff-budget gates.** `--max-added`, `--max-removed`, and + `--max-version-changed` exit 2 after rendering when a PR changes more + dependencies than the configured budget allows. + +- **Focused markdown comments.** `--findings-only` keeps the summary and + risk-bearing sections but omits raw Added / Removed / Version changed + detail rows for high-churn PRs. + +- **License-change threshold.** `--fail-on license-change` exits 2 on + same-version license drift without also requiring `--fail-on any`. + +- **GitHub Action inputs for policy controls.** The action now accepts + `config`, `findings-only`, `max-added`, `max-removed`, and + `max-version-changed` and passes them through to the CLI. + ## [0.5.0] - 2026-04-29 The adoption milestone: bomdrift now works as a copy-paste GitHub Action, diff --git a/Cargo.lock b/Cargo.lock index d1997c4..27307a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,7 @@ dependencies = [ "strsim", "supports-color 3.0.2", "thiserror", + "toml", "ureq", ] @@ -1000,6 +1001,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1130,6 +1140,47 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unarray" version = "0.1.4" @@ -1388,6 +1439,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index c79840f..350e9a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ strsim = "0.11" owo-colors = { version = "4", features = ["supports-colors"] } supports-color = "3" directories = "6" +toml = "0.8" [dev-dependencies] criterion = { version = "0.5", default-features = false, features = ["html_reports"] } diff --git a/README.md b/README.md index 1f018bb..182378a 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,13 @@ jobs: steps: - uses: Metbcy/bomdrift@v1 # Optional inputs (all have sensible defaults): - # fail-on: critical-cve | cve | typosquat | any | none + # fail-on: critical-cve | cve | typosquat | license-change | any | none # baseline: .bomdrift/baseline.json + # findings-only: true # verify-signatures: true (set false on trusted mirrors) ``` -Pin to `@v1` for the latest v0.x; pin to `@v0.5.0` for reproducible builds. See the [Action reference](https://metbcy.github.io/bomdrift/github-action.html) for every input. +Pin to `@v1` for the latest v0.x; pin to `@v0.5.0` for reproducible builds. Run `bomdrift init` if you want a checked-in `.bomdrift.toml` policy and both workflows scaffolded locally. See the [Action reference](https://metbcy.github.io/bomdrift/github-action.html) for every input. #### Optional: in-comment suppression (v0.5+) @@ -148,9 +149,18 @@ bomdrift diff before.json after.json --output sarif # Exit 2 on findings (the action wraps this for PR-comment workflows) bomdrift diff before.json after.json --fail-on critical-cve +# Keep raw churn out of PR comments while preserving risk sections +bomdrift diff before.json after.json --findings-only + +# Block unusually large dependency churn +bomdrift diff before.json after.json --max-added 25 --max-version-changed 10 + # Suppress findings already present in a baseline snapshot bomdrift diff before.json after.json --baseline .bomdrift/baseline.json +# Scaffold .bomdrift.toml and GitHub Action workflows +bomdrift init + # Hand-curate a baseline (or let the comment-suppress sub-action do it) bomdrift baseline add GHSA-xxxx-yyyy-zzzz @@ -159,7 +169,7 @@ bomdrift refresh-typosquat # all ecosystems bomdrift refresh-typosquat --ecosystem pypi # one specific list ``` -`bomdrift diff` exits 0 on success regardless of findings unless `--fail-on` is set — then it exits 2 when the threshold trips. Stdout is Markdown by default when piped/redirected (the PR-comment path) and ANSI-colored when stdout is a TTY. `--output markdown|json|terminal|sarif` overrides detection. +`bomdrift diff` exits 0 on success regardless of findings unless `--fail-on` or a diff budget is set — then it exits 2 when the policy trips. Stdout is Markdown by default when piped/redirected (the PR-comment path) and ANSI-colored when stdout is a TTY. `--output markdown|json|terminal|sarif` overrides detection. See the [`examples/`](./examples/) directory for end-to-end scenarios (axios incident, multi-ecosystem typosquats, version jumps, baseline suppression). @@ -206,10 +216,11 @@ With network access, an additional Vulnerabilities section lists each advisory I - Flag deps whose **top GitHub maintainer joined the project recently** (the xz-style takeover signal). Honors `GITHUB_TOKEN`, rate-limit-aware, skipped when the repo has > 50 contributors. - Flag **multi-major version jumps** (≥ 2 majors) in a single diff — often correlates with takeover swaps and namespace reuse. - **Output formats**: terminal (colored, TTY-aware), Markdown (PR comment, with collapsible sections + severity sort), **JSON**, and **SARIF v2.1.0** for GitHub Code Scanning ingestion. -- **`--fail-on`** thresholds (`cve` / `critical-cve` / `typosquat` / `any`) exit code 2 on trip while still emitting the comment body, so the PR comment posts even when the workflow step fails. +- **`--fail-on`** thresholds (`cve` / `critical-cve` / `typosquat` / `license-change` / `any`) and diff budgets (`--max-added`, `--max-removed`, `--max-version-changed`) exit code 2 on trip while still emitting the comment body, so the PR comment posts even when the workflow step fails. +- **`.bomdrift.toml` + `bomdrift init`** let repos keep policy in version control instead of repeating inputs in workflow YAML. - **`/bomdrift suppress `** in-comment suppression (v0.5+) via a companion sub-action. - **`--baseline `** suppresses findings already captured in a previously stored `bomdrift diff --output json` snapshot. -- **`--summary-only`** + automatic comment-size fallback (default 60 KB) keeps big SBOM diffs under GitHub's 65,536-char comment-body cap. +- **`--summary-only`**, **`--findings-only`**, and automatic comment-size fallback (default 60 KB) keep big SBOM diffs under GitHub's 65,536-char comment-body cap. - Ships as a **single Rust binary** (~3.4 MB, stripped + LTO) **and** a composite GitHub Action — no Docker. - Releases are **cosign-signed** keyless via Sigstore + GitHub OIDC — eat-your-own-supply-chain-dogfood. diff --git a/action.yml b/action.yml index d069985..84a1292 100644 --- a/action.yml +++ b/action.yml @@ -64,7 +64,8 @@ inputs: fail-on: description: | Threshold to fail the action with exit code 2 when findings of the - configured kind surface. Accepted: none|cve|critical-cve|typosquat|any. + configured kind surface. Accepted: none|cve|critical-cve|typosquat| + license-change|any. `critical-cve` filters on real OSV severity (HIGH or above per GHSA's `database_specific.severity`); advisories with no resolvable severity surface in the diff but don't trip this threshold. The PR comment is @@ -79,6 +80,29 @@ inputs: to 0 to disable the fallback (a too-large comment will then 422 from the GitHub API and the action posts nothing). default: '60000' + config: + description: | + Path to a `.bomdrift.toml` repo policy file. When set, the CLI loads + defaults such as fail_on, baseline, findings_only, and diff budgets + before applying explicit action inputs. Leave empty to auto-load + `.bomdrift.toml` when present. + default: '' + findings-only: + description: | + Markdown-only. Keep the summary table and risk-bearing sections, but + omit raw Added / Removed / Version changed detail tables from the PR + comment. Useful for high-churn repos where reviewers only want the + actionable findings inline. + default: 'false' + max-added: + description: Exit 2 when more than this many dependencies are added. + default: '' + max-removed: + description: Exit 2 when more than this many dependencies are removed. + default: '' + max-version-changed: + description: Exit 2 when more than this many dependencies change version. + default: '' baseline: description: | Path to a previously captured `bomdrift diff --output json` snapshot. @@ -158,6 +182,11 @@ runs: OUTPUT_FORMAT: ${{ inputs.output }} COMMENT_ON_PR: ${{ inputs.comment-on-pr }} COMMENT_SIZE_LIMIT: ${{ inputs.comment-size-limit }} + CONFIG_PATH: ${{ inputs.config }} + FINDINGS_ONLY: ${{ inputs.findings-only }} + MAX_ADDED: ${{ inputs.max-added }} + MAX_REMOVED: ${{ inputs.max-removed }} + MAX_VERSION_CHANGED: ${{ inputs.max-version-changed }} FAIL_ON: ${{ inputs.fail-on }} BASELINE: ${{ inputs.baseline }} VERIFY_SIGNATURES: ${{ inputs.verify-signatures }} diff --git a/docs/src/cli-reference.md b/docs/src/cli-reference.md index 2f60816..95c6a33 100644 --- a/docs/src/cli-reference.md +++ b/docs/src/cli-reference.md @@ -8,6 +8,8 @@ groups the same information by behavior so it's easier to look up. ```text bomdrift diff [OPTIONS] +bomdrift init [--config-only] [--force] +bomdrift baseline add [--path ] bomdrift refresh-typosquat [--ecosystem ] ``` @@ -48,6 +50,46 @@ Markdown-only. Emits just the summary table + a footer pointing at the full output. Used by the action's comment-size fallback when the full diff exceeds GitHub's 65,536-char comment-body cap. +#### `--findings-only` + +Markdown-only. Keeps the summary table and risk-bearing sections +(vulnerabilities, typosquats, version jumps, young maintainers, license +changes) but omits raw Added / Removed / Version changed detail tables. +This is useful when a PR intentionally updates a large lockfile and +reviewers only want the actionable findings inline. + +The counts still appear in the summary table, so churn is visible even +when the long per-dependency rows are hidden. + +### Repo policy config + +#### `--config ` + +Load defaults from a `.bomdrift.toml` policy file. When omitted, +`bomdrift diff` auto-loads `.bomdrift.toml` from the current working +directory if it exists; missing default config is ignored. An explicit +`--config` path must exist and parse. + +CLI flags override config values for one-off runs. Positive booleans in +config, such as `findings_only = true`, turn the behavior on; v0.6 does +not add parallel `--no-*` flags to turn those booleans off from the CLI. + +Example: + +```toml +[diff] +fail_on = "critical-cve" +baseline = ".bomdrift/baseline.json" +findings_only = true +max_added = 25 +max_version_changed = 10 +``` + +Supported `[diff]` keys map to the CLI flags: `output`, `format`, +`no_osv`, `no_osv_cache`, `baseline`, `no_maintainer_age`, `fail_on`, +`summary_only`, `findings_only`, `include_file_components`, `repo_url`, +`max_added`, `max_removed`, and `max_version_changed`. + ### Enrichment flags #### `--no-osv` @@ -83,6 +125,7 @@ Exit with code 2 when findings of the configured threshold surface. One of: many actively-exploited advisories ship as HIGH. - `typosquat` — trips on any typosquat finding (always `severity = none`, but the threshold lets you gate on the structural signal). +- `license-change` — trips on same-version license changes. - `any` — trips on any finding (CVE, typosquat, version-jump, maintainer-age) OR any license-changed-without-version-bump. @@ -90,6 +133,14 @@ The PR-comment body is written to stdout **before** exit-2 — the action's `tee` + `PIPESTATUS` wrapper relies on this so the comment posts even when the workflow step fails. +#### Diff budgets + +`--max-added `, `--max-removed `, and +`--max-version-changed ` fail the run with exit code 2 when a diff +exceeds the configured dependency-churn budget. The rendered body is +still written before exit, just like `--fail-on`, so GitHub Actions can +post the PR comment and then block the merge. + #### `--baseline ` Path to a previously captured `bomdrift diff --output json` snapshot. @@ -98,6 +149,26 @@ and from the `--fail-on` trip-evaluation. Match keys are conservative — a finding at a different version than baseline still surfaces. See [Baseline & suppression](./baseline.md) for full match-key semantics. +## `bomdrift init` + +Scaffold a copy-paste adoption setup in the current repository: + +```bash +bomdrift init +``` + +This writes: + +- `.bomdrift.toml` +- `.github/workflows/sbom-diff.yml` +- `.github/workflows/bomdrift-suppress.yml` + +Flags: + +- `--config-only` — write only `.bomdrift.toml`. +- `--force` — overwrite existing generated files. Without `--force`, + existing files are preserved and the command fails loudly. + ## `bomdrift refresh-typosquat` Refresh the bundled typosquat top-package lists from upstream sources. @@ -131,7 +202,7 @@ over the embedded snapshot when present and parseable. |---|---| | 0 | Success. | | 1 | bomdrift internal error (parse failure, network mishap not gated by best-effort path, etc.). | -| 2 | `--fail-on` threshold tripped. The body is still on stdout — the action posts it before propagating the exit code. | +| 2 | `--fail-on` threshold or diff budget tripped. The body is still on stdout — the action posts it before propagating the exit code. | | (clap 2) | Usage error from clap (unknown flag, missing required argument). Distinguishable from exit-2 from `--fail-on` by stderr containing `error: ...` rather than the v0.2 caveat warning. | ## Environment variables diff --git a/docs/src/enrichers/osv-cve.md b/docs/src/enrichers/osv-cve.md index f6f7279..b903fbc 100644 --- a/docs/src/enrichers/osv-cve.md +++ b/docs/src/enrichers/osv-cve.md @@ -104,6 +104,8 @@ bomdrift diff before.json after.json --no-osv | `none` | Never. | | `cve` | Any vuln finding present (regardless of severity). | | `critical-cve` | Any finding with `severity >= High` (covers HIGH and CRITICAL). | +| `typosquat` | Any typosquat finding; OSV findings do not trip it. | +| `license-change` | Any same-version license change; OSV findings do not trip it. | | `any` | Any finding of any kind, plus license-changed-without-version-bump. | The `critical-cve` name covers HIGH-or-CRITICAL because CRITICAL alone diff --git a/docs/src/github-action.md b/docs/src/github-action.md index f0dfcb9..54cbdbf 100644 --- a/docs/src/github-action.md +++ b/docs/src/github-action.md @@ -27,6 +27,11 @@ generates CycloneDX-JSON SBOMs via Syft (installed automatically and cached across job runs), and posts the rendered diff as an upserted PR comment. +For a repo-owned policy, run `bomdrift init` once and commit the generated +`.bomdrift.toml` plus workflows. The action auto-loads `.bomdrift.toml` +from the repo root when present, or you can pass +`config: .bomdrift.toml` explicitly. + If you already produce SBOMs through a non-Syft toolchain — Trivy, SPDX-tools, an in-house generator — supply the file paths via the `before-sbom` / `after-sbom` inputs instead. The advanced flow below @@ -44,9 +49,14 @@ documents that path; both flows continue to be supported in v1. | `format` | no | `auto` | Force input format detection: `auto`/`cdx`/`spdx`/`syft`. | | `output` | no | `markdown` | Output format: `terminal`/`markdown`/`json`/`sarif`. The PR-comment path requires `markdown`. | | `comment-on-pr` | no | `true` | Post the rendered diff as a PR comment when the workflow runs on a `pull_request` event. Set to `false` for diff-only / report-only workflows. | -| `fail-on` | no | `none` | Exit code 2 on findings of the configured kind: `none`/`cve`/`critical-cve`/`typosquat`/`any`. The PR comment is still posted on a tripped run. | +| `fail-on` | no | `none` | Exit code 2 on findings of the configured kind: `none`/`cve`/`critical-cve`/`typosquat`/`license-change`/`any`. The PR comment is still posted on a tripped run. | | `comment-size-limit` | no | `60000` | Bytes. When the rendered diff exceeds this size, bomdrift re-renders with `--summary-only` for the PR comment while keeping the full body in the workflow step summary. Set to `0` to disable the fallback. GitHub's hard cap is 65,536 chars. | | `verify-signatures` | no | `true` | Whether to install cosign and verify the bomdrift release archive's Sigstore signature. Set to `false` on trusted mirrors / cached runners to skip the cosign-installer step (~15s saved). | +| `config` | no | `` (empty) | Path to `.bomdrift.toml`. Leave empty to auto-load `.bomdrift.toml` from the repo root when present. | +| `findings-only` | no | `false` | Markdown-only. Keep summary + risk-bearing sections, but omit raw Added / Removed / Version changed detail rows from the PR comment. | +| `max-added` | no | `` (empty) | Exit 2 when more than this many dependencies are added. | +| `max-removed` | no | `` (empty) | Exit 2 when more than this many dependencies are removed. | +| `max-version-changed` | no | `` (empty) | Exit 2 when more than this many dependencies change version. | | `baseline` | no | `` (empty) | Path to a previously captured `bomdrift diff --output json` snapshot. Findings present in the baseline are suppressed from the rendered output and the `--fail-on` trip evaluation. See [Baseline & suppression](./baseline.md) for match-key semantics. | | `github-token` | no | `${{ github.token }}` | Token used to post PR comments. | @@ -63,12 +73,35 @@ The action does not declare formal outputs. Its side effects are: is upserted into a single PR comment marked ``. Subsequent pushes update the same comment instead of accumulating new ones (`peter-evans/create-or-update-comment`-style upsert). -4. When `fail-on` trips, the action exits with code 2 — but only **after** - the PR comment has been posted, so reviewers see the findings even when - the workflow step fails. +4. When `fail-on` or a diff budget trips, the action exits with code 2 — + but only **after** the PR comment has been posted, so reviewers see the + findings even when the workflow step fails. ## Common patterns +### Repo policy file + +Use `.bomdrift.toml` when you want the policy in version control instead +of repeated YAML inputs: + +```toml +[diff] +fail_on = "critical-cve" +baseline = ".bomdrift/baseline.json" +findings_only = true +max_added = 25 +max_version_changed = 10 +``` + +```yaml +- uses: Metbcy/bomdrift@v1 + with: + config: .bomdrift.toml +``` + +Explicit action inputs still override the config-backed defaults for +one-off workflows. + ### Bring your own SBOMs (advanced / pre-v0.5 flow) When the SBOMs come from a non-Syft toolchain (Trivy, SPDX-tools, @@ -105,8 +138,9 @@ behave. Existing v0.4 workflows continue to function unchanged after a ``` `critical-cve` filters on `severity >= High` per the OSV-fetched severity -(see [OSV.dev CVE lookup](./enrichers/osv-cve.md)). `typosquat` and `any` -are also accepted thresholds — see [`--fail-on`](./cli-reference.md#--fail-on). +(see [OSV.dev CVE lookup](./enrichers/osv-cve.md)). `typosquat`, +`license-change`, and `any` are also accepted thresholds — see +[`--fail-on`](./cli-reference.md#--fail-on). ### Self-hosted / trusted-mirror runners @@ -171,8 +205,8 @@ The `output: sarif` produces SARIF v2.1.0 with stable rule IDs (see `pull-requests: write` is required when `comment-on-pr: true` (the default). Without it, the comment-upsert step fails with a 403; the -action's exit code remains the bomdrift exit (so a `fail-on` trip still -fails the workflow correctly). +action's exit code remains the bomdrift exit (so a `fail-on` or budget +trip still fails the workflow correctly). `contents: read` is required so the action's internal `actions/checkout` steps (zero-config flow) can fetch both refs. In the bring-your-own-SBOMs diff --git a/docs/src/quickstart.md b/docs/src/quickstart.md index 6d7a7de..42a2aa1 100644 --- a/docs/src/quickstart.md +++ b/docs/src/quickstart.md @@ -28,6 +28,11 @@ The `@v1` mutable tag tracks the latest v0.x release. Pin to a specific version (`@v0.5.0`) if you prefer reproducible builds. See [GitHub Action](./github-action.md) for every input. +If you prefer a checked-in policy file, install the binary and run +`bomdrift init` once. It writes `.bomdrift.toml` plus the SBOM-diff and +comment-suppression workflows, so future policy tweaks happen in TOML +instead of workflow YAML. + ## Locally with the binary Pre-built binaries cover Linux x86_64 + aarch64, macOS aarch64, and diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index baa1528..112b9c0 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -6,9 +6,9 @@ acceptance criteria for new contributions look like. ## Planned The list below is intentionally short — bomdrift is small on purpose. -Items are grouped by likely v0.4+ landing and rough sizing. +Items are grouped by likely landing area and rough sizing. -### v0.5 candidates (not committed) +### Future candidates (not committed) - **GraphQL maintainer-age** — was investigated for v0.4 and deferred. The current REST implementation already uses `?per_page=1` + Link-header @@ -27,9 +27,6 @@ Items are grouped by likely v0.4+ landing and rough sizing. PR comments. The CLI is already CI-agnostic; this is glue + docs. - **OCI artifact attestation** — verify SBOMs are themselves signed by the build system before diffing. Pairs with cosign attest. -- **Diff-stat threshold flags** — `--fail-on-added `, - `--fail-on-removed-from-allowlist `. Useful for governance - workflows. ### Calibration backlog diff --git a/entrypoint.sh b/entrypoint.sh index 27bb717..589f81c 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -233,9 +233,8 @@ run_diff() { local fmt="${4:-markdown}" local input_format="${5:-auto}" shift 5 - # Remaining args are passed through verbatim — used for `--fail-on ` - # and any future bomdrift CLI flags the action wants to plumb through - # without having to re-position parameters. + # Remaining args are passed through verbatim — used for policy flags such as + # `--config`, `--fail-on`, and diff budgets without re-positioning params. local args=(diff "$before" "$after" --output "$fmt") if [ "$input_format" != "auto" ]; then @@ -264,6 +263,11 @@ main() { local output_format="${OUTPUT_FORMAT:-markdown}" local comment_on_pr="${COMMENT_ON_PR:-true}" local comment_size_limit="${COMMENT_SIZE_LIMIT:-60000}" + local config_path="${CONFIG_PATH:-}" + local findings_only="${FINDINGS_ONLY:-false}" + local max_added="${MAX_ADDED:-}" + local max_removed="${MAX_REMOVED:-}" + local max_version_changed="${MAX_VERSION_CHANGED:-}" local fail_on="${FAIL_ON:-none}" local baseline="${BASELINE:-}" @@ -335,10 +339,29 @@ main() { if [ -n "$baseline" ]; then baseline_args=(--baseline "$baseline") fi + local config_args=() + if [ -n "$config_path" ]; then + config_args=(--config "$config_path") + fi + local focus_args=() + if [ "$findings_only" = "true" ]; then + focus_args=(--findings-only) + fi + local budget_args=() + if [ -n "$max_added" ]; then + budget_args+=(--max-added "$max_added") + fi + if [ -n "$max_removed" ]; then + budget_args+=(--max-removed "$max_removed") + fi + if [ -n "$max_version_changed" ]; then + budget_args+=(--max-version-changed "$max_version_changed") + fi set +e run_diff "$bin" "$before" "$after" "$output_format" "$input_format" \ - "${fail_on_args[@]}" "${baseline_args[@]}" \ + "${config_args[@]}" "${fail_on_args[@]}" "${baseline_args[@]}" \ + "${focus_args[@]}" "${budget_args[@]}" \ | tee "$out_file" rc="${PIPESTATUS[0]}" set -e @@ -369,7 +392,8 @@ main() { summary_file="$(mktemp)" set +e run_diff "$bin" "$before" "$after" "$output_format" "$input_format" \ - "${fail_on_args[@]}" "${baseline_args[@]}" --summary-only > "$summary_file" + "${config_args[@]}" "${fail_on_args[@]}" "${baseline_args[@]}" \ + "${focus_args[@]}" "${budget_args[@]}" --summary-only > "$summary_file" set -e body="$(cat "$summary_file")" rm -f "$summary_file" @@ -378,9 +402,9 @@ main() { post_pr_comment "$body" fi - # bomdrift exit 2 means --fail-on tripped; surface that to the runner so - # the workflow step fails as the consumer requested. Other non-zero codes - # are bomdrift bugs / parse errors and should also propagate. + # bomdrift exit 2 means a configured policy gate tripped; surface that to + # the runner so the workflow step fails as the consumer requested. Other + # non-zero codes are bomdrift bugs / parse errors and should also propagate. exit "$rc" } diff --git a/src/cli.rs b/src/cli.rs index 00214c8..bf12a92 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::Deserialize; use crate::model::SbomFormat; @@ -37,6 +38,19 @@ pub enum Command { #[command(subcommand)] action: BaselineAction, }, + /// Scaffold bomdrift config and GitHub Actions workflows in this repo. + Init(InitArgs), +} + +#[derive(Args, Debug)] +pub struct InitArgs { + /// Overwrite existing generated files. + #[arg(long)] + pub force: bool, + + /// Only write `.bomdrift.toml`; skip GitHub workflow files. + #[arg(long)] + pub config_only: bool, } #[derive(Subcommand, Debug)] @@ -115,12 +129,17 @@ pub struct DiffArgs { pub before: PathBuf, /// Path to the "after" SBOM (CycloneDX, SPDX, or Syft JSON). pub after: PathBuf, - /// Output format. - #[arg(long, value_enum, default_value_t = OutputFormat::Terminal)] - pub output: OutputFormat, - /// Force input format detection. - #[arg(long, value_enum, default_value_t = InputFormat::Auto)] - pub format: InputFormat, + /// Path to a repo policy config file. When omitted, `.bomdrift.toml` is + /// loaded if it exists in the current working directory. + #[arg(long)] + pub config: Option, + /// Output format (default: terminal, unless `.bomdrift.toml` sets one). + #[arg(long, value_enum)] + pub output: Option, + /// Force input format detection (default: auto, unless `.bomdrift.toml` + /// sets one). + #[arg(long, value_enum)] + pub format: Option, /// Skip OSV.dev CVE enrichment (offline mode, faster, deterministic). #[arg(long)] pub no_osv: bool, @@ -143,10 +162,9 @@ pub struct DiffArgs { #[arg(long)] pub no_maintainer_age: bool, /// Exit with code 2 when findings of the configured severity or higher - /// surface. Default `none` is informational-only (always exit 0 on a - /// successful run). - #[arg(long, value_enum, default_value_t = FailOn::None)] - pub fail_on: FailOn, + /// surface (default: none, unless `.bomdrift.toml` sets one). + #[arg(long, value_enum)] + pub fail_on: Option, /// Emit only the summary table (counts per change/finding category) and /// a footer pointing at the full output, omitting every per-category /// section. The PR-comment-friendly form for diffs that would otherwise @@ -156,6 +174,11 @@ pub struct DiffArgs { /// goal is comment-size compression, not data loss). #[arg(long)] pub summary_only: bool, + /// Markdown-only. Omit raw Added / Removed / Version changed detail + /// sections, leaving the summary table plus risk-bearing sections. Useful + /// for PR comments where reviewers only want actionable findings. + #[arg(long)] + pub findings_only: bool, /// Keep `Ecosystem::Other("file")` pseudo-components emitted by Syft's /// directory cataloger. Off by default — the cataloger emits each /// YAML / lockfile / source file in the scanned directory as a synthetic @@ -173,6 +196,15 @@ pub struct DiffArgs { /// use don't render dead links to bomdrift's own issue tracker. #[arg(long)] pub repo_url: Option, + /// Exit 2 when more than this many components are added in one diff. + #[arg(long)] + pub max_added: Option, + /// Exit 2 when more than this many components are removed in one diff. + #[arg(long)] + pub max_removed: Option, + /// Exit 2 when more than this many components change version in one diff. + #[arg(long)] + pub max_version_changed: Option, } /// Threshold for `--fail-on` exit-code-2 behavior. @@ -180,7 +212,8 @@ pub struct DiffArgs { /// Variants are intentionally ordered loosest-to-strictest in their /// declaration order, but the comparison logic in [`crate::tripped`] is /// per-variant rather than ordinal — adding a new variant later is safe. -#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum FailOn { /// Never trip. Default. The diff is informational-only. None, @@ -194,12 +227,15 @@ pub enum FailOn { CriticalCve, /// Trip when at least one typosquat finding is present. Typosquat, + /// Trip when at least one same-version license change is present. + LicenseChange, /// Trip on ANY finding (CVE, typosquat, version-jump, young-maintainer) /// OR any license-changed-without-version-bump pair (the suspicious case). Any, } -#[derive(ValueEnum, Clone, Copy, Debug)] +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum OutputFormat { Terminal, Markdown, @@ -207,7 +243,8 @@ pub enum OutputFormat { Sarif, } -#[derive(ValueEnum, Clone, Copy, Debug)] +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum InputFormat { Auto, Cdx, diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f17ded6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,185 @@ +//! Repository-level policy config (`.bomdrift.toml`). +//! +//! The config supplies defaults for CLI runs and the GitHub Action. CLI flags +//! remain the escape hatch for one-off overrides; boolean config values only +//! turn on positive flags in v0.6 so the CLI surface does not grow a parallel +//! set of `--no-*` negations. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::cli::{DiffArgs, FailOn, InputFormat, OutputFormat}; + +const DEFAULT_CONFIG_PATH: &str = ".bomdrift.toml"; + +#[derive(Debug, Default, Deserialize)] +pub struct Config { + pub diff: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct DiffConfig { + pub output: Option, + pub format: Option, + pub no_osv: Option, + pub no_osv_cache: Option, + pub baseline: Option, + pub no_maintainer_age: Option, + pub fail_on: Option, + pub summary_only: Option, + pub findings_only: Option, + pub include_file_components: Option, + pub repo_url: Option, + pub max_added: Option, + pub max_removed: Option, + pub max_version_changed: Option, +} + +pub fn apply_diff_config(args: &mut DiffArgs) -> Result<()> { + let Some(config) = load_config(args.config.as_deref())? else { + return Ok(()); + }; + + apply_loaded_diff_config(args, config); + Ok(()) +} + +fn apply_loaded_diff_config(args: &mut DiffArgs, config: Config) { + let Some(diff) = config.diff else { + return; + }; + + if args.output.is_none() { + args.output = diff.output; + } + if args.format.is_none() { + args.format = diff.format; + } + args.no_osv |= diff.no_osv.unwrap_or(false); + args.no_osv_cache |= diff.no_osv_cache.unwrap_or(false); + if args.baseline.is_none() { + args.baseline = diff.baseline; + } + args.no_maintainer_age |= diff.no_maintainer_age.unwrap_or(false); + if args.fail_on.is_none() { + args.fail_on = diff.fail_on; + } + args.summary_only |= diff.summary_only.unwrap_or(false); + args.findings_only |= diff.findings_only.unwrap_or(false); + args.include_file_components |= diff.include_file_components.unwrap_or(false); + if args.repo_url.is_none() { + args.repo_url = diff.repo_url.filter(|s| !s.is_empty()); + } + if args.max_added.is_none() { + args.max_added = diff.max_added; + } + if args.max_removed.is_none() { + args.max_removed = diff.max_removed; + } + if args.max_version_changed.is_none() { + args.max_version_changed = diff.max_version_changed; + } +} + +fn load_config(explicit: Option<&Path>) -> Result> { + let path = match explicit { + Some(path) => path.to_path_buf(), + None => { + let default = PathBuf::from(DEFAULT_CONFIG_PATH); + if !default.exists() { + return Ok(None); + } + default + } + }; + + let raw = fs::read_to_string(&path) + .with_context(|| format!("reading bomdrift config: {}", path.display()))?; + let config = toml::from_str(&raw) + .with_context(|| format!("parsing bomdrift config: {}", path.display()))?; + Ok(Some(config)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::DiffArgs; + + fn args() -> DiffArgs { + DiffArgs { + before: "before.json".into(), + after: "after.json".into(), + config: None, + output: None, + format: None, + no_osv: false, + no_osv_cache: false, + baseline: None, + no_maintainer_age: false, + fail_on: None, + summary_only: false, + findings_only: false, + include_file_components: false, + repo_url: None, + max_added: None, + max_removed: None, + max_version_changed: None, + } + } + + #[test] + fn parses_diff_config() { + let parsed: Config = toml::from_str( + r#" + [diff] + output = "markdown" + format = "cdx" + fail_on = "license-change" + baseline = ".bomdrift/baseline.json" + no_osv = true + findings_only = true + max_added = 10 + "#, + ) + .expect("valid config"); + let diff = parsed.diff.expect("diff section"); + assert_eq!(diff.output, Some(OutputFormat::Markdown)); + assert_eq!(diff.format, Some(InputFormat::Cdx)); + assert_eq!(diff.fail_on, Some(FailOn::LicenseChange)); + assert_eq!( + diff.baseline, + Some(PathBuf::from(".bomdrift/baseline.json")) + ); + assert_eq!(diff.no_osv, Some(true)); + assert_eq!(diff.findings_only, Some(true)); + assert_eq!(diff.max_added, Some(10)); + } + + #[test] + fn merge_keeps_explicit_cli_values() { + let mut args = args(); + args.output = Some(OutputFormat::Json); + args.fail_on = Some(FailOn::Typosquat); + args.baseline = Some("cli-baseline.json".into()); + let diff = DiffConfig { + output: Some(OutputFormat::Markdown), + fail_on: Some(FailOn::CriticalCve), + baseline: Some("config-baseline.json".into()), + findings_only: Some(true), + max_added: Some(5), + ..Default::default() + }; + + let config = Config { diff: Some(diff) }; + apply_loaded_diff_config(&mut args, config); + + assert_eq!(args.output, Some(OutputFormat::Json)); + assert_eq!(args.fail_on, Some(FailOn::Typosquat)); + assert_eq!(args.baseline, Some(PathBuf::from("cli-baseline.json"))); + assert!(args.findings_only); + assert_eq!(args.max_added, Some(5)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5094555..0850d2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod baseline; pub mod cli; +pub mod config; pub mod diff; pub mod enrich; pub mod model; @@ -13,7 +14,7 @@ use std::path::Path; use anyhow::{Context, Result}; -use crate::cli::{BaselineAction, Cli, Command, DiffArgs, FailOn, OutputFormat}; +use crate::cli::{BaselineAction, Cli, Command, DiffArgs, FailOn, InitArgs, OutputFormat}; use crate::diff::ChangeSet; use crate::enrich::{Enrichment, Severity}; @@ -27,9 +28,42 @@ pub fn run(cli: Cli) -> Result<()> { Command::Diff(args) => run_diff(args), Command::RefreshTyposquat(args) => refresh::run(args), Command::Baseline { action } => run_baseline(action), + Command::Init(args) => run_init(args), } } +fn run_init(args: InitArgs) -> Result<()> { + write_scaffold_file(Path::new(".bomdrift.toml"), INIT_CONFIG, args.force)?; + if !args.config_only { + write_scaffold_file( + Path::new(".github/workflows/sbom-diff.yml"), + INIT_SBOM_WORKFLOW, + args.force, + )?; + write_scaffold_file( + Path::new(".github/workflows/bomdrift-suppress.yml"), + INIT_SUPPRESS_WORKFLOW, + args.force, + )?; + } + eprintln!("bomdrift: initialized repository files"); + Ok(()) +} + +fn write_scaffold_file(path: &Path, contents: &str, force: bool) -> Result<()> { + if path.exists() && !force { + anyhow::bail!( + "{} already exists; re-run with --force to overwrite", + path.display() + ); + } + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent) + .with_context(|| format!("creating parent directory: {}", parent.display()))?; + } + fs::write(path, contents).with_context(|| format!("writing scaffold file: {}", path.display())) +} + fn run_baseline(action: BaselineAction) -> Result<()> { match action { BaselineAction::Add(args) => { @@ -55,8 +89,14 @@ fn run_baseline(action: BaselineAction) -> Result<()> { } } -fn run_diff(args: DiffArgs) -> Result<()> { - let format_hint = args.format.to_sbom_format(); +fn run_diff(mut args: DiffArgs) -> Result<()> { + config::apply_diff_config(&mut args)?; + + let output = args.output.unwrap_or(OutputFormat::Terminal); + let format = args.format.unwrap_or(cli::InputFormat::Auto); + let fail_on = args.fail_on.unwrap_or(FailOn::None); + + let format_hint = format.to_sbom_format(); let before = load_sbom(&args.before, format_hint, args.include_file_components)?; let after = load_sbom(&args.after, format_hint, args.include_file_components)?; @@ -117,9 +157,10 @@ fn run_diff(args: DiffArgs) -> Result<()> { .filter(|s| !s.is_empty()); let md_options = render::markdown::Options { summary_only: args.summary_only, + findings_only: args.findings_only, repo_url, }; - let rendered = match args.output { + let rendered = match output { OutputFormat::Terminal => { // ANSI escapes are only safe on a real TTY. Piped/redirected stdout // (e.g. captured by a CI step that posts a PR comment) must stay @@ -141,7 +182,22 @@ fn run_diff(args: DiffArgs) -> Result<()> { // Body must be fully written before we exit-2 — the action's `tee` // wrapper still wants the comment posted even when fail-on trips. - if tripped(&cs, &enrichment, args.fail_on) { + let budget_tripped = budget_tripped( + &cs, + args.max_added, + args.max_removed, + args.max_version_changed, + ); + if budget_tripped { + log_budget_trips( + &cs, + args.max_added, + args.max_removed, + args.max_version_changed, + ); + } + + if tripped(&cs, &enrichment, fail_on) || budget_tripped { std::process::exit(FAIL_ON_EXIT_CODE); } @@ -164,14 +220,107 @@ pub fn tripped(cs: &ChangeSet, e: &Enrichment, threshold: FailOn) -> bool { FailOn::Cve => !e.vulns.is_empty(), FailOn::CriticalCve => any_advisory_at_or_above(e, Severity::High), FailOn::Typosquat => !e.typosquats.is_empty(), + FailOn::LicenseChange => !cs.license_changed.is_empty(), FailOn::Any => e.has_findings() || !cs.license_changed.is_empty(), } } +pub fn budget_tripped( + cs: &ChangeSet, + max_added: Option, + max_removed: Option, + max_version_changed: Option, +) -> bool { + max_added.is_some_and(|max| cs.added.len() > max) + || max_removed.is_some_and(|max| cs.removed.len() > max) + || max_version_changed.is_some_and(|max| cs.version_changed.len() > max) +} + +fn log_budget_trips( + cs: &ChangeSet, + max_added: Option, + max_removed: Option, + max_version_changed: Option, +) { + if let Some(max) = max_added.filter(|max| cs.added.len() > *max) { + eprintln!( + "bomdrift: policy gate tripped: added count {} exceeds --max-added {}", + cs.added.len(), + max + ); + } + if let Some(max) = max_removed.filter(|max| cs.removed.len() > *max) { + eprintln!( + "bomdrift: policy gate tripped: removed count {} exceeds --max-removed {}", + cs.removed.len(), + max + ); + } + if let Some(max) = max_version_changed.filter(|max| cs.version_changed.len() > *max) { + eprintln!( + "bomdrift: policy gate tripped: version-changed count {} exceeds --max-version-changed {}", + cs.version_changed.len(), + max + ); + } +} + fn any_advisory_at_or_above(e: &Enrichment, threshold: Severity) -> bool { e.vulns.values().flatten().any(|v| v.severity >= threshold) } +const INIT_CONFIG: &str = r#"# bomdrift repo policy. +# CLI flags override these defaults for one-off runs. + +[diff] +fail_on = "critical-cve" +baseline = ".bomdrift/baseline.json" +findings_only = false + +# Optional churn budgets. Uncomment to fail the workflow when a PR changes too +# many dependencies at once. +# max_added = 25 +# max_removed = 50 +# max_version_changed = 10 +"#; + +const INIT_SBOM_WORKFLOW: &str = r#"name: SBOM diff + +on: pull_request + +permissions: + contents: read + pull-requests: write + +jobs: + diff: + runs-on: ubuntu-latest + steps: + - uses: Metbcy/bomdrift@v1 + with: + config: .bomdrift.toml +"#; + +const INIT_SUPPRESS_WORKFLOW: &str = r#"name: bomdrift suppress + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + +jobs: + suppress: + if: | + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/bomdrift suppress ') + runs-on: ubuntu-latest + steps: + - uses: Metbcy/bomdrift/comment-suppress@v1 +"#; + fn load_sbom( path: &Path, format_hint: Option, @@ -387,6 +536,25 @@ mod tests { )); } + #[test] + fn fail_on_license_change_trips_only_on_license_changes() { + assert!(tripped( + &cs_with_license_change(), + &Enrichment::default(), + FailOn::LicenseChange + )); + assert!(!tripped( + &ChangeSet::default(), + &enrichment_with_cve(), + FailOn::LicenseChange + )); + assert!(!tripped( + &ChangeSet::default(), + &enrichment_with_typosquat(), + FailOn::LicenseChange + )); + } + #[test] fn fail_on_typosquat_ignores_license_change() { // license_changed is a ChangeSet field, not an enrichment. The @@ -399,4 +567,18 @@ mod tests { FailOn::Typosquat )); } + + #[test] + fn budget_trips_when_counts_exceed_limits() { + let cs = ChangeSet { + added: vec![comp("a"), comp("b")], + removed: vec![comp("c")], + version_changed: vec![(comp("d"), comp("d"))], + ..Default::default() + }; + assert!(budget_tripped(&cs, Some(1), None, None)); + assert!(budget_tripped(&cs, None, Some(0), None)); + assert!(budget_tripped(&cs, None, None, Some(0))); + assert!(!budget_tripped(&cs, Some(2), Some(1), Some(1))); + } } diff --git a/src/render/markdown.rs b/src/render/markdown.rs index 57e6f96..d2c3973 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -36,6 +36,11 @@ pub struct Options { /// reviewer follows the footer link to the full report (workflow-step /// summary, JSON artifact, etc.) when they need detail. pub summary_only: bool, + /// When true, keep the summary table and risk-bearing sections but omit + /// raw dependency churn detail (Added / Removed / Version changed). This + /// keeps PR comments focused on review decisions while preserving the + /// counts that show how large the dependency change is. + pub findings_only: bool, /// Repository URL — `https://github.com//` form, no /// trailing slash. When supplied, the renderer appends a footer /// linking to a pre-filled "Report this finding" issue and the @@ -103,7 +108,16 @@ pub fn render_with_options(cs: &ChangeSet, enrichment: &Enrichment, opts: Option return out; } - if !cs.added.is_empty() { + if opts.findings_only + && (!cs.added.is_empty() || !cs.removed.is_empty() || !cs.version_changed.is_empty()) + { + out.push_str( + "_Raw dependency churn detail elided (`--findings-only`); risk-bearing \ + sections remain below._\n\n", + ); + } + + if !opts.findings_only && !cs.added.is_empty() { section_open(&mut out, "Added", cs.added.len(), None); out.push_str("| Ecosystem | Name | Version |\n|---|---|---|\n"); for c in &cs.added { @@ -112,7 +126,7 @@ pub fn render_with_options(cs: &ChangeSet, enrichment: &Enrichment, opts: Option section_close(&mut out); } - if !cs.removed.is_empty() { + if !opts.findings_only && !cs.removed.is_empty() { section_open(&mut out, "Removed", cs.removed.len(), None); out.push_str("| Ecosystem | Name | Version |\n|---|---|---|\n"); for c in &cs.removed { @@ -121,7 +135,7 @@ pub fn render_with_options(cs: &ChangeSet, enrichment: &Enrichment, opts: Option section_close(&mut out); } - if !cs.version_changed.is_empty() { + if !opts.findings_only && !cs.version_changed.is_empty() { section_open(&mut out, "Version changed", cs.version_changed.len(), None); out.push_str("| Ecosystem | Name | Before | After |\n|---|---|---|---|\n"); for (b, a) in &cs.version_changed { @@ -631,6 +645,7 @@ mod tests { &e, Options { summary_only: true, + findings_only: false, repo_url: None, }, ); @@ -656,6 +671,7 @@ mod tests { &Enrichment::default(), Options { summary_only: true, + findings_only: false, repo_url: None, }, ); @@ -663,6 +679,49 @@ mod tests { assert!(!out.contains("Per-category detail elided")); } + #[test] + fn findings_only_hides_raw_churn_but_keeps_risk_sections() { + let cs = ChangeSet { + added: vec![comp( + "axios", + "1.14.1", + Ecosystem::Npm, + Some("pkg:npm/axios@1.14.1"), + )], + version_changed: vec![( + comp("left-pad", "1.0.0", Ecosystem::Npm, None), + comp("left-pad", "4.0.0", Ecosystem::Npm, None), + )], + ..Default::default() + }; + let mut e = Enrichment::default(); + e.vulns.insert( + "pkg:npm/axios@1.14.1".to_string(), + vec![crate::enrich::VulnRef { + id: "GHSA-xxxx-yyyy-zzzz".to_string(), + severity: crate::enrich::Severity::High, + }], + ); + + let md = render_with_options( + &cs, + &e, + Options { + summary_only: false, + findings_only: true, + repo_url: None, + }, + ); + + assert!(md.contains("| Added | 1 |")); + assert!(md.contains("| Version changed | 1 |")); + assert!(md.contains("Raw dependency churn detail elided")); + assert!(!md.contains("### Added")); + assert!(!md.contains("### Version changed")); + assert!(md.contains("### Vulnerabilities")); + assert!(md.contains("GHSA-xxxx-yyyy-zzzz")); + } + #[test] fn vulnerability_section_omitted_when_no_findings() { let cs = ChangeSet { @@ -991,6 +1050,7 @@ mod tests { &Enrichment::default(), Options { summary_only: false, + findings_only: false, repo_url: Some("https://github.com/example/proj".to_string()), }, ); @@ -1013,6 +1073,7 @@ mod tests { &Enrichment::default(), Options { summary_only: false, + findings_only: false, repo_url: Some("https://github.com/example/proj/".to_string()), }, ); diff --git a/tests/cli.rs b/tests/cli.rs index 319a00e..0a165da 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2,7 +2,10 @@ //! through the `CARGO_BIN_EXE_` env var. These verify the user-visible //! behavior of `bomdrift diff ` rather than internal API shape. +use std::fs; +use std::path::PathBuf; use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; fn bin() -> &'static str { env!("CARGO_BIN_EXE_bomdrift") @@ -12,6 +15,27 @@ fn manifest_dir() -> &'static str { env!("CARGO_MANIFEST_DIR") } +fn fixture_path(name: &str) -> String { + PathBuf::from(manifest_dir()) + .join("tests/fixtures") + .join(name) + .display() + .to_string() +} + +fn temp_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "bomdrift-cli-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + #[test] fn diff_axios_fixture_pair_prints_pr_comment_markdown() { let out = Command::new(bin()) @@ -522,3 +546,215 @@ fn diff_no_maintainer_age_flag_skips_enricher() { "young-maintainers summary row must not appear when the enricher is skipped" ); } + +#[test] +fn init_config_only_scaffolds_policy_file_without_workflows() { + let dir = temp_dir("init-config-only"); + let out = Command::new(bin()) + .current_dir(&dir) + .args(["init", "--config-only"]) + .output() + .expect("spawn bomdrift"); + + assert!( + out.status.success(), + "exit code: {}\nstderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let config = fs::read_to_string(dir.join(".bomdrift.toml")).expect("read generated config"); + assert!(config.contains("[diff]")); + assert!(config.contains("fail_on = \"critical-cve\"")); + assert!(!dir.join(".github/workflows/sbom-diff.yml").exists()); + + let rerun = Command::new(bin()) + .current_dir(&dir) + .args(["init", "--config-only"]) + .output() + .expect("spawn bomdrift"); + assert!(!rerun.status.success()); + assert!( + String::from_utf8_lossy(&rerun.stderr).contains("already exists"), + "rerun should explain existing file, stderr:\n{}", + String::from_utf8_lossy(&rerun.stderr) + ); + + fs::remove_dir_all(dir).ok(); +} + +#[test] +fn diff_missing_explicit_config_fails_usefully() { + let out = Command::new(bin()) + .current_dir(manifest_dir()) + .args([ + "diff", + "tests/fixtures/cdx-minimal.json", + "tests/fixtures/cdx-after.json", + "--config", + "tests/fixtures/does-not-exist.toml", + "--no-osv", + "--no-maintainer-age", + ]) + .output() + .expect("spawn bomdrift"); + + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("tests/fixtures/does-not-exist.toml")); + assert!(stderr.contains("reading bomdrift config")); +} + +#[test] +fn diff_default_config_can_focus_comment_and_trip_budget() { + let dir = temp_dir("config-budget"); + fs::write( + dir.join(".bomdrift.toml"), + r#" + [diff] + no_osv = true + no_maintainer_age = true + findings_only = true + max_added = 0 + "#, + ) + .expect("write config"); + + let out = Command::new(bin()) + .current_dir(&dir) + .args([ + "diff", + &fixture_path("cdx-minimal.json"), + &fixture_path("cdx-after.json"), + ]) + .output() + .expect("spawn bomdrift"); + + assert_eq!( + out.status.code(), + Some(2), + "budget gate must exit 2; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + assert!(stdout.contains("| Added | 1 |")); + assert!(stdout.contains("Raw dependency churn detail elided")); + assert!(!stdout.contains("### Added")); + assert!(stdout.contains("### Possible typosquats")); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("--max-added 0")); + + fs::remove_dir_all(dir).ok(); +} + +#[test] +fn diff_explicit_flag_overrides_config_default() { + let dir = temp_dir("config-override"); + fs::write( + dir.join(".bomdrift.toml"), + r#" + [diff] + output = "json" + no_osv = true + no_maintainer_age = true + "#, + ) + .expect("write config"); + + let out = Command::new(bin()) + .current_dir(&dir) + .args([ + "diff", + &fixture_path("cdx-minimal.json"), + &fixture_path("cdx-after.json"), + "--output", + "markdown", + ]) + .output() + .expect("spawn bomdrift"); + + assert!( + out.status.success(), + "exit code: {}\nstderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + assert!(stdout.starts_with("## SBOM diff")); + assert!( + serde_json::from_str::(&stdout).is_err(), + "explicit --output markdown should override config output=json" + ); + + fs::remove_dir_all(dir).ok(); +} + +#[test] +fn diff_fail_on_license_change_exits_2() { + let dir = temp_dir("license-change"); + let before = dir.join("before.json"); + let after = dir.join("after.json"); + fs::write( + &before, + r#"{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { + "type": "library", + "name": "pkg", + "version": "1.0.0", + "purl": "pkg:npm/pkg@1.0.0", + "licenses": [{"license": {"id": "MIT"}}] + } + ] + }"#, + ) + .expect("write before fixture"); + fs::write( + &after, + r#"{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "components": [ + { + "type": "library", + "name": "pkg", + "version": "1.0.0", + "purl": "pkg:npm/pkg@1.0.0", + "licenses": [{"license": {"id": "GPL-3.0"}}] + } + ] + }"#, + ) + .expect("write after fixture"); + + let out = Command::new(bin()) + .current_dir(&dir) + .args([ + "diff", + before.to_str().expect("utf-8 before path"), + after.to_str().expect("utf-8 after path"), + "--no-osv", + "--no-maintainer-age", + "--fail-on", + "license-change", + ]) + .output() + .expect("spawn bomdrift"); + + assert_eq!( + out.status.code(), + Some(2), + "license-change gate must exit 2; stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + assert!(stdout.contains("### License changed (same version)")); + assert!(stdout.contains("MIT")); + assert!(stdout.contains("GPL-3.0")); + + fs::remove_dir_all(dir).ok(); +}