diff --git a/.github/ISSUE_TEMPLATE/action-broke.md b/.github/ISSUE_TEMPLATE/action-broke.md index c8f28bf..3b4c604 100644 --- a/.github/ISSUE_TEMPLATE/action-broke.md +++ b/.github/ISSUE_TEMPLATE/action-broke.md @@ -36,6 +36,6 @@ failure is usually obvious if you expand all groups. --> ## Environment -- **bomdrift version pin**: `@v1` / `@v0.6.1` / `@` +- **bomdrift version pin**: `@v1` / `@v0.7.0` / `@` - **Runner**: - **Trigger event**: diff --git a/CHANGELOG.md b/CHANGELOG.md index effa53e..320474b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,96 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.0] - 2026-04-30 + +The "broaden the platform, polish the edges" milestone. v0.7 takes the +v0.6 policy-config foundation and adds GitLab CI as a first-class +target, closes the open issue backlog around adoption pain points, +and lays groundwork for calibration tuning. + +### Added + +- **GitLab CI integration.** `bomdrift diff` now renders a + GitLab-shaped MR-note footer when `--platform gitlab` is set (or + when `GITLAB_CI=true` is auto-detected). A copy-paste-ready + template ships under `examples/gitlab-ci/` with a diff job + (curl + jq upsert against the GitLab notes API), a manual + suppression job, and a setup README covering the two-token model + and Self-Managed considerations. Full chapter at + [docs/src/gitlab-ci.md](docs/src/gitlab-ci.md). + +- **`--platform ` flag** on `bomdrift diff` (also + loadable from `[diff] platform = "..."` in `.bomdrift.toml`). + Default is auto-detection from the CI environment with `github` + as the fallback. Explicit flag always wins. + +- **CI auto-detection on GitLab.** When `GITLAB_CI=true` is set, + bomdrift selects the GitLab footer shape; when `CI_PROJECT_URL` + is set and `--repo-url` / `BOMDRIFT_REPO_URL` are unset, it's + used as the footer link target. + +- **`--debug-calibration` flag.** Off by default. When set, the + diff command emits one pipe-delimited record per finding to + stderr (`kind|key|score|threshold`). Lets adopters dump a + calibration sample across many PRs and feed back tuning data on + `SIMILARITY_THRESHOLD`, `YOUNG_MAINTAINER_DAYS`, etc. No + telemetry — the user owns the file. + +- **Typosquat top-package data top-up** for Go (+35 entries from + CNCF / HashiCorp / gRPC ecosystem / awesome-go), Composer (+43 + from Symfony / Laravel / Doctrine / testing communities), and + Gem (+44 from Rails ecosystem / dry-rb / API serializers). + Closes #6, #7, #8. + +### Changed + +- **Markdown renderer is now platform-aware.** Backward compatible: + the GitHub footer shape is preserved byte-for-byte for existing + callers. New `MarkdownOpts.platform` field controls the + switch; `Default` resolves to GitHub. + +- **Better "scan path not found" error in the GitHub Action.** + `entrypoint.sh` now lists what was actually checked out and + links to the new monorepo docs section instead of the prior + one-line "no such file" error. Closes #11. + +### Docs + +- **GitLab CI chapter** (`docs/src/gitlab-ci.md`) — quickstart, + token model, suppression paths, Self-Managed notes, what's + scoped out for v0.7. +- **False-positive triage worked example** in + `docs/src/baseline.md` (a typosquat misfire with the exact + baseline entry that suppresses it). Closes #12. +- **Monorepo setup section** in `docs/src/github-action.md` + covering matrix-per-service patterns and shared-baseline + pattern. Closes #9. +- **Action-broke troubleshooting checklist** in + `docs/src/github-action.md` covering the top-N failure modes. + Closes #13. +- **CLI reference** updated for `--platform`, + `--debug-calibration`, and the new `BOMDRIFT_REPO_URL` / + `GITLAB_CI` / `CI_PROJECT_URL` environment variables. + +### Tests + +- Regression test for `BOMDRIFT_REPO_URL` env-var → footer URL + plumbing (#10) — previously only the rendering function was + unit-tested, not the env-to-option plumbing in `lib.rs`. +- E2E tests for `GITLAB_CI` auto-detection and `--platform` + override. +- Smoke test for `--debug-calibration` stderr output. + +### Scope notes + +In-comment suppression on GitLab (`/bomdrift suppress ` in an +MR note) is **deferred to v0.8**. GitLab note webhooks have a +different model than GitHub PR comments, and the safe wiring +(rate-limit, fork-MR safety, command parsing, double-trigger +debouncing) is materially harder. v0.7 ships the manual-job path +in `examples/gitlab-ci/suppress.gitlab-ci.yml`, which covers the +same user need without standing up a webhook handler. + ## [0.6.1] - 2026-04-29 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 158cb90..b23f056 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,7 +114,7 @@ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bomdrift" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d3ddf1e..04f4f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bomdrift" -version = "0.6.1" +version = "0.7.0" edition = "2024" rust-version = "1.85" description = "SBOM diff with supply-chain risk signals (CVEs, typosquats, maintainer-age)." diff --git a/README.md b/README.md index e3a673a..8eeada7 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ jobs: # verify-signatures: true (set false on trusted mirrors) ``` -Pin to `@v1` for the latest v0.x; pin to `@v0.6.1` 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. +Pin to `@v1` for the latest v0.x; pin to `@v0.7.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+) @@ -112,7 +112,7 @@ Comment `/bomdrift suppress GHSA-xxxx` on any PR; the sub-action appends to `.bo Pre-built binaries cover Linux x86_64 + aarch64, macOS aarch64, and Windows x86_64. Each archive is cosign-signed via Sigstore + GitHub OIDC. ```bash -VERSION=v0.6.1 +VERSION=v0.7.0 TARGET=x86_64-unknown-linux-gnu curl -sSL -o bomdrift.tar.gz \ "https://github.com/Metbcy/bomdrift/releases/download/${VERSION}/bomdrift-${VERSION}-${TARGET}.tar.gz" @@ -128,7 +128,7 @@ Verify the archive's signature before you trust the binary — see [Release sign ### From source ```bash -cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.6.1 bomdrift +cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.7.0 bomdrift ``` Requires Rust 1.85+ (the project uses edition 2024). @@ -230,7 +230,7 @@ Every release archive is signed with cosign keyless via Sigstore (GitHub OIDC). ```bash # Replace VERSION + TARGET with your downloaded archive's pair -VERSION=v0.6.1 +VERSION=v0.7.0 TARGET=x86_64-unknown-linux-gnu ARCHIVE=bomdrift-${VERSION}-${TARGET}.tar.gz diff --git a/STATUS.md b/STATUS.md index f69576b..5a21fa1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -11,9 +11,10 @@ keeping the project OSS-first: no hosted dashboard, no account, no telemetry. | GitHub.com pull requests | Supported through `Metbcy/bomdrift@v1` | | Local CLI | Supported on Linux x86_64/aarch64, macOS aarch64, Windows x86_64 | | SBOM formats | CycloneDX JSON, SPDX JSON, Syft JSON | -| In-comment suppression | Supported through `Metbcy/bomdrift/comment-suppress@v1` | +| In-comment suppression (GitHub) | Supported through `Metbcy/bomdrift/comment-suppress@v1` | +| GitLab CI merge requests | Supported through the `examples/gitlab-ci/` template (v0.7+); in-comment suppression deferred to v0.8 | | GitHub Enterprise / self-hosted runners | Expected to work, not broadly tested yet | -| GitLab / Bitbucket | Not supported | +| Bitbucket | Not supported | | Hosted dashboard / SaaS | Not planned | ## Known limitations diff --git a/data/composer-top200.txt b/data/composer-top200.txt index 290fb4f..26bafc0 100644 --- a/data/composer-top200.txt +++ b/data/composer-top200.txt @@ -136,3 +136,56 @@ roots/wordpress wpackagist-plugin/jetpack wpackagist-plugin/wordpress-seo wpackagist-plugin/akismet + +# --- v0.7 top-up: Symfony components (source: packagist.org top installs) --- +symfony/security-bundle +symfony/security-core +symfony/form +symfony/messenger +symfony/mailer +symfony/framework-bundle +symfony/options-resolver +symfony/config +symfony/dotenv +symfony/error-handler +symfony/css-selector +symfony/dom-crawler +symfony/expression-language + +# --- v0.7 top-up: Laravel ecosystem (source: packagist.org top installs) --- +laravel/sail +laravel/pint +laravel/telescope +laravel/octane +laravel/dusk +laravel/serializable-closure + +# --- v0.7 top-up: Doctrine (source: packagist.org top installs) --- +doctrine/migrations +doctrine/doctrine-bundle +doctrine/persistence +doctrine/event-manager +doctrine/deprecations + +# --- v0.7 top-up: testing & static analysis (source: packagist.org top installs) --- +behat/behat +phpspec/phpspec +infection/infection +rector/rector +phpmd/phpmd + +# --- v0.7 top-up: misc popular (source: packagist.org top installs) --- +nesbot/carbon +predis/predis +intervention/image +aws/aws-sdk-php +vlucas/phpdotenv +erusev/parsedown +dompdf/dompdf +phpoffice/phpspreadsheet +maatwebsite/excel +barryvdh/laravel-debugbar +barryvdh/laravel-ide-helper +league/fractal +league/route +mtdowling/jmespath.php diff --git a/data/gem-top200.txt b/data/gem-top200.txt index a6c6f86..9cc742d 100644 --- a/data/gem-top200.txt +++ b/data/gem-top200.txt @@ -183,3 +183,63 @@ good_job solid_queue mailcatcher resque-scheduler + +# --- v0.7 top-up: Rails ecosystem extras (source: rubygems.org top downloads) --- +jbuilder +bootsnap +spring +web-console +rack-cors +responders +friendly_id +aasm +annotate +letter_opener +rails-html-sanitizer +rails-i18n + +# --- v0.7 top-up: testing (source: rubygems.org top downloads) --- +minitest +minitest-reporters +shoulda-matchers +timecop +pry-rails +pry-doc + +# --- v0.7 top-up: dotenv / config (source: rubygems.org top downloads) --- +dotenv +dotenv-rails +foreman + +# --- v0.7 top-up: API / serializers (source: rubygems.org top downloads) --- +jsonapi-serializer +active_model_serializers +alba +grape +grape-entity +rswag + +# --- v0.7 top-up: cache / jobs / KV (source: rubygems.org top downloads) --- +connection_pool +dalli +redis-namespace +sneakers +sucker_punch + +# --- v0.7 top-up: search / admin (source: rubygems.org top downloads) --- +searchkick +pg_search +activeadmin +administrate + +# --- v0.7 top-up: dry-rb / utility (source: rubygems.org top downloads) --- +dry-validation +dry-types +dry-struct +dry-monads + +# --- v0.7 top-up: documents / files (source: rubygems.org top downloads) --- +prawn +caxlsx +roo +pdf-reader diff --git a/data/go-top200.txt b/data/go-top200.txt index fd4e14f..19a41f1 100644 --- a/data/go-top200.txt +++ b/data/go-top200.txt @@ -141,3 +141,48 @@ github.com/cli/cli github.com/charmbracelet/bubbletea github.com/charmbracelet/lipgloss github.com/charmbracelet/glamour + +# --- v0.7 top-up: CNCF / containers (source: cncf.io graduated + sandbox lists) --- +github.com/containerd/containerd +github.com/opencontainers/runc +github.com/opencontainers/image-spec +github.com/argoproj/argo-cd +github.com/fluxcd/flux2 +github.com/istio/istio +github.com/cilium/cilium +github.com/grafana/grafana +github.com/grafana/loki +github.com/jaegertracing/jaeger +github.com/open-telemetry/opentelemetry-collector +github.com/thanos-io/thanos +github.com/minio/minio +github.com/minio/minio-go + +# --- v0.7 top-up: HashiCorp (source: github.com/hashicorp top repos) --- +github.com/hashicorp/terraform +github.com/hashicorp/vault +github.com/hashicorp/consul +github.com/hashicorp/nomad +github.com/hashicorp/hcl +github.com/hashicorp/raft + +# --- v0.7 top-up: gRPC ecosystem (source: github.com/grpc-ecosystem) --- +github.com/grpc-ecosystem/grpc-gateway +github.com/grpc-ecosystem/go-grpc-middleware +github.com/twitchtv/twirp + +# --- v0.7 top-up: data / search / storage (source: awesome-go databases) --- +github.com/elastic/go-elasticsearch +github.com/cockroachdb/cockroach +github.com/cockroachdb/pebble +github.com/influxdata/influxdb +github.com/syndtr/goleveldb + +# --- v0.7 top-up: utility / awesome-go (source: awesome-go popular libs) --- +github.com/klauspost/compress +github.com/cespare/xxhash +github.com/yuin/goldmark +github.com/PuerkitoBio/goquery +github.com/google/cel-go +go.uber.org/goleak +github.com/DATA-DOG/go-sqlmock diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index b8599a7..74b4ae8 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -6,6 +6,7 @@ - [Quickstart](./quickstart.md) - [GitHub Action](./github-action.md) +- [GitLab CI](./gitlab-ci.md) - [CLI reference](./cli-reference.md) # Output diff --git a/docs/src/baseline.md b/docs/src/baseline.md index 121f7a6..955a13d 100644 --- a/docs/src/baseline.md +++ b/docs/src/baseline.md @@ -184,3 +184,113 @@ When this fails on a new finding, the maintainer either: *for now*", not "ignore this entire class". - **For findings you'll fix in the next PR.** A baseline is a long-lived artifact; for one-PR exceptions, just upgrade the dep. + +## Worked example: triaging a false positive + +Real-world false positives are the most common reason adopters reach +for the baseline. A typical case looks like this on a PR: + +> **🚨 Typosquat candidate** — new dependency `colour-print` is within +> Levenshtein distance 1 of well-known package `colorprint`. Review +> for impersonation. + +In our example, `colour-print` is a deliberate British-English spelling +maintained by a long-trusted internal team — this is the canonical +"signal that's true in the abstract, wrong for our codebase" case. The +Levenshtein heuristic *should* fire on this; what's wrong is the +verdict, not the detection. Suppressing the whole `typosquat` class +(via `--fail-on cve`) loses coverage on actually-malicious squats; a +wildcard config field would over-suppress; what we want is exactly +this finding suppressed. + +### Step 1 — capture the current finding shape + +Before deciding what to suppress, see what bomdrift saw. Run with +`--output json` and pull out the typosquat finding: + +```bash +bomdrift diff before.json after.json --output json \ + | jq '.enrichment.typosquat[] | select(.purl | contains("colour-print"))' +``` + +Output: + +```json +{ + "purl": "pkg:npm/colour-print@2.1.0", + "candidate_for": "colorprint", + "distance": 1, + "ecosystem": "npm" +} +``` + +The `purl_with_version` here is `pkg:npm/colour-print@2.1.0` — the +match key for the typosquat entry per the table above. + +### Step 2 — write a per-component baseline entry + +Edit `.bomdrift/baseline.json` (the file `bomdrift init` scaffolds, or +whatever path you pass to `--baseline`). The diff-output JSON shape +takes precedence, so a hand-written entry uses the same fields the +JSON output produces: + +```json +{ + "suppressed_advisories": [], + "findings": { + "typosquat": [ + { + "purl": "pkg:npm/colour-print@2.1.0", + "candidate_for": "colorprint", + "ecosystem": "npm", + "_note": "British-English spelling, owned by team-foo since 2019. Re-evaluate on major-version bump." + } + ] + } +} +``` + +The `_note` field is an underscore-prefixed extension; bomdrift +preserves unknown fields verbatim on round-trip and never reads them +back, so it's a safe place to capture the *why*. Future maintainers +who read the baseline see the rationale without spelunking through +git blame. + +### Step 3 — verify the suppression takes effect + +Re-run the diff with the baseline applied: + +```bash +bomdrift diff before.json after.json \ + --baseline .bomdrift/baseline.json +``` + +The `colour-print` finding is gone; everything else (including any +*other* typosquat candidate that shows up the same week) still +surfaces. That's the trade-off: a precise hand-written entry beats a +wildcard or a class-wide opt-out, because the next typosquat against a +new package still trips the gate. + +### Why a hand-edited entry beats `--fail-on` tuning + +It's tempting to "just" loosen `--fail-on typosquat` to `--fail-on +critical-cve`. Don't: + +- The typosquat enricher is your earliest signal for malicious + packages — a real squat (`colorize` impersonating `colorise`) is + caught here before the OSV.dev advisory exists. +- A baseline entry is auditable: `git log .bomdrift/baseline.json` + shows when this exception was made and by whom. +- A wildcard config setting (e.g., a hypothetical + `[diff.typosquat] allow_distance_1 = true`) would also suppress + unrelated future squats. Per-component is the smallest possible + exception that still fixes this one PR. + +### When the bump is the false positive + +Sometimes the finding is a multi-major version jump on a package you +*expect* to leap (a calver-style release schedule, a coordinated +ecosystem-wide bump). The same per-component recipe works — replace +the `typosquat` array with `version_jump`, key by the after-version's +`purl`. Update the entry on the next jump. + diff --git a/docs/src/cli-reference.md b/docs/src/cli-reference.md index 95c6a33..a38f06b 100644 --- a/docs/src/cli-reference.md +++ b/docs/src/cli-reference.md @@ -88,7 +88,33 @@ 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`. +`platform`, `max_added`, `max_removed`, and `max_version_changed`. + +### Forge / CI integration + +#### `--platform ` + +`github` (default) or `gitlab`. Drives the rendered markdown +comment's footer: + +- `github` — `/issues/new?...` URL shape, `/bomdrift suppress ` + comment-driven flow (requires the [comment-suppress + sub-action](./baseline.md#in-comment-suppression-v05)). +- `gitlab` — `/-/issues/new?issuable_template=false-positive` URL + shape, points reviewers at `bomdrift baseline add ` instead + (the v0.5 `/bomdrift suppress` comment-driven flow on GitLab is + deferred to v0.8). + +When the flag is omitted, bomdrift auto-detects from CI environment +variables: `GITLAB_CI=true` flips to GitLab; otherwise GitHub. The +explicit flag always wins. Also configurable via `[diff] platform = +"gitlab"` in `.bomdrift.toml`. + +Set in lockstep with `--repo-url` (or `BOMDRIFT_REPO_URL`, or — on +GitLab CI — `CI_PROJECT_URL`). Without a URL the footer is omitted +entirely; the platform flag controls only the footer's *shape*. + +See [GitLab CI](./gitlab-ci.md) for the full template. ### Enrichment flags @@ -196,6 +222,39 @@ Refreshed lists are written to `/bomdrift/typosquat/.txt` via temp-file + atomic rename. The typosquat enricher prefers cache files over the embedded snapshot when present and parseable. +## Calibration + +#### `--debug-calibration` + +Off by default. When set, `bomdrift diff` writes one +pipe-delimited line to stderr per finding it considers, with the +schema: + +``` +kind|key|score|threshold +``` + +`kind` is one of `typosquat`, `version-jump`, `maintainer-age`, or +`cve`. `key` is a stable identifier (the package purl, advisory ID, +etc.). `score` and `threshold` are the numeric inputs to the +gating decision — for `cve` the score column carries the severity +bucket label rather than a numeric CVSS score (bomdrift doesn't +parse CVSS numerically). + +Pipe-delimited because purls contain commas. The flag is purely +diagnostic — it doesn't change which findings get rendered. Pipe +to a file: + +```bash +bomdrift diff old.cdx.json new.cdx.json --debug-calibration 2> calibration.tsv +``` + +If you collect a calibration sample across many PRs and have a +hunch on a better default for `SIMILARITY_THRESHOLD` / +`YOUNG_MAINTAINER_DAYS`, please share on issue +[#5](https://github.com/Metbcy/bomdrift/issues/5) — there is no +telemetry; you own the file. + ## Exit codes | Code | Meaning | @@ -210,6 +269,9 @@ over the embedded snapshot when present and parseable. | Variable | Purpose | |---|---| | `GITHUB_TOKEN` | Bumps the GitHub REST rate limit from 60/hr unauth to 5000/hr authenticated, used by the maintainer-age enricher. | +| `BOMDRIFT_REPO_URL` | Fallback for `--repo-url` when the flag isn't passed. Used to render the comment footer's "Report this finding" / "Suppress" links. | +| `GITLAB_CI` | When `true`, auto-selects `--platform gitlab` (unless overridden). | +| `CI_PROJECT_URL` | On GitLab CI, used as a final fallback for `--repo-url` after `BOMDRIFT_REPO_URL`. | | `XDG_CACHE_HOME` | Cache root for the OSV severity cache and the refreshed typosquat lists. Defaults to `~/.cache` on Linux. | | `NO_COLOR` | Honored by the terminal renderer; falls back to plain output. | | `CLICOLOR_FORCE` | Honored by the terminal renderer; forces ANSI even on a non-TTY. | diff --git a/docs/src/enrichers/typosquat.md b/docs/src/enrichers/typosquat.md index 279baec..141c939 100644 --- a/docs/src/enrichers/typosquat.md +++ b/docs/src/enrichers/typosquat.md @@ -130,10 +130,18 @@ Embedded snapshots ship in the binary: | `data/pypi-top200.txt` | [hugovk/top-pypi-packages](https://hugovk.github.io/top-pypi-packages/) | 200 | | `data/cargo-top200.txt` | crates.io API `?sort=downloads` | 200 | | `data/maven-top100.txt` | mvnrepository.com Most Popular (curated) | ~100 | -| `data/go-top200.txt` | pkg.go.dev + awesome-go (curated) | ~140 | -| `data/gem-top200.txt` | rubygems.org popular gems (curated) | ~185 | +| `data/go-top200.txt` | pkg.go.dev + awesome-go (curated) | ~180 | +| `data/gem-top200.txt` | rubygems.org popular gems (curated) | ~245 | | `data/nuget-top200.txt` | nuget.org v3 search API `?orderby=totalDownloads` | 200 | -| `data/composer-top200.txt` | packagist.org popular categories (curated) | ~140 | +| `data/composer-top200.txt` | packagist.org popular categories (curated) | ~190 | + +v0.7 expanded the curated Go, Composer, and Gem lists — the +ship-with-binary snapshots now cover the CNCF / HashiCorp / gRPC- +ecosystem corners of Go, the Symfony / Laravel / Doctrine / +testing / Packagist-popular tail of Composer, and the Rails / +dry-rb / serializer / search corners of RubyGems. Each top-up is +grouped under a `# --- v0.7 top-up: (source: ...) ---` +header in the data file so future curators can see provenance. Lists are intentionally smaller than `npm-top1k.txt` for the multi- ecosystem ships (v0.2 + v0.4): the algorithm is identical across diff --git a/docs/src/github-action.md b/docs/src/github-action.md index 54cbdbf..a9d971a 100644 --- a/docs/src/github-action.md +++ b/docs/src/github-action.md @@ -234,3 +234,114 @@ When the zero-config flow runs (no explicit `before-sbom` / `after-sbom`): The new behavior costs about 30 MB of one-time tool cache and 3–5s of cold-cache wall time per first invocation. Subsequent runs in the same job (or in repos that share the runner's tool cache) reuse Syft. + +## Monorepo setup + +When a single repo owns N services with independent dependency trees +(`services/api`, `services/worker`, `apps/web`, ...), running one +bomdrift job per service gives each PR a focused, per-service comment +without merging unrelated diff churn into a single 65k-char wall. + +### Pattern A — `path:` per matrix entry + +The simplest setup uses a job matrix and the action's `path` input: + +```yaml +on: pull_request +permissions: + contents: read + pull-requests: write +jobs: + diff: + strategy: + fail-fast: false + matrix: + service: [api, worker, web] + runs-on: ubuntu-latest + steps: + - uses: Metbcy/bomdrift@v1 + with: + path: services/${{ matrix.service }} + fail-on: critical-cve +``` + +Each matrix leg posts (or upserts) **its own PR comment**, distinguished +by the rendered title (e.g. "SBOM diff — services/api"). The +`` upsert marker is namespaced internally by +`path:`, so leg N's comment doesn't clobber leg N-1's. + +`fail-fast: false` is recommended: a vulnerability in `worker` shouldn't +hide an emergent `api` finding from the same PR. + +### Pattern B — share a baseline across services + +Most monorepos *do* want one shared exception list (the same false +positive will show up in any service that depends on the same +package). Point each leg at the same file: + +```yaml +- uses: Metbcy/bomdrift@v1 + with: + path: services/${{ matrix.service }} + baseline: .bomdrift/baseline.json +``` + +The baseline file is keyed by `(purl_with_version, advisory_id)` — see +[Match keys](./baseline.md#match-keys) — so a suppression for +`pkg:npm/colour-print@2.1.0` covers every service that pulls in that +exact version. New versions still surface (intentional; that's the +point of the version-pinned key). + +When services pin different versions of the same dep, you'll get +per-version baseline entries. That's working-as-intended — a known-fine +finding at v1.0.0 should still get a fresh review at v1.1.0. + +### Pattern C — per-service `.bomdrift.toml` + +When the policy itself differs (worker has a stricter `fail-on`, +docs-site has a generous `max-added`), drop a `.bomdrift.toml` per +service: + +```yaml +- uses: Metbcy/bomdrift@v1 + with: + path: services/${{ matrix.service }} + config: services/${{ matrix.service }}/.bomdrift.toml +``` + +The auto-discovery only checks the repo root, so an explicit +`config:` is required for nested files. + +### What to scope per service vs. globally + +| Setting | Scope | Why | +|---|---|---| +| `fail-on`, `max-*` budgets | Per-service | Worker's risk surface ≠ web's | +| `baseline` | **Shared** | Same false positives across services | +| `comment-on-pr`, `output` | Per-service | Diff-only legs vs. PR-comment legs | +| `verify-signatures` | Global | Runner-image property, not service property | + +## Action-broke troubleshooting checklist + +When a previously-working bomdrift action job starts failing — typically +right after a merge to your default branch, a token rotation, or a +runner-image upgrade — work through these in order. Each row is **one +symptom, one fix** so you can grep your job log for the symptom and +land on the recipe. + +| Symptom (in the job log) | Likely cause | Fix | +|---|---|---| +| `403 Resource not accessible by integration` on the comment-upsert step | `pull-requests: write` permission missing on the workflow / job | Add `permissions: { pull-requests: write, contents: read }` at the workflow or job level. PR comments need `pull-requests: write`; the action's internal checkouts need `contents: read`. | +| `Forks cannot post PR comments` warning, exit 0 | PR is from a fork; default `GITHUB_TOKEN` on `pull_request` events is read-only | Switch the trigger to `pull_request_target` (and harden — see [GitHub's guidance][prtarget]), or accept that fork PRs only get the workflow step summary, not a PR comment. | +| `Could not find SBOM at services/api` after a green earlier run | Default branch protection bumped the merge-base; `before-ref` now points at a commit that predates the `services/api` directory | Either move the `path:` value to match the new layout, or pin `before-ref` explicitly to a known-good commit (`before-ref: main`). | +| `cosign: signature verification failed` after a release-archive rotation | Cached release archive in the runner's tool cache is stale and predates a rotation | Bump to the latest patch tag (e.g. `Metbcy/bomdrift@v1` re-resolves to the floating tag), or set `verify-signatures: false` on a self-hosted runner you've pinned manually. | +| `path: services/api` warning + empty SBOM | The path doesn't exist post-checkout — typo, or the directory was renamed in `before-ref` only | bomdrift v0.7+ surfaces an actionable error pointing at this exact case. See the [monorepo section](#monorepo-setup) for the matrix recipe; double-check `${{ matrix.service }}` substitution. | +| "Comment exceeds 65,536 characters" 422 from GitHub | A massive diff blew past the size cap; the v0.3 fallback to `--summary-only` was disabled (`comment-size-limit: 0`) | Re-enable the fallback (drop `comment-size-limit` to use the default, or set it to `60000`). The full body is preserved in the workflow step summary. | +| Action runs, no PR comment appears, exit 0 | Workflow event isn't `pull_request` (the comment path is gated on PR events), or `comment-on-pr: false` was set explicitly | For `push`/`schedule` events, the comment path is intentionally skipped — use the step summary or upload the markdown as an artifact. | + +[prtarget]: https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-pull_request_target + +If you hit a failure mode not in the table above, please [open an +issue](https://github.com/Metbcy/bomdrift/issues/new?labels=action-broke) +with the failing job log — the troubleshooting table grows from real +reports. diff --git a/docs/src/gitlab-ci.md b/docs/src/gitlab-ci.md new file mode 100644 index 0000000..e768e1e --- /dev/null +++ b/docs/src/gitlab-ci.md @@ -0,0 +1,178 @@ +# GitLab CI + +bomdrift v0.7+ ships first-class GitLab support via a documented +`.gitlab-ci.yml` template plus a `--platform gitlab` CLI flag that +swaps the rendered footer to the GitLab MR-note shape. The template +lives in [`examples/gitlab-ci/`](https://github.com/Metbcy/bomdrift/tree/main/examples/gitlab-ci); +this chapter walks through the moving parts. + +## Why a template instead of a custom action + +GitLab CI doesn't have a "marketplace action" model; the unit of +reusability is a YAML snippet. A composite GitHub-Action-style binary +would still need a YAML wrapper, so v0.7 ships the YAML directly. You +can `include:` it from a shared CI repo if you run bomdrift across +many projects: + +```yaml +include: + - project: 'platform/ci-templates' + file: '/bomdrift/diff.gitlab-ci.yml' + ref: main +``` + +## Quickstart (zero-config, v0.7+) + +On an MR pipeline, the template defaults to comparing the merge-base +SHA against the MR head SHA — no manual SBOM wiring needed: + +1. Copy [`examples/gitlab-ci/.gitlab-ci.yml`](https://github.com/Metbcy/bomdrift/blob/main/examples/gitlab-ci/.gitlab-ci.yml) + to your project root. +2. Add `BOMDRIFT_API_TOKEN` as a masked CI/CD variable. The token must + be a Project Access Token with the `api` scope; `CI_JOB_TOKEN` + doesn't work (it's read-only on most instances). +3. Push an MR. The `bomdrift:diff` job runs Syft on both refs, + renders the markdown diff, and posts/upserts an MR note marked + ``. + +That's it. No `.bomdrift.toml` required for the default flow; add one +only when you want a repo-pinned policy. + +## What the job does + +Step-by-step (matches the `bash <<'BOMDRIFT'` block in the template): + +1. **Detects arch** (x86_64 / aarch64) and downloads the matching + `bomdrift-${VERSION}-...musl.tar.gz` from GitHub Releases. +2. **Optionally cosign-verifies** the archive when `cosign` is on + PATH and `BOMDRIFT_VERIFY_SIGNATURES=true` (default). Falls back + to a warning when cosign isn't installed; set + `BOMDRIFT_VERIFY_SIGNATURES=false` to silence the warning on a + runner image you've pinned manually. +3. **Installs Syft** via the upstream `install.sh`. +4. **Creates two `git worktree`s** — one at the merge-base SHA + (`CI_MERGE_REQUEST_DIFF_BASE_SHA`), one at the MR head + (`CI_COMMIT_SHA`). Worktrees share the active checkout's `.git`, + so this is cheap. +5. **Generates CycloneDX-JSON SBOMs** for both worktrees with `syft + scan dir:...`. +6. **Runs `bomdrift diff`** with `--platform gitlab`, which renders + the GitLab-shaped footer (`/-/issues/new?...` plus `bomdrift + baseline add` hint instead of the GitHub `/bomdrift suppress` + comment-driven flow). +7. **Posts/upserts the MR note** via the GitLab REST API — finds the + existing note by the `` marker and PATCHes + it, otherwise POSTs a new one. + +The full markdown body is also kept as a job artifact (`diff.md`) +with a 7-day retention so reviewers can recover it after the MR +merges. + +## Tokens & permissions + +| Token | Scope | Used for | +|---|---|---| +| `BOMDRIFT_API_TOKEN` | `api` | Posting / updating MR notes | +| `BOMDRIFT_PUSH_TOKEN` (optional) | `api` + `write_repository` | Suppression job's commit-back-to-MR-branch step | + +Splitting the two tokens means the diff path keeps working even if +the suppression token is rotated, and you can give the diff token a +narrower blast radius. Mark both as **Masked** and as **Protected** +when your default branch is the only place suppression commits should +land. + +`CI_JOB_TOKEN` is intentionally not used for the comment path: on +most GitLab instances its scope is read-only, and even where it can +post comments the surface area is wider than what bomdrift needs. + +## CLI auto-detection + +`bomdrift diff` auto-detects GitLab CI from the environment: + +- `GITLAB_CI=true` → flips `--platform` to `gitlab` (unless overridden). +- `CI_PROJECT_URL` → used as `repo_url` (footer link target) when + `--repo-url` and `BOMDRIFT_REPO_URL` are both unset. + +Explicit flags always win; the env detection only fills in unset +values. To force GitHub-shape output from a GitLab runner (rare — +mostly useful when cross-posting to a mirror), pass +`--platform github` explicitly. + +## Suppressions + +For v0.7, GitLab suppressions are **manual or job-driven**, not +comment-driven. Two paths: + +### Path 1 — CLI + +The same `bomdrift baseline add ` command works in any GitLab +job or local shell: + +```bash +bomdrift baseline add GHSA-xxxx-yyyy-zzzz +bomdrift baseline add CVE-2026-12345 --path custom/baseline.json +``` + +Commit `.bomdrift/baseline.json` to your MR branch and the next +`bomdrift:diff` run sees the finding as suppressed. See +[Baseline & suppression](./baseline.md) for match-key semantics and +the worked false-positive example. + +### Path 2 — manual GitLab job + +Copy [`examples/gitlab-ci/suppress.gitlab-ci.yml`](https://github.com/Metbcy/bomdrift/blob/main/examples/gitlab-ci/suppress.gitlab-ci.yml) +to your project (or merge its job into your main `.gitlab-ci.yml`). +The job is `when: manual` — invisible until a reviewer triggers it +from the MR's pipeline view with a `BOMDRIFT_SUPPRESS_ID` variable. +On trigger it runs `bomdrift baseline add` and pushes the result back +to the MR branch using `BOMDRIFT_PUSH_TOKEN`. + +### What's NOT in v0.7 (deferred to v0.8) + +In-comment `/bomdrift suppress ` flow on GitLab. GitLab's note +webhook fires on every comment on every MR with no command-prefix +filter, so wiring it safely (rate-limit, fork-MR safety, command +parsing, double-trigger debouncing) is materially harder than on +GitHub. v0.7 ships the manual-job path because it covers the same +user need (one click per accepted finding) without standing up a +webhook handler. v0.8 will track the comment-driven flow under a +follow-up issue once we see real adoption data on the v0.7 manual +path. + +## Self-Managed GitLab + +The template uses `CI_API_V4_URL` (auto-populated on every job) +instead of hardcoding `gitlab.com/api/v4`, so it works against +Self-Managed instances unchanged. Two things to watch: + +- **Outbound reachability.** The job downloads the bomdrift archive + from GitHub Releases and Syft from the upstream install script. If + your runners can't reach those, mirror them to your internal Nexus + / Artifactory and override the `BOMDRIFT_RELEASE_BASE_URL` variable + shown in the example README. +- **Cosign + Sigstore.** Keyless verification needs OIDC connectivity + to `oauth2.sigstore.dev`. On air-gapped runners, set + `BOMDRIFT_VERIFY_SIGNATURES=false` — bomdrift fails loudly rather + than silently skipping when the env var is absent and cosign isn't + reachable, so the explicit opt-out is the right escape hatch. + +## Troubleshooting + +See the [examples README troubleshooting table](https://github.com/Metbcy/bomdrift/tree/main/examples/gitlab-ci#troubleshooting) +for the most common failure modes (token scoping, signature +verification on locked-down runners, push-back-to-protected-branch +permissions). + +## What's the same vs. the GitHub Action + +| Feature | GitHub Action | GitLab template | +|---|---|---| +| Zero-config flow | ✅ | ✅ | +| Syft auto-install | ✅ | ✅ | +| MR/PR comment upsert | ✅ | ✅ | +| `--summary-only` size fallback | ✅ (65k cap) | n/a (1MB cap is rarely hit) | +| Cosign verification of release archive | ✅ | ✅ | +| Per-service monorepo support | ✅ matrix | ✅ matrix (`parallel` keyword) | +| In-comment suppression | ✅ | v0.8 | +| Manual suppression job | n/a | ✅ | +| `` marker | ✅ | ✅ (same shape — cross-platform tooling can grep one shape) | diff --git a/docs/src/quickstart.md b/docs/src/quickstart.md index 685541c..80f0b5e 100644 --- a/docs/src/quickstart.md +++ b/docs/src/quickstart.md @@ -25,7 +25,7 @@ jobs: ``` The `@v1` mutable tag tracks the latest v0.x release. Pin to a specific -version (`@v0.6.1`) if you prefer reproducible builds. See +version (`@v0.7.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 @@ -39,7 +39,7 @@ Pre-built binaries cover Linux x86_64 + aarch64, macOS aarch64, and Windows x86_64. Each archive is cosign-signed via Sigstore + GitHub OIDC. ```bash -VERSION=v0.6.1 +VERSION=v0.7.0 TARGET=x86_64-unknown-linux-gnu curl -sSL -o bomdrift.tar.gz \ "https://github.com/Metbcy/bomdrift/releases/download/${VERSION}/bomdrift-${VERSION}-${TARGET}.tar.gz" @@ -56,7 +56,7 @@ To verify the archive's signature before you trust the binary, see ## From source ```bash -cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.6.1 bomdrift +cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.7.0 bomdrift ``` Requires Rust 1.85+ (the project uses edition 2024). diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 112b9c0..b29e8d7 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -22,9 +22,19 @@ Items are grouped by likely landing area and rough sizing. organization-specific enrichers (e.g. "flag any dep from internal-mirror.example.com without a SHA-256 attestation"). Probably WASM-based for sandboxing. -- **GitLab CI integration** — same `bomdrift diff` invocation, but - with a wrapper that posts to GitLab merge-request notes instead of - PR comments. The CLI is already CI-agnostic; this is glue + docs. +- **GitLab in-comment suppression** — v0.7 ships the GitLab CI + template + `--platform gitlab` (the diff path), but the + comment-driven `/bomdrift suppress ` flow on GitLab is + deferred. GitLab note-event webhooks have a different model than + GitHub PR comments — wiring the safe path (rate-limit, fork-MR + safety, command parsing, double-trigger debounce) is a v0.8 + candidate once we see real adoption data on the v0.7 manual + path. +- **Calibration tuning from `--debug-calibration` data** — v0.7 + added the diagnostic flag; v0.8 may revise + `SIMILARITY_THRESHOLD`, `YOUNG_MAINTAINER_DAYS`, and OSV cache + TTL defaults based on adopter-collected samples shared on + issue #5. - **OCI artifact attestation** — verify SBOMs are themselves signed by the build system before diffing. Pairs with cosign attest. diff --git a/entrypoint.sh b/entrypoint.sh index 589f81c..41e6d66 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -208,7 +208,29 @@ generate_sbom() { fi local source_dir="${checkout_dir}/${clean}" if [ ! -d "$source_dir" ]; then - fail "scan path not found: ${source_dir} (resolved from input path='${subpath}')" + # Build an actionable error: what we tried, what's actually on disk + # at that level, and where to read about the monorepo pattern. The + # most common cause of this firing is a `path:` typo or a `path:` + # value that exists in `after-ref` but not `before-ref` (a directory + # added in the PR head). Show the first ~10 entries at the checkout + # root so the reviewer can see whether the directory is just + # misnamed (e.g. `services/api` vs `service/api`). + local actual + actual="$(cd "$checkout_dir" && ls -1 2>/dev/null | head -10 | sed 's/^/ /')" + if [ -z "$actual" ]; then + actual=" (checkout root is empty)" + fi + fail "scan path not found: ${source_dir} + Resolved from input path='${subpath}' against checkout '${checkout_dir}'. + The checkout root contains: +${actual} + + Common causes: + - typo in the 'path:' input (case-sensitive; 'Services/api' != 'services/api') + - the directory exists in after-ref but not before-ref (or vice versa) + - a monorepo split renamed the directory in the default branch since the PR was opened + See https://metbcy.github.io/bomdrift/github-action.html#monorepo-setup + for the matrix-per-service recipe." fi if ! command -v syft >/dev/null 2>&1; then diff --git a/examples/gitlab-ci/.gitlab-ci.yml b/examples/gitlab-ci/.gitlab-ci.yml new file mode 100644 index 0000000..ede8ca8 --- /dev/null +++ b/examples/gitlab-ci/.gitlab-ci.yml @@ -0,0 +1,146 @@ +# bomdrift on GitLab CI — copy-paste template (v0.7+) +# +# Posts an upserted MR note with the SBOM diff between the MR's target +# branch and the MR's head SHA. Mirrors the GitHub Action UX: +# - Generates CycloneDX SBOMs via Syft for both refs. +# - Renders the diff to markdown with the GitLab footer shape +# (`/-/issues/new?...` + `bomdrift baseline add` hint). +# - Posts/updates a single note marked `` so +# subsequent pushes update the same note instead of accumulating. +# +# Tokens: +# * `CI_JOB_TOKEN` is read-only on most GitLab instances and CANNOT +# post MR notes. Provide a Project Access Token (PAT) with at least +# the `api` scope as a CI/CD variable named `BOMDRIFT_API_TOKEN`. +# Mark it "Masked" and "Protected" if your default branch is the +# only place suppression commits land. +# +# Self-Managed GitLab: +# * `CI_API_V4_URL` is exposed automatically; no rewriting needed. +# * If your runners can't reach Sigstore endpoints, set +# `BOMDRIFT_VERIFY_SIGNATURES: "false"` to skip cosign install. +# +# This template uses a single shell job for portability; nothing here +# requires a sidecar service or a custom runner image. + +variables: + # Pin the bomdrift release the MR is checked against. Bump in lockstep + # with security-relevant feature flags. Floating `v1` is supported but + # the pin is recommended for reproducibility. + BOMDRIFT_VERSION: "v0.7.0" + BOMDRIFT_VERIFY_SIGNATURES: "true" + # Where the marker comment lands. Keep in sync with the GitHub Action's + # `` so cross-platform tooling can grep one shape. + BOMDRIFT_MARKER: "" + +bomdrift:diff: + stage: test + image: alpine:3.20 + rules: + # MR pipelines only — push pipelines on protected branches don't + # have a target ref to diff against. + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + before_script: + - apk add --no-cache bash curl jq git tar gzip ca-certificates + script: + - | + set -euo pipefail + bash <<'BOMDRIFT' + + # --- Detect arch & download bomdrift --------------------------------- + ARCH="$(uname -m)" + case "$ARCH" in + x86_64) ARCH="x86_64-unknown-linux-musl" ;; + aarch64) ARCH="aarch64-unknown-linux-musl" ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; + esac + BOMDRIFT_TARBALL="bomdrift-${BOMDRIFT_VERSION}-${ARCH}.tar.gz" + BOMDRIFT_URL="https://github.com/Metbcy/bomdrift/releases/download/${BOMDRIFT_VERSION}/${BOMDRIFT_TARBALL}" + curl -fsSL -o "${BOMDRIFT_TARBALL}" "${BOMDRIFT_URL}" + + # --- Optional: cosign-verify the release archive --------------------- + if [ "${BOMDRIFT_VERIFY_SIGNATURES}" = "true" ]; then + if command -v cosign >/dev/null 2>&1; then + curl -fsSL -o "${BOMDRIFT_TARBALL}.sig" "${BOMDRIFT_URL}.sig" + curl -fsSL -o "${BOMDRIFT_TARBALL}.pem" "${BOMDRIFT_URL}.pem" + cosign verify-blob \ + --certificate "${BOMDRIFT_TARBALL}.pem" \ + --signature "${BOMDRIFT_TARBALL}.sig" \ + --certificate-identity-regexp 'https://github.com/Metbcy/bomdrift/.*' \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${BOMDRIFT_TARBALL}" + else + echo "WARN: cosign not on PATH; skipping signature verification (set BOMDRIFT_VERIFY_SIGNATURES=false to silence)" >&2 + fi + fi + + tar -xzf "${BOMDRIFT_TARBALL}" + chmod +x ./bomdrift + + # --- Install Syft ---------------------------------------------------- + curl -fsSL https://raw.githubusercontent.com/anchore/syft/main/install.sh \ + | sh -s -- -b /usr/local/bin + + # --- Generate the two SBOMs ------------------------------------------ + # CI_MERGE_REQUEST_DIFF_BASE_SHA is the actual merge-base, which is + # what we want for "what does this MR change?" rather than the tip + # of the target branch (which can drift). + BEFORE_REF="${CI_MERGE_REQUEST_DIFF_BASE_SHA}" + AFTER_REF="${CI_COMMIT_SHA}" + + # Worktrees keep the active checkout untouched and give us two + # filesystem snapshots Syft can scan independently. Both share the + # same .git so this is cheap. + git worktree add ../bomdrift-before "${BEFORE_REF}" + git worktree add ../bomdrift-after "${AFTER_REF}" + + syft scan "dir:../bomdrift-before" -o "cyclonedx-json=before.cdx.json" --quiet + syft scan "dir:../bomdrift-after" -o "cyclonedx-json=after.cdx.json" --quiet + + # --- Run bomdrift diff ---------------------------------------------- + # --platform gitlab swaps the footer to the GitLab shape; the env + # CI_PROJECT_URL is auto-honored as repo_url. + ./bomdrift diff before.cdx.json after.cdx.json \ + --platform gitlab \ + --output markdown \ + > diff.md + + # --- Post / update the MR note -------------------------------------- + if [ -z "${BOMDRIFT_API_TOKEN:-}" ]; then + echo "BOMDRIFT_API_TOKEN not set; skipping note upsert (workflow log still has the diff above)" >&2 + cat diff.md + exit 0 + fi + + NOTES_URL="${CI_API_V4_URL}/projects/${CI_MERGE_REQUEST_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes" + EXISTING_ID="$(curl -fsSL --header "PRIVATE-TOKEN: ${BOMDRIFT_API_TOKEN}" "${NOTES_URL}?per_page=100" \ + | jq --arg marker "${BOMDRIFT_MARKER}" \ + '[.[] | select(.body | startswith($marker))][0].id // empty')" + + # Prepend the marker so future runs find this exact note. + BODY="$(printf '%s\n\n%s' "${BOMDRIFT_MARKER}" "$(cat diff.md)")" + JSON_BODY="$(jq -nc --arg b "${BODY}" '{body:$b}')" + + if [ -n "${EXISTING_ID}" ]; then + curl -fsSL --request PUT \ + --header "PRIVATE-TOKEN: ${BOMDRIFT_API_TOKEN}" \ + --header "Content-Type: application/json" \ + --data "${JSON_BODY}" \ + "${NOTES_URL}/${EXISTING_ID}" >/dev/null + echo "Updated existing bomdrift note (id=${EXISTING_ID})" + else + curl -fsSL --request POST \ + --header "PRIVATE-TOKEN: ${BOMDRIFT_API_TOKEN}" \ + --header "Content-Type: application/json" \ + --data "${JSON_BODY}" \ + "${NOTES_URL}" >/dev/null + echo "Posted new bomdrift note" + fi + BOMDRIFT + artifacts: + when: always + paths: + - diff.md + - before.cdx.json + - after.cdx.json + expire_in: 7 days diff --git a/examples/gitlab-ci/README.md b/examples/gitlab-ci/README.md new file mode 100644 index 0000000..79e0d8b --- /dev/null +++ b/examples/gitlab-ci/README.md @@ -0,0 +1,80 @@ +# bomdrift + GitLab CI + +Drop-in templates for running bomdrift on GitLab MRs. Two files: + +- [`.gitlab-ci.yml`](./.gitlab-ci.yml) — runs on every MR pipeline, + posts the diff as an upserted MR note. +- [`suppress.gitlab-ci.yml`](./suppress.gitlab-ci.yml) — companion + manual job that takes an advisory ID and commits the suppression to + the MR branch (analog of the v0.5 GitHub `/bomdrift suppress` + comment, but triggered from the pipeline view in v0.7). + +## Quickstart + +1. Copy `.gitlab-ci.yml` to your project root (or `include:` it from a + shared CI repo). +2. Create a Project Access Token with the `api` scope. Add it as a + masked CI/CD variable named `BOMDRIFT_API_TOKEN`. (`CI_JOB_TOKEN` + does **not** work for posting MR notes — its scope is read-only on + most instances.) +3. Push an MR. The `bomdrift:diff` job runs, generates SBOMs with Syft + for the merge-base and the MR head, renders the diff to markdown, + and posts a note marked ``. Subsequent pushes + update the same note. + +## Optional: enable suppressions + +1. Copy `suppress.gitlab-ci.yml` alongside the diff template (or + merge its job into your main `.gitlab-ci.yml`). +2. Create a second Project Access Token with the `api` and + `write_repository` scopes; expose it as `BOMDRIFT_PUSH_TOKEN`. +3. From the MR's pipeline view, run the manual `bomdrift:suppress` job + with `BOMDRIFT_SUPPRESS_ID=`. The job commits the + suppression to `.bomdrift/baseline.json` on the MR branch; the next + `bomdrift:diff` run sees the finding as suppressed. + +## Why two tokens? + +Splitting "post a comment" from "push to a branch" lets you give the +diff job a low-blast-radius token (read + comment) and gate the +push-back-to-branch capability behind a second token reviewers have to +opt in to. If a future bomdrift CVE involves the suppression flow, the +diff path keeps working with no token rotation. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `BOMDRIFT_API_TOKEN not set` warning, no MR note | Token variable missing or scoped to a protected branch only | Re-add the variable; uncheck "Protected" if the MR is from an unprotected branch. | +| 401 from `${CI_API_V4_URL}/projects/.../notes` | Token lacks `api` scope, or it's a `CI_JOB_TOKEN` | Use a Project Access Token, not the job token. | +| `cosign: signature verification failed` | Runner image lacks cosign or can't reach Sigstore | Set `BOMDRIFT_VERIFY_SIGNATURES: "false"` (documented escape hatch); only use this on a runner image you control. | +| `git push` 403 from suppression job | `BOMDRIFT_PUSH_TOKEN` lacks `write_repository`, or the head branch is protected | Reissue the token with `write_repository`; if the branch is protected, allow merge access for the token's user / group. | +| MR note has no styling | GitLab's MR-note markdown subset doesn't support every GitHub flavor (e.g. some `
` nuances) | Expected — bomdrift's renderer is the same shape as on GitHub; GitLab's renderer handles 95% of it. | + +## Self-Managed GitLab notes + +The template uses `CI_API_V4_URL` (auto-populated on every job) instead +of hardcoding `gitlab.com/api/v4`, so it works against Self-Managed +instances unchanged. If your instance restricts outbound traffic to +GitHub Releases (where the bomdrift binary lives), mirror the release +archives to your internal Nexus/Artifactory and override the URL in +the `bomdrift:diff` job: + +```yaml +variables: + BOMDRIFT_RELEASE_BASE_URL: "https://nexus.example.com/repository/bomdrift" +``` + +(then edit the `curl` line in the script to use this base URL). + +## What v0.7 does NOT ship for GitLab + +- **In-comment suppression via note webhook** — deferred to v0.8. + GitLab's note webhook fires on every comment on every MR with no + command-prefix filter; wiring it safely (debouncing, fork-MR safety, + command parsing) is non-trivial and the v0.7 manual job covers the + same need without standing up a webhook handler. +- **`comment-size-limit` MR-note fallback** — the GitHub Action's v0.3 + `--summary-only` fallback for >65k bodies hasn't been ported because + GitLab's note size cap is much higher (1MB by default). Open an + issue if you hit the cap. diff --git a/examples/gitlab-ci/suppress.gitlab-ci.yml b/examples/gitlab-ci/suppress.gitlab-ci.yml new file mode 100644 index 0000000..626f2d5 --- /dev/null +++ b/examples/gitlab-ci/suppress.gitlab-ci.yml @@ -0,0 +1,89 @@ +# bomdrift suppression job for GitLab CI (v0.7+) +# +# Manual job that takes an advisory ID as a CI variable and commits the +# resulting `.bomdrift/baseline.json` change to the MR's head branch. +# +# Why a manual job and not a note webhook? +# GitLab's `Note` webhook fires on every comment on every MR, with no +# command-prefix filter built into the trigger. Wiring a v0.5-style +# `/bomdrift suppress` flow safely (rate-limit, command parsing, +# double-trigger debouncing, fork-MR safety) is non-trivial — that's +# the v0.8 follow-up. For v0.7, the manual-job path covers the same +# user need (one click per accepted finding) without standing up a +# webhook handler. +# +# Trigger: +# * From the MR's pipeline view, click "Run job" on `bomdrift:suppress`. +# * Set the `BOMDRIFT_SUPPRESS_ID` variable in the prompt +# (e.g. `GHSA-1234-5678-9abc`). +# +# Tokens: +# * Needs a Project Access Token with `write_repository` scope so it +# can push the baseline change back to the MR's head branch. +# Configure as `BOMDRIFT_PUSH_TOKEN`. + +bomdrift:suppress: + stage: deploy + image: alpine:3.20 + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: manual + variables: + BOMDRIFT_SUPPRESS_ID: "" + BOMDRIFT_BASELINE_PATH: ".bomdrift/baseline.json" + BOMDRIFT_VERSION: "v0.7.0" + before_script: + - apk add --no-cache bash curl git tar gzip ca-certificates + script: + - | + set -euo pipefail + bash <<'BOMDRIFT' + + if [ -z "${BOMDRIFT_SUPPRESS_ID}" ]; then + echo "BOMDRIFT_SUPPRESS_ID is empty — pass an advisory ID (GHSA / CVE / MAL pattern) when triggering the job" >&2 + exit 1 + fi + if [ -z "${BOMDRIFT_PUSH_TOKEN:-}" ]; then + echo "BOMDRIFT_PUSH_TOKEN not set; cannot push baseline update" >&2 + exit 1 + fi + + # --- Validate the ID shape early; the CLI does this too but a clear + # --- error here saves the download round-trip. ----------------------- + case "${BOMDRIFT_SUPPRESS_ID}" in + GHSA-* | CVE-* | MAL-*) ;; + *) echo "Unrecognized advisory ID shape: ${BOMDRIFT_SUPPRESS_ID}" >&2; exit 1 ;; + esac + + ARCH="$(uname -m)" + case "$ARCH" in + x86_64) ARCH="x86_64-unknown-linux-musl" ;; + aarch64) ARCH="aarch64-unknown-linux-musl" ;; + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; + esac + curl -fsSL -o bomdrift.tar.gz \ + "https://github.com/Metbcy/bomdrift/releases/download/${BOMDRIFT_VERSION}/bomdrift-${BOMDRIFT_VERSION}-${ARCH}.tar.gz" + tar -xzf bomdrift.tar.gz + chmod +x ./bomdrift + + # --- Resolve MR head ref -------------------------------------------- + HEAD_REF="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" + git fetch origin "${HEAD_REF}" + git checkout "${HEAD_REF}" + + # --- Append + commit ------------------------------------------------- + ./bomdrift baseline add "${BOMDRIFT_SUPPRESS_ID}" --path "${BOMDRIFT_BASELINE_PATH}" + + if git diff --quiet -- "${BOMDRIFT_BASELINE_PATH}"; then + echo "Baseline unchanged — ${BOMDRIFT_SUPPRESS_ID} was already suppressed." + exit 0 + fi + + git config user.email "bomdrift-suppress@${CI_SERVER_HOST}" + git config user.name "bomdrift suppress" + git add "${BOMDRIFT_BASELINE_PATH}" + git commit -m "chore(bomdrift): suppress ${BOMDRIFT_SUPPRESS_ID}" + + AUTH_REMOTE="https://oauth2:${BOMDRIFT_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" + git push "${AUTH_REMOTE}" "HEAD:${HEAD_REF}" + BOMDRIFT diff --git a/src/cli.rs b/src/cli.rs index bf12a92..33c37b8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use serde::Deserialize; use crate::model::SbomFormat; +use crate::render::markdown; #[derive(Parser, Debug)] #[command( @@ -123,6 +124,43 @@ pub enum RefreshEcosystem { Composer, } +/// Forge the rendered markdown is destined for. Drives the action-affordance +/// footer in `render::markdown` and CI-side defaults (e.g. detection of +/// `GITLAB_CI` / `CI_PROJECT_URL`). +/// +/// Variants intentionally cover only forges with a wired-up footer +/// implementation. New forges (Bitbucket, Gitea, ...) are an additive change. +#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + /// GitHub.com or GitHub Enterprise. Default — preserves the v0.5 + /// footer shape for existing consumers. + #[default] + #[value(name = "github")] + GitHub, + /// GitLab.com or Self-Managed GitLab. The MR-note footer omits the + /// `/bomdrift suppress` hint and points at `bomdrift baseline add` + /// instead, because GitLab in-comment suppression is deferred to + /// v0.8 (note webhooks have a different model than GitHub PR + /// comments). + #[value(name = "gitlab")] + GitLab, +} + +impl From for markdown::Platform { + /// User-facing CLI / config enum maps 1:1 to the renderer's enum. The + /// two are kept separate so the renderer doesn't take a clap+serde + /// dependency, but the variants must stay in lockstep — the match + /// here is exhaustive on purpose: a new variant added to one side + /// fails the build on the other until both are updated. + fn from(value: Platform) -> Self { + match value { + Platform::GitHub => markdown::Platform::GitHub, + Platform::GitLab => markdown::Platform::GitLab, + } + } +} + #[derive(Args, Debug)] pub struct DiffArgs { /// Path to the "before" SBOM (CycloneDX, SPDX, or Syft JSON). @@ -196,6 +234,14 @@ pub struct DiffArgs { /// use don't render dead links to bomdrift's own issue tracker. #[arg(long)] pub repo_url: Option, + /// Forge the rendered markdown is destined for. Controls the action- + /// affordance footer shape (GitHub uses the `/bomdrift suppress` + /// comment-driven flow; GitLab points reviewers at the manual + /// `bomdrift baseline add` CLI flow). When omitted, auto-detects from + /// CI environment variables (`GITLAB_CI=true` → GitLab; default + /// otherwise is GitHub). + #[arg(long, value_enum)] + pub platform: Option, /// Exit 2 when more than this many components are added in one diff. #[arg(long)] pub max_added: Option, @@ -205,6 +251,19 @@ pub struct DiffArgs { /// Exit 2 when more than this many components change version in one diff. #[arg(long)] pub max_version_changed: Option, + /// Print one CSV-friendly stderr line per finding showing the score + /// and the threshold that gated it. Off by default. Used to gather + /// real-world calibration data — `SIMILARITY_THRESHOLD` for + /// typosquats, `YOUNG_MAINTAINER_DAYS` for maintainer-age — without + /// shipping telemetry. The output is opt-in and the user owns the + /// resulting CSV; pipe to a file with `2>calibration.csv`. + /// + /// Format: `kind|key|score|threshold` per line. `kind` is one of + /// `typosquat`, `maintainer-age`, `version-jump`, `cve`. `score` is + /// the underlying similarity / age / jump-size / CVSS value; + /// `threshold` is the constant the finding was compared against. + #[arg(long)] + pub debug_calibration: bool, } /// Threshold for `--fail-on` exit-code-2 behavior. diff --git a/src/config.rs b/src/config.rs index 83cca0a..e97dfee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; -use crate::cli::{DiffArgs, FailOn, InputFormat, OutputFormat}; +use crate::cli::{DiffArgs, FailOn, InputFormat, OutputFormat, Platform}; const DEFAULT_CONFIG_PATH: &str = ".bomdrift.toml"; @@ -33,6 +33,7 @@ pub struct DiffConfig { pub findings_only: Option, pub include_file_components: Option, pub repo_url: Option, + pub platform: Option, pub max_added: Option, pub max_removed: Option, pub max_version_changed: Option, @@ -82,6 +83,9 @@ fn apply_loaded_diff_config(args: &mut DiffArgs, config: Config) { if args.repo_url.is_none() { args.repo_url = diff.repo_url.filter(|s| !s.is_empty()); } + if args.platform.is_none() { + args.platform = diff.platform; + } if args.max_added.is_none() { args.max_added = diff.max_added; } @@ -133,9 +137,11 @@ mod tests { findings_only: false, include_file_components: false, repo_url: None, + platform: None, max_added: None, max_removed: None, max_version_changed: None, + debug_calibration: false, } } diff --git a/src/lib.rs b/src/lib.rs index 0850d2a..c311293 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,18 +147,50 @@ fn run_diff(mut args: DiffArgs) -> Result<()> { baseline::apply(&mut cs, &mut enrichment, &baseline); } + // Calibration tap. Off by default; opt-in via `--debug-calibration`. + // Emits one CSV-friendly line per finding to stderr so an adopter + // can run the flag across a representative N PRs and feed the + // resulting CSV back as tuning data (issue #5). The output is + // deliberately plain — no JSON, no schema versioning — because the + // intended consumer is a one-off awk/jq pipeline, not a long-lived + // integration. Format: `kind|key|score|threshold`. No telemetry: the + // user owns the bytes and pipes them wherever they want. + if args.debug_calibration { + write_calibration_lines(&enrichment, &mut std::io::stderr()); + } + // CLI flag wins; otherwise the env var supplies the default. Empty // strings are treated as unset to match shell-script callers that // pass `BOMDRIFT_REPO_URL=` to clear the value rather than `unset`. + // GitLab CI exposes the project URL as `CI_PROJECT_URL` (analog of + // GitHub's `GITHUB_REPOSITORY`-derived URL); honor it as a third + // fallback so users on the GitLab template don't have to plumb + // `BOMDRIFT_REPO_URL` themselves. let repo_url = args .repo_url .clone() .or_else(|| std::env::var("BOMDRIFT_REPO_URL").ok()) + .or_else(|| std::env::var("CI_PROJECT_URL").ok()) .filter(|s| !s.is_empty()); + + // Platform precedence: explicit `--platform` (or `[diff] platform` + // in `.bomdrift.toml`, already merged into `args.platform`) wins; + // otherwise auto-detect from CI env. `GITLAB_CI=true` is GitLab's + // canonical CI marker — set unconditionally on every job in every + // GitLab pipeline. Fall through to `Platform::GitHub` (the default) + // so existing GitHub Action consumers see no behavior change. + let platform = args.platform.unwrap_or_else(|| { + if std::env::var("GITLAB_CI").is_ok_and(|v| v == "true") { + crate::cli::Platform::GitLab + } else { + crate::cli::Platform::GitHub + } + }); let md_options = render::markdown::Options { summary_only: args.summary_only, findings_only: args.findings_only, repo_url, + platform: platform.into(), }; let rendered = match output { OutputFormat::Terminal => { @@ -236,6 +268,77 @@ pub fn budget_tripped( || max_version_changed.is_some_and(|max| cs.version_changed.len() > max) } +/// Emit one CSV-friendly line per finding to the given writer, capturing +/// the score and the constant it was compared against. Off by default +/// (driven by `--debug-calibration`); when set, the user pipes stderr +/// to a file and feeds the resulting CSV back as tuning data. +/// +/// Schema: `kind|key|score|threshold` — pipe-delimited because purls +/// already contain commas (`pkg:npm/@scope/name`) which would force CSV +/// quoting. `kind` ∈ {`typosquat`, `version-jump`, `maintainer-age`, +/// `cve`}. `score` is the underlying numeric the enricher computed +/// (similarity for typosquat, major-version delta for version-jump, +/// days-old for maintainer-age, max CVSS-equivalent for cve); +/// `threshold` is the constant the score was gated against. CVE rows +/// surface every advisory (no internal threshold) so adopters can see +/// the score distribution before tuning `--fail-on critical-cve`. +fn write_calibration_lines(e: &Enrichment, out: &mut W) { + use crate::enrich::maintainer::YOUNG_MAINTAINER_DAYS; + use crate::enrich::typosquat::SIMILARITY_THRESHOLD; + use crate::enrich::version_jump::MIN_MAJOR_DELTA; + + for f in &e.typosquats { + let _ = writeln!( + out, + "typosquat|{}|{:.4}|{:.4}", + f.component + .purl + .as_deref() + .unwrap_or(f.component.name.as_str()), + f.score, + SIMILARITY_THRESHOLD, + ); + } + for f in &e.version_jumps { + let _ = writeln!( + out, + "version-jump|{}|{}|{}", + f.after.purl.as_deref().unwrap_or(f.after.name.as_str()), + f.after_major.saturating_sub(f.before_major), + MIN_MAJOR_DELTA, + ); + } + for f in &e.maintainer_age { + let _ = writeln!( + out, + "maintainer-age|{}|{}|{}", + f.component + .purl + .as_deref() + .unwrap_or(f.component.name.as_str()), + f.days_old, + YOUNG_MAINTAINER_DAYS, + ); + } + for (purl, refs) in &e.vulns { + for vuln in refs { + // Severity has no numeric score in our model; emit the + // bucket label as a non-numeric "score" so the CSV row is + // still well-formed. Adopters who want raw CVSS can grep + // the JSON output instead — the calibration tap is for the + // ranked-bucket choice (cve vs critical-cve), not for + // reverse-engineering CVSS. + let _ = writeln!( + out, + "cve|{}#{}|{}|high+", + purl, + vuln.id, + vuln.severity.as_str(), + ); + } + } +} + fn log_budget_trips( cs: &ChangeSet, max_added: Option, diff --git a/src/render/markdown.rs b/src/render/markdown.rs index d2c3973..dfa86d4 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -26,6 +26,23 @@ use crate::enrich::typosquat::TyposquatFinding; use crate::enrich::version_jump::VersionJumpFinding; use crate::model::Component; +/// Which forge the rendered markdown is destined for. Drives the action- +/// affordance footer: GitHub uses the v0.5 `/bomdrift suppress` comment-driven +/// flow and `/issues/new?...` URL shape; GitLab uses the project's +/// `/-/issues/new` shape and points reviewers at the manual `bomdrift baseline +/// add` CLI flow because GitLab in-comment suppression is deferred to v0.8. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + /// GitHub.com or GitHub Enterprise. Default — preserves the v0.5 + /// footer shape for existing consumers. + #[default] + GitHub, + /// GitLab.com or Self-Managed GitLab. The MR-note footer omits the + /// `/bomdrift suppress` hint and points at `bomdrift baseline add` + /// instead. + GitLab, +} + /// Renderer toggles. Defaults match v0.2 behavior so existing callers keep /// working unchanged. #[derive(Debug, Default, Clone)] @@ -41,13 +58,18 @@ pub struct Options { /// 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 - /// `/bomdrift suppress ` comment-affordance hint. When `None`, - /// the footer is omitted entirely so forks / standalone CLI use - /// don't render dead links to bomdrift's own issue tracker. + /// Repository URL — `https://github.com//` (or + /// `https://gitlab.com//`) form, no trailing slash. + /// When supplied, the renderer appends a footer linking to a + /// pre-filled "Report this finding" issue and a suppression hint. + /// When `None`, the footer is omitted entirely so forks / standalone + /// CLI use don't render dead links to bomdrift's own issue tracker. pub repo_url: Option, + /// Forge that the rendered markdown is destined for. Defaults to + /// `GitHub` so existing consumers keep their v0.5 footer shape with + /// no migration. The CLI flips this to `GitLab` when `--platform + /// gitlab` is passed or the `GITLAB_CI` environment variable is set. + pub platform: Platform, } pub fn render(cs: &ChangeSet, enrichment: &Enrichment) -> String { @@ -309,19 +331,44 @@ fn section_close(out: &mut String) { /// when `repo_url` is `None` so forks / standalone CLI use don't render /// dead links to a repo they don't own. Wrapped in `` so it doesn't /// compete visually with the section bodies. +/// +/// Footer shape branches on [`Options::platform`]. GitHub points reviewers +/// at the v0.5 `/bomdrift suppress ` companion-action flow; GitLab +/// points them at the manual `bomdrift baseline add ` CLI flow because +/// GitLab in-comment suppression is deferred to v0.8 (note webhooks are a +/// different model than GitHub PR comments). fn write_footer(out: &mut String, opts: &Options) { let Some(repo) = opts.repo_url.as_deref() else { return; }; let repo = repo.trim_end_matches('/'); out.push_str("---\n"); - let _ = writeln!( - out, - "**False positive?** [Report it]({repo}/issues/new?labels=false-positive&template=false-positive.md) · \ - **Suppress a finding?** Comment `/bomdrift suppress ` (requires the \ - [comment-suppress sub-action]({repo})) · \ - [Docs](https://metbcy.github.io/bomdrift/)", - ); + match opts.platform { + Platform::GitHub => { + let _ = writeln!( + out, + "**False positive?** [Report it]({repo}/issues/new?labels=false-positive&template=false-positive.md) · \ + **Suppress a finding?** Comment `/bomdrift suppress ` (requires the \ + [comment-suppress sub-action]({repo})) · \ + [Docs](https://metbcy.github.io/bomdrift/)", + ); + } + Platform::GitLab => { + // GitLab issue creation uses `/-/issues/new` (the `-/` is the + // namespace separator GitLab inserts between the project URL + // and the issue tracker route). `issuable_template=` selects a + // saved description template if the project has one named + // `false-positive`; projects without that template still get + // a working "new issue" form. + let _ = writeln!( + out, + "**False positive?** [Report it]({repo}/-/issues/new?issuable_template=false-positive) · \ + **Suppress a finding?** Run `bomdrift baseline add ` and commit \ + `.bomdrift/baseline.json` to your MR branch · \ + [Docs](https://metbcy.github.io/bomdrift/)", + ); + } + } } /// Cross-component vulnerability ordering: components affected by the @@ -645,8 +692,7 @@ mod tests { &e, Options { summary_only: true, - findings_only: false, - repo_url: None, + ..Default::default() }, ); // Summary table is preserved (the load-bearing part of the comment). @@ -671,8 +717,7 @@ mod tests { &Enrichment::default(), Options { summary_only: true, - findings_only: false, - repo_url: None, + ..Default::default() }, ); assert!(out.contains("_No dependency changes._")); @@ -707,9 +752,8 @@ mod tests { &cs, &e, Options { - summary_only: false, findings_only: true, - repo_url: None, + ..Default::default() }, ); @@ -1049,9 +1093,8 @@ mod tests { &cs, &Enrichment::default(), Options { - summary_only: false, - findings_only: false, repo_url: Some("https://github.com/example/proj".to_string()), + ..Default::default() }, ); assert!(md.contains("False positive?")); @@ -1072,15 +1115,73 @@ mod tests { &cs, &Enrichment::default(), Options { - summary_only: false, - findings_only: false, repo_url: Some("https://github.com/example/proj/".to_string()), + ..Default::default() }, ); assert!(md.contains("https://github.com/example/proj/issues/new")); assert!(!md.contains("proj//issues")); } + #[test] + fn footer_renders_gitlab_shape_when_platform_is_gitlab() { + // Platform::GitLab swaps two things: the issue-creation URL uses + // GitLab's `/-/issues/new?issuable_template=...` shape, and the + // suppression hint points at `bomdrift baseline add` instead of + // the `/bomdrift suppress` comment-driven flow (deferred to v0.8 + // for GitLab). + let cs = ChangeSet { + added: vec![comp("a", "1.0", Ecosystem::Npm, None)], + ..Default::default() + }; + let md = render_with_options( + &cs, + &Enrichment::default(), + Options { + repo_url: Some("https://gitlab.com/group/project".to_string()), + platform: Platform::GitLab, + ..Default::default() + }, + ); + assert!(md.contains("False positive?")); + assert!( + md.contains("https://gitlab.com/group/project/-/issues/new"), + "expected GitLab `/-/issues/new` URL shape; got:\n{md}" + ); + assert!( + md.contains("bomdrift baseline add"), + "expected GitLab footer to point at `bomdrift baseline add`; got:\n{md}" + ); + assert!( + !md.contains("/bomdrift suppress"), + "GitLab footer must NOT mention the GitHub-only `/bomdrift suppress` comment flow; got:\n{md}" + ); + assert!(md.contains("https://metbcy.github.io/bomdrift/")); + } + + #[test] + fn footer_default_platform_preserves_github_shape() { + // Backward-compat guarantee: callers that don't set `platform` + // explicitly (i.e. v0.5 / v0.6 consumers compiled against the + // pre-v0.7 Options struct after migration) get the GitHub footer + // they had before. `Platform::default()` is GitHub. + assert_eq!(Platform::default(), Platform::GitHub); + let cs = ChangeSet { + added: vec![comp("a", "1.0", Ecosystem::Npm, None)], + ..Default::default() + }; + let md = render_with_options( + &cs, + &Enrichment::default(), + Options { + repo_url: Some("https://github.com/example/proj".to_string()), + ..Default::default() + }, + ); + assert!(md.contains("/issues/new?labels=false-positive")); + assert!(md.contains("/bomdrift suppress")); + } + #[test] fn why_this_matters_link_appears_in_each_finding_section() { let cs = ChangeSet { diff --git a/tests/cli.rs b/tests/cli.rs index 6100f64..88b38e3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -843,3 +843,199 @@ fn diff_config_baseline_missing_file_does_not_error() { fs::remove_dir_all(dir).ok(); } + +#[test] +fn diff_renders_github_footer_when_only_repo_url_env_var_is_set() { + // Regression coverage for issue #10 (todo b5): the unit test in + // `render::markdown` covers the rendering function, but the env-var + // → `Options.repo_url` plumbing in `lib::run_diff` was previously + // exercised only by the GitHub Action E2E. This test pins the CLI + // path: pass the URL via env var (not `--repo-url`) and assert the + // footer renders the expected GitHub shape. + let out = Command::new(bin()) + .current_dir(manifest_dir()) + .env_remove("GITLAB_CI") + .env_remove("CI_PROJECT_URL") + .env("BOMDRIFT_REPO_URL", "https://github.com/example/proj") + .args([ + "diff", + "tests/fixtures/cdx-minimal.json", + "tests/fixtures/cdx-after.json", + "--no-osv", + ]) + .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.contains("https://github.com/example/proj/issues/new"), + "expected GitHub footer URL from BOMDRIFT_REPO_URL env var; got:\n{stdout}" + ); + assert!( + stdout.contains("/bomdrift suppress"), + "GitHub footer must include the `/bomdrift suppress` hint; got:\n{stdout}" + ); +} + +#[test] +fn diff_auto_detects_gitlab_when_gitlab_ci_env_is_true() { + // a2 acceptance: `GITLAB_CI=true` flips the rendered footer to the + // GitLab shape without requiring `--platform gitlab`. `CI_PROJECT_URL` + // doubles as the repo-URL source so users on the GitLab template + // don't have to plumb `BOMDRIFT_REPO_URL` themselves. + let out = Command::new(bin()) + .current_dir(manifest_dir()) + .env_remove("BOMDRIFT_REPO_URL") + .env("GITLAB_CI", "true") + .env("CI_PROJECT_URL", "https://gitlab.com/group/project") + .args([ + "diff", + "tests/fixtures/cdx-minimal.json", + "tests/fixtures/cdx-after.json", + "--no-osv", + ]) + .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.contains("https://gitlab.com/group/project/-/issues/new"), + "expected GitLab `/-/issues/new` URL shape; got:\n{stdout}" + ); + assert!( + stdout.contains("bomdrift baseline add"), + "GitLab footer must point at `bomdrift baseline add`; got:\n{stdout}" + ); + assert!( + !stdout.contains("/bomdrift suppress"), + "GitLab footer must NOT mention the GitHub-only `/bomdrift suppress` flow; got:\n{stdout}" + ); +} + +#[test] +fn diff_explicit_platform_flag_overrides_ci_env_detection() { + // Precedence: explicit `--platform github` wins even if `GITLAB_CI=true` + // happens to be set in the caller's shell. Same shape guarantee as + // the env-var test above, but with the flag forcing GitHub. + let out = Command::new(bin()) + .current_dir(manifest_dir()) + .env("GITLAB_CI", "true") + .env_remove("BOMDRIFT_REPO_URL") + .env_remove("CI_PROJECT_URL") + .args([ + "diff", + "tests/fixtures/cdx-minimal.json", + "tests/fixtures/cdx-after.json", + "--no-osv", + "--platform", + "github", + "--repo-url", + "https://github.com/example/proj", + ]) + .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.contains("https://github.com/example/proj/issues/new"), + "expected GitHub footer URL from explicit --platform github + --repo-url; got:\n{stdout}" + ); + assert!(stdout.contains("/bomdrift suppress")); + assert!( + !stdout.contains("/-/issues/new"), + "explicit --platform github must override GITLAB_CI auto-detection; got:\n{stdout}" + ); +} + +#[test] +fn diff_debug_calibration_prints_csv_lines_to_stderr() { + // b7 acceptance: `--debug-calibration` emits one + // `kind|key|score|threshold` line per finding to stderr, leaves + // stdout untouched, and exits 0 in the no-fail-on path. The + // axios-fixture pair is known to produce 1 typosquat finding (see + // `diff_axios_fixture_pair_renders_typosquat_section`); pinning on + // that lets us verify the schema without depending on the exact + // count of all finding kinds. + let out = Command::new(bin()) + .current_dir(manifest_dir()) + .args([ + "diff", + "tests/fixtures/cdx-minimal.json", + "tests/fixtures/cdx-after.json", + "--no-osv", + "--debug-calibration", + ]) + .output() + .expect("spawn bomdrift"); + + assert!( + out.status.success(), + "exit code: {}\nstderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + + let stderr = String::from_utf8(out.stderr).expect("stderr is utf-8"); + let typosquat_lines: Vec<&str> = stderr + .lines() + .filter(|l| l.starts_with("typosquat|")) + .collect(); + assert!( + !typosquat_lines.is_empty(), + "expected at least one typosquat calibration line; got stderr:\n{stderr}" + ); + // Schema: `kind|key|score|threshold` — exactly 4 pipe-separated fields. + for line in &typosquat_lines { + let fields: Vec<&str> = line.split('|').collect(); + assert_eq!( + fields.len(), + 4, + "calibration line must have 4 pipe-separated fields; got: {line}" + ); + assert_eq!(fields[0], "typosquat"); + // score and threshold are floats; both should parse. + let score: f64 = fields[2].parse().expect("score is a float"); + let threshold: f64 = fields[3].parse().expect("threshold is a float"); + assert!( + (0.0..=1.0).contains(&score), + "typosquat similarity score must be in [0, 1]; got {score}" + ); + assert!( + score >= threshold, + "a reported finding must clear its threshold; score={score} threshold={threshold}" + ); + } + + // Stdout must remain pure markdown — calibration is a stderr-only + // side channel so it doesn't pollute PR-comment posting pipelines. + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + assert!( + stdout.starts_with("## SBOM diff"), + "stdout must remain pure markdown when calibration is enabled; got:\n{stdout}" + ); + assert!( + !stdout.contains("typosquat|"), + "calibration output must NOT leak into stdout; got:\n{stdout}" + ); +}