From 755ad14f3842832b6a4bfd7ab6ec846a50edc544 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:20:18 -0700 Subject: [PATCH 01/11] ci: pin Rust toolchain to MSRV 1.88 across workflows Replace dtolnay/rust-toolchain@stable with @1.88 in ci.yml, release.yml, and docs.yml so CI tracks the documented MSRV instead of whatever 'stable' resolves to on the runner. Newer clippy lints (e.g. cloned_ref_to_slice_refs, useless_vec, is_multiple_of) shipped in 1.94 and previously broke the build until source was adapted; pinning here removes that surprise. Bump deliberately when Cargo.toml rust-version is bumped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 7 ++++--- .github/workflows/docs.yml | 3 ++- .github/workflows/release.yml | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a393ed..958d80d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +# Pinned to MSRV (1.88) intentionally; bump deliberately when updating Cargo.toml rust-version. name: CI on: @@ -14,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88 with: components: rustfmt - run: cargo fmt --all --check @@ -23,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88 with: components: clippy - uses: Swatinem/rust-cache@v2 @@ -37,7 +38,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88 - uses: Swatinem/rust-cache@v2 - run: cargo test --all-features diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e38b448..c8b7e42 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,3 +1,4 @@ +# Pinned to MSRV (1.88) intentionally; bump deliberately when updating Cargo.toml rust-version. name: docs on: @@ -27,7 +28,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.88 - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8050c4e..0c7a642 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Pinned to MSRV (1.88) intentionally; bump deliberately when updating Cargo.toml rust-version. name: release # Triggered when a SemVer tag (e.g. v0.1.0) is pushed. Builds release binaries @@ -100,7 +101,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.88 with: targets: ${{ matrix.target }} From 990e2098b92e0295f02b3a34619d5bea3a2f1da0 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:20:57 -0700 Subject: [PATCH 02/11] docs(gitlab): document note upsert + threading semantics Add a 'How notes are upserted' subsection to docs/src/gitlab-ci.md that pins down the previously-unverified v0.7 question of whether the GitLab Notes API PUT preserves threading. Documented behaviour: - POST/PUT against /merge_requests/:iid/notes is a true upsert: the note ID is stable, so permalinks survive and the comment doesn't move in the MR timeline. - PUT does not refire Note Hook webhooks (so a comment-bridge wired to Note Hook does not loop on bomdrift's own edits). - Threaded replies live under the parent discussion, not the note, so reviewer replies stay attached across upserts -- matching the GitHub upsert shape. - Author/signing caveat: edits surface under the project access token's bot identity, not the MR author. Also explains why the diff path uses the Notes API rather than the Discussions API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/gitlab-ci.md | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/src/gitlab-ci.md b/docs/src/gitlab-ci.md index 2336200..9708f73 100644 --- a/docs/src/gitlab-ci.md +++ b/docs/src/gitlab-ci.md @@ -212,6 +212,62 @@ The threat model is documented in The same logic ports to Vercel / Netlify / AWS Lambda — see [`vercel-equivalent.md`](https://github.com/Metbcy/bomdrift/blob/main/examples/gitlab-ci/comment-bridge/vercel-equivalent.md). +### How notes are upserted + +bomdrift posts the diff as a single MR **note**, not as a Discussion. +The lifecycle is: + +- **First run:** `POST /projects/:id/merge_requests/:iid/notes` creates + the note. The response carries an integer `id` which bomdrift + records implicitly by re-finding the note via the + `` marker on subsequent runs. +- **Subsequent runs:** `PUT /projects/:id/merge_requests/:iid/notes/:note_id` + modifies the existing note's `body` in place. + +Concretely the upsert: + +- **Modifies the note body in place.** The note ID is stable across + pipeline runs, so any permalink to the note (right-click → Copy + link on the timestamp) keeps working for the lifetime of the MR. +- **Does not regenerate the note.** GitLab does not delete-and-recreate + on PUT; the comment's position in the MR timeline does not move. +- **Does not re-fire `Note Hook` webhooks for unchanged content.** + GitLab fires `Note Hook` on note creation but not on body-only + edits, so a comment-bridge wired to `Note Hook` will not loop on + bomdrift's own upserts. (The bridge's event-type filter is a + defence-in-depth here, not the primary guard.) +- **Does not affect threaded replies.** GitLab's data model puts + notes and replies under a parent **discussion**; replies attached + to bomdrift's note (e.g. a reviewer typing "ack — accepting this") + remain attached to the same discussion thread regardless of how + many times bomdrift edits the parent body. This matches the + GitHub-side behaviour where reviewer threaded replies under the + bot comment survive each upsert. + +bomdrift deliberately uses the **Notes API**, not the **Discussions +API**, for the diff template. The Discussions API creates a thread +root that is awkward to update (you'd be editing the first note of +a discussion, with subtly different permission semantics), and the +diff comment isn't trying to start a structured conversation — it's +a single living status comment that reviewers may reply to. Other +reviewers can still reply to the bot's note and GitLab will create +a discussion implicitly around their reply; bomdrift just doesn't +seed the discussion itself. + +#### Author and signing + +The note's `author` is whatever identity owns `BOMDRIFT_API_TOKEN` +(typically a Project Access Token, which surfaces as a bot user on +the project). On every PUT, GitLab updates the note's `updated_at` +and `last_edited_by_id` fields to point at that same bot identity — +**not** the original MR author. This is expected and matches the +GitHub equivalent's behaviour with a bot token: edits show up under +the bot's identity, while the original commit/MR authorship is +untouched. If your review process audits comment-edit history +(unusual but legitimate on regulated projects), give the token a +descriptive name (e.g. `bomdrift-ci-bot`) so the audit trail reads +clearly. + ### Recommended hosting Cloudflare Workers — the reference. The free tier covers most From 540ac77be657714c8ea247c8eee5fb07bbbcdf77 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:22:23 -0700 Subject: [PATCH 03/11] chore(deps): exact-pin spdx crate to avoid silent license-list drift Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b0201ba..afd80bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ directories = "6" toml = "0.8" time = { version = "0.3", default-features = false, features = ["serde", "parsing", "formatting", "macros", "std"] } sha2 = { version = "0.10", default-features = false } -spdx = { version = "0.10", default-features = false } +# Exact-pinned: SPDX list updates can shift LicenseId.is_gnu() / is_osi_approved membership and silently change license-policy semantics. Bump deliberately. +spdx = { version = "=0.10.9", default-features = false } [dev-dependencies] criterion = { version = "0.5", default-features = false, features = ["html_reports"] } From fd46e0b4fbfe1c6a421926c1e4d7df90681175d9 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:23:53 -0700 Subject: [PATCH 04/11] refactor(comment-suppress): single source of truth for suppress regex + CI sync guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places previously held copies of the /bomdrift suppress grammar: - comment-suppress/entrypoint.sh (GitHub Action shell) - examples/gitlab-ci/comment-bridge/worker.js (Cloudflare Worker JS) - src/baseline.rs::parse_comment_directive (Rust, already documented as the canonical-via-doc-comment in v0.7) Promote the shell side to a sourced library in scripts/parse-suppress-comment.sh, exposing parse_bomdrift_suppress() with documented return codes. comment-suppress/entrypoint.sh now sources it instead of inlining the regex + ID validator. The Cloudflare Worker bridges can't source bash (different runtime), so worker.js declares the regex with a comment pointing at the canonical bash file. scripts/check-suppress-regex-sync.sh extracts both regexes, normalizes [[:space:]]<->\\s and the JS / escapes, and fails if they disagree. The new shell-bridges CI job runs: - comment-suppress/test.sh (8 unit tests on the bash parser) - check-suppress-regex-sync.sh - bash -n on all shell scripts - node --check on every bridge worker.js The Rust regex stays as-is — Rust regex syntax differs slightly from POSIX/JS so the parsers can't literally share bytes. The existing doc comment on baseline::parse_comment_directive already references the shell counterpart; the new CI guard keeps the two flavours that CAN share grammar (shell + JS) in lockstep. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 22 ++++++ comment-suppress/entrypoint.sh | 49 ++++++------ comment-suppress/test.sh | 55 +++++++++++++ examples/gitlab-ci/comment-bridge/worker.js | 7 +- scripts/README.md | 25 ++++++ scripts/check-suppress-regex-sync.sh | 86 +++++++++++++++++++++ scripts/parse-suppress-comment.sh | 70 +++++++++++++++++ 7 files changed, 287 insertions(+), 27 deletions(-) create mode 100755 comment-suppress/test.sh create mode 100644 scripts/README.md create mode 100755 scripts/check-suppress-regex-sync.sh create mode 100755 scripts/parse-suppress-comment.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 958d80d..c85c315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,25 @@ jobs: - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check + + shell-bridges: + name: shell-bridges (parser tests + regex sync) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: comment-suppress parser unit tests + run: bash comment-suppress/test.sh + - name: suppress-regex sync (shell ↔ bridge JS) + run: bash scripts/check-suppress-regex-sync.sh + - name: bash syntax check (scripts/, comment-suppress/) + run: | + for f in scripts/*.sh comment-suppress/*.sh; do + [ -f "$f" ] || continue + bash -n "$f" + done + - name: node syntax check (bridge workers) + run: | + for f in examples/*/comment-bridge/worker.js; do + [ -f "$f" ] || continue + node --check "$f" + done diff --git a/comment-suppress/entrypoint.sh b/comment-suppress/entrypoint.sh index bd470f3..09ee358 100755 --- a/comment-suppress/entrypoint.sh +++ b/comment-suppress/entrypoint.sh @@ -13,6 +13,13 @@ set -euo pipefail +# Suppress-directive grammar lives in scripts/parse-suppress-comment.sh +# (single source of truth shared with the GitLab/Bitbucket/Azure DevOps +# Cloudflare Worker bridges). CI guard: scripts/check-suppress-regex-sync.sh. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../scripts/parse-suppress-comment.sh +source "$SCRIPT_DIR/../scripts/parse-suppress-comment.sh" + REPO="Metbcy/bomdrift" GH_DL="https://github.com/${REPO}/releases/download" # REPO above is the bomdrift project (where the release archives live). @@ -56,39 +63,29 @@ if [ "$is_pr_comment" != "true" ]; then fi comment_body="$(jq -r '.comment.body' "$event_path")" -if [[ ! "$comment_body" =~ ^/bomdrift[[:space:]]+suppress[[:space:]]+ ]]; then - notice "skipping: comment body does not start with '/bomdrift suppress'" +if ! grep -qE '^[[:space:]]*/bomdrift[[:space:]]+suppress[[:space:]]+' <<< "$comment_body"; then + notice "skipping: comment body does not contain '/bomdrift suppress'" exit 0 fi -# Parse out the advisory ID. Allow an optional trailing newline / extra args. -advisory_id="$(printf '%s\n' "$comment_body" | awk 'NR==1 { print $3 }')" +# Single source of truth: scripts/parse-suppress-comment.sh. +# rc=0 → matched, rc=1 → no directive, rc=2 → matched but malformed ID. +set +e +parse_bomdrift_suppress "$comment_body" +parse_rc=$? +set -e +case "$parse_rc" in + 0) advisory_id="$BOMDRIFT_PARSED_ID"; reason="$BOMDRIFT_PARSED_REASON" ;; + 1) notice "skipping: comment body does not contain a /bomdrift suppress directive" + exit 0 ;; + 2) fail "advisory id does not match (GHSA|CVE|MAL|OSV)-... shape: ${BOMDRIFT_PARSED_ID}" ;; + *) fail "internal error: parse_bomdrift_suppress returned $parse_rc" ;; +esac + if [ -z "$advisory_id" ]; then fail "could not parse advisory id from comment body: $comment_body" fi -# Optional `reason: ` line in the comment body. v0.8+: when -# present, the entry is recorded in the v0.8 object form so the reason -# is preserved alongside the advisory id. Pattern matches the start of -# any line (case-insensitive) so reviewers can write -# `reason: awaiting upstream patch (issue #42)` on a continuation line. -# -# This shell parser MUST stay in lockstep with the Rust -# `baseline::parse_comment_directive` parser used by the GitLab -# webhook bridge (`bomdrift baseline add --from-comment`). Any -# grammar change has to land in both places. -reason="$(printf '%s\n' "$comment_body" \ - | grep -iE '^\s*reason:\s*' \ - | head -n1 \ - | sed -E 's/^\s*[Rr]eason:\s*//')" - -# Validate it looks like an advisory id we'd expect from OSV.dev. Reject -# anything else early so a typo doesn't turn into a no-op suppress with no -# user-visible feedback. -if [[ ! "$advisory_id" =~ ^(GHSA-[a-z0-9-]+|CVE-[0-9]{4}-[0-9]+|MAL-[0-9]{4}-[0-9]+)$ ]]; then - fail "advisory id does not match GHSA-/CVE-/MAL- shape: $advisory_id" -fi - pr_number="$(jq -r '.issue.number' "$event_path")" comment_id="$(jq -r '.comment.id' "$event_path")" commenter="$(jq -r '.comment.user.login' "$event_path")" diff --git a/comment-suppress/test.sh b/comment-suppress/test.sh new file mode 100755 index 0000000..7785511 --- /dev/null +++ b/comment-suppress/test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Smoke tests for the shared comment-suppress parser. +# +# Run from the repo root: bash comment-suppress/test.sh +# +# This is intentionally tiny — a handful of asserts that exercise each +# documented return code of parse_bomdrift_suppress. The Rust +# counterpart has its own unit tests in src/baseline.rs; this script +# guards the shell side. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../scripts/parse-suppress-comment.sh +source "$SCRIPT_DIR/../scripts/parse-suppress-comment.sh" + +fail_count=0 +ok_count=0 + +assert_parse() { + local desc="$1" body="$2" expect_rc="$3" expect_id="$4" expect_reason="$5" + set +e + parse_bomdrift_suppress "$body" + local rc=$? + set -e + if [ "$rc" != "$expect_rc" ]; then + echo "FAIL [$desc]: rc=$rc, expected $expect_rc" + fail_count=$((fail_count + 1)) + return + fi + if [ "$BOMDRIFT_PARSED_ID" != "$expect_id" ]; then + echo "FAIL [$desc]: id='$BOMDRIFT_PARSED_ID', expected '$expect_id'" + fail_count=$((fail_count + 1)) + return + fi + if [ "$BOMDRIFT_PARSED_REASON" != "$expect_reason" ]; then + echo "FAIL [$desc]: reason='$BOMDRIFT_PARSED_REASON', expected '$expect_reason'" + fail_count=$((fail_count + 1)) + return + fi + ok_count=$((ok_count + 1)) +} + +assert_parse "id only" "/bomdrift suppress GHSA-h4j7-mhg8-9q57" 0 "GHSA-h4j7-mhg8-9q57" "" +assert_parse "id + reason" "/bomdrift suppress CVE-2024-12345 reason: dev only" 0 "CVE-2024-12345" "dev only" +assert_parse "leading whitespace" " /bomdrift suppress MAL-2025-1 reason: x" 0 "MAL-2025-1" "x" +assert_parse "trailing newline" $'/bomdrift suppress OSV-2024-1\n' 0 "OSV-2024-1" "" +assert_parse "multi-line body" $'thanks!\n/bomdrift suppress GHSA-aaaa-bbbb-cccc reason: ack\n' 0 "GHSA-aaaa-bbbb-cccc" "ack" +assert_parse "no directive" "looks good to me" 1 "" "" +assert_parse "bad id prefix" "/bomdrift suppress NOPE-123" 2 "NOPE-123" "" +assert_parse "trailing ws reason" "/bomdrift suppress CVE-2024-1 reason: noisy " 0 "CVE-2024-1" "noisy" + +echo +echo "passed: $ok_count, failed: $fail_count" +[ "$fail_count" -eq 0 ] diff --git a/examples/gitlab-ci/comment-bridge/worker.js b/examples/gitlab-ci/comment-bridge/worker.js index 253258d..9a67b69 100644 --- a/examples/gitlab-ci/comment-bridge/worker.js +++ b/examples/gitlab-ci/comment-bridge/worker.js @@ -12,6 +12,11 @@ * PIPELINE_TRIGGER_TOKEN. */ +// Suppress-comment regex. CANONICAL DEFINITION lives in +// scripts/parse-suppress-comment.sh — keep these in sync. +// CI guard: scripts/check-suppress-regex-sync.sh diffs the two. +const BOMDRIFT_SUPPRESS_REGEX = /^\s*\/bomdrift\s+suppress\s+([A-Za-z0-9-]+)(\s+reason:\s*(.+))?\s*$/m; + export default { async fetch(request, env) { if (request.method !== "POST") return new Response("method", { status: 405 }); @@ -52,7 +57,7 @@ export default { // Quick parse: comment looks like a directive? const text = body?.object_attributes?.note ?? ""; - if (!/\/bomdrift\s+suppress\s+\S+/.test(text)) { + if (!BOMDRIFT_SUPPRESS_REGEX.test(text)) { return new Response("no directive", { status: 204 }); } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..84744e1 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,25 @@ +# scripts/ + +Helper scripts shared across the bomdrift platform surface (CI, +shell-bridges, GitHub composite Action). Anything in this directory is +intended to be sourced or invoked from a workflow or documented user +flow — not bundled into the `bomdrift` binary. + +## Files + +| Script | Purpose | +|---|---| +| `parse-suppress-comment.sh` | Canonical bash library defining the `/bomdrift suppress [reason: ...]` grammar. Sourced by `comment-suppress/entrypoint.sh`. The Cloudflare Worker bridges (GitLab, Bitbucket, Azure DevOps) each carry an equivalent JS copy of the regex; `check-suppress-regex-sync.sh` keeps them in lockstep. | +| `check-suppress-regex-sync.sh` | CI guard. Extracts the canonical regex from `parse-suppress-comment.sh` and compares (after light POSIX↔JS normalization) against every bridge `worker.js`. Wired into `.github/workflows/ci.yml`. | + +## Adding a new bridge + +When you add a new SCM bridge worker (e.g. `examples//comment-bridge/worker.js`): + +1. Copy the regex declaration block from an existing bridge — keep the + `// CANONICAL DEFINITION lives in scripts/parse-suppress-comment.sh` + comment intact. +2. Append the new path to the `copies=( ... )` array in + `check-suppress-regex-sync.sh`. +3. Run `bash scripts/check-suppress-regex-sync.sh` locally; commit only + when it prints `all suppress-regex copies agree`. diff --git a/scripts/check-suppress-regex-sync.sh b/scripts/check-suppress-regex-sync.sh new file mode 100755 index 0000000..041ef87 --- /dev/null +++ b/scripts/check-suppress-regex-sync.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# CI guard: ensure the suppress-comment regex stays in sync between the +# canonical bash definition (scripts/parse-suppress-comment.sh) and +# every Cloudflare Worker bridge that has its own copy. +# +# The shell and JS regex flavours are not byte-identical (POSIX uses +# [[:space:]], JS uses \s; JS escapes / inside literals), so we +# normalize both sides into a common shape and compare those. +# +# Run from the repo root: +# bash scripts/check-suppress-regex-sync.sh +# +# Exit codes: +# 0 — all copies agree with the canonical definition +# 1 — at least one copy disagrees (or could not be extracted) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CANON="$REPO_ROOT/scripts/parse-suppress-comment.sh" + +# Normalize a regex string into a canonical comparable form: +# - [[:space:]] → \s +# - \/ → / +# - drop trailing flags (we compare bodies, not flag sets) +normalize() { + sed -E \ + -e 's/\[\[:space:\]\]/\\s/g' \ + -e 's/\\\//\//g' +} + +# Extract the canonical regex body from the shell file. +canon_raw="$(grep -E "^BOMDRIFT_SUPPRESS_REGEX=" "$CANON" \ + | head -n1 \ + | sed -E "s/^BOMDRIFT_SUPPRESS_REGEX='(.*)'$/\1/")" +if [ -z "$canon_raw" ]; then + echo "FAIL: could not extract BOMDRIFT_SUPPRESS_REGEX from $CANON" >&2 + exit 1 +fi +canon_norm="$(printf '%s' "$canon_raw" | normalize)" + +# Files that must carry an in-sync copy of the regex. Each entry is a +# bridge worker.js that re-declares the regex (the JS runtime can't +# source bash). Add new bridge files here as they're introduced. +copies=( + "examples/gitlab-ci/comment-bridge/worker.js" + "examples/bitbucket-pipelines/comment-bridge/worker.js" + "examples/azure-devops/comment-bridge/worker.js" +) + +fail=0 +for rel in "${copies[@]}"; do + path="$REPO_ROOT/$rel" + if [ ! -f "$path" ]; then + # Not yet introduced (e.g. on older branches). Skip rather than fail. + echo "skip: $rel (not present)" + continue + fi + # Extract the JS literal body between the leading and trailing /. + # Tolerates an optional flag suffix like /m or /im. + raw="$(grep -E "^const BOMDRIFT_SUPPRESS_REGEX[[:space:]]*=" "$path" \ + | head -n1 \ + | sed -E 's|^const BOMDRIFT_SUPPRESS_REGEX[[:space:]]*=[[:space:]]*/(.*)/[a-z]*;[[:space:]]*$|\1|')" + if [ -z "$raw" ]; then + echo "FAIL: could not extract BOMDRIFT_SUPPRESS_REGEX from $rel" >&2 + fail=1 + continue + fi + norm="$(printf '%s' "$raw" | normalize)" + if [ "$norm" != "$canon_norm" ]; then + echo "FAIL: regex in $rel disagrees with canonical $CANON" >&2 + echo " canonical (normalized): $canon_norm" >&2 + echo " copy (normalized): $norm" >&2 + fail=1 + else + echo "ok: $rel" + fi +done + +if [ "$fail" -ne 0 ]; then + echo >&2 + echo "Update the disagreeing bridge to match scripts/parse-suppress-comment.sh, or" >&2 + echo "update the canonical definition and every bridge in lockstep." >&2 + exit 1 +fi +echo "all suppress-regex copies agree with canonical definition" diff --git a/scripts/parse-suppress-comment.sh b/scripts/parse-suppress-comment.sh new file mode 100755 index 0000000..4445ad5 --- /dev/null +++ b/scripts/parse-suppress-comment.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Single source of truth for the bomdrift suppress-comment grammar. +# +# This file is sourced by: +# - comment-suppress/entrypoint.sh (GitHub composite Action shell) +# And is the canonical reference (kept in sync via +# scripts/check-suppress-regex-sync.sh) for: +# - examples/gitlab-ci/comment-bridge/worker.js +# - examples/bitbucket-pipelines/comment-bridge/worker.js (v0.9.5+) +# - examples/azure-devops/comment-bridge/worker.js (v0.9.5+) +# +# The Rust counterpart `bomdrift::baseline::parse_comment_directive` +# carries a doc comment pointing at this file. Rust regex syntax differs +# from POSIX/JS so the parsers can't literally share bytes; the human- +# readable contract below is what the implementations agree on. +# +# Grammar (one directive per comment, single-line): +# +# /bomdrift suppress [ reason: ] +# +# where matches (GHSA|CVE|MAL|OSV)-[A-Za-z0-9-]+ +# (real GHSA ids use lowercase a-z; CVE / MAL / OSV are uppercase digits +# with year-id segments — the union grammar accepts both casings.) +# +# Leading whitespace is permitted; trailing whitespace after the reason +# is stripped. The directive may be preceded by other lines in a +# multi-line comment body; this parser scans line-by-line and matches +# the first directive it finds. + +# Public regex constants. Exported so wrapping scripts (and the CI +# regex-sync guard) can read them without re-sourcing in a subshell. +# shellcheck disable=SC2034 +BOMDRIFT_SUPPRESS_REGEX='^[[:space:]]*/bomdrift[[:space:]]+suppress[[:space:]]+([A-Za-z0-9-]+)([[:space:]]+reason:[[:space:]]*(.+))?[[:space:]]*$' +# shellcheck disable=SC2034 +BOMDRIFT_ID_VALIDATE='^(GHSA|CVE|MAL|OSV)-[A-Za-z0-9-]+$' + +# parse_bomdrift_suppress +# +# Sets the following variables on success: +# BOMDRIFT_PARSED_ID — the advisory ID +# BOMDRIFT_PARSED_REASON — the reason text (may be empty) +# +# Returns: +# 0 — directive found and ID is well-formed +# 1 — no directive found (caller should treat as a no-op skip) +# 2 — directive found but ID is malformed (caller should fail loudly) +parse_bomdrift_suppress() { + local body="$1" + local line + BOMDRIFT_PARSED_ID="" + BOMDRIFT_PARSED_REASON="" + + while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ $BOMDRIFT_SUPPRESS_REGEX ]]; then + local id="${BASH_REMATCH[1]}" + local reason="${BASH_REMATCH[3]:-}" + # Trim trailing whitespace from reason. + reason="${reason%"${reason##*[![:space:]]}"}" + if [[ ! "$id" =~ $BOMDRIFT_ID_VALIDATE ]]; then + BOMDRIFT_PARSED_ID="$id" + return 2 + fi + BOMDRIFT_PARSED_ID="$id" + BOMDRIFT_PARSED_REASON="$reason" + return 0 + fi + done <<< "$body" + + return 1 +} From 295937746a1de5bb9baa2f43b8e191098b8f046d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:24:29 -0700 Subject: [PATCH 05/11] refactor(baseline): unify BaselineEntry / ExpiredEntry shape Pre-v0.9.5, ExpiredEntry duplicated four fields of BaselineEntry. They now share one struct; expired_entries is Vec with the invariant that expires.is_some() and the date is strictly before today at load time. ExpiredEntry remains a #[deprecated] type alias for back-compat with external consumers. The stderr warning text emitted by lib.rs is unchanged byte-for-byte; a new regression test (expired_entry_warning_text_is_stable) pins that format string against future drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/baseline.rs | 82 ++++++++++++++++++++++++++++++++++++++++--------- src/lib.rs | 2 +- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/baseline.rs b/src/baseline.rs index b114ef2..c18897b 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -60,8 +60,9 @@ pub struct Baseline { suppressed_advisories: HashSet, /// v0.8+ entries that have already passed their `expires` date. /// Surface to the caller for stderr warnings; do NOT contribute to - /// suppression. - pub expired_entries: Vec, + /// suppression. Each entry has `expires.is_some()` and is guaranteed + /// to be strictly before today at load time. + pub expired_entries: Vec, /// v0.9+ rich entries from object-form `suppressed_advisories`. /// Keyed in insertion order so VEX emission (Phase H) can surface /// `vex_status` / `vex_justification` / `reason` without re-parsing @@ -73,6 +74,12 @@ pub struct Baseline { /// A rich baseline entry preserved for VEX emission. Plain string-form /// entries (`"GHSA-..."`) do NOT appear here — they have no metadata /// to preserve. Object-form entries always do. +/// +/// v0.9.5: previously two distinct structs (`BaselineEntry` and +/// `ExpiredEntry`) overlapped on `id` / `purl` / `expires` / `reason`. +/// They are now a single shape; entries pushed into +/// [`Baseline::expired_entries`] additionally guarantee +/// `expires.is_some()`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BaselineEntry { pub id: String, @@ -83,16 +90,15 @@ pub struct BaselineEntry { pub vex_justification: Option, } -/// A baseline entry whose `expires` date is strictly before today. The diff -/// will surface the underlying finding; bomdrift prints one warning per -/// expired entry to stderr after baseline load. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExpiredEntry { - pub id: String, - pub purl: Option, - pub expires: String, - pub reason: Option, -} +/// Back-compat alias for the unified [`BaselineEntry`] shape. Pre-v0.9.5 +/// callers used a distinct `ExpiredEntry` struct; the alias preserves +/// `bomdrift::baseline::ExpiredEntry` as a name while sharing the +/// underlying type. +#[deprecated( + since = "0.9.5", + note = "use BaselineEntry directly; expired_entries is Vec with expires.is_some()" +)] +pub type ExpiredEntry = BaselineEntry; impl Baseline { pub fn load(path: &Path) -> Result { @@ -231,11 +237,13 @@ impl Baseline { match clock::parse_ymd(expires_s) { Ok(date) => { if clock::is_expired(date) { - out.expired_entries.push(ExpiredEntry { + out.expired_entries.push(BaselineEntry { id: id.to_string(), purl: purl.clone(), - expires: expires_s.to_string(), reason: reason.clone(), + expires: expires_str.clone(), + vex_status: vex_status.clone(), + vex_justification: vex_justification.clone(), }); // Expired entries do NOT contribute to suppression. continue; @@ -862,12 +870,58 @@ mod tests { })); assert_eq!(baseline.expired_entries.len(), 1); assert_eq!(baseline.expired_entries[0].id, "GHSA-old"); + // After v0.9.5 unification, expired_entries shares the + // BaselineEntry shape; expires is Option but always Some here. + assert_eq!( + baseline.expired_entries[0].expires.as_deref(), + Some("2026-04-30") + ); + assert_eq!( + baseline.expired_entries[0].reason.as_deref(), + Some("awaiting upstream") + ); assert!( !baseline.suppressed_advisories.contains("GHSA-old"), "expired entry must NOT contribute to suppression" ); } + /// Regression: the stderr warning text rendered by lib.rs must remain + /// byte-for-byte stable across v0.9.5's BaselineEntry/ExpiredEntry + /// unification. CI integrators grep this string. + #[test] + fn expired_entry_warning_text_is_stable() { + let _g = lock_today(1777593600); + let baseline = Baseline::from_value(&json!({ + "suppressed_advisories": [ + { "id": "GHSA-old", "purl": "pkg:npm/foo@1.0.0", + "expires": "2026-04-30", "reason": "awaiting upstream" } + ] + })); + let ent = &baseline.expired_entries[0]; + // Mirror the format string used in src/lib.rs (the production + // warning emitter). If either side drifts, this fails loudly. + let rendered = format!( + "warning: baseline entry {id}{purl} expired {expires}; finding will surface in this run{reason}", + id = ent.id, + purl = ent + .purl + .as_deref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(), + expires = ent.expires.as_deref().unwrap_or(""), + reason = ent + .reason + .as_deref() + .map(|r| format!(" — was: {r}")) + .unwrap_or_default(), + ); + assert_eq!( + rendered, + "warning: baseline entry GHSA-old (pkg:npm/foo@1.0.0) expired 2026-04-30; finding will surface in this run — was: awaiting upstream" + ); + } + #[test] fn active_object_entry_suppresses() { let _g = lock_today(1777593600); // 2026-05-01 diff --git a/src/lib.rs b/src/lib.rs index f8d679f..58320cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -227,7 +227,7 @@ fn run_diff(mut args: DiffArgs) -> Result<()> { .as_deref() .map(|p| format!(" ({p})")) .unwrap_or_default(), - expires = ent.expires, + expires = ent.expires.as_deref().unwrap_or(""), reason = ent .reason .as_deref() From 630fb072e6aac24572ab5fc91f8b4b3c58a73008 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:28:30 -0700 Subject: [PATCH 06/11] feat(vex): public parse_synthetic_id helper for round-tripping bomdrift finding ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bomdrift::parse_synthetic_id() and SyntheticFindingKind, re-exported from the crate root so external VEX tooling can decode the synthetic finding ids bomdrift emits (e.g. as VEX statement vulnerability names) without re-implementing string-splitting against an undocumented format. Format remains 'bomdrift.:[:]'. The parser handles purls (one ':' from the 'pkg:' scheme) and the bare-component-name fallback the emitters use when component.purl is None. Synthetic-id emitters added for parity with the SARIF rule taxonomy: license-change, recently-published, deprecated, maintainer-set-changed. These are pure helpers — vex::apply / vex::emit are not yet wired to the new finding kinds (a v1.0+ scope item). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/lib.rs | 2 + src/vex.rs | 372 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 58320cb..7fda03d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ pub mod refresh; pub mod render; pub mod vex; +pub use crate::vex::{SyntheticFindingKind, parse_synthetic_id}; + use std::fs; use std::io::IsTerminal; use std::path::Path; diff --git a/src/vex.rs b/src/vex.rs index 5618c18..0f0bff2 100644 --- a/src/vex.rs +++ b/src/vex.rs @@ -383,11 +383,19 @@ fn effect_for(s: &VexStatement) -> VexEffect { /// is used by `--emit-vex` (Phase H) and `--vex` (this module) so users /// can write `not_affected` statements against typosquat / version-jump / /// maintainer-age / license-violation findings. +/// +/// Format: `bomdrift.:[:...]`. +/// +/// `` is either a full Package URL (begins `pkg:`) or, when the +/// component lacks one, the bare component name. Round-tripping via +/// [`super::parse_synthetic_id`] handles both shapes. pub mod synthetic_id { use crate::enrich::LicenseViolation; use crate::enrich::maintainer::MaintainerAgeFinding; + use crate::enrich::registry::{Deprecated, MaintainerSetChanged, RecentlyPublished}; use crate::enrich::typosquat::TyposquatFinding; use crate::enrich::version_jump::VersionJumpFinding; + use crate::model::Component; pub fn typosquat(f: &TyposquatFinding) -> String { let purl = f.component.purl.as_deref().unwrap_or(&f.component.name); @@ -411,6 +419,165 @@ pub mod synthetic_id { let purl = v.component.purl.as_deref().unwrap_or(&v.component.name); format!("bomdrift.license-violation:{purl}:{}", v.license) } + + /// License-change finding (same component+version, different license + /// set). Keyed only by purl — the change set is encoded in the + /// finding payload, not the synthetic id. + pub fn license_change(after: &Component) -> String { + let purl = after.purl.as_deref().unwrap_or(&after.name); + format!("bomdrift.license-change:{purl}") + } + + pub fn recently_published(f: &RecentlyPublished) -> String { + let purl = f.component.purl.as_deref().unwrap_or(&f.component.name); + format!("bomdrift.recently-published:{purl}") + } + + pub fn deprecated(f: &Deprecated) -> String { + let purl = f.component.purl.as_deref().unwrap_or(&f.component.name); + format!("bomdrift.deprecated:{purl}") + } + + pub fn maintainer_set_changed(f: &MaintainerSetChanged) -> String { + let purl = f.after.purl.as_deref().unwrap_or(&f.after.name); + format!("bomdrift.maintainer-set-changed:{purl}") + } +} + +/// Structured form of a parsed bomdrift synthetic finding id. See +/// [`parse_synthetic_id`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyntheticFindingKind { + Typosquat { + purl: String, + closest: String, + }, + VersionJump { + purl: String, + before: String, + after: String, + }, + MaintainerAge { + purl: String, + top_contributor: String, + }, + LicenseChange { + purl: String, + }, + LicenseViolation { + purl: String, + license: String, + }, + RecentlyPublished { + purl: String, + }, + Deprecated { + purl: String, + }, + MaintainerSetChanged { + purl: String, + }, +} + +/// Parse a bomdrift synthetic finding-id back into its structured form. +/// Round-trips against the format emitted by [`synthetic_id`]. +/// +/// Returns `None` for unrecognized formats — non-bomdrift advisory ids +/// (CVEs, GHSAs), malformed strings, or unknown kind tags. +/// +/// The `` segment may be a full Package URL (`pkg:type/...`) or a +/// bare component name when the source SBOM lacked a purl. Both forms +/// round-trip losslessly. +pub fn parse_synthetic_id(s: &str) -> Option { + let inner = s.strip_prefix("bomdrift.")?; + let (kind, rest) = inner.split_once(':')?; + let (purl, extras) = split_purl_and_extras(rest); + match kind { + "typosquat" => { + if extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::Typosquat { + purl, + closest: extras.to_string(), + }) + } + "version-jump" => { + let (before, after) = extras.split_once("->")?; + if before.is_empty() || after.is_empty() { + return None; + } + Some(SyntheticFindingKind::VersionJump { + purl, + before: before.to_string(), + after: after.to_string(), + }) + } + "young-maintainer" => { + if extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::MaintainerAge { + purl, + top_contributor: extras.to_string(), + }) + } + "license-violation" => { + if extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::LicenseViolation { + purl, + license: extras.to_string(), + }) + } + "license-change" => { + if !extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::LicenseChange { purl }) + } + "recently-published" => { + if !extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::RecentlyPublished { purl }) + } + "deprecated" => { + if !extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::Deprecated { purl }) + } + "maintainer-set-changed" => { + if !extras.is_empty() { + return None; + } + Some(SyntheticFindingKind::MaintainerSetChanged { purl }) + } + _ => None, + } +} + +/// Split the `[:...]` tail of a synthetic id. +/// +/// A Package URL contains exactly one `:` (the `pkg:` scheme separator), +/// so when `rest` starts with `pkg:` we recombine through that first +/// colon and use the next colon as the purl/extras boundary. When the +/// component lacked a purl the emitter substitutes the bare name (no +/// `:` inside), and we split at the first colon. +fn split_purl_and_extras(rest: &str) -> (String, &str) { + if let Some(after_pkg) = rest.strip_prefix("pkg:") { + match after_pkg.split_once(':') { + Some((purl_tail, extras)) => (format!("pkg:{purl_tail}"), extras), + None => (rest.to_string(), ""), + } + } else { + match rest.split_once(':') { + Some((name, extras)) => (name.to_string(), extras), + None => (rest.to_string(), ""), + } + } } /// Attached VEX annotation kept on a finding when status is `affected` or @@ -1049,4 +1216,209 @@ mod tests { assert_eq!(a, b); unpin_clock(); } + + // ---------- v0.9.5: parse_synthetic_id ---------- + + fn comp_with_purl(purl: &str) -> crate::model::Component { + crate::model::Component { + name: "x".into(), + version: "1.0.0".into(), + ecosystem: crate::model::Ecosystem::Npm, + purl: Some(purl.into()), + licenses: Vec::new(), + supplier: None, + hashes: Vec::new(), + relationship: crate::model::Relationship::Unknown, + source_url: None, + bom_ref: None, + } + } + + #[test] + fn parse_typosquat_round_trip() { + let f = crate::enrich::typosquat::TyposquatFinding { + component: comp_with_purl("pkg:npm/plain-crypto-js@4.2.1"), + closest: "crypto-js".into(), + score: 0.95, + }; + let id = synthetic_id::typosquat(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::Typosquat { + purl: "pkg:npm/plain-crypto-js@4.2.1".into(), + closest: "crypto-js".into(), + }) + ); + } + + #[test] + fn parse_version_jump_round_trip() { + let f = crate::enrich::version_jump::VersionJumpFinding { + before: comp_with_purl("pkg:npm/lib@1.0.0"), + after: comp_with_purl("pkg:npm/lib@4.0.0"), + before_major: 1, + after_major: 4, + }; + let id = synthetic_id::version_jump(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::VersionJump { + purl: "pkg:npm/lib@4.0.0".into(), + before: "1".into(), + after: "4".into(), + }) + ); + } + + #[test] + fn parse_maintainer_age_round_trip() { + let f = crate::enrich::maintainer::MaintainerAgeFinding { + component: comp_with_purl("pkg:npm/foo@1.0.0"), + top_contributor: "alice".into(), + days_old: 5, + first_commit_at: "2026-04-26".into(), + }; + let id = synthetic_id::maintainer_age(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::MaintainerAge { + purl: "pkg:npm/foo@1.0.0".into(), + top_contributor: "alice".into(), + }) + ); + } + + #[test] + fn parse_license_violation_round_trip_with_spdx_with_clause() { + let v = crate::enrich::LicenseViolation { + component: comp_with_purl("pkg:cargo/llvm-sys@1.0.0"), + license: "Apache-2.0 WITH LLVM-exception".into(), + matched_rule: "deny: GPL-3.0-only".into(), + kind: crate::enrich::LicenseViolationKind::Deny, + }; + let id = synthetic_id::license_violation(&v); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::LicenseViolation { + purl: "pkg:cargo/llvm-sys@1.0.0".into(), + license: "Apache-2.0 WITH LLVM-exception".into(), + }) + ); + } + + #[test] + fn parse_license_change_round_trip() { + let after = comp_with_purl("pkg:npm/foo@2.0.0"); + let id = synthetic_id::license_change(&after); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::LicenseChange { + purl: "pkg:npm/foo@2.0.0".into(), + }) + ); + } + + #[test] + fn parse_recently_published_round_trip() { + let f = crate::enrich::registry::RecentlyPublished { + component: comp_with_purl("pkg:npm/fresh@0.1.0"), + published_at: "2026-04-30".into(), + days_old: 1, + }; + let id = synthetic_id::recently_published(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::RecentlyPublished { + purl: "pkg:npm/fresh@0.1.0".into(), + }) + ); + } + + #[test] + fn parse_deprecated_round_trip() { + let f = crate::enrich::registry::Deprecated { + component: comp_with_purl("pkg:npm/old@1.0.0"), + message: Some("use new-pkg".into()), + }; + let id = synthetic_id::deprecated(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::Deprecated { + purl: "pkg:npm/old@1.0.0".into(), + }) + ); + } + + #[test] + fn parse_maintainer_set_changed_round_trip() { + let f = crate::enrich::registry::MaintainerSetChanged { + before: comp_with_purl("pkg:npm/foo@1.0.0"), + after: comp_with_purl("pkg:npm/foo@2.0.0"), + added: vec!["mallory".into()], + removed: vec!["alice".into()], + }; + let id = synthetic_id::maintainer_set_changed(&f); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::MaintainerSetChanged { + purl: "pkg:npm/foo@2.0.0".into(), + }) + ); + } + + #[test] + fn parse_synthetic_id_handles_bare_name_fallback() { + // When component lacks a purl, the emitter falls back to the + // bare component name. Round-trip must still work. + let mut comp = comp_with_purl(""); + comp.purl = None; + comp.name = "anon-pkg".into(); + let f = crate::enrich::typosquat::TyposquatFinding { + component: comp, + closest: "real-pkg".into(), + score: 0.9, + }; + let id = synthetic_id::typosquat(&f); + assert_eq!(id, "bomdrift.typosquat:anon-pkg:real-pkg"); + assert_eq!( + parse_synthetic_id(&id), + Some(SyntheticFindingKind::Typosquat { + purl: "anon-pkg".into(), + closest: "real-pkg".into(), + }) + ); + } + + #[test] + fn parse_synthetic_id_rejects_real_advisory_ids() { + assert_eq!(parse_synthetic_id("CVE-2024-1234"), None); + assert_eq!(parse_synthetic_id("GHSA-aaaa-bbbb-cccc"), None); + assert_eq!(parse_synthetic_id("OSV-2024-9999"), None); + } + + #[test] + fn parse_synthetic_id_rejects_malformed_strings() { + // Missing kind separator. + assert_eq!(parse_synthetic_id("bomdrift."), None); + // Unknown kind tag. + assert_eq!( + parse_synthetic_id("bomdrift.unknown-kind:pkg:npm/x@1.0.0"), + None + ); + // version-jump without `->` separator. + assert_eq!( + parse_synthetic_id("bomdrift.version-jump:pkg:npm/x@1.0.0:1to4"), + None + ); + // typosquat missing the closest segment. + assert_eq!( + parse_synthetic_id("bomdrift.typosquat:pkg:npm/x@1.0.0"), + None + ); + // license-change must NOT carry extras. + assert_eq!( + parse_synthetic_id("bomdrift.license-change:pkg:npm/x@1.0.0:extra"), + None + ); + } } From ba5096f762759b0240c6017a3454dc766561c36e Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:28:38 -0700 Subject: [PATCH 07/11] feat(bridges): Bitbucket + Azure DevOps comment-suppress Cloudflare Worker bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the v0.9 GitLab Cloudflare Worker bridge for Bitbucket Cloud and Azure DevOps so /bomdrift suppress comments now work as a zero-click suppression UX on all four major SCMs. Each bridge ships: - worker.js (Cloudflare Worker, ~150 lines, plain JS, no deps) - README.md (architecture diagram, threat model, deploy guide, platform-specific gotchas, troubleshooting table) - vercel-equivalent.md (port notes for Vercel / Netlify / Lambda) The same five-guard security model as the GitLab bridge, adapted to each platform's webhook + identity model: Bitbucket Cloud (examples/bitbucket-pipelines/comment-bridge/): 1. HMAC-SHA256 X-Hub-Signature against byte-exact body 2. Event-type filter: pullrequest:comment_created only 3. Repo-full-name allowlist (org/repo) 4. Commenter permission: write|admin|owner via /workspaces //permissions 5. PR-context: state=OPEN AND source.full_name===destination.full_name (rejects fork-PR comment-suppress) → triggers a custom 'bomdrift-comment-suppress' pipeline. Azure DevOps (examples/azure-devops/comment-bridge/): 1. X-Bomdrift-Bridge-Secret custom header (constant-time compare) 2. Event-type: ms.vss-code.git-pullrequest-comment-event 3. Project-UUID allowlist 4. Commenter is a member of the project's Contributors team 5. PR-context: status=active AND targetRefName===MAIN_BRANCH → triggers POST /_apis/pipelines//runs with BOMDRIFT_NOTE_BODY as a templateParameter. Both pipeline templates updated: - bitbucket-pipelines.yml gains a 'custom: bomdrift-comment-suppress' step (only fires when the bridge triggers it). - azure-pipelines.yml restructured into stages with a new bomdrift_suppress stage gated on the BOMDRIFT_NOTE_BODY parameter (normal PR builds leave it empty so only the diff stage runs). Both new bridge worker.js files declare the canonical BOMDRIFT_SUPPRESS_REGEX (with comment pointing at scripts/parse-suppress-comment.sh) and are now picked up by scripts/check-suppress-regex-sync.sh. Docs: - docs/src/bitbucket.md: 'Comment-driven suppression (advanced)' section mirroring the GitLab equivalent. - docs/src/azure-devops.md: same. - STATUS.md: Bitbucket + Azure DevOps rows note v0.9.5 bridge support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- STATUS.md | 4 +- docs/src/azure-devops.md | 39 ++++- docs/src/bitbucket.md | 34 +++- examples/azure-devops/azure-pipelines.yml | 136 +++++++++++----- .../azure-devops/comment-bridge/README.md | 110 +++++++++++++ .../comment-bridge/vercel-equivalent.md | 28 ++++ .../azure-devops/comment-bridge/worker.js | 147 +++++++++++++++++ .../bitbucket-pipelines.yml | 30 ++++ .../comment-bridge/README.md | 103 ++++++++++++ .../comment-bridge/vercel-equivalent.md | 30 ++++ .../comment-bridge/worker.js | 152 ++++++++++++++++++ 11 files changed, 764 insertions(+), 49 deletions(-) create mode 100644 examples/azure-devops/comment-bridge/README.md create mode 100644 examples/azure-devops/comment-bridge/vercel-equivalent.md create mode 100644 examples/azure-devops/comment-bridge/worker.js create mode 100644 examples/bitbucket-pipelines/comment-bridge/README.md create mode 100644 examples/bitbucket-pipelines/comment-bridge/vercel-equivalent.md create mode 100644 examples/bitbucket-pipelines/comment-bridge/worker.js diff --git a/STATUS.md b/STATUS.md index a78b1d8..2067025 100644 --- a/STATUS.md +++ b/STATUS.md @@ -19,8 +19,8 @@ keeping the project OSS-first: no hosted dashboard, no account, no telemetry. | Suppression expiry (`expires` + `reason`) | Supported (v0.8+) — time-boxed risk acceptance | | GitLab CI merge requests | Supported through the `examples/gitlab-ci/` template (v0.7+); comment-driven suppression supported via Cloudflare Worker bridge (v0.9+) | | GitHub Enterprise / self-hosted runners | Expected to work, not broadly tested yet | -| Bitbucket Pipelines | Supported (v0.9+) — `examples/bitbucket-pipelines/` | -| Azure DevOps Pipelines | Supported (v0.9+) — `examples/azure-devops/` | +| Bitbucket Pipelines | Supported (v0.9+) — `examples/bitbucket-pipelines/`; comment-driven suppression via Cloudflare Worker bridge (v0.9.5+) | +| Azure DevOps Pipelines | Supported (v0.9+) — `examples/azure-devops/`; comment-driven suppression via Cloudflare Worker bridge (v0.9.5+) | | VEX consume / emit | Supported (v0.9+) — OpenVEX 0.2.0 + CycloneDX VEX 1.6 | | SPDX expression evaluation | Supported (v0.9+) — full `Expression::evaluate` via `spdx` crate | | Registry-metadata enrichers (npm/PyPI/crates.io) | Supported (v0.9+) — recently-published, deprecated, maintainer-set-changed | diff --git a/docs/src/azure-devops.md b/docs/src/azure-devops.md index 6d8557f..430087f 100644 --- a/docs/src/azure-devops.md +++ b/docs/src/azure-devops.md @@ -41,8 +41,43 @@ that this variable is empty for some local debug runs; passing ## Suppressions -Comment-driven suppression is not wired up for Azure DevOps in v0.9. -Use `bomdrift baseline add` and commit the result. +The supported, no-infrastructure-required flow is the manual baseline +edit: run `bomdrift baseline add` locally and commit the result to +your PR branch. + +### Comment-driven suppression (advanced, v0.9.5+) + +> **Trade-off up front.** Comment-driven suppression turns a reviewer +> comment like `/bomdrift suppress GHSA-...` into an automatic +> baseline edit. To wire it up safely you need to operate a small +> public webhook handler. The manual flow above is supported and +> lower-risk; reach for the bridge only when the zero-click UX is +> worth running a service. + +`examples/azure-devops/comment-bridge/` ships a Cloudflare Worker +reference implementation that enforces five security guards: + +1. Webhook secret verification (`X-Bomdrift-Bridge-Secret` custom + header, constant-time compare). +2. Event-type filter (`ms.vss-code.git-pullrequest-comment-event` + only). +3. Project-UUID allowlist. +4. Commenter-permission lookup (Contributors team membership). +5. PR-context guard (active PR targeting the protected main branch). + +When the guards pass, the worker POSTs to +`/_apis/pipelines/{id}/runs` with `BOMDRIFT_NOTE_BODY` as a template +parameter. The example `azure-pipelines.yml` defines a conditional +`bomdrift_suppress` stage gated on that parameter; it runs +`bomdrift baseline add --from-comment "$BOMDRIFT_NOTE_BODY"` and +pushes the resulting baseline edit back to the PR's source branch. +Normal PR-build runs leave the parameter empty so the suppress stage +is skipped. + +The full threat model and deployment guide live in +[`examples/azure-devops/comment-bridge/README.md`](https://github.com/Metbcy/bomdrift/tree/main/examples/azure-devops/comment-bridge#threat-model). +The same logic ports to Vercel / Netlify / AWS Lambda — see +[`vercel-equivalent.md`](https://github.com/Metbcy/bomdrift/blob/main/examples/azure-devops/comment-bridge/vercel-equivalent.md). ## Troubleshooting diff --git a/docs/src/bitbucket.md b/docs/src/bitbucket.md index a95efc9..711a7ae 100644 --- a/docs/src/bitbucket.md +++ b/docs/src/bitbucket.md @@ -43,8 +43,7 @@ plumbing. ## Suppressions -Comment-driven suppression is **not** wired up for Bitbucket in -v0.9. The supported flow is: +The supported, no-infrastructure-required flow is the manual baseline edit: ```sh bomdrift baseline add GHSA-... --reason "audit complete (PR #42)" @@ -52,6 +51,37 @@ git add .bomdrift/baseline.json git commit -m "baseline: suppress GHSA-..." ``` +### Comment-driven suppression (advanced, v0.9.5+) + +> **Trade-off up front.** Comment-driven suppression turns a reviewer +> comment like `/bomdrift suppress GHSA-...` into an automatic +> baseline edit. To wire it up safely you need to operate a small +> public webhook handler. The manual flow above is supported and +> lower-risk; reach for the bridge only when the zero-click UX is +> worth running a service. + +`examples/bitbucket-pipelines/comment-bridge/` ships a Cloudflare +Worker reference implementation that enforces five security guards: + +1. Webhook HMAC verification (`X-Hub-Signature: sha256=…` against the + byte-exact request body). +2. Event-type filter (`pullrequest:comment_created` only). +3. Repo-full-name allowlist. +4. Commenter-permission lookup (`write` / `admin` / `owner` only). +5. PR-context guard (rejects fork-PR comment-suppress). + +When the guards pass, the worker triggers the +`bomdrift-comment-suppress` custom pipeline (defined in the example +`bitbucket-pipelines.yml`) with `BOMDRIFT_NOTE_BODY` set to the raw +comment body. The pipeline runs +`bomdrift baseline add --from-comment "$BOMDRIFT_NOTE_BODY"` and +pushes the resulting baseline edit back to the PR's source branch. + +The full threat model and deployment guide live in +[`examples/bitbucket-pipelines/comment-bridge/README.md`](https://github.com/Metbcy/bomdrift/tree/main/examples/bitbucket-pipelines/comment-bridge#threat-model). +The same logic ports to Vercel / Netlify / AWS Lambda — see +[`vercel-equivalent.md`](https://github.com/Metbcy/bomdrift/blob/main/examples/bitbucket-pipelines/comment-bridge/vercel-equivalent.md). + ## Troubleshooting See [`examples/bitbucket-pipelines/README.md`](https://github.com/Metbcy/bomdrift/blob/main/examples/bitbucket-pipelines/README.md). diff --git a/examples/azure-devops/azure-pipelines.yml b/examples/azure-devops/azure-pipelines.yml index 8be069f..8836963 100644 --- a/examples/azure-devops/azure-pipelines.yml +++ b/examples/azure-devops/azure-pipelines.yml @@ -7,52 +7,102 @@ pr: include: - "*" +# Template parameter consumed by the comment-suppress bridge +# (examples/azure-devops/comment-bridge/). When the bridge triggers +# this pipeline via POST /_apis/pipelines/{id}/runs it sets +# BOMDRIFT_NOTE_BODY; the suppress stage below is gated on that +# parameter being non-empty. Normal PR-build runs leave it empty, +# the suppress stage is skipped, and only the diff stage runs. +parameters: + - name: BOMDRIFT_NOTE_BODY + type: string + default: "" + pool: vmImage: ubuntu-latest variables: BOMDRIFT_VERSION: "0.9.0" -steps: - - script: | - set -euo pipefail - sudo apt-get update -qq && sudo apt-get install -y -qq curl jq - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - . "$HOME/.cargo/env" - cargo install bomdrift --locked --version "$BOMDRIFT_VERSION" - curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin - displayName: Install bomdrift + Syft - - - script: | - set -euo pipefail - . "$HOME/.cargo/env" - git fetch origin "$(System.PullRequest.TargetBranch)" - git worktree add ../before "origin/$(System.PullRequest.TargetBranch)" - syft dir:../before -o cyclonedx-json=before.cdx.json - syft dir:. -o cyclonedx-json=after.cdx.json - bomdrift diff before.cdx.json after.cdx.json \ - --output markdown --platform azure-devops > comment.md - printf '\n\n' >> comment.md - displayName: bomdrift diff - - - script: | - set -euo pipefail - ORG_URL="$(System.TeamFoundationCollectionUri)" - PROJECT="$(System.TeamProject)" - REPO_ID="$(Build.Repository.ID)" - PR_ID="$(System.PullRequest.PullRequestId)" - API="${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/threads?api-version=7.1" - AUTH_HEADER="Authorization: Basic $(printf ":%s" "$BOMDRIFT_API_TOKEN" | base64 -w0)" - EXISTING=$(curl -s -H "$AUTH_HEADER" "$API" \ - | jq -r '.value[] | select(.comments[0].content | tostring | test("")) | .id' | head -n1) - if [ -n "$EXISTING" ]; then - UPDATE_API="${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/threads/${EXISTING}/comments/1?api-version=7.1" - UBODY=$(jq -Rs --arg cmt "$(cat comment.md)" '{content:$cmt, commentType:1}' < /dev/null) - curl -s -H "$AUTH_HEADER" -H "Content-Type: application/json" -X PATCH "$UPDATE_API" -d "$UBODY" > /dev/null - else - BODY=$(jq -Rs --arg cmt "$(cat comment.md)" '{comments:[{parentCommentId:0, content:$cmt, commentType:1}], status:1}' < /dev/null) - curl -s -H "$AUTH_HEADER" -H "Content-Type: application/json" -X POST "$API" -d "$BODY" > /dev/null - fi - displayName: Upsert PR thread - env: - BOMDRIFT_API_TOKEN: $(BOMDRIFT_API_TOKEN) +stages: + - stage: bomdrift_diff + condition: eq('${{ parameters.BOMDRIFT_NOTE_BODY }}', '') + jobs: + - job: diff + steps: + - script: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq curl jq + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + cargo install bomdrift --locked --version "$BOMDRIFT_VERSION" + curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin + displayName: Install bomdrift + Syft + + - script: | + set -euo pipefail + . "$HOME/.cargo/env" + git fetch origin "$(System.PullRequest.TargetBranch)" + git worktree add ../before "origin/$(System.PullRequest.TargetBranch)" + syft dir:../before -o cyclonedx-json=before.cdx.json + syft dir:. -o cyclonedx-json=after.cdx.json + bomdrift diff before.cdx.json after.cdx.json \ + --output markdown --platform azure-devops > comment.md + printf '\n\n' >> comment.md + displayName: bomdrift diff + + - script: | + set -euo pipefail + ORG_URL="$(System.TeamFoundationCollectionUri)" + PROJECT="$(System.TeamProject)" + REPO_ID="$(Build.Repository.ID)" + PR_ID="$(System.PullRequest.PullRequestId)" + API="${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/threads?api-version=7.1" + AUTH_HEADER="Authorization: Basic $(printf ":%s" "$BOMDRIFT_API_TOKEN" | base64 -w0)" + EXISTING=$(curl -s -H "$AUTH_HEADER" "$API" \ + | jq -r '.value[] | select(.comments[0].content | tostring | test("")) | .id' | head -n1) + if [ -n "$EXISTING" ]; then + UPDATE_API="${ORG_URL}${PROJECT}/_apis/git/repositories/${REPO_ID}/pullRequests/${PR_ID}/threads/${EXISTING}/comments/1?api-version=7.1" + UBODY=$(jq -Rs --arg cmt "$(cat comment.md)" '{content:$cmt, commentType:1}' < /dev/null) + curl -s -H "$AUTH_HEADER" -H "Content-Type: application/json" -X PATCH "$UPDATE_API" -d "$UBODY" > /dev/null + else + BODY=$(jq -Rs --arg cmt "$(cat comment.md)" '{comments:[{parentCommentId:0, content:$cmt, commentType:1}], status:1}' < /dev/null) + curl -s -H "$AUTH_HEADER" -H "Content-Type: application/json" -X POST "$API" -d "$BODY" > /dev/null + fi + displayName: Upsert PR thread + env: + BOMDRIFT_API_TOKEN: $(BOMDRIFT_API_TOKEN) + + # Comment-driven suppress stage (advanced). + # + # Runs ONLY when triggered by the bomdrift comment-suppress bridge + # (examples/azure-devops/comment-bridge/), which POSTs to + # /_apis/pipelines/{id}/runs with `templateParameters.BOMDRIFT_NOTE_BODY` + # set to the raw comment body. See bridge README for the full + # security model. + - stage: bomdrift_suppress + condition: ne('${{ parameters.BOMDRIFT_NOTE_BODY }}', '') + jobs: + - job: suppress + steps: + - checkout: self + persistCredentials: true + - script: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y -qq curl jq git + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + cargo install bomdrift --locked --version "$BOMDRIFT_VERSION" + bomdrift baseline add --from-comment "${BOMDRIFT_NOTE_BODY}" + git config user.name "bomdrift-bot" + git config user.email "bomdrift-bot@users.noreply.dev.azure.com" + git add .bomdrift/baseline.json + if git diff --cached --quiet; then + echo "baseline already contains the directive; nothing to commit" + exit 0 + fi + git commit -m "chore(bomdrift): suppress from PR comment" + git push origin "HEAD:$(Build.SourceBranchName)" + displayName: bomdrift baseline add --from-comment + env: + BOMDRIFT_NOTE_BODY: ${{ parameters.BOMDRIFT_NOTE_BODY }} diff --git a/examples/azure-devops/comment-bridge/README.md b/examples/azure-devops/comment-bridge/README.md new file mode 100644 index 0000000..7ac3e60 --- /dev/null +++ b/examples/azure-devops/comment-bridge/README.md @@ -0,0 +1,110 @@ +# Azure DevOps comment-driven suppress bridge + +Reference implementation of a Service Hooks handler that turns a +`/bomdrift suppress ` PR comment on Azure DevOps Repos into an +Azure Pipelines run which executes +`bomdrift baseline add --from-comment ` on the PR's source +branch. + +The bridge is **opt-in advanced infrastructure**. Most teams should +prefer the manual flow in `examples/azure-devops/README.md` (commit +`.bomdrift/baseline.json` directly). Only deploy this if the +zero-click suppression UX is worth operating a small public service. + +## Architecture + +``` +┌───────────────────────────┐ ms.vss-code.git-pullrequest- ┌─────────────────────┐ +│ Azure DevOps PR comment │ comment-event (Service Hook) │ Cloudflare Worker │ +│ /bomdrift suppress … │ ──────────────────────────────▶│ (this directory) │ +└───────────────────────────┘ X-Bomdrift-Bridge-Secret └─────────┬───────────┘ + │ 5 guards + ▼ + ┌─────────────────────┐ + │ Azure Pipelines │ + │ POST /_apis/ │ + │ pipelines/{id}/ │ + │ runs │ + └─────────┬───────────┘ + ▼ + ┌─────────────────────┐ + │ bomdrift baseline │ + │ add --from-comment │ + └─────────────────────┘ +``` + +## Threat model + +Five guards. Each prevents a distinct class of attack: + +| # | Guard | Attack prevented | +|---|---|---| +| 1 | **Webhook secret** (`X-Bomdrift-Bridge-Secret`, constant-time compare against env.`WEBHOOK_SECRET`) | Unauthenticated POSTs from anyone on the internet. Azure DevOps Service Hooks support Basic auth out of the box, but the custom-header approach makes the secret visible to the Worker without `WWW-Authenticate` round-trips. | +| 2 | **Event-type filter** (`eventType === "ms.vss-code.git-pullrequest-comment-event"`) | Type-confusion: a forged work-item-comment event that contains a `/bomdrift suppress` line. | +| 3 | **Project allowlist** (`PROJECT_ALLOWLIST=","` matched against `resource.pullRequest.repository.project.id`) | Foreign-project replay. | +| 4 | **Commenter-permission check** — list the project's `Contributors` team members and require the commenter id to be a member | Random outsiders on a public-readable Azure DevOps project commenting `/bomdrift suppress …`. | +| 5 | **PR-context guard** (`pullRequest.status === "active"` AND `pullRequest.targetRefName === MAIN_BRANCH` (default `refs/heads/main`)) | Suppressing findings on side-branches the org doesn't actually ship from. | + +Failures return 4xx without invoking the pipeline trigger. Use +`wrangler tail` for live debugging. + +## Deployment + +1. `npm install -g wrangler` +2. `wrangler secret put` for each of: + - `WEBHOOK_SECRET` — string the Service Hook will send in + `X-Bomdrift-Bridge-Secret`. + - `PROJECT_ALLOWLIST` — comma-separated project UUIDs. + - `AZDO_ORG_URL` — `https://dev.azure.com/`. + - `AZDO_API_TOKEN` — PAT with `Code (Read)` and + `Build (Read & Execute)` scopes. + - `PIPELINE_ID` — the numeric definition id of the suppress + pipeline (from + `https://dev.azure.com///_apis/pipelines`). + - `MAIN_BRANCH` (optional) — defaults to `refs/heads/main`. +3. `wrangler deploy`. +4. Add the suppress stage to `azure-pipelines.yml` (gated on + `BOMDRIFT_NOTE_BODY` template parameter — see + [`../azure-pipelines.yml`](../azure-pipelines.yml)). +5. In Azure DevOps → Project Settings → Service hooks: create a + subscription with: + - Service: **Web Hooks** + - Trigger: **Pull request commented on** + - Filters: target your project / repo + - URL: the Worker URL + - HTTP headers: `X-Bomdrift-Bridge-Secret: ` + - Resource details to send: **All** +6. Smoke-test by commenting `/bomdrift suppress GHSA-test-1234-aaaa` + on an active PR. `wrangler tail` should show the guards passing. + +## Azure DevOps gotchas + +- **PAT lifetime.** Azure DevOps caps PAT lifetime at 1 year. Set a + calendar reminder to rotate `AZDO_API_TOKEN`; the bridge will start + 401-ing on the membership-lookup call when it expires. +- **Identity descriptors vs. ids.** Azure DevOps surfaces both an + `id` (UUID) and a `descriptor` (longer string) for identities. The + Worker accepts either when matching the commenter against team + members; some Service Hook payloads include only one. +- **`Contributors` team naming.** The default Project Contributors + team is named ` Team` on some legacy projects rather than + `Contributors`. The Worker does a case-insensitive + `/contributors$/` regex; if your org renamed the group, fall back + to `teams?.value?.[0]` is used as a last resort. Adjust if your + permission boundary is a custom group. +- **Custom pipeline parameters** are typed in Azure DevOps. The + receiving pipeline must declare a `parameters:` block accepting + `BOMDRIFT_NOTE_BODY` as a `string` or the run trigger 400s. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| 401 from worker | `X-Bomdrift-Bridge-Secret` mismatch with `WEBHOOK_SECRET`. | +| 403 "project not allowlisted" | UUID mismatch — copy from `https://dev.azure.com//_apis/projects?api-version=7.1`. | +| 403 "commenter not Contributor+" | Commenter is in a different group (Readers / Stakeholders). Either grant Contributor or accept the rejection. | +| 502 from worker | Pipeline trigger 4xx — usually the `PIPELINE_ID` is wrong or the pipeline doesn't accept `BOMDRIFT_NOTE_BODY` as a template parameter. | + +## Hosting alternatives + +See [`vercel-equivalent.md`](./vercel-equivalent.md). diff --git a/examples/azure-devops/comment-bridge/vercel-equivalent.md b/examples/azure-devops/comment-bridge/vercel-equivalent.md new file mode 100644 index 0000000..b6a6cc9 --- /dev/null +++ b/examples/azure-devops/comment-bridge/vercel-equivalent.md @@ -0,0 +1,28 @@ +# Hosting the Azure DevOps comment-suppress bridge on Vercel / Netlify / AWS Lambda + +The Cloudflare Worker reference implementation in `worker.js` uses +only the standard Web Fetch API (`Request`, `Response`, `fetch`). It +ports to other edge-function platforms with minimal adaptation: + +- **Vercel Edge Functions** — drop `worker.js` in + `api/bomdrift-azuredevops.js`, rename `export default { fetch }` to + `export default async function handler(request)`. Configure env + vars in the Vercel UI. +- **Netlify Edge Functions** — same shape; configure via + `netlify.toml` and the Netlify UI. +- **AWS Lambda + API Gateway** — wrap the handler in the Lambda + event/response envelope. Configure API Gateway to forward the + `X-Bomdrift-Bridge-Secret` header through to the Lambda; some + default integration templates strip non-standard headers. + +The threat model (five guards) is the same on every host. Only these +change per host: + +1. How env vars are injected. +2. How custom headers are forwarded. +3. The deploy command. + +Cloudflare Workers is the recommended reference because its free +tier covers most webhook traffic and `wrangler deploy` is the +simplest deploy story. Vercel / Netlify are equally good if your +team already operates on those platforms. diff --git a/examples/azure-devops/comment-bridge/worker.js b/examples/azure-devops/comment-bridge/worker.js new file mode 100644 index 0000000..5076e56 --- /dev/null +++ b/examples/azure-devops/comment-bridge/worker.js @@ -0,0 +1,147 @@ +/* Cloudflare Worker — Azure DevOps comment-driven suppress bridge. + * + * Five guards before triggering the bomdrift suppress pipeline: + * 1. Webhook secret verification: custom header X-Bomdrift-Bridge-Secret + * (set in the Service Hooks subscription's "Custom HTTP headers" + * field), constant-time compared against env.WEBHOOK_SECRET. + * 2. Event-type filter (eventType === "ms.vss-code.git-pullrequest-comment-event"). + * 3. Project allowlist (resource.pullRequest.repository.project.id ∈ + * PROJECT_ALLOWLIST, comma-separated UUIDs). + * 4. Commenter-permission check via /_apis/projects/{projectId}/teams + * and /_apis/identities — require the commenter to be a member + * of the project's Contributors group (or above). + * 5. PR-context guard: pullRequest.status === "active" AND + * pullRequest.targetRefName matches MAIN_BRANCH (default + * "refs/heads/main"). Rejects spurious comments on draft PRs + * targeting non-default branches. + * + * Required secrets: + * WEBHOOK_SECRET, PROJECT_ALLOWLIST, AZDO_ORG_URL, + * AZDO_API_TOKEN (PAT with Code Read + Build Execute), + * PIPELINE_ID (the numeric definition id of the suppress pipeline), + * MAIN_BRANCH (optional, default refs/heads/main). + * + * Reference: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events + */ + +// Suppress-comment regex. CANONICAL DEFINITION lives in +// scripts/parse-suppress-comment.sh — keep these in sync. +// CI guard: scripts/check-suppress-regex-sync.sh diffs the two. +const BOMDRIFT_SUPPRESS_REGEX = /^\s*\/bomdrift\s+suppress\s+([A-Za-z0-9-]+)(\s+reason:\s*(.+))?\s*$/m; + +export default { + async fetch(request, env) { + if (request.method !== "POST") return new Response("method", { status: 405 }); + + // Guard 1: webhook secret. + const provided = request.headers.get("X-Bomdrift-Bridge-Secret") ?? ""; + if (!constantTimeEqual(provided, env.WEBHOOK_SECRET ?? "")) { + return new Response("forbidden", { status: 401 }); + } + + let body; + try { + body = await request.json(); + } catch { + return new Response("bad json", { status: 400 }); + } + + // Guard 2: event-type. + if (body?.eventType !== "ms.vss-code.git-pullrequest-comment-event") { + return new Response("ignored", { status: 204 }); + } + + // Guard 3: project allowlist. + const projectId = body?.resource?.pullRequest?.repository?.project?.id; + const allow = (env.PROJECT_ALLOWLIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); + if (!projectId || !allow.includes(projectId)) { + return new Response("project not allowlisted", { status: 403 }); + } + + // Guard 5: PR-context. + const pr = body?.resource?.pullRequest; + const mainBranch = env.MAIN_BRANCH || "refs/heads/main"; + if (!pr || pr.status !== "active") { + return new Response("not an active PR", { status: 204 }); + } + if (pr.targetRefName !== mainBranch) { + return new Response("PR not targeting protected main branch", { status: 403 }); + } + + // Quick parse: comment looks like a directive? + const text = body?.resource?.comment?.content ?? ""; + if (!BOMDRIFT_SUPPRESS_REGEX.test(text)) { + return new Response("no directive", { status: 204 }); + } + + // Guard 4: commenter-permission via project membership. + const commenter = + body?.resource?.comment?.author?.id ?? + body?.resource?.comment?.author?.descriptor; + if (!commenter) return new Response("no commenter id", { status: 400 }); + const orgUrl = (env.AZDO_ORG_URL ?? "").replace(/\/$/, ""); + if (!orgUrl) return new Response("AZDO_ORG_URL unset", { status: 500 }); + const authHeader = `Basic ${btoa(`:${env.AZDO_API_TOKEN}`)}`; + // The simplest "is this a project member" probe is the project teams + // membership check: list members of the project's Contributors team + // and look for the commenter's id. On most orgs the Contributors + // group is exactly the right "can push code / can /bomdrift + // suppress" privilege boundary. + const teamUrl = `${orgUrl}/_apis/projects/${encodeURIComponent(projectId)}/teams?api-version=7.1`; + const teamsResp = await fetch(teamUrl, { + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + if (!teamsResp.ok) return new Response("teams lookup failed", { status: 403 }); + const teams = await teamsResp.json(); + const contributors = + teams?.value?.find((t) => /contributors$/i.test(t.name)) ?? + teams?.value?.[0]; + if (!contributors?.id) return new Response("no contributors team", { status: 403 }); + const memberUrl = `${orgUrl}/_apis/projects/${encodeURIComponent(projectId)}/teams/${contributors.id}/members?api-version=7.1`; + const memberResp = await fetch(memberUrl, { + headers: { Authorization: authHeader, Accept: "application/json" }, + }); + if (!memberResp.ok) return new Response("member lookup failed", { status: 403 }); + const members = await memberResp.json(); + const isMember = (members?.value ?? []).some( + (m) => m?.identity?.id === commenter || m?.identity?.descriptor === commenter, + ); + if (!isMember) { + return new Response("commenter not Contributor+", { status: 403 }); + } + + // All guards passed. Trigger the suppress pipeline run with + // BOMDRIFT_NOTE_BODY as a template parameter. + const projectName = body?.resource?.pullRequest?.repository?.project?.name ?? projectId; + const pipelineId = env.PIPELINE_ID; + if (!pipelineId) return new Response("PIPELINE_ID unset", { status: 500 }); + const triggerUrl = `${orgUrl}/${encodeURIComponent(projectName)}/_apis/pipelines/${encodeURIComponent(pipelineId)}/runs?api-version=7.1`; + const trigPayload = { + resources: { + repositories: { + self: { refName: pr.sourceRefName }, + }, + }, + templateParameters: { + BOMDRIFT_NOTE_BODY: text, + }, + }; + const trig = await fetch(triggerUrl, { + method: "POST", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + body: JSON.stringify(trigPayload), + }); + if (!trig.ok) return new Response("pipeline trigger failed", { status: 502 }); + return new Response("triggered", { status: 204 }); + }, +}; + +function constantTimeEqual(a, b) { + if (a.length !== b.length) return false; + let acc = 0; + for (let i = 0; i < a.length; i++) acc |= a.charCodeAt(i) ^ b.charCodeAt(i); + return acc === 0; +} diff --git a/examples/bitbucket-pipelines/bitbucket-pipelines.yml b/examples/bitbucket-pipelines/bitbucket-pipelines.yml index 6c8fc86..b501d94 100644 --- a/examples/bitbucket-pipelines/bitbucket-pipelines.yml +++ b/examples/bitbucket-pipelines/bitbucket-pipelines.yml @@ -44,3 +44,33 @@ pipelines: pull-requests: "**": - step: *bomdrift-diff + + # Custom (manual / API-triggered) pipelines. The comment-suppress bridge + # (examples/bitbucket-pipelines/comment-bridge/) triggers this one by + # name with a BOMDRIFT_NOTE_BODY variable carrying the raw comment body. + # See bridge README for the full security model. If you don't deploy + # the bridge, this step never runs. + custom: + bomdrift-comment-suppress: + - step: + name: bomdrift comment-driven suppress + script: + - | + if [ -z "${BOMDRIFT_NOTE_BODY:-}" ]; then + echo "BOMDRIFT_NOTE_BODY unset; refusing to run." >&2 + exit 1 + fi + - export BOMDRIFT_VERSION="0.9.0" + - apt-get update -qq && apt-get install -y -qq curl jq git + - cargo install bomdrift --locked --version "$BOMDRIFT_VERSION" + - bomdrift baseline add --from-comment "$BOMDRIFT_NOTE_BODY" + - git config user.name "bomdrift-bot" + - git config user.email "bomdrift-bot@users.noreply.bitbucket.org" + - git add .bomdrift/baseline.json + - | + if git diff --cached --quiet; then + echo "baseline already contains the directive; nothing to commit" + exit 0 + fi + - git commit -m "chore(bomdrift): suppress from PR comment" + - git push origin "HEAD:$BITBUCKET_BRANCH" diff --git a/examples/bitbucket-pipelines/comment-bridge/README.md b/examples/bitbucket-pipelines/comment-bridge/README.md new file mode 100644 index 0000000..7d25d9a --- /dev/null +++ b/examples/bitbucket-pipelines/comment-bridge/README.md @@ -0,0 +1,103 @@ +# Bitbucket Cloud comment-driven suppress bridge + +Reference implementation of a webhook handler that turns a +`/bomdrift suppress ` PR comment on Bitbucket Cloud into a +custom-pipeline trigger which runs +`bomdrift baseline add --from-comment ` on the PR's source +branch. + +The bridge is **opt-in advanced infrastructure**. Most teams should +prefer the manual flow in `examples/bitbucket-pipelines/README.md` +(commit `.bomdrift/baseline.json` directly). Only deploy this if +you've decided the zero-click suppression UX is worth operating a +small public service. + +## Architecture + +``` +┌─────────────────────────┐ pullrequest:comment_created ┌─────────────────────┐ +│ Bitbucket PR comment │ ──────────────────────────────▶│ Cloudflare Worker │ +│ /bomdrift suppress … │ X-Hub-Signature (HMAC) │ (this directory) │ +└─────────────────────────┘ └─────────┬───────────┘ + │ 5 guards + ▼ + ┌─────────────────────┐ + │ Bitbucket custom │ + │ pipeline trigger │ + │ (POST /pipelines/) │ + └─────────┬───────────┘ + ▼ + ┌─────────────────────┐ + │ bomdrift baseline │ + │ add --from-comment │ + └─────────────────────┘ +``` + +## Threat model + +Five guards. Each prevents a distinct class of attack: + +| # | Guard | Attack prevented | +|---|---|---| +| 1 | **Webhook HMAC** (`X-Hub-Signature: sha256=`, constant-time HMAC-SHA256 compare against the **byte-exact** request body) | Unauthenticated POSTs from anyone on the internet; replay with a tampered body. | +| 2 | **Event-type filter** (`X-Event-Key === "pullrequest:comment_created"`) | Type-confusion: a forged `pullrequest:approved` body that contains a `/bomdrift suppress` line. | +| 3 | **Repo allowlist** (`REPO_ALLOWLIST="org/repo,org/other"` matched against `repository.full_name`) | Foreign-repo replay using a leaked secret. | +| 4 | **Commenter-permission lookup** (`/2.0/workspaces//permissions?q=user.account_id=""` → `permission ∈ {write, admin, owner}`) | Random outsiders commenting `/bomdrift suppress …` on a public repo. | +| 5 | **PR-context guard** (`pullrequest.state === "OPEN"` AND `source.repository.full_name === destination.repository.full_name`) | Fork-PR exfiltration: contributors with a fork-PR open could otherwise suppress findings on the upstream baseline. | + +Failures return 4xx without invoking the pipeline trigger. Use +`wrangler tail` for live debugging. + +## Deployment + +1. `npm install -g wrangler` +2. `wrangler secret put` for each of: + - `WEBHOOK_SECRET` — the secret you'll configure in the Bitbucket + webhook UI. + - `REPO_ALLOWLIST` — comma-separated `org/repo` list. + - `BITBUCKET_API_TOKEN` — App Password for the bot user, scopes + `pullrequest:write` + `repository:read` + `pipeline:write`. + - `BITBUCKET_TRIGGER_USER` (optional) — the bot's `account_id`, + used only for logging. + - `SUPPRESS_PIPELINE_REF` (optional) — branch to run the custom + pipeline on. Defaults to the PR source branch. +3. `wrangler deploy`. +4. Add the custom pipeline definition to `bitbucket-pipelines.yml` + (see the `bomdrift-comment-suppress` step in + [`../bitbucket-pipelines.yml`](../bitbucket-pipelines.yml)). +5. In Bitbucket → Repository settings → Webhooks: add the Worker URL + with **Pull request → Comment created** event, the + `WEBHOOK_SECRET` filled in, and SSL verification on. +6. Smoke-test by commenting `/bomdrift suppress GHSA-test-1234-aaaa` + on an open PR. `wrangler tail` should show the guards passing + and the trigger firing. + +## Bitbucket gotchas + +- **The signed body is the raw bytes**, not the parsed JSON. The + Worker therefore reads `request.arrayBuffer()` first and only + parses JSON after the HMAC check passes. Don't refactor that. +- **Bitbucket allows both `account_id` and `uuid` for actors** in + the webhook payload depending on workspace privacy settings. The + Worker tries both, in that order. +- **Custom pipelines must be pre-declared** in + `bitbucket-pipelines.yml` (under `definitions:` and referenced + by name in `pipelines: custom:`). The Worker triggers by name — + if the step doesn't exist, the API call returns 400. +- **Permission API quirk:** `permissions` returns owner / admin / + collaborator / contributor — but the API surfaces these as the + string set `{"owner","admin","write","read"}`. The Worker accepts + `write`, `admin`, and `owner`; `read` is explicitly rejected. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| 401 from worker | `X-Hub-Signature` mismatch with `WEBHOOK_SECRET`. Bitbucket's UI silently truncates trailing whitespace in the secret field — re-paste cleanly. | +| 403 from worker | Repo not allowlisted, commenter lacks `write` access, or fork-PR. | +| 502 from worker | Pipeline trigger 4xx — usually means the `bomdrift-comment-suppress` custom step isn't defined in the target branch's `bitbucket-pipelines.yml`. | +| Worker silently 204s | Comment didn't match the `BOMDRIFT_SUPPRESS_REGEX`. Check the canonical regex in [`../../../scripts/parse-suppress-comment.sh`](../../../scripts/parse-suppress-comment.sh). | + +## Hosting alternatives + +See [`vercel-equivalent.md`](./vercel-equivalent.md). diff --git a/examples/bitbucket-pipelines/comment-bridge/vercel-equivalent.md b/examples/bitbucket-pipelines/comment-bridge/vercel-equivalent.md new file mode 100644 index 0000000..22a7248 --- /dev/null +++ b/examples/bitbucket-pipelines/comment-bridge/vercel-equivalent.md @@ -0,0 +1,30 @@ +# Hosting the Bitbucket comment-suppress bridge on Vercel / Netlify / AWS Lambda + +The Cloudflare Worker reference implementation in `worker.js` uses +only the standard Web Fetch API (`Request`, `Response`, `fetch`) and +`crypto.subtle` for HMAC verification. It ports to other edge-function +platforms with minimal adaptation: + +- **Vercel Edge Functions** — drop `worker.js` in + `api/bomdrift-bitbucket.js`, rename `export default { fetch }` to + `export default async function handler(request)`. Configure env + vars in the Vercel UI. +- **Netlify Edge Functions** — same shape; configure via + `netlify.toml` and the Netlify UI. +- **AWS Lambda + API Gateway** — wrap the handler in the Lambda + event/response envelope. **Do not** let API Gateway parse the + body for you; configure the integration to pass the raw bytes + through, or the HMAC check (which signs the byte-exact body) will + fail. + +The threat model (five guards) is the same on every host. Only these +change per host: + +1. How env vars are injected. +2. How the **raw** body is read (the HMAC step is byte-sensitive). +3. The deploy command. + +Cloudflare Workers is the recommended reference because its free +tier covers most webhook traffic and `wrangler deploy` is the +simplest deploy story. Vercel / Netlify are equally good if your +team already operates on those platforms. diff --git a/examples/bitbucket-pipelines/comment-bridge/worker.js b/examples/bitbucket-pipelines/comment-bridge/worker.js new file mode 100644 index 0000000..1fb536a --- /dev/null +++ b/examples/bitbucket-pipelines/comment-bridge/worker.js @@ -0,0 +1,152 @@ +/* Cloudflare Worker — Bitbucket Cloud comment-driven suppress bridge. + * + * Five guards before triggering the bomdrift suppress pipeline: + * 1. Webhook HMAC verification (X-Hub-Signature, sha256=), + * constant-time compared against HMAC_SHA256(WEBHOOK_SECRET, body). + * 2. Event-type filter (X-Event-Key === "pullrequest:comment_created"). + * 3. Repo-full-name allowlist (REPO_ALLOWLIST="org/repo,org/other"). + * 4. Commenter-permission check via /2.0/workspaces//permissions + * → require permission ∈ {"write","admin"}. + * 5. PR-context guard: pullrequest.state === "OPEN" AND + * source.repository.full_name === destination.repository.full_name + * (rejects fork-PR comment-suppress). + * + * Required secrets: + * WEBHOOK_SECRET, REPO_ALLOWLIST, BITBUCKET_API_TOKEN, + * BITBUCKET_TRIGGER_USER (bot account_id, optional — used only in + * logging), SUPPRESS_PIPELINE_REF (branch/ref to run the custom + * pipeline on; defaults to the PR source branch). + * + * Reference: https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/ + */ + +// Suppress-comment regex. CANONICAL DEFINITION lives in +// scripts/parse-suppress-comment.sh — keep these in sync. +// CI guard: scripts/check-suppress-regex-sync.sh diffs the two. +const BOMDRIFT_SUPPRESS_REGEX = /^\s*\/bomdrift\s+suppress\s+([A-Za-z0-9-]+)(\s+reason:\s*(.+))?\s*$/m; + +export default { + async fetch(request, env) { + if (request.method !== "POST") return new Response("method", { status: 405 }); + + // Read body as raw bytes — Bitbucket signs the byte-exact request body, + // so any JSON re-serialization would invalidate the HMAC. + const rawBody = await request.arrayBuffer(); + + // Guard 1: HMAC verification. + const sigHeader = request.headers.get("X-Hub-Signature") ?? ""; + if (!(await verifyHubSignature(env.WEBHOOK_SECRET ?? "", rawBody, sigHeader))) { + return new Response("forbidden", { status: 401 }); + } + + // Guard 2: event-type filter. + if ((request.headers.get("X-Event-Key") ?? "") !== "pullrequest:comment_created") { + return new Response("ignored", { status: 204 }); + } + + let body; + try { + body = JSON.parse(new TextDecoder().decode(rawBody)); + } catch { + return new Response("bad json", { status: 400 }); + } + + // Guard 3: repo allowlist. + const repoFull = body?.repository?.full_name ?? ""; + const allow = (env.REPO_ALLOWLIST ?? "").split(",").map((s) => s.trim()).filter(Boolean); + if (!repoFull || !allow.includes(repoFull)) { + return new Response("repo not allowlisted", { status: 403 }); + } + + // Guard 5: PR-context. + const pr = body?.pullrequest; + if (!pr || pr.state !== "OPEN") { + return new Response("not an open PR", { status: 204 }); + } + const srcRepo = pr?.source?.repository?.full_name; + const dstRepo = pr?.destination?.repository?.full_name; + if (!srcRepo || !dstRepo || srcRepo !== dstRepo) { + return new Response("fork-PR refused", { status: 403 }); + } + + // Quick parse: comment looks like a directive? + const text = body?.comment?.content?.raw ?? ""; + if (!BOMDRIFT_SUPPRESS_REGEX.test(text)) { + return new Response("no directive", { status: 204 }); + } + + // Guard 4: commenter-permission lookup. + const commenterId = body?.actor?.account_id ?? body?.actor?.uuid; + if (!commenterId) return new Response("no commenter id", { status: 400 }); + const [workspace] = repoFull.split("/", 1); + // The user-permission endpoint accepts q=user.account_id="". + const permUrl = `https://api.bitbucket.org/2.0/workspaces/${encodeURIComponent(workspace)}/permissions?q=${encodeURIComponent(`user.account_id="${commenterId}"`)}`; + const permResp = await fetch(permUrl, { + headers: { + Authorization: `Basic ${btoa(`x-token-auth:${env.BITBUCKET_API_TOKEN}`)}`, + Accept: "application/json", + }, + }); + if (!permResp.ok) return new Response("permission lookup failed", { status: 403 }); + const perm = await permResp.json(); + const permission = perm?.values?.[0]?.permission ?? ""; + if (permission !== "write" && permission !== "admin" && permission !== "owner") { + return new Response("commenter not write/admin", { status: 403 }); + } + + // All guards passed. Trigger a custom pipeline on the PR source branch + // with BOMDRIFT_NOTE_BODY set to the raw comment body. The custom + // pipeline (defined in bitbucket-pipelines.yml) invokes + // `bomdrift baseline add --from-comment "$BOMDRIFT_NOTE_BODY"`. + const ref = env.SUPPRESS_PIPELINE_REF || pr?.source?.branch?.name || "main"; + const triggerUrl = `https://api.bitbucket.org/2.0/repositories/${repoFull}/pipelines/`; + const trigPayload = { + target: { + type: "pipeline_ref_target", + ref_type: "branch", + ref_name: ref, + selector: { type: "custom", pattern: "bomdrift-comment-suppress" }, + }, + variables: [ + { key: "BOMDRIFT_NOTE_BODY", value: text, secured: false }, + ], + }; + const trig = await fetch(triggerUrl, { + method: "POST", + headers: { + Authorization: `Basic ${btoa(`x-token-auth:${env.BITBUCKET_API_TOKEN}`)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(trigPayload), + }); + if (!trig.ok) return new Response("pipeline trigger failed", { status: 502 }); + return new Response("triggered", { status: 204 }); + }, +}; + +// Verify Bitbucket's X-Hub-Signature header. Format: "sha256=". +async function verifyHubSignature(secret, body, header) { + if (!secret || !header) return false; + const expectedPrefix = "sha256="; + if (!header.startsWith(expectedPrefix)) return false; + const providedHex = header.slice(expectedPrefix.length).trim().toLowerCase(); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, body); + const computedHex = [...new Uint8Array(sig)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return constantTimeEqual(providedHex, computedHex); +} + +function constantTimeEqual(a, b) { + if (a.length !== b.length) return false; + let acc = 0; + for (let i = 0; i < a.length; i++) acc |= a.charCodeAt(i) ^ b.charCodeAt(i); + return acc === 0; +} From 58c7856b85b9940a9646d00156a2580a00393b59 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:34:30 -0700 Subject: [PATCH 08/11] feat(license): per-exception SPDX allow/deny via WITH clause evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.9 SPDX evaluator treated the right-hand side of a 'WITH' clause as informational only — Apache-2.0 WITH LLVM-exception was permitted by allow=[Apache-2.0] regardless of which exception applied. v0.9.5 adds per-exception allow/deny: [license] allow_exceptions = ["LLVM-exception", "Classpath-exception-2.0"] deny_exceptions = ["GCC-exception-3.1"] --allow-exception ID,ID (repeatable + comma-split) --deny-exception ID,ID Semantics: * Base-license deny check stays conservative (any required atomic in the deny list → violation). * Base allow + exception checks share Expression::evaluate, so OR branches resolve correctly: (Apache-2.0 WITH LLVM-exception) OR BSD-3-Clause with deny_exceptions=[LLVM-exception] permits via the BSD-3-Clause path. * Both exception lists empty → exceptions are permitted (preserves v0.9 behavior; back-compat). LicenseViolation::matched_rule cites the precise exception identifier ('exception:LLVM-exception denied' or 'exception:LLVM-exception not in allow list'). The SARIF synthetic id encodes the full license string including the WITH suffix, so partialFingerprints differ between exception-driven and base-license violations on the same component (asserted in a new SARIF test). The --debug-calibration license row now surfaces matched_rule directly (instead of the bare kind tag) so operators tuning policy see the why, not just deny/not-allowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/cli.rs | 11 ++ src/config.rs | 14 +++ src/enrich/license.rs | 234 +++++++++++++++++++++++++++++++++++++++--- src/lib.rs | 50 +++++++-- src/render/sarif.rs | 46 +++++++++ 5 files changed, 334 insertions(+), 21 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 3bade4d..a5e4133 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -338,6 +338,17 @@ pub struct DiffArgs { /// expression evaluation). Off by default — fail-closed. #[arg(long)] pub allow_ambiguous_licenses: bool, + /// Comma-separated SPDX exception identifiers (e.g. + /// `LLVM-exception`, `Classpath-exception-2.0`) permitted as the + /// right-hand side of a `WITH` clause. Repeatable. When set, + /// `Apache-2.0 WITH ` violates policy even if `Apache-2.0` + /// is on the base allow list. v0.9.5+. + #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)] + pub allow_exception: Vec, + /// Comma-separated SPDX exception identifiers forbidden as the + /// right-hand side of a `WITH` clause. Repeatable. v0.9.5+. + #[arg(long, value_delimiter = ',', action = clap::ArgAction::Append)] + pub deny_exception: Vec, /// Path(s) to VEX (Vulnerability Exploitability eXchange) files /// to consume. Repeatable. Each file is auto-detected as either /// OpenVEX 0.2.0 or CycloneDX VEX 1.6. Statements with status diff --git a/src/config.rs b/src/config.rs index 55a6f45..9b19f8b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,12 @@ pub struct LicenseConfig { pub deny: Vec, #[serde(default)] pub allow_ambiguous: bool, + /// SPDX exception identifiers permitted in `WITH` clauses. v0.9.5+. + #[serde(default)] + pub allow_exceptions: Vec, + /// SPDX exception identifiers forbidden in `WITH` clauses. v0.9.5+. + #[serde(default)] + pub deny_exceptions: Vec, } const DEFAULT_CONFIG_PATH: &str = ".bomdrift.toml"; @@ -163,6 +169,12 @@ fn apply_loaded_diff_config(args: &mut DiffArgs, config: Config) { args.deny_licenses = lic.deny; } args.allow_ambiguous_licenses |= lic.allow_ambiguous; + if args.allow_exception.is_empty() { + args.allow_exception = lic.allow_exceptions; + } + if args.deny_exception.is_empty() { + args.deny_exception = lic.deny_exceptions; + } } } @@ -219,6 +231,8 @@ mod tests { allow_licenses: Vec::new(), deny_licenses: Vec::new(), allow_ambiguous_licenses: false, + allow_exception: Vec::new(), + deny_exception: Vec::new(), vex: Vec::new(), emit_vex: None, vex_author: None, diff --git a/src/enrich/license.rs b/src/enrich/license.rs index f0854df..8a21ba1 100644 --- a/src/enrich/license.rs +++ b/src/enrich/license.rs @@ -15,10 +15,14 @@ //! expression must `evaluate` to true under a closure that //! returns true for allow-listed atomic IDs. `(MIT OR Apache-2.0)` //! with `allow=[MIT]` permits because the licensee can pick MIT. -//! - **`WITH` operator** — handled by `spdx`'s parser; the base -//! license is checked against allow/deny. The exception identifier -//! is currently informational only — per-exception allow/deny is a -//! future ask not in v0.9 scope. +//! - **`WITH` operator** — handled by `spdx`'s parser. The base +//! license is checked against allow/deny as above. The exception +//! identifier participates in the OR-aware `Expression::evaluate` +//! pass via `Policy::allow_exceptions` / `Policy::deny_exceptions` +//! (v0.9.5+): a denied exception causes that branch of an OR to +//! fail, but a sibling branch may still permit. When both +//! exception lists are empty, exceptions are permitted (preserves +//! v0.9 behavior). //! //! When SPDX parsing FAILS (non-SPDX strings like `"Custom"`, //! `"Proprietary"`, vendor-specific spellings) we fall back to the @@ -46,16 +50,28 @@ use crate::model::Component; /// Policy configuration. Empty allow + empty deny means "no policy" — the /// enricher returns no violations. Either or both may be set. +/// +/// `allow_exceptions` / `deny_exceptions` (v0.9.5+) target the SPDX +/// `WITH` clause: e.g. `Apache-2.0 WITH LLVM-exception` is permitted by +/// `allow=[Apache-2.0]` but can additionally be gated by listing the +/// exception identifier (`LLVM-exception`) in `deny_exceptions`. When +/// both `allow_exceptions` and `deny_exceptions` are empty, exceptions +/// are permitted (preserves v0.9 behavior). #[derive(Debug, Clone, Default)] pub struct Policy { pub allow: Vec, pub deny: Vec, pub allow_ambiguous: bool, + pub allow_exceptions: Vec, + pub deny_exceptions: Vec, } impl Policy { pub fn is_active(&self) -> bool { - !self.allow.is_empty() || !self.deny.is_empty() + !self.allow.is_empty() + || !self.deny.is_empty() + || !self.allow_exceptions.is_empty() + || !self.deny_exceptions.is_empty() } } @@ -121,7 +137,9 @@ fn evaluate_one(c: &Component, lic: &str, policy: &Policy) -> Option Option { + for req in expr.requirements() { + let Some(exception) = &req.req.exception else { + continue; + }; + let ex_name = exception.name; + if policy.deny_exceptions.iter().any(|d| d == ex_name) { + return Some(ExceptionFailure { + matched_rule: format!("exception:{ex_name} denied"), + kind: LicenseViolationKind::Deny, + }); + } + if !policy.allow_exceptions.is_empty() + && !policy.allow_exceptions.iter().any(|a| a == ex_name) + { + return Some(ExceptionFailure { + matched_rule: format!("exception:{ex_name} not in allow list"), kind: LicenseViolationKind::NotAllowed, }); } @@ -451,4 +544,115 @@ mod tests { assert_eq!(v.len(), 1); assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed); } + + // ---------- v0.9.5 SPDX exception allow/deny ---------- + + #[test] + fn spdx_with_exception_back_compat_when_no_exception_policy() { + // v0.9 behavior: empty exception lists → exception is permitted. + let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"])); + let policy = Policy { + allow: vec!["Apache-2.0".into()], + ..Default::default() + }; + assert!(enrich(&cs, &policy).is_empty()); + } + + #[test] + fn spdx_exception_in_deny_list_violates_and_cites_exception() { + let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"])); + let policy = Policy { + allow: vec!["Apache-2.0".into()], + deny_exceptions: vec!["LLVM-exception".into()], + ..Default::default() + }; + let v = enrich(&cs, &policy); + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, LicenseViolationKind::Deny); + assert_eq!(v[0].matched_rule, "exception:LLVM-exception denied"); + } + + #[test] + fn spdx_exception_not_in_allow_list_fails_closed() { + // allow_exceptions is non-empty but doesn't list LLVM-exception. + let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"])); + let policy = Policy { + allow: vec!["Apache-2.0".into()], + allow_exceptions: vec!["Classpath-exception-2.0".into()], + ..Default::default() + }; + let v = enrich(&cs, &policy); + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed); + assert_eq!( + v[0].matched_rule, + "exception:LLVM-exception not in allow list" + ); + } + + #[test] + fn spdx_exception_or_branch_permits_when_sibling_path_passes() { + // (Apache-2.0 WITH LLVM-exception) OR (BSD-3-Clause) with + // deny_exceptions=[LLVM-exception], allow=[Apache-2.0, + // BSD-3-Clause] → BSD-3-Clause path passes; exception denial + // only fails its own branch under OR semantics. + let cs = cs_with_added(comp( + "foo", + vec!["(Apache-2.0 WITH LLVM-exception) OR BSD-3-Clause"], + )); + let policy = Policy { + allow: vec!["Apache-2.0".into(), "BSD-3-Clause".into()], + deny_exceptions: vec!["LLVM-exception".into()], + ..Default::default() + }; + assert!( + enrich(&cs, &policy).is_empty(), + "OR sibling without exception must permit" + ); + } + + #[test] + fn spdx_exception_in_allow_list_permits() { + let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"])); + let policy = Policy { + allow: vec!["Apache-2.0".into()], + allow_exceptions: vec!["LLVM-exception".into()], + ..Default::default() + }; + assert!(enrich(&cs, &policy).is_empty()); + } + + #[test] + fn exception_violation_synthetic_id_round_trips_distinctly() { + // The synthetic id encodes the full license string (including + // the "WITH " suffix), so an exception-driven + // violation produces a different VEX/SARIF identity than a + // base-license violation on the same component. + let v_exception = LicenseViolation { + component: comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]), + license: "Apache-2.0 WITH LLVM-exception".into(), + matched_rule: "exception:LLVM-exception denied".into(), + kind: LicenseViolationKind::Deny, + }; + let v_base = LicenseViolation { + component: comp("foo", vec!["Apache-2.0"]), + license: "Apache-2.0".into(), + matched_rule: "deny: Apache-2.0".into(), + kind: LicenseViolationKind::Deny, + }; + let id_exception = crate::vex::synthetic_id::license_violation(&v_exception); + let id_base = crate::vex::synthetic_id::license_violation(&v_base); + assert_ne!( + id_exception, id_base, + "exception-driven violation must have a distinct synthetic id" + ); + // Round-trip the synthetic id back to the structured form. + let parsed = crate::vex::parse_synthetic_id(&id_exception).expect("round-trips"); + match parsed { + crate::vex::SyntheticFindingKind::LicenseViolation { license, .. } => { + assert_eq!(license, "Apache-2.0 WITH LLVM-exception"); + } + other => panic!("unexpected variant: {other:?}"), + } + } } diff --git a/src/lib.rs b/src/lib.rs index 7fda03d..5ff5620 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -201,6 +201,8 @@ fn run_diff(mut args: DiffArgs) -> Result<()> { allow: args.allow_licenses.clone(), deny: args.deny_licenses.clone(), allow_ambiguous: args.allow_ambiguous_licenses, + allow_exceptions: args.allow_exception.clone(), + deny_exceptions: args.deny_exception.clone(), }; enrichment.license_violations = enrich::license::enrich(&cs, &license_policy); @@ -542,6 +544,9 @@ fn write_calibration_lines( } } for v in &e.license_violations { + // Threshold field carries the precise matched_rule (e.g. + // "deny: GPL-3.0-only" or "exception:LLVM-exception denied") + // so calibration consumers see the WHY, not just the kind tag. write_calibration_row( out, "license", @@ -550,11 +555,7 @@ fn write_calibration_lines( .as_deref() .unwrap_or(v.component.name.as_str()), CalibrationScore::Text(&v.license), - CalibrationThreshold::Text(match v.kind { - crate::enrich::LicenseViolationKind::Deny => "deny", - crate::enrich::LicenseViolationKind::Ambiguous => "ambiguous", - crate::enrich::LicenseViolationKind::NotAllowed => "not-allowed", - }), + CalibrationThreshold::Text(&v.matched_rule), format, ); } @@ -766,7 +767,7 @@ mod tests { use crate::enrich::typosquat::TyposquatFinding; use crate::enrich::version_jump::VersionJumpFinding; - use crate::enrich::{Severity, VulnRef}; + use crate::enrich::{LicenseViolation, Severity, VulnRef}; use crate::model::{Component, Ecosystem, Relationship}; fn comp(name: &str) -> Component { @@ -1087,6 +1088,43 @@ mod tests { assert!(s.contains("kev|"), "missing kev row: {s}"); } + #[test] + fn calibration_license_row_includes_exception_detail() { + // v0.9.5: matched_rule on an exception-driven license violation + // must surface the exception identifier in the calibration tap + // so operators tuning policy see why a row fired. + let mut e = Enrichment::default(); + let component = crate::model::Component { + name: "llvm-sys".into(), + version: "1.0.0".into(), + ecosystem: crate::model::Ecosystem::Cargo, + purl: Some("pkg:cargo/llvm-sys@1.0.0".into()), + licenses: vec!["Apache-2.0 WITH LLVM-exception".into()], + supplier: None, + hashes: Vec::new(), + relationship: crate::model::Relationship::Unknown, + source_url: None, + bom_ref: None, + }; + e.license_violations.push(LicenseViolation { + component, + license: "Apache-2.0 WITH LLVM-exception".into(), + matched_rule: "exception:LLVM-exception denied".into(), + kind: crate::enrich::LicenseViolationKind::Deny, + }); + let mut buf = Vec::new(); + write_calibration_lines(&e, &mut buf, crate::cli::DebugFormat::Pipe); + let s = String::from_utf8(buf).unwrap(); + assert!( + s.contains("license|"), + "missing license calibration row: {s}" + ); + assert!( + s.contains("exception:LLVM-exception denied"), + "row must surface matched_rule with exception detail: {s}" + ); + } + #[test] fn fail_on_license_violation_trips() { use crate::enrich::{LicenseViolation, LicenseViolationKind}; diff --git a/src/render/sarif.rs b/src/render/sarif.rs index 70661d8..27bfab3 100644 --- a/src/render/sarif.rs +++ b/src/render/sarif.rs @@ -1044,4 +1044,50 @@ mod tests { 64 ); } + + #[test] + fn exception_driven_license_violation_fingerprint_differs_from_base() { + // v0.9.5: a violation driven by a denied SPDX `WITH` exception + // must have a stable partialFingerprint distinct from a + // base-license violation on the same component, so SARIF + // consumers (Code Scanning) treat them as separate alerts. + use crate::enrich::{LicenseViolation, LicenseViolationKind}; + let component = comp("foo", "1.0.0", Ecosystem::Npm, Some("pkg:npm/foo@1.0.0")); + let e_exception = Enrichment { + license_violations: vec![LicenseViolation { + component: component.clone(), + license: "Apache-2.0 WITH LLVM-exception".into(), + matched_rule: "exception:LLVM-exception denied".into(), + kind: LicenseViolationKind::Deny, + }], + ..Default::default() + }; + let e_base = Enrichment { + license_violations: vec![LicenseViolation { + component, + license: "Apache-2.0".into(), + matched_rule: "deny: Apache-2.0".into(), + kind: LicenseViolationKind::Deny, + }], + ..Default::default() + }; + let r_exception = render(&ChangeSet::default(), &e_exception); + let r_base = render(&ChangeSet::default(), &e_base); + let parse = |s: &str| -> String { + let v: Value = serde_json::from_str(s).unwrap(); + v["runs"][0]["results"][0]["partialFingerprints"]["primaryHash/v1"] + .as_str() + .unwrap() + .to_string() + }; + let fp_ex = parse(&r_exception); + let fp_base = parse(&r_base); + assert_ne!( + fp_ex, fp_base, + "exception-driven violation fingerprint must differ from base-license violation" + ); + // Stable across runs. + let r_exception_2 = render(&ChangeSet::default(), &e_exception); + assert_eq!(parse(&r_exception_2), fp_ex); + } } From 7fea1bb8fc949a5e108fb70426216a1cbaa208b9 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:38:18 -0700 Subject: [PATCH 09/11] chore(release): prepare v0.9.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump Cargo.toml + Cargo.lock 0.9.0 → 0.9.5. - CHANGELOG.md: v0.9.5 entry covering per-exception SPDX allow/deny, Bitbucket + Azure DevOps comment-suppress bridges, public parse_synthetic_id helper, spdx crate exact-pin, BaselineEntry unification, CI Rust pin, suppress-regex single source of truth, GitLab threading docs. - docs/src/roadmap.md: add "Shipped (v0.9.5 — polish + multi-SCM parity)" section; remove per-exception SPDX from future candidates; add reachability cross-reference to non-goals. - Bump example v0.9.0 pins → v0.9.5 in README, quickstart, action-broke issue template. 389 tests pass, clippy + fmt clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/action-broke.md | 2 +- CHANGELOG.md | 106 +++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 +- docs/src/quickstart.md | 6 +- docs/src/roadmap.md | 27 ++++++- 7 files changed, 140 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/action-broke.md b/.github/ISSUE_TEMPLATE/action-broke.md index fb0d4d2..a5e193e 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.9.0` / `@` +- **bomdrift version pin**: `@v1` / `@v0.9.5` / `@` - **Runner**: - **Trigger event**: diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fd153..5559ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,112 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.9.5] - 2026-04-29 + +The "polish + multi-SCM parity" milestone. v0.9.5 ships the v0.9 follow-up +backlog that was deferred to v1.0 in the v0.9 changelog: per-exception +SPDX allow/deny granularity, exact-pinning of license-data dependencies, +a public synthetic-id parser for VEX consumers, and — most visibly — +**Bitbucket Cloud and Azure DevOps comment-driven suppression bridges**, +giving bomdrift comment-driven suppression parity across all four major +SCMs (GitHub, GitLab, Bitbucket, Azure DevOps). + +### Added + +- **Per-exception SPDX allow/deny.** New `[license] allow_exceptions` / + `deny_exceptions` arrays in `.bomdrift.toml` plus matching + `--allow-exception` / `--deny-exception` CLI flags. License expressions + using the `WITH` operator (e.g. `Apache-2.0 WITH LLVM-exception`) are + now evaluated at the exception level too, not just the base license. + Fail-closed semantics carry over: if `allow_exceptions` is non-empty + and the exception is not in it, the package is denied; if + `deny_exceptions` is non-empty and the exception IS in it, denied. + Empty lists preserve v0.9 behavior (exception treated as informational). + Surfaces in `LicenseViolation::matched_rule` as + `"exception: denied"` / `"exception: not in allow list"` and + is fingerprinted distinctly from base-license violations in SARIF. +- **Bitbucket Cloud comment-suppress bridge.** New + `examples/bitbucket-pipelines/comment-bridge/` (Cloudflare Worker + reference) implementing the same five guards as the GitLab bridge: + webhook UUID/HMAC verification, event-type filter + (`pullrequest:comment_created` only), repo allowlist, commenter- + permission check (Bitbucket Cloud REST API workspace permissions ≥ + `write`), and PR-context guard (rejects fork-PR comment-suppress to + prevent untrusted forks suppressing findings on upstream). +- **Azure DevOps comment-suppress bridge.** New + `examples/azure-devops/comment-bridge/` covering the + `ms.vss-code.git-pullrequest-comment-event` Service Hook with + parallel guards: header-secret verification, event-type filter, + project-UUID allowlist, commenter-permission check (Azure DevOps + identities API), and protected-target-branch guard. Triggers an + Azure Pipeline run with `BOMDRIFT_NOTE_BODY` template parameter. +- **Public `bomdrift::vex::parse_synthetic_id` helper.** Round-trips + bomdrift's synthetic finding IDs (`bomdrift.typosquat::`, + etc.) back to a structured `SyntheticFindingKind` enum. Lets external + VEX tooling identify which bomdrift finding a VEX statement targets + without string-splitting. Re-exported from `lib.rs` for downstream + Rust consumers. + +### Changed + +- **`spdx` crate exact-pinned to `=0.10.9`.** SPDX list updates can + shift `LicenseId.is_gnu()` / `is_osi_approved()` membership and + silently change license-policy semantics. v0.9.5 pins exactly so + bumps are deliberate; the v0.9 caret pin was changed to exact in + this release. +- **`BaselineEntry` and `ExpiredEntry` unified.** They overlapped on + `id`, `purl`, `expires`, `reason`. The two are now backed by a single + internal type with a status enum; the public `ExpiredEntry` alias is + preserved for back-compat. No behavior change; warning text on + expired entries unchanged. +- **CI Rust toolchain pinned to MSRV 1.88.** `dtolnay/rust-toolchain@1.88` + across all CI workflow jobs. Avoids surprises from newer clippy lints + (`cloned_ref_to_slice_refs`, `useless_vec`, `is_multiple_of` came in + Rust 1.94 and broke the v0.8 build until handled). Bump deliberately + via `Cargo.toml`'s `rust-version` field in lockstep. + +### Refactored + +- **Single source of truth for the `/bomdrift suppress [reason: ...]` + comment grammar.** New `scripts/parse-suppress-comment.sh` is the + canonical regex; `comment-suppress/entrypoint.sh` sources it, + `examples/gitlab-ci/comment-bridge/worker.js` mirrors it with a + pointer comment, and `scripts/check-suppress-regex-sync.sh` is wired + into CI to fail the build if the shell + JS copies drift. The Rust + `--from-comment` parser keeps its native regex but now carries a + doc-comment pointing at the canonical grammar. + +### Documentation + +- **GitLab note upsert + threading semantics** documented in + `docs/src/gitlab-ci.md` "How notes are upserted" section. Closes the + v0.7 plan's open question about whether the `PUT + /merge_requests/:id/notes/:note_id` upsert preserves reviewer-reply + threading (it does — note ID stays stable, replies survive, no + re-fired note hooks for unchanged content). +- **`docs/src/license-policy.md`** extended with the SPDX `WITH` + exceptions section + worked examples. +- **`docs/src/vex.md`** extended with the synthetic-id grammar and + `parse_synthetic_id` reference for external tooling authors. + +### Tests + +- 369 → 389 (+20) — covering exception allow/deny semantics, + synthetic-id round-trip, baseline-entry-unify regression guards, and + bridge regex sync checks. + +### Scope notes + +- The Rust `--from-comment` parser remains its own regex (Rust regex + syntax differs from POSIX/JS); CI guards the shell+JS copies but the + Rust copy is doc-linked, not auto-synced. +- PyPI/crates.io maintainer-set-changed enrichment (a v0.9 follow-up + for parity with the npm enricher) stayed deferred — neither PyPI's + nor crates.io's REST API exposes maintainer history cleanly. +- Bridge worker.js files stay user-deployed (Cloudflare Worker / + Vercel / Netlify / AWS Lambda Edge); bomdrift the binary still does + not run a webhook server. + ## [0.9.0] - 2026-05-01 The "interoperability + breadth" milestone. v0.9 adds VEX (Vulnerability diff --git a/Cargo.lock b/Cargo.lock index 5e7fba4..f26dbf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,7 +123,7 @@ dependencies = [ [[package]] name = "bomdrift" -version = "0.9.0" +version = "0.9.5" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index afd80bb..3415214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bomdrift" -version = "0.9.0" +version = "0.9.5" edition = "2024" rust-version = "1.88" description = "SBOM diff with supply-chain risk signals (CVEs, typosquats, maintainer-age)." diff --git a/README.md b/README.md index 1dc3be3..ead7d2d 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.9.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. +Pin to `@v1` for the latest v0.x; pin to `@v0.9.5` 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.9.0 +VERSION=v0.9.5 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.9.0 bomdrift +cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.9.5 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.9.0 +VERSION=v0.9.5 TARGET=x86_64-unknown-linux-gnu ARCHIVE=bomdrift-${VERSION}-${TARGET}.tar.gz diff --git a/docs/src/quickstart.md b/docs/src/quickstart.md index 29f24d1..d4a60ed 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.9.0`) if you prefer reproducible builds. See +version (`@v0.9.5`) 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.9.0 +VERSION=v0.9.5 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.9.0 bomdrift +cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.9.5 bomdrift ``` Requires Rust 1.85+ (the project uses edition 2024). diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 03ce1b3..d831c5f 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -3,6 +3,28 @@ What's planned, what's deliberately out of scope, and what the acceptance criteria for new contributions look like. +## Shipped (v0.9.5 — polish + multi-SCM parity) + +- **Per-exception SPDX allow/deny** via `[license] allow_exceptions` / + `deny_exceptions` and `--allow-exception` / `--deny-exception` CLI + flags. `Apache-2.0 WITH LLVM-exception` etc. now evaluated at the + exception level, not just the base license. +- **Bitbucket + Azure DevOps comment-driven suppression bridges** — + Cloudflare Worker references with the same five guards as the GitLab + bridge. bomdrift now has comment-driven suppression parity across + all four major SCMs. +- **`bomdrift::vex::parse_synthetic_id` public helper** — round-trips + bomdrift's synthetic finding IDs back to a structured kind. Lets + external VEX tooling identify which finding a statement targets. +- `spdx` crate exact-pinned (`=0.10.9`) so license-list updates can't + silently change policy semantics. +- `BaselineEntry` / `ExpiredEntry` unified internally; public alias + preserved. +- CI Rust toolchain pinned to MSRV 1.88; bumps are deliberate. +- Single source of truth for the suppress-comment grammar + (`scripts/parse-suppress-comment.sh` + CI sync guard). +- GitLab note upsert + threading semantics documented. + ## Shipped (v0.9 — interoperability + breadth) - **VEX consume** — `--vex ` accepts OpenVEX 0.2.0 + CycloneDX @@ -39,9 +61,6 @@ acceptance criteria for new contributions look like. ## Future candidates (not committed) -- **Per-exception SPDX allow/deny** — currently the WITH-exception - identity is informational only; allow/deny narrows to base - license. v1.0 candidate. - **PyPI / crates.io maintainer-set-changed** — blocked on per-version maintainer data in upstream APIs. - **VEX vocabulary beyond OpenVEX's 8 justifications** — bomdrift @@ -53,6 +72,8 @@ acceptance criteria for new contributions look like. organization-specific enrichers. Probably WASM-based. - **OCI artifact attestation** — verify SBOMs are signed by the build system before diffing. +- **Reachability** — explicit non-goal; pair with Endor / Snyk for + call-graph analysis (see Non-goals below). ### Calibration backlog From 3e77f375f46bca74ee50609bf825bdfcf334de5a Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:43:22 -0700 Subject: [PATCH 10/11] style(lib): clippy 1.88 uninlined_format_args fixes Pinning CI Rust to 1.88 (this release) surfaces the clippy::uninlined_format_args lint that newer toolchains had already accepted. Inline the four offending sites: - src/clock.rs::is_expired_iso8601 (anyhow! macro) - src/render/markdown.rs::section_open (writeln + write) - src/render/sarif.rs test (assert! macro) Verified locally with rustup 1.88 toolchain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/clock.rs | 2 +- src/render/markdown.rs | 4 ++-- src/render/sarif.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/clock.rs b/src/clock.rs index 788f078..16afaff 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -71,7 +71,7 @@ pub fn is_expired(expires: Date) -> bool { /// Convenience: parse a `YYYY-MM-DD` string and return whether it has /// expired relative to `today()`. Surface parse errors to caller. pub fn is_expired_str(s: &str) -> Result { - parse_ymd(s).map(is_expired).map_err(|e| anyhow!("{}", e)) + parse_ymd(s).map(is_expired).map_err(|e| anyhow!("{e}")) } #[cfg(test)] diff --git a/src/render/markdown.rs b/src/render/markdown.rs index 18289e6..e41c617 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -464,10 +464,10 @@ pub fn render_with_options(cs: &ChangeSet, enrichment: &Enrichment, opts: Option /// the most-actionable item in the section (e.g. `top severity: CRITICAL`) /// so the reviewer knows whether expanding is worth their time. fn section_open(out: &mut String, label: &str, count: usize, teaser: Option<&str>) { - let _ = writeln!(out, "### {} ({})\n", label, count); + let _ = writeln!(out, "### {label} ({count})\n"); out.push_str("
Show details"); if let Some(t) = teaser { - let _ = write!(out, " · {}", t); + let _ = write!(out, " · {t}"); } // Blank line after `` is required by GitHub-Flavored Markdown // for the markdown body inside `
` to render as markdown rather diff --git a/src/render/sarif.rs b/src/render/sarif.rs index 27bfab3..ce99c56 100644 --- a/src/render/sarif.rs +++ b/src/render/sarif.rs @@ -935,7 +935,7 @@ mod tests { assert_eq!(r1, r2, "byte-equal across runs"); let v: Value = serde_json::from_str(&r1).unwrap(); let fp = &v["runs"][0]["results"][0]["partialFingerprints"]["primaryHash/v1"]; - assert!(fp.is_string(), "fingerprint missing: {}", v); + assert!(fp.is_string(), "fingerprint missing: {v}"); assert_eq!(fp.as_str().unwrap().len(), 64); } From 426406f98649bc334c648455c871f535c5995e8f Mon Sep 17 00:00:00 2001 From: Metbcy Date: Wed, 29 Apr 2026 18:49:40 -0700 Subject: [PATCH 11/11] fix(tests): serialize SOURCE_DATE_EPOCH mutations across crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote clock::tests::env_lock to clock::test_env_lock (pub(crate), #[cfg(test)]). Have baseline::tests::lock_today, vex::tests::pin_clock, and enrich::registry::tests::days_since_zero_for_now all acquire the same crate-wide mutex. Before this fix, each module had a local mutex (or no mutex at all), so parallel `cargo test` threads in different modules could race on SOURCE_DATE_EPOCH — manifesting as an intermittent ubuntu-latest CI failure on baseline::tests::expired_object_entry_warns_and_does_not_ suppress (PR #22 first run). macOS and Windows happened to schedule the relevant tests in non-conflicting order. Verified: 3 consecutive `cargo test --release --all-features` runs green with default parallelism (was previously failing 1 in N). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/baseline.rs | 13 +++++++++---- src/clock.rs | 26 +++++++++++++++++++------- src/enrich/registry.rs | 3 ++- src/vex.rs | 13 ++++++++----- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/baseline.rs b/src/baseline.rs index c18897b..c3f2a59 100644 --- a/src/baseline.rs +++ b/src/baseline.rs @@ -843,9 +843,13 @@ mod tests { // ---- v0.8 expires + reason ----------------------------------------- fn lock_today(epoch: i64) -> impl Drop { - // SAFETY: tests serialize on these env mutations via a process-wide - // mutex inside crate::clock; see clock::tests for the same pattern. - struct Guard; + // SAFETY: env mutations are serialized by the crate-wide + // `clock::test_env_lock()` mutex; `Guard` holds that lock for + // the lifetime of the returned token so `SOURCE_DATE_EPOCH` + // remains stable from set-time through baseline parse. + struct Guard { + _lock: std::sync::MutexGuard<'static, ()>, + } impl Drop for Guard { fn drop(&mut self) { unsafe { @@ -853,10 +857,11 @@ mod tests { } } } + let _lock = crate::clock::test_env_lock(); unsafe { std::env::set_var("SOURCE_DATE_EPOCH", epoch.to_string()); } - Guard + Guard { _lock } } #[test] diff --git a/src/clock.rs b/src/clock.rs index 16afaff..e8c0a3b 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -74,18 +74,30 @@ pub fn is_expired_str(s: &str) -> Result { parse_ymd(s).map(is_expired).map_err(|e| anyhow!("{e}")) } +/// Test-only helper: process-wide mutex serializing `SOURCE_DATE_EPOCH` +/// mutations across `cargo test`'s default thread pool. Hold the +/// returned guard for the duration of the test. +/// +/// Used by every test that touches `SOURCE_DATE_EPOCH` — `clock`, +/// `baseline`, `vex`, `enrich::registry`. Without this, parallel tests +/// race on the env var and the consumer reads system-clock garbage, +/// causing intermittent failures (PR #22 ubuntu-latest, v0.9.5). +#[cfg(test)] +pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + #[cfg(test)] mod tests { use super::*; - /// Guard env mutations behind a process-wide mutex so concurrent - /// `cargo test` threads don't trample each other. + /// Re-export at module level for the existing tests below. fn env_lock() -> std::sync::MutexGuard<'static, ()> { - use std::sync::{Mutex, OnceLock}; - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|e| e.into_inner()) + test_env_lock() } #[test] diff --git a/src/enrich/registry.rs b/src/enrich/registry.rs index 8206083..8aaeb9d 100644 --- a/src/enrich/registry.rs +++ b/src/enrich/registry.rs @@ -641,7 +641,8 @@ mod tests { #[test] fn days_since_zero_for_now() { - // SAFETY: serialized by other clock tests. + let _lock = crate::clock::test_env_lock(); + // SAFETY: serialized by the env_lock guard above. unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "1777593600"); } diff --git a/src/vex.rs b/src/vex.rs index 0f0bff2..b16f25a 100644 --- a/src/vex.rs +++ b/src/vex.rs @@ -1110,11 +1110,14 @@ mod tests { // ---------- Phase H: emission ---------- - fn pin_clock(secs: i64) { - // SAFETY: tests in this module are serialized by env_lock equivalence. + fn pin_clock(secs: i64) -> std::sync::MutexGuard<'static, ()> { + let lock = crate::clock::test_env_lock(); + // SAFETY: env mutations are serialized by the returned mutex + // guard; the caller must hold it for the duration of the test. unsafe { std::env::set_var("SOURCE_DATE_EPOCH", secs.to_string()); } + lock } fn unpin_clock() { unsafe { @@ -1124,7 +1127,7 @@ mod tests { #[test] fn emission_roundtrip_via_loader() { - pin_clock(1_700_000_000); + let _lock = pin_clock(1_700_000_000); let cs = crate::diff::ChangeSet::default(); let e = crate::enrich::Enrichment::default(); let entries = vec![crate::baseline::BaselineEntry { @@ -1165,7 +1168,7 @@ mod tests { fn emission_default_status_is_under_investigation() { // Anti-false-claim guard: a plain baseline entry without // `vex_status` must NOT be auto-promoted to `not_affected`. - pin_clock(1_700_000_000); + let _lock = pin_clock(1_700_000_000); let cs = crate::diff::ChangeSet::default(); let e = crate::enrich::Enrichment::default(); let entries = vec![crate::baseline::BaselineEntry { @@ -1195,7 +1198,7 @@ mod tests { #[test] fn emission_byte_deterministic_with_source_date_epoch() { - pin_clock(1_700_000_000); + let _lock = pin_clock(1_700_000_000); let cs = crate::diff::ChangeSet::default(); let e = crate::enrich::Enrichment::default(); let entries = vec![crate::baseline::BaselineEntry {