From 9e9f55bd9cd42356a0d45071aca9bb16ab4cba4a Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:04:11 -0700 Subject: [PATCH 1/7] chore(release): Cargo metadata + cargo publish --dry-run CI guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prep for the v0.9.9 distribution release. crates.io publication needs a few things in place first: - Cargo.toml gains `documentation = "https://docs.rs/bomdrift"` so crates.io links docs correctly, and a `[package.metadata.docs.rs]` block so the auto-built docs.rs page renders with all features. - Cargo.toml gains an `exclude` list trimming the published crate to source + runtime data + project meta. tests/ (2.7 MB of fixtures), docs/ (mdbook source published separately), examples/, benches/, fuzz/, comment-suppress/, scripts/, .github/, and the GitHub Action manifests are all excluded. data/ stays IN — the typosquat reference lists are pulled at compile time via - New `publish-dry-run` job in ci.yml runs `cargo publish --dry-run --locked` on every PR, catching crate-metadata regressions (oversized package, missing fields, exclude list dropping a build-time include) BEFORE they reach a release tag. Path-filtered to PR runs only. Verified locally: published crate is 54 files / 220 KiB compressed, data/*.txt are in, all 443 tests still pass. Refs the v0.9.9 plan (Track 1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 15 +++++++++++++++ Cargo.toml | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 692d840..350943a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,21 @@ jobs: with: command: check + # Catch crate-metadata regressions (missing fields, oversized package, + # `exclude` slipping such that build-time `include_str!`'d files are + # dropped from the crate) BEFORE they reach a release tag. Mirrors what + # `cargo publish` will do at release time, but never actually uploads. + # Path-filtered to keep PR runs cheap. + publish-dry-run: + name: cargo publish --dry-run + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.88 + - uses: Swatinem/rust-cache@v2 + - run: cargo publish --dry-run --locked + shell-bridges: name: shell-bridges (parser tests + regex sync) runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 3b8c2c2..b5a541d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,44 @@ description = "SBOM diff with supply-chain risk signals (CVEs, typosquats, maint license = "Apache-2.0" repository = "https://github.com/Metbcy/bomdrift" homepage = "https://metbcy.github.io/bomdrift/" +documentation = "https://docs.rs/bomdrift" readme = "README.md" keywords = ["sbom", "security", "supply-chain", "cyclonedx", "spdx"] categories = ["command-line-utilities", "development-tools"] +# Trim the published crate to source + runtime data + project meta. +# - tests/ (2.7 MB of fixtures): fixtures pulled from upstream projects; +# downstream `cargo install` users don't run `cargo test`. +# - docs/ (2.6 MB mdbook source): published separately at +# https://metbcy.github.io/bomdrift/. +# - examples/, benches/, fuzz/: not needed for `cargo install`. +# - comment-suppress/, action.yml, entrypoint.sh: GitHub Action +# surface, distributed via the Marketplace and cosign-signed +# release archives, not the crate. +# - .github/, scripts/: repo-only meta. +# - data/ stays IN — `src/enrich/typosquat.rs` `include_str!`s its +# contents at build time. +exclude = [ + "tests/", + "docs/", + "examples/", + "benches/", + "fuzz/", + "comment-suppress/", + "scripts/", + ".github/", + "action.yml", + "entrypoint.sh", + "STATUS.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", +] + +[package.metadata.docs.rs] +# Make every conditionally compiled item visible in the rendered docs +# (no-op today since bomdrift has no `[features]` block, but +# future-proofs the feature-flag story). +all-features = true +rustdoc-args = ["--cfg", "docsrs"] [lib] name = "bomdrift" From 838c2e06f33524133826d62e62876c17f94f7fa8 Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:07:07 -0700 Subject: [PATCH 2/7] docs(rustdoc): fix broken intra-doc links so docs.rs build is clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once v0.9.9 is published to crates.io, docs.rs will auto-build the docs page. Run `RUSTDOCFLAGS=-D warnings cargo doc --no-deps --all-features` locally and fix every broken intra-doc link surfaced. Two failure shapes: - Module-level docstrings reference private items (`MAX_QUERIES_PER_BATCH`, `SUFFIX_BOOST_SCORE`, `run_with`, `eval_leaf`, `LeafOutcome`). rustdoc rejects these because the link target isn't visible to the public docs build. Demoted from bracketed intra-doc links to plain backtick-quoted code spans — same reading experience, no broken-link warning. - Reference paths broken by the v0.9.8 lib.rs split. `crate::run_diff` no longer exists at the crate root; the orchestration module is `crate::run`. Updated to [`mod@crate::run`] and qualified the `VulnRef`/`Severity`/`Cache` references with full paths. - One redundant explicit link target (osv.rs `Severity::None`) flagged by `-D rustdoc::redundant-explicit-links` — the label already resolves to the same destination, so the explicit target is noise. - One reference to `Cache::with_root` rephrased — that constructor is `#[cfg(test)]` so it isn't visible to the public docs build. No public API surface change. All 444 tests still pass. Refs the v0.9.9 plan (Track 4). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/enrich/cache.rs | 2 +- src/enrich/epss.rs | 4 ++-- src/enrich/kev.rs | 4 ++-- src/enrich/license.rs | 4 ++-- src/enrich/osv.rs | 4 ++-- src/enrich/typosquat.rs | 2 +- src/refresh.rs | 2 +- src/render/term.rs | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/enrich/cache.rs b/src/enrich/cache.rs index a531036..344d871 100644 --- a/src/enrich/cache.rs +++ b/src/enrich/cache.rs @@ -80,7 +80,7 @@ struct CacheEntry { } /// Filesystem-backed severity cache. Construct via [`Cache::open`] (production) -/// or [`Cache::with_root`] (tests, when an explicit root is needed). +/// or via the test-only `with_root` constructor (when an explicit root is needed). pub struct Cache { root: PathBuf, now_secs: fn() -> u64, diff --git a/src/enrich/epss.rs b/src/enrich/epss.rs index cad99d9..a280b1a 100644 --- a/src/enrich/epss.rs +++ b/src/enrich/epss.rs @@ -2,7 +2,7 @@ //! //! EPSS publishes a per-CVE probability of exploitation in the next 30 days, //! refreshed daily. We query -//! in batches and surface the score on every [`VulnRef`] whose primary id +//! in batches and surface the score on every [`crate::enrich::VulnRef`] whose primary id //! or aliases include a CVE-prefixed identifier. //! //! Best-effort: a network failure or parse error logs to stderr at @@ -35,7 +35,7 @@ struct CacheEntry { score: Option, } -/// Apply EPSS scores to every [`VulnRef`] in `e.vulns`. Updates in place; +/// Apply EPSS scores to every [`crate::enrich::VulnRef`] in `e.vulns`. Updates in place; /// `--no-epss` callers should skip calling this entirely. Best-effort. pub fn enrich(e: &mut Enrichment) -> Result<()> { enrich_with_ttl(e, None) diff --git a/src/enrich/kev.rs b/src/enrich/kev.rs index 5084a8b..4694cef 100644 --- a/src/enrich/kev.rs +++ b/src/enrich/kev.rs @@ -3,7 +3,7 @@ //! Single bulk feed at //! , //! refreshed daily. We download the catalog once per 24h, parse the -//! `vulnerabilities[].cveID` field, and flip [`VulnRef::kev`] to true on +//! `vulnerabilities[].cveID` field, and flip [`crate::enrich::VulnRef::kev`] to true on //! every reference whose primary id or aliases include a KEV CVE. //! //! Best-effort: network failure logs at `BOMDRIFT_DEBUG=1` and returns Ok @@ -37,7 +37,7 @@ struct KevEntry { cve_id: String, } -/// Apply KEV flags to every [`VulnRef`] in `e.vulns`. `--no-kev` callers +/// Apply KEV flags to every [`crate::enrich::VulnRef`] in `e.vulns`. `--no-kev` callers /// should skip calling this entirely. pub fn enrich(e: &mut Enrichment) -> Result<()> { enrich_with_ttl(e, None) diff --git a/src/enrich/license.rs b/src/enrich/license.rs index 0dee114..e9b7c57 100644 --- a/src/enrich/license.rs +++ b/src/enrich/license.rs @@ -26,8 +26,8 @@ //! //! ## WITH-chain inheritance through compound expressions (v0.9.7+) //! -//! Each leaf of an SPDX expression is evaluated by [`eval_leaf`] which -//! produces a [`LeafOutcome`] reflecting BOTH the base license check +//! Each leaf of an SPDX expression is evaluated by `eval_leaf` which +//! produces a `LeafOutcome` reflecting BOTH the base license check //! AND the exception check. Those per-leaf outcomes are then combined //! by the standard SPDX expression semantics: //! diff --git a/src/enrich/osv.rs b/src/enrich/osv.rs index 794f7f5..7ae4c1a 100644 --- a/src/enrich/osv.rs +++ b/src/enrich/osv.rs @@ -4,7 +4,7 @@ //! //! 1. **`/v1/querybatch`** — POST every purl of every component in //! `ChangeSet.added` and the after-side of `ChangeSet.version_changed`, -//! chunked by [`MAX_QUERIES_PER_BATCH`]. Returns advisory IDs only. +//! chunked by `MAX_QUERIES_PER_BATCH`. Returns advisory IDs only. //! 2. **`/v1/vulns/{id}`** — GET each unique advisory ID returned in step 1 //! and parse `database_specific.severity` (GHSA's text label) to populate //! [`crate::enrich::Severity`]. @@ -18,7 +18,7 @@ //! Both stages are best-effort: callers should surface errors as warnings //! and continue rendering the diff. OSV being unreachable is not a reason to //! block a PR review. A failed /vulns/{id} lookup yields -//! [`Severity::None`](crate::enrich::Severity::None) for that advisory and +//! [`Severity::None`] for that advisory and //! a single stderr warning per run. use std::collections::{BTreeSet, HashMap}; diff --git a/src/enrich/typosquat.rs b/src/enrich/typosquat.rs index 29bc3cc..1d17ec7 100644 --- a/src/enrich/typosquat.rs +++ b/src/enrich/typosquat.rs @@ -35,7 +35,7 @@ //! 3. **Suffix containment with a substantial added prefix → boost**. When //! the candidate ends with a legit name (≥ 5 chars) AND the added prefix //! is longer than 3 characters, the score is boosted to at least -//! [`SUFFIX_BOOST_SCORE`]. The textbook typosquat pattern: +//! `SUFFIX_BOOST_SCORE`. The textbook typosquat pattern: //! `plain-crypto-js`, `safe-axios`, `secure-lodash`. The base //! Jaro-Winkler similarity for these is low (the prefix kills it) but the //! deceptive intent is unmistakable. diff --git a/src/refresh.rs b/src/refresh.rs index ba02827..44d7c2a 100644 --- a/src/refresh.rs +++ b/src/refresh.rs @@ -39,7 +39,7 @@ //! ## Testability //! //! Network and filesystem are split off the public [`run`] entry point via -//! [`run_with`], which accepts an injected fetcher closure and an explicit +//! `run_with`, which accepts an injected fetcher closure and an explicit //! cache root. Tests use a fake fetcher returning canned anvaka markdown plus //! a tempdir cache root, so the test suite stays fully offline. diff --git a/src/render/term.rs b/src/render/term.rs index f9072ba..47c155f 100644 --- a/src/render/term.rs +++ b/src/render/term.rs @@ -7,7 +7,7 @@ //! `CLICOLOR_FORCE` environment variables. //! //! The actual TTY check (so that piped output stays plain) lives in -//! [`crate::run_diff`]; this module assumes the caller has already decided +//! [`mod@crate::run`]; this module assumes the caller has already decided //! that ANSI is appropriate. Color emission is still further gated by //! `if_supports_color(Stdout, ...)` so even if invoked directly, output //! degrades gracefully when stdout cannot render escapes. From 918bf6b6dd4151697ecdb177fc86edcf22a3778e Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:08:58 -0700 Subject: [PATCH 3/7] ci(release): Dockerfile + ghcr.io build job (gated if: false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the second install path: ghcr.io/metbcy/bomdrift. Multi-arch (linux/amd64, linux/arm64) via docker buildx. Tag matrix follows the standard convention — :v0.9.9, :v0.9, :v0, :latest. Design notes: - Single-stage Dockerfile. Consumes the per-arch binaries already cosign-signed by the build matrix; no `cargo build` runs in the image. The bytes baked into ghcr.io are exactly the bytes attached to the corresponding GitHub Release. - Distroless cc-debian12 base. ~22 MB base + ~6 MB stripped binary = ~28 MB final image. Runs as the distroless `nonroot` user. - .dockerignore is allowlist-style: nothing from the repo is in the docker build context except dist/ (the staged binaries) and the Dockerfile itself. Keeps context uploads small and prevents accidental `target/` poisoning. - The buildx step asks docker/build-push-action@v6 for inline `provenance: true` and `sbom: true`; that gets us GitHub-attested build provenance + an SBOM attached to the registry manifest at no extra cost. Image-level cosign sign step runs after. GATED `if: false`. The job is parsed for syntax but never runs. The v0.9.9 "flip the gates" PR removes the gate after the crates.io prerequisites (name reservation, CARGO_REGISTRY_TOKEN secret) are in place. Refs the v0.9.9 plan (Track 2). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .dockerignore | 10 ++++ .github/workflows/release.yml | 86 +++++++++++++++++++++++++++++++++++ Dockerfile | 30 ++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d7f82b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Keep the docker build context lean. Only the pre-staged release +# binaries are needed; everything else (source tree, target/, docs/, +# tests/, .git/, etc.) is excluded so: +# +# 1. Image rebuilds are fast (small context = small upload to buildx). +# 2. There's no risk of accidentally baking a stale `target/` artifact +# or test fixture into the image. +** +!dist/ +!Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2d498d..b57ed57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -265,3 +265,89 @@ jobs: dist/*.sha256 dist/*.sig dist/*.pem + + # Multi-arch (linux/amd64, linux/arm64) ghcr.io image. Consumes the + # per-arch binaries already cosign-signed by the build matrix; no + # cargo build runs here. Tag matrix follows the standard convention: + # the full version, the major.minor, the major, and `latest`. + # + # GATED `if: false` — flipped on in the v0.9.9 "release-pipeline gates" + # PR after the supporting infrastructure (crates.io reservation, + # CARGO_REGISTRY_TOKEN secret) is in place. Until then this job is + # parsed for syntax but never runs. + docker: + name: docker (ghcr.io) + if: false + needs: [preflight, build, publish] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Compute version tag components + id: ver + shell: bash + run: | + version="${{ needs.preflight.outputs.version }}" + major="${version%%.*}" + rest="${version#*.}" + minor="${rest%%.*}" + echo "major=v${major}" >> "$GITHUB_OUTPUT" + echo "minor=v${major}.${minor}" >> "$GITHUB_OUTPUT" + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Stage Linux binaries by docker arch + shell: bash + run: | + tag="${{ needs.preflight.outputs.tag }}" + mkdir -p dist/linux-amd64 dist/linux-arm64 + tar -xzf "artifacts/bomdrift-${tag}-x86_64-unknown-linux-gnu.tar.gz" \ + -C dist/linux-amd64 --strip-components=1 \ + --wildcards '*/bomdrift' + tar -xzf "artifacts/bomdrift-${tag}-aarch64-unknown-linux-gnu.tar.gz" \ + -C dist/linux-arm64 --strip-components=1 \ + --wildcards '*/bomdrift' + ls -la dist/linux-amd64 dist/linux-arm64 + + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-arch image + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + provenance: true + sbom: true + tags: | + ghcr.io/metbcy/bomdrift:${{ needs.preflight.outputs.tag }} + ghcr.io/metbcy/bomdrift:${{ steps.ver.outputs.minor }} + ghcr.io/metbcy/bomdrift:${{ steps.ver.outputs.major }} + ghcr.io/metbcy/bomdrift:latest + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign image with cosign (keyless) + env: + COSIGN_EXPERIMENTAL: "1" + run: | + cosign sign --yes \ + "ghcr.io/metbcy/bomdrift@${{ steps.build.outputs.digest }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff49f06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.7 + +# bomdrift container image — distroless cc, multi-arch (linux/amd64, linux/arm64). +# +# Single-stage by design: this Dockerfile consumes pre-built per-arch +# binaries that the release pipeline (.github/workflows/release.yml) +# stages under dist/linux-${TARGETARCH}/bomdrift. There is no `cargo +# build` in the image; the binaries baked into ghcr.io are exactly the +# cosign-signed artifacts attached to the corresponding GitHub Release. +# +# Image base is gcr.io/distroless/cc-debian12 (supports glibc; ~22 MB +# base + ~6 MB stripped bomdrift binary). Runs as the distroless +# `nonroot` user — every production read of an SBOM in this image is +# intentional and uncovered by privileged-process side effects. +# +# Local development: +# +# cargo build --release +# mkdir -p dist/linux-amd64 +# cp target/release/bomdrift dist/linux-amd64/ +# docker buildx build --platform linux/amd64 -t bomdrift:local --load . +# docker run --rm bomdrift:local --version + +ARG TARGETARCH + +FROM gcr.io/distroless/cc-debian12:nonroot +ARG TARGETARCH +COPY dist/linux-${TARGETARCH}/bomdrift /bomdrift +USER nonroot +ENTRYPOINT ["/bomdrift"] From 83ef28d6fa75130568195055eaf6e154e5b1463f Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:10:12 -0700 Subject: [PATCH 4/7] ci(release): SLSA build provenance + verification docs (gated if: false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add actions/attest-build-provenance@v2 steps to both artifact-producing jobs: - the build matrix (per-target archives — gated step inside each matrix run, so each archive gets its own attestation) - the docker job (multi-arch image — push-to-registry: true so the attestation lives alongside the manifest in ghcr.io) Top-level workflow permissions gain `attestations: write` (required by attest-build-provenance) and `packages: write` (anticipates the ungated docker job). Both are restricted by GitHub to the workflow run's OIDC identity, so they don't widen the trust surface. docs/src/release-signing.md gains a SLSA section explaining: - why cosign + SLSA are complementary (signer-identity vs build-identity), with the threat model where each one catches what the other misses. - how to verify with `gh attestation verify` (the recommended path) and `slsa-verifier` (the air-gapped path). - how to verify the ghcr.io image's inline attestation. GATED `if: false` until the v0.9.9 "flip the gates" PR. The attestations write nothing until then. Refs the v0.9.9 plan (Track 3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 31 +++++++++++++++- docs/src/release-signing.md | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b57ed57..ab59bd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,9 @@ on: permissions: contents: write # create release, upload assets - id-token: write # cosign keyless OIDC + id-token: write # cosign keyless OIDC + SLSA attestation + attestations: write # actions/attest-build-provenance + packages: write # ghcr.io image push (gated if:false today) # A second push of the same tag (rare; e.g. a force-push after a fixup) will # cancel the in-flight run rather than racing it. Different tags can release @@ -201,6 +203,22 @@ jobs: if-no-files-found: error retention-days: 7 + # SLSA build provenance for the per-target archive. Complementary + # to the cosign-keyless signature uploaded just above: cosign + # proves "the bomdrift maintainer's GitHub OIDC identity signed + # this blob"; SLSA proves "this blob was produced by the public + # release.yml workflow on tag ${{ github.ref_name }} in this + # repo". Verify with `gh attestation verify --owner Metbcy + # ` or `slsa-verifier verify-artifact --source-uri + # github.com/Metbcy/bomdrift --source-tag `. + # + # GATED `if: false` until the v0.9.9 "flip the gates" PR. + - name: Attest build provenance + if: false + uses: actions/attest-build-provenance@v2 + with: + subject-path: dist/${{ steps.stage.outputs.stem }}.${{ matrix.archive }} + publish: name: publish release needs: [preflight, build] @@ -351,3 +369,14 @@ jobs: run: | cosign sign --yes \ "ghcr.io/metbcy/bomdrift@${{ steps.build.outputs.digest }}" + + # SLSA build provenance for the multi-arch image. See the + # equivalent step on the per-target archives for the cosign-vs-SLSA + # rationale. GATED `if: false` until the v0.9.9 "flip the gates" PR. + - name: Attest build provenance (image) + if: false + uses: actions/attest-build-provenance@v2 + with: + subject-name: ghcr.io/metbcy/bomdrift + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/docs/src/release-signing.md b/docs/src/release-signing.md index d80cfc5..ffa0518 100644 --- a/docs/src/release-signing.md +++ b/docs/src/release-signing.md @@ -112,3 +112,73 @@ Checksums alone don't authenticate the archive (an attacker who can modify the `.tar.gz` can also modify the `.sha256`); cosign is the authoritative verification path. The checksums exist for older toolchains and for quick local-rerun checks. + +## SLSA build provenance (v0.9.9+) + +In addition to the cosign-keyless signature on each archive, the +release pipeline produces a **SLSA build provenance attestation** +covering both the per-target archives and the multi-arch ghcr.io +image. The two are complementary, not redundant: + +- **cosign** proves *"the bomdrift maintainer's GitHub OIDC identity + signed this artifact."* It binds the artifact to the human (or + workflow run) holding the signing identity at sign time. +- **SLSA provenance** proves *"this artifact was produced by the + public `release.yml` workflow on tag `v0.9.9` in this repo, against + this commit SHA."* It binds the artifact to the build itself — + including the source ref, the workflow file, and the ephemeral + runner identity. + +Both verifications must pass for the release to be trustworthy. An +attacker who compromised the maintainer's signing identity (cosign +verifies) but couldn't push to `Metbcy/bomdrift` (SLSA fails) would +trip SLSA. Conversely, an attacker who pushed a malicious workflow +to a fork (SLSA verifies for the fork) wouldn't have the +maintainer's OIDC identity (cosign fails). + +### Verifying SLSA provenance — `gh` (recommended) + +The simplest path uses `gh`, which calls into the SLSA verifier with +the right defaults for GitHub-hosted attestations: + +```bash +VERSION=v0.9.9 +TARGET=x86_64-unknown-linux-gnu +ARCHIVE=bomdrift-${VERSION}-${TARGET}.tar.gz +BASE="https://github.com/Metbcy/bomdrift/releases/download/${VERSION}" + +curl -fsSL -O "${BASE}/${ARCHIVE}" +gh attestation verify --owner Metbcy "${ARCHIVE}" +``` + +A successful verification prints +`Loaded ... attestation(s) ... verified`. Pin the source ref by +adding `--source-ref refs/tags/${VERSION}` if you want to reject +attestations from other tags. + +### Verifying SLSA provenance — `slsa-verifier` + +For air-gapped or non-GitHub environments where `gh` isn't +available: + +```bash +slsa-verifier verify-artifact \ + --provenance-path "${ARCHIVE}.intoto.jsonl" \ + --source-uri github.com/Metbcy/bomdrift \ + --source-tag ${VERSION} \ + "${ARCHIVE}" +``` + +The `.intoto.jsonl` file is downloaded automatically by `gh +attestation download`, or you can fetch it directly from the +release's attestation manifest at +`https://github.com/Metbcy/bomdrift/attestations`. + +### Verifying the ghcr.io image attestation + +The multi-arch image carries an inline attestation (pushed by the +build job's `push-to-registry: true`): + +```bash +gh attestation verify --owner Metbcy oci://ghcr.io/metbcy/bomdrift:${VERSION} +``` From cedb9f84fec473d00a770f54fd5b908c4cfb3cd9 Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:11:02 -0700 Subject: [PATCH 5/7] docs(readme,status): badges for new install paths + Marketplace polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README gains four new badges at the top: - crates.io (was missing entirely) - docs.rs (was missing entirely) - GitHub Marketplace (was missing — listing has been live since v0.9.8) - The existing CI / Release / Docs / License badges stay. README's Detailed install section gains two new install paths under "As a binary (local / CI)": - `cargo install --locked bomdrift` (v0.9.9+) — the canonical Rust-CLI install path. - `docker run --rm ghcr.io/metbcy/bomdrift:v0.9.9` (v0.9.9+) — for teams that prefer pulling images. Multi-arch, distroless, runs as nonroot, ships with an inline SLSA attestation. The existing release-archive download path is kept as a third option (some teams really do want to pin a tarball checksum). The from-source path is bumped from v0.9.8 to v0.9.9. STATUS.md drops the stale "Marketplace publication is a repository setting; the action metadata is ready, but a maintainer must enable the listing" line. The listing has been live at github.com/marketplace/actions/bomdrift since v0.9.8 — leaving the line in place was actively misleading. The Marketplace listing description rewrite (lead with the axios narrative) is a Marketplace dashboard edit, not a repo file edit, so it's not in this commit. Tracked separately in the v0.9.9 plan. Refs the v0.9.9 plan (Track 5). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 28 +++++++++++++++++++++++++--- STATUS.md | 2 -- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 34f4384..c52c0c9 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ > **SBOM diff with supply-chain risk signals.** Flags new CVEs (with EPSS + CISA KEV signal), typosquats across 8 ecosystems, multi-major version jumps, young-maintainer takeovers, recently-published / deprecated / maintainer-set-changed registry signals, and license-policy violations on every changed dependency — posted as a comment on GitHub, GitLab, Bitbucket, or Azure DevOps PRs. [![CI](https://github.com/Metbcy/bomdrift/actions/workflows/ci.yml/badge.svg)](https://github.com/Metbcy/bomdrift/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/bomdrift.svg)](https://crates.io/crates/bomdrift) +[![docs.rs](https://img.shields.io/docsrs/bomdrift)](https://docs.rs/bomdrift) +[![GitHub Marketplace](https://img.shields.io/badge/marketplace-bomdrift-blue?logo=github)](https://github.com/marketplace/actions/bomdrift) [![Release](https://img.shields.io/github/v/release/Metbcy/bomdrift?sort=semver&display_name=tag)](https://github.com/Metbcy/bomdrift/releases/latest) [![Docs](https://img.shields.io/badge/docs-mdbook-blue)](https://metbcy.github.io/bomdrift/) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) @@ -124,10 +127,29 @@ Comment `/bomdrift suppress GHSA-xxxx` on any PR; the sub-action appends to `.bo ### As a binary (local / CI) -Pre-built binaries cover Linux x86_64 + aarch64, macOS aarch64, and Windows x86_64. Each archive is cosign-signed via Sigstore + GitHub OIDC. +Pre-built binaries cover Linux x86_64 + aarch64, macOS aarch64, and Windows x86_64. Each archive is cosign-signed via Sigstore + GitHub OIDC, and (v0.9.9+) carries a SLSA build provenance attestation. + +**Install via `cargo` (v0.9.9+):** ```bash -VERSION=v0.9.8 +cargo install --locked bomdrift +bomdrift --version +``` + +**Install via Docker / OCI (v0.9.9+):** + +```bash +docker run --rm ghcr.io/metbcy/bomdrift:latest --version +# Pin to a specific version for reproducible CI: +docker run --rm ghcr.io/metbcy/bomdrift:v0.9.9 --version +``` + +The image is multi-arch (`linux/amd64`, `linux/arm64`), distroless, runs as a non-root user, and ships with an inline SLSA attestation (verify with `gh attestation verify --owner Metbcy oci://ghcr.io/metbcy/bomdrift:v0.9.9`). + +**Install from a release archive:** + +```bash +VERSION=v0.9.9 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" @@ -143,7 +165,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.8 bomdrift +cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.9.9 bomdrift ``` Requires Rust 1.85+ (the project uses edition 2024). diff --git a/STATUS.md b/STATUS.md index fdb5c96..af8c034 100644 --- a/STATUS.md +++ b/STATUS.md @@ -111,8 +111,6 @@ for the rationale. - The comment-suppress companion action currently suppresses an advisory ID across all components. Use a hand-curated baseline entry when you need per-component suppression. -- GitHub Marketplace publication is a repository setting. The action metadata - is ready, but a maintainer must enable the listing in GitHub settings. ## Feedback wanted From d41655b5c319c56e9dbb8ecc1e7f077ef8c68c4a Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:12:34 -0700 Subject: [PATCH 6/7] ci(release): flip distribution gates + cargo publish + v1 retag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.9.9 release is the first that actually pushes to crates.io, ghcr.io, and the SLSA attestation store. This PR flips the `if: false` gates added in the prior commits and adds the two remaining jobs the plan called for: - `docker` job (and its embedded SLSA attestation step) ungated. - `Attest build provenance` step in the build matrix ungated. - `Attest build provenance (image)` step in docker ungated. - New `cargo-publish` job: runs `cargo publish --locked` after the GitHub Release is up. Independent of docker / retag-major so a transient crates.io failure doesn't fail the rest of the release. - New `retag-major` job: force-pushes the major-version tag (v1 today; v${major} once we hit v1.0.0+) to point at the new release. Marketplace + sloppy adopters consume the floating tag and expect it to track latest. Runs against the same SHA the GitHub Release used. Job graph: preflight └→ build (matrix) └→ publish (GitHub Release) ├→ docker (ghcr.io + SLSA) ├→ cargo-publish (crates.io) └→ retag-major (v1 → this tag) The three terminal jobs run in parallel; a failure in any one doesn't unwind the others. This is the design the plan called for — crates.io being temporarily unreachable shouldn't block the GitHub Release that's already live. PREREQUISITE before tagging v0.9.9: reserve `bomdrift` on crates.io and add `CARGO_REGISTRY_TOKEN` to repo secrets. Without the secret, the cargo-publish job will fail; the rest of the release still succeeds. Refs the v0.9.9 plan (Track 1 + 2 + 3 finalized). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 65 +++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab59bd1..0f32fce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -211,10 +211,7 @@ jobs: # repo". Verify with `gh attestation verify --owner Metbcy # ` or `slsa-verifier verify-artifact --source-uri # github.com/Metbcy/bomdrift --source-tag `. - # - # GATED `if: false` until the v0.9.9 "flip the gates" PR. - name: Attest build provenance - if: false uses: actions/attest-build-provenance@v2 with: subject-path: dist/${{ steps.stage.outputs.stem }}.${{ matrix.archive }} @@ -288,14 +285,8 @@ jobs: # per-arch binaries already cosign-signed by the build matrix; no # cargo build runs here. Tag matrix follows the standard convention: # the full version, the major.minor, the major, and `latest`. - # - # GATED `if: false` — flipped on in the v0.9.9 "release-pipeline gates" - # PR after the supporting infrastructure (crates.io reservation, - # CARGO_REGISTRY_TOKEN secret) is in place. Until then this job is - # parsed for syntax but never runs. docker: name: docker (ghcr.io) - if: false needs: [preflight, build, publish] runs-on: ubuntu-latest permissions: @@ -372,11 +363,63 @@ jobs: # SLSA build provenance for the multi-arch image. See the # equivalent step on the per-target archives for the cosign-vs-SLSA - # rationale. GATED `if: false` until the v0.9.9 "flip the gates" PR. + # rationale. - name: Attest build provenance (image) - if: false uses: actions/attest-build-provenance@v2 with: subject-name: ghcr.io/metbcy/bomdrift subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true + + # Publish to crates.io. Independent of `docker` and `publish` so a + # crates.io transient (network, rate-limit, already-published-this-tag) + # doesn't fail the rest of the release. The CARGO_REGISTRY_TOKEN secret + # must be set on the repo before the first v0.9.9 tag push. + cargo-publish: + name: cargo publish (crates.io) + needs: [preflight, build, publish] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.88 + - uses: Swatinem/rust-cache@v2 + - name: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked + + # Force-update the major-version tag to point at this release. + # GitHub Marketplace + sloppy adopters consume `Metbcy/bomdrift@v1` and + # expect it to track the latest stable release. During the v0.x line we + # track v1 (because that's what Marketplace currently references); once + # v1.0.0 ships, the logic switches to v${major} (so v2 will track v2.x.y). + retag-major: + name: retag major version + needs: [preflight, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Force-update major-version tag to current release + shell: bash + run: | + version="${{ needs.preflight.outputs.version }}" + tag="${{ needs.preflight.outputs.tag }}" + major="${version%%.*}" + + # During v0.x, track v1 (Marketplace already pins it). + # From v1.0.0 onward, track v${major}. + if [ "$major" -ge 1 ]; then + major_tag="v${major}" + else + major_tag="v1" + fi + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag -f "$major_tag" "$tag" + git push --force origin "$major_tag" + echo "Pointed $major_tag at $tag" From 6115333e63526c1dfd580920fd2b6a68d4f7aa1a Mon Sep 17 00:00:00 2001 From: Metbcy <7751771+Metbcy@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:15:06 -0700 Subject: [PATCH 7/7] chore(release): prepare v0.9.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cargo.toml + Cargo.lock: 0.9.8 → 0.9.9. CHANGELOG.md: rename Unreleased → [0.9.9] - 2026-04-30 with the full release-notes prose for the distribution release. Brings together: - Added: crates.io publish + Cargo.toml metadata + exclude; ghcr.io multi-arch image + Dockerfile; SLSA build provenance on archives + image; publish-dry-run PR guard. - Changed: README install section + badges; Marketplace listing description; Node.js 24 runner opt-in; v1 major-tag retag. - Fixed: coverage-comment fake-link autolink (carried from Unreleased). - Documentation: SLSA section in release-signing.md; rustdoc cleanup; STATUS.md + README drift fixes. - Tests: 444 → 444. Distribution-release plumbing only; no source-code feature change. - Scope notes: what's deferred and why (Homebrew, nix, AUR, winget/Scoop, README diet, asciinema, file splits, mutation testing, coverage ratchet). The release.yml preflight job validates this matches the tag at push time. Locally: - cargo publish --dry-run --locked → 220 KiB compressed, ok - cargo test → 444 passed - cargo build --release → ok - cargo doc --no-deps --all-features → clean with -D warnings PREREQUISITE before pushing the v0.9.9 tag: 1. Reserve `bomdrift` on crates.io and verify owner. 2. Generate a publish-scoped registry token. 3. Add it as repo secret CARGO_REGISTRY_TOKEN. Without that secret, `cargo-publish` fails; the rest of the release pipeline (GitHub Release, ghcr.io image, SLSA, v1 retag) still runs to completion. Refs the v0.9.9 plan (final commit; tag and verify is the next operator step). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0e9ad..68dba65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,65 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.9.9] - 2026-04-30 + +The "distribution release." Phase 1 of the bomdrift adoption plan: every +plausible install path now works in one command. The release pipeline +gains four new outputs — crates.io, ghcr.io, SLSA build provenance, and +an automated `v1` major-tag retag — alongside the existing GitHub +Release + cosign-signed archives. No source-code feature work; the +v0.9.9 binary is functionally identical to the v0.9.8 binary. + +### Added + +- **`cargo install bomdrift` works.** The crate is now published to + crates.io. Cargo.toml gains `documentation = "https://docs.rs/bomdrift"` + and a `[package.metadata.docs.rs]` block so the auto-built docs.rs + page renders cleanly. The published-crate footprint is 54 files / + 220 KiB compressed, achieved via an `exclude` list trimming `tests/` + (2.7 MB of fixtures), `docs/` (mdbook source), `examples/`, + `benches/`, `fuzz/`, `comment-suppress/`, `scripts/`, `.github/`, the + GitHub Action manifests, and the CONTRIBUTING / CODE_OF_CONDUCT / + STATUS files. `data/` (typosquat reference lists, `include_str!`'d + at compile time) stays in. +- **`docker run ghcr.io/metbcy/bomdrift:latest` works.** A new + multi-arch (linux/amd64, linux/arm64) image is published to + GitHub Container Registry on every release. Single-stage Dockerfile; + the image consumes the cosign-signed binaries already produced by the + release matrix — no `cargo build` runs in the image. Distroless cc + base, runs as the distroless `nonroot` user, ~28 MB. Tag matrix: + `:vX.Y.Z`, `:vX.Y`, `:vX`, `:latest`. The image carries an inline + SLSA build provenance attestation (verify with + `gh attestation verify --owner Metbcy oci://ghcr.io/metbcy/bomdrift:vX.Y.Z`). +- **SLSA build provenance attestations.** Every release archive AND + the multi-arch ghcr.io image are now covered by + `actions/attest-build-provenance@v2`. SLSA proves *"this artifact + was produced by the public release.yml workflow on tag X in this + repo"*; the existing cosign signatures continue to prove *"the + bomdrift maintainer's GitHub OIDC identity signed this artifact."* + Both verifications must pass for the release to be trustworthy. See + [docs/src/release-signing.md](docs/src/release-signing.md) for the + full threat-model framing and the + `gh attestation verify` / `slsa-verifier` recipes. +- **`publish-dry-run` PR-time CI guard.** New `ci.yml` job runs + `cargo publish --dry-run --locked` on every PR. Catches + crate-metadata regressions (oversized package, missing required + fields, an `exclude` list change that drops a build-time + `include_str!` source) before they reach a release tag. + ### Changed +- **README install section ships three new install paths.** `cargo + install --locked bomdrift`, `docker run ghcr.io/metbcy/bomdrift`, + and the existing release-archive curl path are now equally + prominent. The from-source `cargo install --git` path stays as a + fourth option for adopters who want to track main. +- **README badges expanded.** crates.io, docs.rs, and GitHub + Marketplace badges added at the top of the README. The CI / Release + / Docs / License badges are kept. +- **GitHub Marketplace listing description rewritten.** Lead with the + axios narrative and the maintainer-age heuristic, drop generic + copy. (Marketplace dashboard edit, not a repo-file change.) - **Workflows opt into the Node.js 24 runner ahead of GitHub's 2026-06-02 forced-default.** Every workflow now sets `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'` at the top-level @@ -18,6 +75,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `ci.yml`, `docs.yml`, `fuzz.yml`, `release.yml`, and `sbom-diff.yml`. Remove the env var once Node.js 24 is the default runtime (after 2026-09-16 per GitHub's deprecation timeline). +- **`v1` major-version tag is now automated.** A new `retag-major` + job in `release.yml` force-pushes `v1` to point at the latest + release on every tag. Until v1.0.0 ships, the floating tag stays + `v1` (Marketplace already references it); from v1.0.0 onward, the + job switches to `v${major}`. ### Fixed @@ -34,6 +96,52 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). work unchanged), and the comment links directly to the workflow run's artifacts tab so users have a real destination to follow. +### Documentation + +- **`docs/src/release-signing.md` gains a SLSA provenance section.** + Frames cosign + SLSA as complementary, with the threat model where + each catches what the other misses. Includes + `gh attestation verify` (recommended) and `slsa-verifier` + (air-gapped) recipes, and the ghcr.io image attestation verification + path. +- **Rustdoc cleanup.** Eight broken intra-doc links in module-level + docstrings (`MAX_QUERIES_PER_BATCH`, `SUFFIX_BOOST_SCORE`, + `run_with`, `eval_leaf`, `LeafOutcome`, `VulnRef`, `Cache::with_root`, + `crate::run_diff`) were either demoted to backtick-quoted code spans + (private items not visible to public docs) or fixed to use the + correct path after the v0.9.8 lib.rs split. Plus one redundant + explicit link target. `RUSTDOCFLAGS=-D warnings cargo doc --no-deps + --all-features` now passes; docs.rs auto-build is clean. No public + API change. +- **README and STATUS.md drift fixed.** Removed STATUS.md's stale + Marketplace-publication-pending line (the listing has been live + since v0.9.8). README from-source bumped from v0.9.8 to v0.9.9. + +### Tests + +- 444 → 444 (no net change). Distribution-release plumbing only. + +### Scope notes — what's in v0.9.9 vs deferred + +In v0.9.9: crates.io, ghcr.io, SLSA, docs.rs, Marketplace polish, +README badges + new install paths. + +Deferred (separate plans, not version-coupled): + +- Homebrew tap (`Metbcy/homebrew-tap` + `bomdrift.rb` formula). +- nix flake, AUR PKGBUILD, winget + Scoop manifests. +- README diet (the wall-of-text comparison table moves to + `docs/src/compare.md`). +- Asciinema demo recorded against `examples/axios-incident/`. + +Deferred from v0.9.8 (held for the next code-hygiene release): + +- File splits for `vex.rs`, `render/markdown.rs`, `render/sarif.rs`, + `baseline.rs`, `enrich/typosquat.rs`, `enrich/license.rs`. +- Mutation-testing audit via `cargo-mutants`. +- Coverage `--fail-under-lines` ratchet (planned for "after 2-3 + releases of visibility" — v0.9.9 is the second). + ## [0.9.8] - 2026-04-30 The "code-review-driven hardening" milestone. External agent review surfaced diff --git a/Cargo.lock b/Cargo.lock index 1d3cf10..b456b95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,7 +123,7 @@ dependencies = [ [[package]] name = "bomdrift" -version = "0.9.8" +version = "0.9.9" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index b5a541d..cc14a29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bomdrift" -version = "0.9.8" +version = "0.9.9" edition = "2024" rust-version = "1.88" description = "SBOM diff with supply-chain risk signals (CVEs, typosquats, maintainer-age)."