diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c85c315..b740c52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,10 @@ on:
branches: [main]
pull_request:
+permissions:
+ contents: read
+ pull-requests: write
+
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
@@ -42,6 +46,56 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features
+ # Informational coverage report. Emits lcov.info as an artifact and
+ # posts a sticky PR comment with the line-coverage %. Intentionally
+ # NOT in the required-status-checks list and NOT using
+ # `--fail-under-lines` — let the number be visible across 2-3
+ # releases before ratcheting (see CONTRIBUTING.md "Coverage").
+ coverage:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@1.88
+ with:
+ components: llvm-tools-preview
+ - uses: Swatinem/rust-cache@v2
+ - name: Install cargo-llvm-cov
+ run: cargo install cargo-llvm-cov --locked
+ - name: Run coverage
+ run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
+ - name: Upload lcov artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: lcov.info
+ path: lcov.info
+ retention-days: 30
+ - name: Compute coverage %
+ id: compute
+ run: |
+ covered=$(grep "^DA:" lcov.info | awk -F, '$2>0' | wc -l)
+ total=$(grep -c "^DA:" lcov.info)
+ if [ "$total" -gt 0 ]; then
+ pct=$(awk "BEGIN{printf \"%.1f\", ${covered}*100/${total}}")
+ else
+ pct="N/A"
+ fi
+ echo "pct=$pct" >> "$GITHUB_OUTPUT"
+ echo "covered=$covered" >> "$GITHUB_OUTPUT"
+ echo "total=$total" >> "$GITHUB_OUTPUT"
+ - name: Post coverage summary as PR comment
+ if: github.event_name == 'pull_request'
+ uses: marocchino/sticky-pull-request-comment@v2
+ with:
+ header: bomdrift-coverage
+ message: |
+ ## Coverage report
+
+ Line coverage: **${{ steps.compute.outputs.pct }}%** (${{ steps.compute.outputs.covered }} / ${{ steps.compute.outputs.total }} lines)
+
+ Full lcov report available as workflow artifact `lcov.info`.
+
+ v0.9.8 introduces this report; `--fail-under-lines` will be added once coverage is visible across 2–3 releases.
+
audit:
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 0000000..693719e
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,48 @@
+name: Fuzz
+
+# cargo-fuzz requires nightly Rust, so this lives in its own workflow
+# rather than bolting onto ci.yml's stable-pinned matrix. PR runs use
+# a 60s budget per target (signal: did the seed corpus crash?); the
+# weekly schedule extends to 600s for deeper coverage.
+on:
+ pull_request:
+ paths:
+ - 'src/parse/**'
+ - 'fuzz/**'
+ - '.github/workflows/fuzz.yml'
+ schedule:
+ - cron: '17 3 * * 0' # Sundays 03:17 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ fuzz:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ target: [parse_cyclonedx, parse_spdx, parse_syft]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@nightly
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: fuzz
+ - name: Install cargo-fuzz
+ run: cargo install cargo-fuzz --locked
+ - name: Run fuzz target
+ run: |
+ BUDGET=60
+ if [ "${{ github.event_name }}" = "schedule" ]; then
+ BUDGET=600
+ fi
+ cd fuzz
+ cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=$BUDGET
+ - name: Upload artifacts on crash
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: fuzz-${{ matrix.target }}-artifacts
+ path: fuzz/artifacts/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5281238..9bba1bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,86 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [0.9.8] - 2026-04-30
+
+The "code-review-driven hardening" milestone. External agent review surfaced
+nine recommendations across P1/P2/P3; v0.9.8 takes five of the six high-leverage
+items, defers the rest as v1.0+ candidates with explicit rationale.
+
+### Added
+
+- **Continuous parser fuzzing.** New `fuzz/` standalone sub-workspace with
+ three `cargo-fuzz` libfuzzer targets (`parse_cyclonedx`, `parse_spdx`,
+ `parse_syft`). Each target two-stages JSON validation before invoking the
+ bomdrift parser, scoping the fuzz to bomdrift-side parsing rather than
+ serde_json's well-tested layer. Seed corpus from `tests/fixtures/`.
+ New `.github/workflows/fuzz.yml` runs each target for 60s on PRs touching
+ `src/parse/**` or `fuzz/**`, and 600s weekly on a Sunday cron schedule.
+ Nightly Rust toolchain pinned per cargo-fuzz convention. Closes the
+ textbook "untrusted-input parser" gap for a security tool.
+- **CI coverage report.** New `coverage` job in `.github/workflows/ci.yml`
+ runs `cargo-llvm-cov`, emits `lcov.info` as a workflow artifact, and posts
+ a sticky PR comment via `marocchino/sticky-pull-request-comment@v2` showing
+ line-coverage percentage. **No `--fail-under-lines` gate yet** — coverage
+ is informational for v0.9.8/v0.9.9 to establish a stable baseline before
+ ratcheting in a later release. CONTRIBUTING.md gains a "Coverage" subsection
+ describing the policy.
+
+### Changed
+
+- **`unwrap`/`expect`/`panic`/`todo`/`unimplemented` lints now warn** at
+ crate root via `#![warn(clippy::unwrap_used, clippy::expect_used,
+ clippy::panic, clippy::todo, clippy::unimplemented)]`. Production code
+ audited; the four remaining `.expect()` sites
+ (`baseline.rs:389`, `render/json.rs:42`, `render/sarif.rs:84`,
+ `vex.rs:932`) are true invariants and gain explicit
+ `#[allow(clippy::expect_used, reason = "...")]` annotations citing the
+ why. Test modules opt-out via inner `#![allow(clippy::unwrap_used,
+ clippy::expect_used)]` (28 modules touched). Zero production `.unwrap()`
+ remain.
+- **Every `unsafe` block now carries a `// SAFETY:` comment.**
+ 16 of 23 unsafe sites (the Rust 2024 `env::set_var` wrappers + a few
+ test helpers) lacked annotation. Added rationale to each, and enforce
+ going forward via `#![warn(clippy::undocumented_unsafe_blocks)]` at
+ crate root.
+
+### Refactored
+
+- **`src/lib.rs` 47 KB → 31 lines.** Extracted the 1,300-line `run_diff`
+ orchestration into a new `src/run.rs` module. `lib.rs` is now pure
+ re-exports + module declarations. Public API surface preserved
+ byte-for-byte: external consumers calling `bomdrift::run_diff(...)` get
+ the same function via re-export. Behavior-preserving — all 432 tests
+ pass without modification beyond import-path updates.
+
+### Documentation
+
+- README.md gains a "Continuous fuzzing (v0.9.8+)" subsection.
+- CONTRIBUTING.md gains a "Coverage" subsection.
+
+### Tests
+
+- 432 → 432 (no net change). Refactor commits are behavior-preserving.
+
+### Scope notes — what's deferred to v1.0
+
+The external review surfaced four other recommendations explicitly deferred:
+
+- **Remaining file splits** for `vex.rs` (50 KB), `render/markdown.rs`
+ (58 KB), `render/sarif.rs` (48 KB), `baseline.rs` (42 KB),
+ `enrich/typosquat.rs` (42 KB), `enrich/license.rs` (34 KB). The lib.rs
+ split was the highest-ROI single split; the others can land
+ organically as future PRs touch those files.
+- **Mutation testing audit** via `cargo-mutants`. High-signal but slow;
+ use as v1.0 audit tool, not a CI gate.
+- **Calibration FPR docs** — running bomdrift on top-1000 npm + PyPI for
+ 12 months of releases needs data-collection infrastructure that
+ doesn't exist yet. Tracked separately.
+- **Coverage `--fail-under-lines` ratchet** — flip on after 2-3 releases
+ of visibility.
+- **WASM-sandboxed plugin model** — carryover from v0.9.7; conflicts with
+ single-binary tenet at current toolchain costs.
+
## [0.9.7] - 2026-04-29
The "v0.9.6 follow-up backlog" milestone. Five concrete items from the
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 61a1b5b..21cb35e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -133,6 +133,17 @@ Network-touching enrichers should have a unit test for the network-
failure path (fake fetcher returns `Err`) — the best-effort contract
matters and silently breaking it would be an easy regression.
+### Coverage (v0.9.8+)
+
+CI runs `cargo llvm-cov` on every PR and posts a sticky comment with
+the overall line coverage % (the full `lcov.info` is uploaded as a
+workflow artifact). The job is informational for now — there is no
+`--fail-under-lines` threshold yet. The plan is to add a ratchet in
+v0.9.9 once 2–3 releases have made the baseline visible. Until then,
+the report is a nudge, not a gate; PRs that move coverage in the
+wrong direction without justification will get a review comment, not
+a red check.
+
### Test conventions (v0.9.5+)
Tests that mutate `SOURCE_DATE_EPOCH` (directly or indirectly via
diff --git a/Cargo.lock b/Cargo.lock
index f69252f..1d3cf10 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -123,7 +123,7 @@ dependencies = [
[[package]]
name = "bomdrift"
-version = "0.9.7"
+version = "0.9.8"
dependencies = [
"anyhow",
"base64",
diff --git a/Cargo.toml b/Cargo.toml
index c24ff07..3b8c2c2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "bomdrift"
-version = "0.9.7"
+version = "0.9.8"
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 cdfc20d..34f4384 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ Recent incidents bomdrift would have surfaced:
The dimensions adopters actually filter on. Sourced from
[`files/competitor-research-v0.7-v0.9.md`](./files/competitor-research-v0.7-v0.9.md);
-correct as of v0.9.7.
+correct as of v0.9.8.
| | bomdrift | Socket | Snyk | Trivy | OSV-Scanner | Grype |
|------------------------------------------|:---:|:---:|:---:|:---:|:---:|:---:|
@@ -94,7 +94,7 @@ jobs:
# verify-signatures: true (set false on trusted mirrors)
```
-Pin to `@v1` for the latest v0.x; pin to `@v0.9.7` 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 — including `upload-to-code-scanning`, `verify-signatures`, `comment-size-limit`, and the `before-sbom`/`after-sbom` escape hatch.
+Pin to `@v1` for the latest v0.x; pin to `@v0.9.8` 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 — including `upload-to-code-scanning`, `verify-signatures`, `comment-size-limit`, and the `before-sbom`/`after-sbom` escape hatch.
**Other forges:** GitLab CI, Bitbucket Pipelines, and Azure DevOps Pipelines all have ready-to-copy templates under [`examples/`](./examples/) and dedicated docs chapters: [GitLab CI](https://metbcy.github.io/bomdrift/gitlab-ci.html), [Bitbucket](https://metbcy.github.io/bomdrift/bitbucket.html), [Azure DevOps](https://metbcy.github.io/bomdrift/azure-devops.html). Comment-driven `/bomdrift suppress` works on all four SCMs via the Cloudflare Worker bridges added in v0.9.5.
@@ -127,7 +127,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.7
+VERSION=v0.9.8
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 +143,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.7 bomdrift
+cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.9.8 bomdrift
```
Requires Rust 1.85+ (the project uses edition 2024).
@@ -279,7 +279,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.7
+VERSION=v0.9.8
TARGET=x86_64-unknown-linux-gnu
ARCHIVE=bomdrift-${VERSION}-${TARGET}.tar.gz
@@ -293,6 +293,16 @@ cosign verify-blob \
The Action verifies signatures automatically by default. Set `verify-signatures: false` on trusted mirrors to skip the cosign install step (~15s saved per run).
+### Continuous fuzzing (v0.9.8+)
+
+The CycloneDX, SPDX, and Syft JSON parsers are continuously fuzzed
+via [`cargo-fuzz`](https://rust-fuzz.github.io/book/cargo-fuzz/).
+Pull requests touching `src/parse/**` get a short fuzz pass per
+target on Linux nightly; a longer scheduled run executes weekly on
+`main`. Crash artifacts are uploaded for triage.
+See [`.github/workflows/fuzz.yml`](./.github/workflows/fuzz.yml) and
+[`fuzz/fuzz_targets/`](./fuzz/fuzz_targets/).
+
## Documentation
- **[Docs site (mdBook)](https://metbcy.github.io/bomdrift/)** — full reference: CLI flags, every action input, output-format anatomy, per-enricher deep dives, architecture notes, roadmap.
diff --git a/docs/src/quickstart.md b/docs/src/quickstart.md
index 4896183..20fbf4f 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.7`) if you prefer reproducible builds. See
+version (`@v0.9.8`) 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.7
+VERSION=v0.9.8
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"
@@ -60,7 +60,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.7 bomdrift
+cargo install --locked --git https://github.com/Metbcy/bomdrift --tag v0.9.8 bomdrift
```
Requires Rust 1.85+ (the project uses edition 2024).
diff --git a/fuzz/.gitignore b/fuzz/.gitignore
new file mode 100644
index 0000000..e274846
--- /dev/null
+++ b/fuzz/.gitignore
@@ -0,0 +1,5 @@
+# cargo-fuzz / libFuzzer build + crash artifacts. The seed corpus
+# under corpus/ IS checked in (those are inputs, not outputs).
+/target
+/artifacts
+Cargo.lock
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..d0f5b0e
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,43 @@
+[package]
+name = "bomdrift-fuzz"
+version = "0.0.0"
+publish = false
+edition = "2024"
+rust-version = "1.88"
+
+[package.metadata]
+cargo-fuzz = true
+
+[dependencies]
+libfuzzer-sys = "0.4"
+serde_json = "1"
+
+[dependencies.bomdrift]
+path = ".."
+
+# Prevent cargo from treating this directory as part of the parent
+# workspace. cargo-fuzz expects fuzz/ to be its own workspace so the
+# nightly toolchain (required for libfuzzer instrumentation) doesn't
+# leak into the main build.
+[workspace]
+
+[[bin]]
+name = "parse_cyclonedx"
+path = "fuzz_targets/parse_cyclonedx.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "parse_spdx"
+path = "fuzz_targets/parse_spdx.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "parse_syft"
+path = "fuzz_targets/parse_syft.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/corpus/parse_cyclonedx/cdx-after.json b/fuzz/corpus/parse_cyclonedx/cdx-after.json
new file mode 100644
index 0000000..dc20850
--- /dev/null
+++ b/fuzz/corpus/parse_cyclonedx/cdx-after.json
@@ -0,0 +1,42 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "serialNumber": "urn:uuid:9e671687-395b-41f5-a30f-a58921a69b80",
+ "version": 1,
+ "metadata": {
+ "timestamp": "2026-04-28T01:00:00Z",
+ "tools": [
+ {"vendor": "anchore", "name": "syft", "version": "1.20.0"}
+ ]
+ },
+ "components": [
+ {
+ "type": "library",
+ "bom-ref": "pkg:npm/axios@1.14.1",
+ "name": "axios",
+ "version": "1.14.1",
+ "purl": "pkg:npm/axios@1.14.1",
+ "licenses": [{"license": {"id": "MIT"}}],
+ "supplier": {"name": "Matt Zabriskie"},
+ "externalReferences": [
+ {"type": "vcs", "url": "https://github.com/axios/axios"}
+ ]
+ },
+ {
+ "type": "library",
+ "bom-ref": "pkg:cargo/serde@1.0.228",
+ "name": "serde",
+ "version": "1.0.228",
+ "purl": "pkg:cargo/serde@1.0.228",
+ "licenses": [{"expression": "MIT OR Apache-2.0"}]
+ },
+ {
+ "type": "library",
+ "bom-ref": "pkg:npm/plain-crypto-js@4.2.1",
+ "name": "plain-crypto-js",
+ "version": "4.2.1",
+ "purl": "pkg:npm/plain-crypto-js@4.2.1",
+ "licenses": [{"license": {"id": "MIT"}}]
+ }
+ ]
+}
diff --git a/fuzz/corpus/parse_cyclonedx/cdx-minimal.json b/fuzz/corpus/parse_cyclonedx/cdx-minimal.json
new file mode 100644
index 0000000..9df585b
--- /dev/null
+++ b/fuzz/corpus/parse_cyclonedx/cdx-minimal.json
@@ -0,0 +1,47 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
+ "version": 1,
+ "metadata": {
+ "timestamp": "2026-04-28T00:00:00Z",
+ "tools": [
+ {"vendor": "anchore", "name": "syft", "version": "1.20.0"}
+ ]
+ },
+ "components": [
+ {
+ "type": "library",
+ "bom-ref": "pkg:npm/axios@1.14.0",
+ "name": "axios",
+ "version": "1.14.0",
+ "purl": "pkg:npm/axios@1.14.0",
+ "licenses": [
+ {"license": {"id": "MIT"}}
+ ],
+ "hashes": [
+ {"alg": "SHA-256", "content": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}
+ ],
+ "supplier": {"name": "Matt Zabriskie"},
+ "externalReferences": [
+ {"type": "vcs", "url": "https://github.com/axios/axios"},
+ {"type": "website", "url": "https://axios-http.com"}
+ ]
+ },
+ {
+ "type": "library",
+ "bom-ref": "pkg:cargo/serde@1.0.228",
+ "name": "serde",
+ "version": "1.0.228",
+ "purl": "pkg:cargo/serde@1.0.228",
+ "licenses": [
+ {"expression": "MIT OR Apache-2.0"}
+ ]
+ },
+ {
+ "type": "library",
+ "name": "no-purl-component",
+ "version": "0.1.0"
+ }
+ ]
+}
diff --git a/fuzz/corpus/parse_spdx/spdx-minimal.json b/fuzz/corpus/parse_spdx/spdx-minimal.json
new file mode 100644
index 0000000..c95560c
--- /dev/null
+++ b/fuzz/corpus/parse_spdx/spdx-minimal.json
@@ -0,0 +1,57 @@
+{
+ "spdxVersion": "SPDX-2.3",
+ "dataLicense": "CC0-1.0",
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": "github.com/Metbcy/bomdrift",
+ "documentNamespace": "https://github.com/Metbcy/bomdrift/dependency_graph/sbom-abc123",
+ "creationInfo": {
+ "created": "2026-04-28T00:00:00Z",
+ "creators": ["Tool: GitHub.com-Dependency-Graph"]
+ },
+ "packages": [
+ {
+ "SPDXID": "SPDXRef-npm-axios-1.14.0",
+ "name": "axios",
+ "versionInfo": "1.14.0",
+ "downloadLocation": "git+https://github.com/axios/axios",
+ "filesAnalyzed": false,
+ "licenseConcluded": "MIT",
+ "licenseDeclared": "NOASSERTION",
+ "supplier": "Person: Matt Zabriskie",
+ "checksums": [
+ {"algorithm": "SHA256", "checksumValue": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}
+ ],
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": "pkg:npm/axios@1.14.0"
+ }
+ ]
+ },
+ {
+ "SPDXID": "SPDXRef-pypi-requests-2.31.0",
+ "name": "requests",
+ "versionInfo": "2.31.0",
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": false,
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "Apache-2.0",
+ "supplier": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": "pkg:pypi/requests@2.31.0"
+ }
+ ]
+ },
+ {
+ "SPDXID": "SPDXRef-Package-NoPurl",
+ "name": "no-purl-component",
+ "versionInfo": "0.1.0",
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": false
+ }
+ ]
+}
diff --git a/fuzz/corpus/parse_syft/syft-minimal.json b/fuzz/corpus/parse_syft/syft-minimal.json
new file mode 100644
index 0000000..48cff81
--- /dev/null
+++ b/fuzz/corpus/parse_syft/syft-minimal.json
@@ -0,0 +1,39 @@
+{
+ "schema": {
+ "version": "16.0.0",
+ "url": "https://raw.githubusercontent.com/anchore/syft/main/internal/jsonschema/anchore.io/schema/syft/json/16.0.0/document.json"
+ },
+ "source": {
+ "id": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
+ "name": "github.com/Metbcy/bomdrift",
+ "type": "directory"
+ },
+ "artifacts": [
+ {
+ "id": "axios-1.14.0-syft-id",
+ "name": "axios",
+ "version": "1.14.0",
+ "type": "npm",
+ "purl": "pkg:npm/axios@1.14.0",
+ "licenses": [
+ {"value": "MIT", "spdxExpression": "MIT", "type": "declared"}
+ ]
+ },
+ {
+ "id": "requests-2.31.0-syft-id",
+ "name": "requests",
+ "version": "2.31.0",
+ "type": "python",
+ "purl": "pkg:pypi/requests@2.31.0",
+ "licenses": [
+ "Apache-2.0"
+ ]
+ },
+ {
+ "id": "no-purl-syft",
+ "name": "no-purl-component",
+ "version": "0.1.0",
+ "type": "rust-crate"
+ }
+ ]
+}
diff --git a/fuzz/fuzz_targets/parse_cyclonedx.rs b/fuzz/fuzz_targets/parse_cyclonedx.rs
new file mode 100644
index 0000000..72e3381
--- /dev/null
+++ b/fuzz/fuzz_targets/parse_cyclonedx.rs
@@ -0,0 +1,18 @@
+#![no_main]
+//! Fuzz target for the CycloneDX JSON parser.
+//!
+//! Two-stage shape: first decode the bytes as `serde_json::Value` so
+//! that ill-formed-JSON inputs are dropped at the well-tested
+//! `serde_json` boundary, then hand the parsed value to bomdrift's
+//! own parser. This focuses fuzzing budget on bomdrift-side logic
+//! (schema interpretation, purl handling, hash normalization) rather
+//! than re-fuzzing serde_json.
+
+use bomdrift::parse::{SbomParser, cyclonedx::CycloneDxParser};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ if let Ok(value) = serde_json::from_slice::(data) {
+ let _ = CycloneDxParser::parse(value);
+ }
+});
diff --git a/fuzz/fuzz_targets/parse_spdx.rs b/fuzz/fuzz_targets/parse_spdx.rs
new file mode 100644
index 0000000..2c6f1e8
--- /dev/null
+++ b/fuzz/fuzz_targets/parse_spdx.rs
@@ -0,0 +1,12 @@
+#![no_main]
+//! Fuzz target for the SPDX 2.3 JSON parser. See parse_cyclonedx.rs
+//! for the rationale behind the two-stage decode.
+
+use bomdrift::parse::{SbomParser, spdx::SpdxParser};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ if let Ok(value) = serde_json::from_slice::(data) {
+ let _ = SpdxParser::parse(value);
+ }
+});
diff --git a/fuzz/fuzz_targets/parse_syft.rs b/fuzz/fuzz_targets/parse_syft.rs
new file mode 100644
index 0000000..e39581b
--- /dev/null
+++ b/fuzz/fuzz_targets/parse_syft.rs
@@ -0,0 +1,12 @@
+#![no_main]
+//! Fuzz target for the Syft JSON parser. See parse_cyclonedx.rs for
+//! the rationale behind the two-stage decode.
+
+use bomdrift::parse::{SbomParser, syft::SyftParser};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+ if let Ok(value) = serde_json::from_slice::(data) {
+ let _ = SyftParser::parse(value);
+ }
+});
diff --git a/src/attestation.rs b/src/attestation.rs
index 9561e46..fbb9a46 100644
--- a/src/attestation.rs
+++ b/src/attestation.rs
@@ -142,6 +142,13 @@ pub fn extract_sbom_from_envelope(stdout: &str) -> Result {
#[cfg(test)]
mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ clippy::todo,
+ clippy::unimplemented
+ )]
use super::*;
use base64::engine::general_purpose::STANDARD as B64;
@@ -302,8 +309,14 @@ mod tests {
// Restore PATH BEFORE asserting so a panic doesn't leave the
// test environment in a weird state for parallel tests.
match prev_path {
- Some(p) => unsafe { std::env::set_var("PATH", p) },
- None => unsafe { std::env::remove_var("PATH") },
+ Some(p) => {
+ // SAFETY: still serialized via the test_env_lock guard held above.
+ unsafe { std::env::set_var("PATH", p) }
+ }
+ None => {
+ // SAFETY: still serialized via the test_env_lock guard held above.
+ unsafe { std::env::remove_var("PATH") }
+ }
}
let _ = std::fs::remove_dir_all(&dir);
@@ -327,9 +340,16 @@ mod tests {
"https://example.com",
);
+ // SAFETY: still serialized via the test_env_lock guard held above.
match prev_path {
- Some(p) => unsafe { std::env::set_var("PATH", p) },
- None => unsafe { std::env::remove_var("PATH") },
+ Some(p) => {
+ // SAFETY: still serialized via the test_env_lock guard held above.
+ unsafe { std::env::set_var("PATH", p) }
+ }
+ None => {
+ // SAFETY: still serialized via the test_env_lock guard held above.
+ unsafe { std::env::remove_var("PATH") }
+ }
}
let err = result.expect_err("must surface clear error when cosign is missing");
diff --git a/src/baseline.rs b/src/baseline.rs
index c3f2a59..a9cb99e 100644
--- a/src/baseline.rs
+++ b/src/baseline.rs
@@ -386,7 +386,13 @@ pub fn add_suppression_full(
);
}
- let obj = doc.as_object_mut().expect("checked is_object above");
+ #[allow(
+ clippy::expect_used,
+ reason = "invariant: is_object() check above guarantees Value::Object so as_object_mut() returns Some"
+ )]
+ let obj = doc
+ .as_object_mut()
+ .expect("invariant: is_object() check above guarantees Value::Object");
obj.entry("schema_version")
.or_insert(serde_json::Value::from(1u64));
@@ -526,6 +532,13 @@ fn doc_kind(v: &serde_json::Value) -> &'static str {
#[cfg(test)]
mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ clippy::todo,
+ clippy::unimplemented
+ )]
use super::*;
use crate::enrich::typosquat::TyposquatFinding;
use crate::enrich::version_jump::VersionJumpFinding;
@@ -852,12 +865,16 @@ mod tests {
}
impl Drop for Guard {
fn drop(&mut self) {
+ // SAFETY: env mutation guarded by the `_lock` field below
+ // which holds the crate-wide `clock::test_env_lock()`
+ // mutex for the lifetime of this Guard.
unsafe {
std::env::remove_var("SOURCE_DATE_EPOCH");
}
}
}
let _lock = crate::clock::test_env_lock();
+ // SAFETY: env mutation serialized by the `_lock` mutex guard above.
unsafe {
std::env::set_var("SOURCE_DATE_EPOCH", epoch.to_string());
}
diff --git a/src/clock.rs b/src/clock.rs
index e8c0a3b..720ad79 100644
--- a/src/clock.rs
+++ b/src/clock.rs
@@ -93,6 +93,13 @@ pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
#[cfg(test)]
mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ clippy::todo,
+ clippy::unimplemented
+ )]
use super::*;
/// Re-export at module level for the existing tests below.
@@ -130,6 +137,7 @@ mod tests {
let t = now();
assert_eq!(t.unix_timestamp(), 1777593600);
assert_eq!(format_ymd(t.date()), "2026-05-01");
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::remove_var("SOURCE_DATE_EPOCH");
}
@@ -138,10 +146,12 @@ mod tests {
#[test]
fn now_is_read_per_call_not_cached() {
let _g = env_lock();
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::set_var("SOURCE_DATE_EPOCH", "1000000000");
}
let a = now();
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::set_var("SOURCE_DATE_EPOCH", "2000000000");
}
@@ -149,6 +159,7 @@ mod tests {
assert_ne!(a.unix_timestamp(), b.unix_timestamp());
assert_eq!(a.unix_timestamp(), 1000000000);
assert_eq!(b.unix_timestamp(), 2000000000);
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::remove_var("SOURCE_DATE_EPOCH");
}
@@ -157,11 +168,13 @@ mod tests {
#[test]
fn malformed_source_date_epoch_falls_back() {
let _g = env_lock();
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
}
// Should not panic; returns system clock now.
let _ = now();
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::remove_var("SOURCE_DATE_EPOCH");
}
@@ -177,12 +190,14 @@ mod tests {
#[test]
fn is_expired_ordering() {
let _g = env_lock();
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::set_var("SOURCE_DATE_EPOCH", "1777593600");
} // 2026-05-01
assert!(is_expired(parse_ymd("2026-04-30").unwrap()));
assert!(!is_expired(parse_ymd("2026-05-01").unwrap()));
assert!(!is_expired(parse_ymd("2026-05-02").unwrap()));
+ // SAFETY: env mutation guarded by process-wide mutex above.
unsafe {
env::remove_var("SOURCE_DATE_EPOCH");
}
diff --git a/src/config.rs b/src/config.rs
index 159785f..dc5c725 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -220,6 +220,13 @@ fn load_config(explicit: Option<&Path>) -> Result