From d97202a9a38c1d3789e84a8eb3d4ac7b078ecfa2 Mon Sep 17 00:00:00 2001 From: Erick Bourgeois Date: Wed, 22 Apr 2026 15:58:28 -0400 Subject: [PATCH] Move to automating VEX creation and add grype Signed-off-by: Erick Bourgeois --- .claude/CHANGELOG.md | 409 +++++++++++++++ .claude/SKILL.md | 3 +- .github/codeql/codeql-config.yml | 22 + .github/workflows/build.yaml | 245 +++++++-- .github/workflows/codeql.yaml | 86 ++++ .vex/CVE-2010-4756.json | 23 + .vex/CVE-2010-4756.toml | 12 - .vex/CVE-2018-20796.json | 23 + .vex/CVE-2018-20796.toml | 12 - .vex/CVE-2019-1010022.json | 23 + .vex/CVE-2019-1010022.toml | 13 - .vex/CVE-2019-1010023.json | 23 + .vex/CVE-2019-1010023.toml | 12 - .vex/CVE-2019-1010024.json | 23 + .vex/CVE-2019-1010024.toml | 12 - .vex/CVE-2019-1010025.json | 23 + .vex/CVE-2019-1010025.toml | 13 - .vex/CVE-2019-9192.json | 23 + .vex/CVE-2019-9192.toml | 13 - .vex/CVE-2026-27171.json | 23 + .vex/CVE-2026-27171.toml | 13 - .vex/CVE-2026-4046.json | 23 + .vex/CVE-2026-4046.toml | 13 - .vex/CVE-2026-4437.json | 23 + .vex/CVE-2026-4437.toml | 14 - .vex/CVE-2026-4438.json | 23 + .vex/CVE-2026-4438.toml | 12 - .vex/CVE-2026-5358.json | 23 + .vex/CVE-2026-5358.toml | 12 - .vex/CVE-2026-5450.json | 23 + .vex/CVE-2026-5450.toml | 13 - .vex/CVE-2026-5928.json | 23 + .vex/CVE-2026-5928.toml | 13 - .vex/GHSA-cq8v-f236-94qc.json | 26 + .vex/GHSA-cq8v-f236-94qc.toml | 33 -- .vex/README.md | 97 +++- Cargo.toml | 4 + Makefile | 81 ++- README.md | 1 + docs/src/security/vex.md | 143 ++++-- src/auto_vex_presence.rs | 225 +++++++++ src/auto_vex_presence_tests.rs | 477 ++++++++++++++++++ src/bin/auto_vex_presence.rs | 142 ++++++ src/lib.rs | 2 + tools/assemble_openvex.py | 137 ----- tools/tests/assemble-openvex-tests.sh | 137 ----- tools/tests/fixtures/README.md | 58 --- .../fixtures/bad-timestamp/CVE-2025-0016.toml | 5 - tools/tests/fixtures/duplicate-cve/first.toml | 5 - .../tests/fixtures/duplicate-cve/second.toml | 6 - .../GHSA-cq8v-f236-94qc-dup.toml | 6 - .../duplicate-ghsa/GHSA-cq8v-f236-94qc.toml | 6 - tools/tests/fixtures/empty-dir/.gitkeep | 0 .../empty-products/CVE-2025-0015.toml | 5 - .../fixtures/invalid-cve-format/CVE-bad.toml | 5 - .../invalid-ghsa-format/GHSA-bad.toml | 5 - .../invalid-justification/CVE-2025-0018.toml | 6 - .../invalid-rustsec-format/RUSTSEC-bad.toml | 5 - .../invalid-status/CVE-2025-0014.toml | 5 - .../malformed-toml/CVE-2025-9999.toml | 7 - .../CVE-2025-0019.toml | 5 - .../missing-author/CVE-2025-0012.toml | 4 - tools/tests/fixtures/missing-cve/case.toml | 5 - .../missing-justification/CVE-2025-0017.toml | 5 - .../missing-products/CVE-2025-0011.toml | 4 - .../missing-status/CVE-2025-0010.toml | 4 - .../missing-timestamp/CVE-2025-0013.toml | 4 - .../valid-affected/CVE-2025-0003.toml | 6 - .../valid-ghsa/GHSA-cq8v-f236-94qc.toml | 7 - .../valid-multiple/CVE-2025-0001.toml | 6 - .../valid-multiple/CVE-2025-0002.toml | 5 - .../valid-rustsec/RUSTSEC-2026-0097.toml | 7 - .../fixtures/valid-single/CVE-2025-0001.toml | 10 - tools/tests/validate-vex-tests.sh | 77 --- tools/validate-vex.sh | 26 - tools/validate_vex.py | 195 ------- 76 files changed, 2189 insertions(+), 1074 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yaml create mode 100644 .vex/CVE-2010-4756.json delete mode 100644 .vex/CVE-2010-4756.toml create mode 100644 .vex/CVE-2018-20796.json delete mode 100644 .vex/CVE-2018-20796.toml create mode 100644 .vex/CVE-2019-1010022.json delete mode 100644 .vex/CVE-2019-1010022.toml create mode 100644 .vex/CVE-2019-1010023.json delete mode 100644 .vex/CVE-2019-1010023.toml create mode 100644 .vex/CVE-2019-1010024.json delete mode 100644 .vex/CVE-2019-1010024.toml create mode 100644 .vex/CVE-2019-1010025.json delete mode 100644 .vex/CVE-2019-1010025.toml create mode 100644 .vex/CVE-2019-9192.json delete mode 100644 .vex/CVE-2019-9192.toml create mode 100644 .vex/CVE-2026-27171.json delete mode 100644 .vex/CVE-2026-27171.toml create mode 100644 .vex/CVE-2026-4046.json delete mode 100644 .vex/CVE-2026-4046.toml create mode 100644 .vex/CVE-2026-4437.json delete mode 100644 .vex/CVE-2026-4437.toml create mode 100644 .vex/CVE-2026-4438.json delete mode 100644 .vex/CVE-2026-4438.toml create mode 100644 .vex/CVE-2026-5358.json delete mode 100644 .vex/CVE-2026-5358.toml create mode 100644 .vex/CVE-2026-5450.json delete mode 100644 .vex/CVE-2026-5450.toml create mode 100644 .vex/CVE-2026-5928.json delete mode 100644 .vex/CVE-2026-5928.toml create mode 100644 .vex/GHSA-cq8v-f236-94qc.json delete mode 100644 .vex/GHSA-cq8v-f236-94qc.toml create mode 100644 src/auto_vex_presence.rs create mode 100644 src/auto_vex_presence_tests.rs create mode 100644 src/bin/auto_vex_presence.rs delete mode 100644 tools/assemble_openvex.py delete mode 100755 tools/tests/assemble-openvex-tests.sh delete mode 100644 tools/tests/fixtures/README.md delete mode 100644 tools/tests/fixtures/bad-timestamp/CVE-2025-0016.toml delete mode 100644 tools/tests/fixtures/duplicate-cve/first.toml delete mode 100644 tools/tests/fixtures/duplicate-cve/second.toml delete mode 100644 tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc-dup.toml delete mode 100644 tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc.toml delete mode 100644 tools/tests/fixtures/empty-dir/.gitkeep delete mode 100644 tools/tests/fixtures/empty-products/CVE-2025-0015.toml delete mode 100644 tools/tests/fixtures/invalid-cve-format/CVE-bad.toml delete mode 100644 tools/tests/fixtures/invalid-ghsa-format/GHSA-bad.toml delete mode 100644 tools/tests/fixtures/invalid-justification/CVE-2025-0018.toml delete mode 100644 tools/tests/fixtures/invalid-rustsec-format/RUSTSEC-bad.toml delete mode 100644 tools/tests/fixtures/invalid-status/CVE-2025-0014.toml delete mode 100644 tools/tests/fixtures/malformed-toml/CVE-2025-9999.toml delete mode 100644 tools/tests/fixtures/missing-action-statement/CVE-2025-0019.toml delete mode 100644 tools/tests/fixtures/missing-author/CVE-2025-0012.toml delete mode 100644 tools/tests/fixtures/missing-cve/case.toml delete mode 100644 tools/tests/fixtures/missing-justification/CVE-2025-0017.toml delete mode 100644 tools/tests/fixtures/missing-products/CVE-2025-0011.toml delete mode 100644 tools/tests/fixtures/missing-status/CVE-2025-0010.toml delete mode 100644 tools/tests/fixtures/missing-timestamp/CVE-2025-0013.toml delete mode 100644 tools/tests/fixtures/valid-affected/CVE-2025-0003.toml delete mode 100644 tools/tests/fixtures/valid-ghsa/GHSA-cq8v-f236-94qc.toml delete mode 100644 tools/tests/fixtures/valid-multiple/CVE-2025-0001.toml delete mode 100644 tools/tests/fixtures/valid-multiple/CVE-2025-0002.toml delete mode 100644 tools/tests/fixtures/valid-rustsec/RUSTSEC-2026-0097.toml delete mode 100644 tools/tests/fixtures/valid-single/CVE-2025-0001.toml delete mode 100755 tools/tests/validate-vex-tests.sh delete mode 100755 tools/validate-vex.sh delete mode 100644 tools/validate_vex.py diff --git a/.claude/CHANGELOG.md b/.claude/CHANGELOG.md index 66c116c..70fb7f2 100644 --- a/.claude/CHANGELOG.md +++ b/.claude/CHANGELOG.md @@ -9,6 +9,415 @@ The format is based on the regulated environment requirements: --- +## [2026-04-23 08:00] - Fix vexctl-install Linux path (wrong asset filename) + +**Author:** Erick Bourgeois + +### Changed +- `Makefile` (`vexctl-install` target): rewrote the Linux branch. The + original was modelled on the `gitleaks-install` target, which + assumes a `name_version_os_arch.tar.gz` tarball convention with a + versioned `name_version_checksums.txt`. vexctl uses neither + convention: releases ship **raw binaries** named + `vexctl--` (no tarball, no version in the filename), and + the checksums file is `vexctl_checksums.txt` (version-less). The + branch now downloads the raw binary, downloads the correct + checksums file, greps with a two-space-prefix + end-of-line + anchor (standard `sha256sum` format), verifies, and `install`s + directly to `/usr/local/bin/vexctl`. No tarball extraction step. +- Help string updated from "GitHub release tarball on Linux" to + "pinned raw binary + sha256 on Linux" to match reality. + +### Why +The first CI run that exercised the `vex-validate` job on a +GitHub-hosted Linux runner failed end-to-end: + +``` +Downloading vexctl_0.4.1_linux_amd64.tar.gz... +curl: (22) The requested URL returned error: 404 +curl: (22) The requested URL returned error: 404 +grep: vexctl_checksums.txt: No such file or directory +sha256sum: vexctl_checksum_file.txt: no properly formatted checksum lines found +tar (child): /tmp/vexctl_0.4.1_linux_amd64.tar.gz: Cannot open: No such file or directory +install: cannot stat '/tmp/vexctl': No such file or directory +✓ vexctl installed: /bin/sh: 41: vexctl: not found +make: *** [Makefile:498: vex-validate] Error 127 +``` + +Root cause: I shipped the Makefile target in Phase 1 without +verifying the vexctl release asset naming — it differs from the +gitleaks convention I copied from. Checked the actual +`/releases/tags/v0.4.1` API response and confirmed the correct +asset names. + +The macOS branch (`brew install vexctl`) was always correct and is +untouched; local `make vex-validate` on the maintainer's Mac +continued working throughout, which is why the bug didn't surface +until CI ran. + +### Verification +- Re-downloaded `vexctl_checksums.txt` from the v0.4.1 release and + confirmed the new `grep " $${BINARY}$$" vexctl_checksums.txt` + pattern matches the `vexctl-linux-amd64` line with its SHA + (`51753968975448c521b693e91a52f7e30b7d427da668375218fa64392ffb93c5`). +- Local `make vex-validate` on macOS continues to pass — brew + branch untouched. +- The Linux branch was eyeballed-verified against the actual asset + URL and checksum-format; will be proven by the next CI run on + this branch. + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [x] Config change only — Makefile fix, no runtime or API change. +- [ ] Documentation only + +--- + +## [2026-04-22 21:45] - Pin CodeQL languages (fallout from Phase 1 Python deletion) + +**Author:** Erick Bourgeois + +### Added +- `.github/workflows/codeql.yaml`: explicit CodeQL Advanced Setup + workflow with a matrix over `rust`, `actions`, and + `javascript-typescript`. `build-mode: none` for all three (Rust + supports it per the CodeQL compiled-languages docs; the other two + don't require a build). Runs on PR, push to main, and a weekly + Sunday 07:17 UTC full re-scan. Uploads SARIF per-language category + to Code Scanning. +- `.github/codeql/codeql-config.yml`: companion config file with + `paths-ignore` for `target/**`, `docs/site/**`, and the three + Poetry-manifest files under `docs/` (`poetry.lock`, + `pyproject.toml`, `.python-version`). + +### Why +Phase 1 (`.claude/CHANGELOG.md` 2026-04-22 19:45) deleted the `tools/` +directory including `assemble_openvex.py` and `validate_vex.py`. After +that change, GitHub's **default setup** CodeQL run started failing on +the next push with: + +``` +CodeQL detected code written in GitHub Actions, Rust and +JavaScript/TypeScript, but not any written in Python. Confirm that +there is some source code for Python in the project. +``` + +Root cause: default setup auto-detected Python because +`docs/pyproject.toml` (a Poetry manifest for MkDocs tooling) still +exists — but no `.py` source files remain, so the Python database +finalize step errors with exit code 32. + +The fix is to take over from default setup with an explicit workflow +that pins the language list, which is what this entry adds. Default +setup will be automatically superseded once any workflow calls +`github/codeql-action/init`, so no Settings-UI change is needed. + +The single JS file in scope is `docs/src/javascripts/mermaid-init.js` +(MkDocs Mermaid diagram init); that's why JS/TS stays on the list. +Poetry manifests are explicitly path-ignored — they are metadata, not +source, and would produce false-empty Python databases. + +### Verification +- `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/codeql.yaml'))"` + and `yaml.safe_load(open('.github/codeql/codeql-config.yml'))` both + parse clean. +- Action pins match the rest of the repo + (`github/codeql-action@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2`, + `actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2`). +- Dependabot will continue updating the codeql-action pin + (`.github/dependabot.yml:41` already lists `github/codeql-action` + in the allow-list). + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [x] Config change only — adds a workflow file and a sibling config, + no runtime change. +- [ ] Documentation only + +### Operator follow-up +If the repo's Code Scanning default setup was previously enabled via +the UI, it will automatically defer to this workflow — no toggle +needed. If a partial status appears under *Settings → Code security → +Code scanning*, it's cosmetic: the advanced-setup workflow takes +precedence. + +--- + +## [2026-04-22 21:15] - Drop AUTO_VEX_PRESENCE gate; Security team is the verifier + +**Author:** Erick Bourgeois + +### Changed +- `.github/workflows/build.yaml`: + - `build-vex` job: removed the `vars.AUTO_VEX_PRESENCE == '1'` + conditional on the auto-presence download step. The artifact is + now downloaded unconditionally, and the merge command includes + `auto/vex.auto-presence.json` on every build. Added an explicit + `test -f auto/vex.auto-presence.json || exit 1` guard so a + missing artifact fails the job (rather than silently producing + hand-authored-only VEX). + - Comment headers on both `auto-vex-presence` and `build-vex` + rewritten to describe the new trust model: CI emits aggressively, + Security team verifies downstream and counter-signs. +- `.vex/README.md`: removed the "parallel-run gate / review-only" + language. Auto-presence statements now merge into the signed VEX on + every build. +- `docs/src/security/vex.md`: + - CI flow step 3: removed the `AUTO_VEX_PRESENCE=1` gate clause. + - Automation-split section: the "Automated" bullet no longer + mentions a feature flag. + - New top-level "Trust model" section describing the two-signature + workflow: CI-side Cosign attestation (keyless OIDC via GitHub) + first, Security-team Cosign attestation (their own OIDC identity) + second. Each sits in the Sigstore transparency log; downstream + consumers can require both via twin + `--certificate-identity-regexp` invocations. +- `~/dev/roadmaps/5spot-automated-vex-generation.md`: + - Top-of-file status line updated: Phase 1 ✅, Phase 2 ✅ (always-on), + Phase 3 ⏳ scoped. + - New "Trust model (load-bearing)" section at the top — replaces + the implicit assumption in the earlier draft that our side would + be the verification gate. + - Phase 2 "Status" line updated to reflect the gate removal. + - Phase 3 tradeoff section simplified: "two independent call-graph + analyses must agree" and "multi-release parallel-run" removed; + the Security team's re-verification is the second check. + - Rollout section rewritten: no per-phase feature flags; each + phase activates on merge. + +### Why +Policy flip requested by the user. The original Phase 2 rollout plan +had an `AUTO_VEX_PRESENCE` repo variable that gated whether +auto-generated statements flowed into the signed VEX document; the +intent was a parallel-run validation window where maintainers could +diff the auto-set against `.vex/` before authorizing it. The revised +trust model moves that validation downstream to the Security team, +which re-derives each machine-authored claim from the signed evidence +(SBOM, call-graph attestations, SLSA provenance) and counter-signs if +they agree. That makes our side's gate redundant — if we get it +wrong, the VEX ships with only one attestation instead of two, which +is a discoverable condition for any downstream consumer running a +two-signature verify gate. + +The trust model extends to the not-yet-implemented Phase 3 +(reachability-based auto-VEX): it also ships unconditionally when +built, with call-graph attestations providing the evidence for +Security to verify. + +### Verification +- `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/build.yaml'))"` + → valid YAML. +- `grep "AUTO_VEX_PRESENCE" .github/workflows/build.yaml` → no + residual references. +- No Rust code touched; `cargo` checks unchanged from the previous + entry. + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [x] Config change only — specifically, removes a config knob that + existed for ~1 hour and was never documented as user-facing. +- [x] Documentation only + +--- + +## [2026-04-22 20:30] - Presence-based auto-VEX (roadmap Phase 2) + +**Author:** Erick Bourgeois + +### Added +- `src/auto_vex_presence.rs` (library module, ~180 LOC) + `src/auto_vex_presence_tests.rs` + (20 unit tests, 100% positive/negative/exception coverage per project rule): + - `compute_presence_vex` — pure function that diffs Grype findings against + SBOMs and the already-triaged CVE set, emitting + `not_affected + component_not_present` statements for CVEs whose purl + is absent from every SBOM. + - `build_document` — wraps statements in the OpenVEX envelope. + - `load_triaged_from_vex_dir` — reads hand-authored `.vex/*.json` and + collects the set of `vulnerability.name` values (permissive on + missing dir, strict on malformed JSON). + - Typed deserializers for Grype JSON and CycloneDX SBOM subsets — + tolerant of unknown upstream fields. + - Output sorted by CVE id for deterministic, diffable artifacts. +- `src/bin/auto_vex_presence.rs` — thin clap-driven CLI wrapping the + library. Reads `--grype-json`, one or more `--sbom` files, a + `--vex-dir`, a `--product-purl`, `--id`, optional `--author` and + `--timestamp`, writes an OpenVEX JSON to `--output` or stdout. +- `Cargo.toml`: `[[bin]] auto-vex-presence` entry. +- `Makefile`: `vex-auto-presence` target (defaults `GRYPE_JSON=grype.json` + and `SBOM_FILES=target/release/*.cdx.json docker-sbom-*.json`; both + overridable). Added to `.PHONY`. +- `.github/workflows/build.yaml`: two new jobs gated on + `github.event_name != 'pull_request'`: + - `grype-triage` — runs Grype against each image variant without VEX + suppression, uploads per-variant JSON (pinned GRYPE_VERSION 0.87.0 + matching the existing `grype` job). + - `auto-vex-presence` — builds the new bin, downloads both triage + scans + Docker SBOMs, runs the bin once per variant, merges the + per-variant outputs via `vexctl merge`, uploads + `vex-auto-presence` artifact. + +### Changed +- `.github/workflows/build.yaml`: + - Docker SBOM generation (`Generate Docker SBOM for ${{ matrix.variant.name }}`): + gate loosened from `if: github.event_name == 'release'` to + `if: github.event_name != 'pull_request'` so push-to-main also + produces SBOMs for auto-vex-presence to consume. + - `build-vex` job: `needs` extended to include `auto-vex-presence`. + Added a feature-flagged download step (`if: vars.AUTO_VEX_PRESENCE == '1'`) + and conditional merge inclusion. When the repo variable is + `AUTO_VEX_PRESENCE=1`, `vex.auto-presence.json` is merged alongside + `.vex/*.json`; otherwise hand-authored only. Artifact is produced + regardless — only the merge is gated. +- `src/lib.rs`: re-export `pub mod auto_vex_presence`. +- `docs/src/security/vex.md`: CI flow section renumbered to 6 steps; + added step 2 for auto-presence. Rewrote "Why we did not auto-generate + statements" section as "What we automate, and what stays human" — the + policy now distinguishes `component_not_present` (mechanical, + automatable) from every other triage decision (human-authored). +- `.vex/README.md`: added "Automated statements (roadmap Phase 2)" + section explaining the review-artifact vs flag-gated-merge split. + +### Why +Phase 2 of the automated VEX generation roadmap +(`~/dev/roadmaps/5spot-automated-vex-generation.md`). Most of the +hand-authored statements under `.vex/` exist because Grype flags +CVEs on libc / glibc / zlib code paths that the Rust binary never +reaches — i.e. the vulnerable component is present in the image +filesystem but never invoked. A subset of those (plus most +base-image CVEs going forward) can be suppressed mechanically by +observing that the flagged package's purl is not in the SBOM at +all: the SBOM is authoritative for what's in the product, so +"component_not_present" is the one OpenVEX justification with a +purely verifiable definition. Automating that specific case shrinks +the `.vex/` maintenance queue without compromising the trust model +for any other justification. + +The implementation is strictly SBOM-driven — no reachability analysis, +no source-code inspection. Reachability is Phase 3. + +### Rollout / feature-flag +Per the roadmap rollout plan, the auto-presence artifact is +produced and uploaded on every push + release, but it is **only +merged into the signed VEX document when `vars.AUTO_VEX_PRESENCE=1` +is set** at the repository or organization level. This gives +maintainers at least one release of parallel-run validation: the +artifact is visible for review, but consumers see only the +hand-authored VEX until the flag is flipped. This matches the +roadmap's explicit guidance ("Run in parallel with hand-authored VEX +for one release; diff the auto-set against .vex/ and confirm no +surprises before flipping it on"). + +### Verification +- `cargo fmt --all -- --check`: clean. +- `cargo clippy --all-targets --all-features -- -D warnings`: clean. +- `cargo test --lib`: 349 tests pass (329 pre-existing + 20 new for + `auto_vex_presence`). +- Smoke test: ran the bin against a synthetic Grype JSON (3 matches), + synthetic SBOM (covering 1 of the 3 purls), and the current `.vex/` + directory (covering 1 of the 3 CVEs). The bin emitted exactly the + one statement for the remaining CVE whose purl was absent, and + `vexctl merge` of the auto-presence output + `.vex/*.json` + produced a 16-statement document with the auto statement correctly + included. + +### Impact +- [ ] Breaking change +- [x] Requires cluster rollout (no — CI only) +- [ ] Config change only +- [x] Documentation only + +*(Rollout note: when maintainers want to flip `AUTO_VEX_PRESENCE=1`, +that's a repo-variable change in GitHub settings, no code deploy.)* + +--- + +## [2026-04-22 19:45] - Replace Python VEX tooling with vexctl (roadmap Phase 1) + +**Author:** Erick Bourgeois + +### Removed +- `tools/` — entire directory deleted. Removed `assemble_openvex.py`, + `validate_vex.py`, `validate-vex.sh`, `tests/assemble-openvex-tests.sh`, + `tests/validate-vex-tests.sh`, and all 18 fixture directories under + `tests/fixtures/`. ~400 LOC of bespoke Python + shell tests replaced + by upstream `vexctl` invocations. + +### Changed +- `.vex/*.toml` → `.vex/*.json` (15 files): migrated every statement + from the bespoke TOML dialect to the native OpenVEX v0.2.0 JSON + shape. Each file is now a single-statement OpenVEX document that + `vexctl merge` can consume directly; no translation layer. +- `Makefile`: added `VEXCTL_VERSION ?= 0.4.1`, `vexctl-install` target + (brew on macOS, pinned GitHub release tarball with checksum + verification on Linux; errors on other OSes), `vex-validate` + (parses every `.vex/*.json` via `vexctl merge` — successful parse + is the validation), and `vex-assemble` (prints the merged document + for local preview). `.PHONY` list updated. +- `.github/workflows/build.yaml`: + - `validate-vex` job: dropped Python setup + custom validator calls; + now runs `make vexctl-install` then `make vex-validate`. + - `build-vex` job: dropped Python setup + `assemble_openvex.py`; + now runs `vexctl merge --id --author + .vex/*.json` directly. Per-event `@id` logic (release / push / + PR) preserved verbatim. Cosign attestation, artifact upload, + and `attest-build-provenance` steps unchanged. + - PR `paths:` filter: removed `tools/**` (directory no longer + exists); `.vex/**` retained. +- `.vex/README.md`: rewritten to document native OpenVEX JSON + authoring and `make vex-validate` / `make vex-assemble` instead of + the old TOML schema and shell validator. +- `docs/src/security/vex.md`: CI flow section updated — step 1 + ("validate") and step 2 ("assemble") now describe `vexctl merge` + behaviour; dropped the obsolete "cross-check with `vexctl validate`" + step (vexctl has no `validate` subcommand). Maintainer-authoring + section rewritten with the JSON shape in place of the TOML shape. +- `.claude/SKILL.md`: release checklist item for VEX updated from the + old three-script check to `make vex-validate`. + +### Why +Phase 1 of the automated VEX generation roadmap +(`~/dev/roadmaps/5spot-automated-vex-generation.md`). The custom +Python toolchain duplicated functionality that `vexctl` — the official +OpenVEX CLI — provides upstream. Maintaining a bespoke TOML dialect, +its validator, its assembler, and two fixture-based test suites +added ~400 LOC of surface area with no offsetting benefit once we +accept OpenVEX JSON as the authoring format. Replacing the whole +stack with `vexctl merge` (which doubles as the validator via +successful parse) removes the Python runtime from CI entirely, +eliminates a class of drift between our validator and the upstream +spec, and prepares the pipeline for Phases 2–3 (automated VEX +generation from SBOM and call-graph reachability). + +The TOML-over-JSON ergonomics hit is real but small: 15 files in +`.vex/`, each ~15 lines of JSON, with no inline comments. In +exchange, future maintainers work with the same format upstream +tooling speaks and any OpenVEX-aware tool can consume the source +directory directly. + +### Verification +- `make vex-validate` parses all 15 `.vex/*.json` successfully. +- `vexctl merge --id --author .vex/*.json` produces a + 15-statement document structurally identical to what the Python + assembler would have produced (confirmed via key-by-key diff of + `@context`, `@id`, `author`, `version`, and every statement's + `products`, `status`, `justification`, `impact_statement`, + `action_statement`, `timestamp`). + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [ ] Config change only +- [x] Documentation only + +*(Not really "documentation only" — code deletion is substantive — +but no runtime, CRD, or API-surface change. Closest pre-existing +box.)* + +--- + ## [2026-04-21 15:00] - Model emergency-reclaim in CALM — ADD retrofit **Author:** Erick Bourgeois diff --git a/.claude/SKILL.md b/.claude/SKILL.md index 8315224..4a13347 100644 --- a/.claude/SKILL.md +++ b/.claude/SKILL.md @@ -326,8 +326,7 @@ done ### If preparing a release: - [ ] Every open Trivy finding has a corresponding statement in `.vex/` (triaged into one of: `not_affected`, `affected`, `fixed`, `under_investigation`). No silent "unknown" CVEs leave the door. -- [ ] `./tools/validate-vex.sh` exits 0. -- [ ] `./tools/tests/validate-vex-tests.sh` and `./tools/tests/assemble-openvex-tests.sh` pass. +- [ ] `make vex-validate` exits 0 (every `.vex/*.json` parses cleanly via `vexctl merge`). - [ ] `.vex/` changes referenced in the release notes / changelog. **Verification:** Every checked box above passes. A task is NOT complete until the full checklist is green. diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..75e1aed --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,22 @@ +# Copyright (c) 2025 Erick Bourgeois, firestoned +# SPDX-License-Identifier: Apache-2.0 +# +# CodeQL config consumed by .github/workflows/codeql.yaml. Keeps +# generated / vendored output out of the scanning database so the +# analyzer doesn't waste time on files we don't ship and can't fix. + +name: "5-Spot CodeQL Config" + +# Exclude generated or third-party content from analysis. We still +# scan every hand-written source file under src/, .github/, and +# docs/src/javascripts/. +paths-ignore: + # Rust build output (compiled artifacts, incremental-build state, + # Cargo-vendored registry caches). + - target/** + # MkDocs build output. + - docs/site/** + # Docs build dependencies — Poetry manifests, not source code. + - docs/poetry.lock + - docs/pyproject.toml + - docs/.python-version diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f18b1a2..c7cc1cb 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,7 +15,6 @@ on: - '.github/workflows/build.yaml' - '.github/actions/**' - '.vex/**' - - 'tools/**' push: branches: - main @@ -139,10 +138,11 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings # ───────────────────────────────────────────────────────────────────────────── - # VEX schema gate — PR only. - # Enforces schema, enum, and CVE-uniqueness on every file under .vex/ so that - # malformed VEX statements can never merge to main. The validator itself has - # its own fixture-based unit tests which also run here. + # VEX parse gate — PR only. + # Every .vex/.json is a single-statement OpenVEX document (the native + # OpenVEX shape). vexctl merge parses each one as input; any malformed file + # fails the merge, giving us free schema + enum + structural validation + # from upstream tooling. No custom validator to maintain. # ───────────────────────────────────────────────────────────────────────────── validate-vex: name: 🧾 Validate VEX Statements @@ -153,16 +153,11 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.12' - - - name: Run validator unit tests - run: ./tools/tests/validate-vex-tests.sh + - name: Install vexctl + run: make vexctl-install - - name: Validate .vex/ directory - run: ./tools/validate-vex.sh + - name: Parse every .vex/*.json via vexctl merge + run: make vex-validate # ───────────────────────────────────────────────────────────────────────────── # Extract version — all events; downstream build jobs depend on this. @@ -428,9 +423,12 @@ jobs: cosign sign --yes \ ghcr.io/${{ matrix.variant.image-repository }}@${{ steps.docker_build.outputs.digest }} - # Release only: generate a CycloneDX SBOM for the published image. + # Push + release: generate a CycloneDX SBOM for the published image. + # Needed on push (not just release) because auto-vex-presence + # (roadmap Phase 2) uses it to decide whether a Grype finding's purl + # is actually present in the product. - name: Generate Docker SBOM for ${{ matrix.variant.name }} - if: github.event_name == 'release' + if: github.event_name != 'pull_request' uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: image: ghcr.io/${{ matrix.variant.image-repository }}:${{ matrix.variant.image-tag }} @@ -439,7 +437,7 @@ jobs: upload-release-assets: false - name: Upload Docker SBOM as artifact - if: github.event_name == 'release' + if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-sbom-${{ matrix.variant.name }} @@ -489,24 +487,198 @@ jobs: subject-digest: ${{ matrix.variant.digest }} push-to-registry: true + # ───────────────────────────────────────────────────────────────────────────── + # Grype triage scan — push + release. + # Runs Grype against each image variant WITHOUT VEX suppression and emits + # JSON (not SARIF). Its output feeds auto-vex-presence so we can see the + # raw set of CVEs before any suppression. The subsequent `grype` job + # (after build-vex) still runs with the final VEX for SARIF upload to + # Code Scanning. Two passes; costs ~30 s per variant; keeps the + # dependency graph acyclic (presence-check needs grype output; final + # grype needs the VEX that includes the presence-check output). + # ───────────────────────────────────────────────────────────────────────────── + grype-triage: + name: 🔭 Grype Triage Scan - ${{ matrix.variant.name }} + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + needs: [docker, extract-version] + permissions: + contents: read + packages: read + strategy: + fail-fast: false + matrix: + variant: + - name: Chainguard + image-tag: ${{ needs.extract-version.outputs.image-tag-chainguard }} + image-repository: ${{ needs.extract-version.outputs.image-repository-chainguard }} + - name: Distroless + image-tag: ${{ needs.extract-version.outputs.image-tag-distroless }} + image-repository: ${{ needs.extract-version.outputs.image-repository-distroless }} + steps: + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Pinned version matches the later grype job. Bump both together. + - name: Install Grype + env: + GRYPE_VERSION: '0.87.0' + run: | + set -euo pipefail + url="https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz" + curl -fsSLo grype.tgz "$url" + tar -xzf grype.tgz grype + sudo install -m 0755 grype /usr/local/bin/grype + grype version + + - name: Run Grype triage (no VEX) + env: + IMAGE_REF: ghcr.io/${{ matrix.variant.image-repository }}:${{ matrix.variant.image-tag }} + run: | + grype "$IMAGE_REF" \ + --output json \ + --file grype-triage-${{ matrix.variant.name }}.json + + - name: Upload triage artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: grype-triage-${{ matrix.variant.name }} + path: grype-triage-${{ matrix.variant.name }}.json + if-no-files-found: error + + # ───────────────────────────────────────────────────────────────────────────── + # Auto-VEX (presence-based) — push + release. Roadmap Phase 2. + # + # For each Grype triage finding whose affected purl is absent from every + # image SBOM and whose CVE id is not already covered by a hand-authored + # .vex/*.json statement, emit a `not_affected + component_not_present` + # OpenVEX statement. The result is uploaded as the `vex-auto-presence` + # artifact and is also merged unconditionally into the signed VEX + # document by `build-vex`. Verification is downstream (Security team + # counter-signs after their own re-evaluation); our side emits + # aggressively. + # ───────────────────────────────────────────────────────────────────────────── + auto-vex-presence: + name: 🤖 Auto-VEX (presence) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + needs: [docker, extract-version, grype-triage] + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly) + + - name: Cache cargo dependencies + uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6 + + - name: Build auto-vex-presence + run: cargo build --release --bin auto-vex-presence + + - name: Download triage scan artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: grype-triage-* + path: ./triage + merge-multiple: true + + - name: Download Docker SBOM artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: docker-sbom-* + path: ./sboms + merge-multiple: true + + # One auto-presence document per image variant, then merged at the + # end. We keep variants separate because Grype's triage scan is + # per-image and we want each statement's `products` to correctly + # scope to the image it was derived from. For simplicity in Phase 2, + # both variants use the same `pkg:oci/5-spot` product purl (matching + # the existing hand-authored statements). + - name: Run auto-vex-presence + run: | + set -euo pipefail + vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-presence" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + per_variant=() + for variant in Chainguard Distroless; do + triage="triage/grype-triage-${variant}.json" + sbom="sboms/docker-sbom-${variant}.json" + if [ ! -f "$triage" ] || [ ! -f "$sbom" ]; then + echo "missing $triage or $sbom; skipping variant $variant" + continue + fi + out="vex.auto-presence-${variant}.json" + ./target/release/auto-vex-presence \ + --grype-json "$triage" \ + --sbom "$sbom" \ + --vex-dir .vex \ + --product-purl "pkg:oci/5-spot" \ + --id "${vex_id}/${variant}" \ + --author "auto-vex-presence" \ + --timestamp "$ts" \ + --output "$out" + per_variant+=("$out") + done + if [ ${#per_variant[@]} -eq 0 ]; then + echo "ERROR: no variant produced an auto-presence document" + exit 1 + fi + + - name: Merge per-variant auto-presence documents + run: | + make vexctl-install + vexctl merge \ + --id "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-presence" \ + --author "auto-vex-presence" \ + vex.auto-presence-*.json > vex.auto-presence.json + echo "── vex.auto-presence.json ─────────────────────────────────────────" + cat vex.auto-presence.json + + - name: Upload auto-presence artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vex-auto-presence + path: vex.auto-presence.json + if-no-files-found: error + # ───────────────────────────────────────────────────────────────────────────── # VEX document — push to main + release. # - # Reads hand-authored per-CVE triage from .vex/*.toml, assembles a single - # OpenVEX document, and (on push/release only) Cosign-attests it to both - # image digests. The `openvex` artifact is always uploaded so the `grype` - # container scan job can consume it via `--vex` and suppress already- - # triaged findings from GitHub Code Scanning. On release, the GitHub - # build-provenance bundle is also emitted and attached to the release. + # Reads hand-authored per-CVE triage from .vex/*.json (each file is a + # single-statement native OpenVEX document), merges them into a single + # document via `vexctl merge`, and (on push/release only) Cosign-attests + # it to both image digests. The `openvex` artifact is always uploaded so + # the `grype` container scan job can consume it via `--vex` and suppress + # already-triaged findings from GitHub Code Scanning. On release, the + # GitHub build-provenance bundle is also emitted and attached to the + # release. # - # The validator runs first so malformed VEX can never leak through, even - # if a bad merge slipped past the PR gate. + # vexctl merge fails on any malformed input, so there's no separate + # validator step — a bad merge slipping past the PR gate would also fail + # the assembly here. + # + # Trust model: this job emits and Cosign-attests the VEX document. The + # auto-presence artifact from the Phase 2 job is merged unconditionally + # alongside the hand-authored `.vex/*.json` statements. Verification of + # the resulting claims is performed downstream by the Security team, + # which independently re-evaluates the VEX against the attached evidence + # (SBOMs, Cosign attestations, SLSA provenance) and counter-signs if + # they agree. There is no feature-flag gate on our side — automation is + # aggressive, verification is external. # ───────────────────────────────────────────────────────────────────────────── build-vex: name: 🧾 Assemble OpenVEX if: github.event_name != 'pull_request' runs-on: ubuntu-latest - needs: [docker, extract-version] + needs: [docker, extract-version, auto-vex-presence] permissions: contents: read id-token: write @@ -516,16 +688,19 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.12' + - name: Install vexctl + run: make vexctl-install - - name: Validate .vex/ (defensive re-run before assembly) - run: ./tools/validate-vex.sh + - name: Download auto-presence artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: vex-auto-presence + path: ./auto # Canonical @id varies by event so downstream consumers can link back # to the exact commit or release tag that produced the document. + # The auto-presence document is merged alongside hand-authored + # statements on every build. - name: Assemble OpenVEX document id: assemble run: | @@ -541,11 +716,11 @@ jobs: vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/vex" ;; esac - python3 tools/assemble_openvex.py \ - --vex-dir .vex \ + test -f auto/vex.auto-presence.json || { echo "ERROR: auto-presence artifact missing"; exit 1; } + vexctl merge \ --id "$vex_id" \ --author "${{ github.event.release.author.login || github.actor }}" \ - --output vex.openvex.json + .vex/*.json auto/vex.auto-presence.json > vex.openvex.json echo "── vex.openvex.json ─────────────────────────────────────────" cat vex.openvex.json diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 0000000..25052d8 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,86 @@ +# Copyright (c) 2025 Erick Bourgeois, firestoned +# SPDX-License-Identifier: Apache-2.0 +# +# CodeQL analysis — explicit language list. +# +# Writing this workflow disables GitHub's "default setup" CodeQL config +# and lets us pin exactly which languages get analyzed. We had to take +# over the default setup because it auto-detected Python from +# docs/pyproject.toml (Poetry manifest for MkDocs tooling) and then +# failed with "CodeQL detected code written in GitHub Actions, Rust +# and JavaScript/TypeScript, but not any written in Python" once the +# 5-Spot VEX Python tooling was removed in the Phase 1 cleanup +# (.claude/CHANGELOG.md 2026-04-22 19:45). +# +# Languages analyzed here are exactly the ones with real source files: +# - `rust` — src/**/*.rs (beta in CodeQL; upstream +# aware, stable enough for static +# vulnerability checks on our surface) +# - `actions` — .github/workflows/**/*.yaml +# - `javascript-typescript` +# — docs/src/javascripts/mermaid-init.js +# (MkDocs Mermaid integration) +# +# We explicitly do NOT list `python`: the pyproject.toml under docs/ +# is a Poetry *manifest* for the MkDocs build, not Python source code, +# and would cause the analyzer to finalize an empty database. + +name: CodeQL + +on: + pull_request: + branches: + - main + push: + branches: + - main + schedule: + # Weekly full re-scan on Sundays at 07:17 UTC. Offsets the minute + # so we don't collide with the hourly cron queue. Keeps the Code + # Scanning dashboard fresh when a week passes without code changes. + - cron: '17 7 * * 0' + +permissions: + contents: read + +jobs: + analyze: + name: 🔍 CodeQL - ${{ matrix.language }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + security-events: write + packages: read + + strategy: + fail-fast: false + matrix: + include: + - language: rust + build-mode: none + runner: ubuntu-latest + - language: actions + build-mode: none + runner: ubuntu-latest + - language: javascript-typescript + build-mode: none + runner: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # Repo-local config file (paths-ignore, query suites). Keeps + # generated / vendored output out of the database. + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.vex/CVE-2010-4756.json b/.vex/CVE-2010-4756.json new file mode 100644 index 0000000..9e0b68f --- /dev/null +++ b/.vex/CVE-2010-4756.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2010-4756", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot performs no file-system glob expansion; the vulnerable glibc glob() implementation is never called from the controller.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2010-4756" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2010-4756.toml b/.vex/CVE-2010-4756.toml deleted file mode 100644 index e0937ae..0000000 --- a/.vex/CVE-2010-4756.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc glob() excessive CPU/memory on crafted patterns. -# 5-Spot does not invoke glob(3) or fnmatch(3): there is no shell -# expansion, no filesystem pattern matching in the controller, and no -# Cargo dependency that wraps glob(3). All file-system interaction is -# performed via kube-rs against the Kubernetes API, not local paths. -cve = "CVE-2010-4756" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot performs no file-system glob expansion; the vulnerable glibc glob() implementation is never called from the controller." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2018-20796.json b/.vex/CVE-2018-20796.json new file mode 100644 index 0000000..882c413 --- /dev/null +++ b/.vex/CVE-2018-20796.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2018-20796", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot uses the Rust regex crate, a separate implementation from glibc's posix/regexec.c; the vulnerable C function is never invoked.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2018-20796" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2018-20796.toml b/.vex/CVE-2018-20796.toml deleted file mode 100644 index 9e5fab4..0000000 --- a/.vex/CVE-2018-20796.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc POSIX regex: uncontrolled recursion in check_dst_limits_calc_pos_1. -# This CVE targets glibc's posix/regexec.c. 5-Spot uses the Rust `regex` -# crate (v1.12.3), which is an independent implementation (RE2-style, -# non-recursive) and does not delegate to glibc's POSIX regex. The -# vulnerable C function is therefore unreachable from the controller. -cve = "CVE-2018-20796" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot uses the Rust regex crate, a separate implementation from glibc's posix/regexec.c; the vulnerable C function is never invoked." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2019-1010022.json b/.vex/CVE-2019-1010022.json new file mode 100644 index 0000000..ac881d5 --- /dev/null +++ b/.vex/CVE-2019-1010022.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2019-1010022", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "The controller does not execute adversary-supplied native code; the pre-conditions required to reach the glibc stack-guard bypass do not exist in this workload.", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2019-1010022" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2019-1010022.toml b/.vex/CVE-2019-1010022.toml deleted file mode 100644 index d351115..0000000 --- a/.vex/CVE-2019-1010022.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc stack guard protection bypass (disputed; no upstream fix). -# Exploitation requires an attacker already able to execute arbitrary code -# in the 5-Spot process. The controller runs as a non-root user in a -# read-only root filesystem with a restricted Kubernetes SecurityContext, -# processes no user-supplied native code, and exposes no memory-corruption -# primitives, so there is no path to controlling the stack guard. -cve = "CVE-2019-1010022" -status = "not_affected" -justification = "vulnerable_code_cannot_be_controlled_by_adversary" -impact_statement = "The controller does not execute adversary-supplied native code; the pre-conditions required to reach the glibc stack-guard bypass do not exist in this workload." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2019-1010023.json b/.vex/CVE-2019-1010023.json new file mode 100644 index 0000000..dfd9330 --- /dev/null +++ b/.vex/CVE-2019-1010023.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2019-1010023", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "The Distroless image ships no ldd or shell; the vulnerable workflow (running ldd on an adversary-supplied ELF) cannot occur at runtime.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2019-1010023" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2019-1010023.toml b/.vex/CVE-2019-1010023.toml deleted file mode 100644 index 9d7e8d4..0000000 --- a/.vex/CVE-2019-1010023.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc ldd on a malicious ELF can lead to code execution. -# Exploitation requires a user to run `ldd` against an attacker-supplied -# ELF file. The 5-Spot container image is Distroless and contains no -# shell, no ldd, and no tooling that invokes the dynamic loader against -# arbitrary binaries. The controller binary itself never calls ldd. -cve = "CVE-2019-1010023" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "The Distroless image ships no ldd or shell; the vulnerable workflow (running ldd on an adversary-supplied ELF) cannot occur at runtime." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2019-1010024.json b/.vex/CVE-2019-1010024.json new file mode 100644 index 0000000..4fe7df3 --- /dev/null +++ b/.vex/CVE-2019-1010024.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2019-1010024", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "An attacker has no primitive within the 5-Spot controller to leak cached stack/heap addresses, so the ASLR-bypass precondition is absent.", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2019-1010024" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2019-1010024.toml b/.vex/CVE-2019-1010024.toml deleted file mode 100644 index d137eb5..0000000 --- a/.vex/CVE-2019-1010024.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc ASLR bypass using cache of thread stack and heap. -# This is an information-disclosure / defence-bypass bug that requires an -# attacker with a pre-existing local read primitive in the process. The -# 5-Spot controller exposes no such primitive: it has no FFI, no memory -# read / write endpoints, and handles only Kubernetes-mediated inputs. -cve = "CVE-2019-1010024" -status = "not_affected" -justification = "vulnerable_code_cannot_be_controlled_by_adversary" -impact_statement = "An attacker has no primitive within the 5-Spot controller to leak cached stack/heap addresses, so the ASLR-bypass precondition is absent." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2019-1010025.json b/.vex/CVE-2019-1010025.json new file mode 100644 index 0000000..987c898 --- /dev/null +++ b/.vex/CVE-2019-1010025.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2019-1010025", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot exposes no interface that leaks raw process memory; the pre-requisite local read primitive for this information-disclosure bug does not exist.", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2019-1010025" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2019-1010025.toml b/.vex/CVE-2019-1010025.toml deleted file mode 100644 index da02d31..0000000 --- a/.vex/CVE-2019-1010025.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc information disclosure of heap addresses of pthread_created threads. -# This is a local information-disclosure flaw: it requires an attacker -# already able to read process memory to extract heap addresses. The -# 5-Spot controller provides no such read primitive; its HTTP endpoints -# are /healthz and /metrics (Prometheus text output) only, neither of -# which expose raw process memory. -cve = "CVE-2019-1010025" -status = "not_affected" -justification = "vulnerable_code_cannot_be_controlled_by_adversary" -impact_statement = "5-Spot exposes no interface that leaks raw process memory; the pre-requisite local read primitive for this information-disclosure bug does not exist." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2019-9192.json b/.vex/CVE-2019-9192.json new file mode 100644 index 0000000..e89c40f --- /dev/null +++ b/.vex/CVE-2019-9192.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2019-9192", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot uses the Rust regex crate, not glibc's POSIX regex; the vulnerable check_dst_limits_calc_pos_1 function is never invoked.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2019-9192" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2019-9192.toml b/.vex/CVE-2019-9192.toml deleted file mode 100644 index a2283e0..0000000 --- a/.vex/CVE-2019-9192.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc POSIX regex: uncontrolled recursion in check_dst_limits_calc_pos_1 -# when REG_EXTENDED is used. -# Same code path as CVE-2018-20796 in glibc's posix/regexec.c. 5-Spot uses -# the Rust `regex` crate (v1.12.3), a separate non-recursive -# implementation. The vulnerable glibc C function is never reached from -# the controller binary. -cve = "CVE-2019-9192" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot uses the Rust regex crate, not glibc's POSIX regex; the vulnerable check_dst_limits_calc_pos_1 function is never invoked." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2026-27171.json b/.vex/CVE-2026-27171.json new file mode 100644 index 0000000..e03f9c5 --- /dev/null +++ b/.vex/CVE-2026-27171.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-27171", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "The 5-Spot controller binary has no link-time or runtime dependency on zlib; the vulnerable crc32_combine code path is never executed.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-27171" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-27171.toml b/.vex/CVE-2026-27171.toml deleted file mode 100644 index ff57e1e..0000000 --- a/.vex/CVE-2026-27171.toml +++ /dev/null @@ -1,13 +0,0 @@ -# zlib CRC32 combine: infinite loop on crafted inputs. -# 5-Spot does not call zlib: no flate2, libz-sys, miniz_oxide, or other -# compression crate appears in Cargo.lock, and the controller performs no -# gzip/zip/deflate processing. libz is only present as a transitive -# dependency of other packages shipped in the Distroless base; nothing in -# the 5-Spot binary invokes it. -cve = "CVE-2026-27171" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "The 5-Spot controller binary has no link-time or runtime dependency on zlib; the vulnerable crc32_combine code path is never executed." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2026-4046.json b/.vex/CVE-2026-4046.json new file mode 100644 index 0000000..a332a96 --- /dev/null +++ b/.vex/CVE-2026-4046.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-4046", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot performs no character-set conversions; the glibc iconv() path exercised by this CVE is unreachable from the controller binary.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-4046" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-4046.toml b/.vex/CVE-2026-4046.toml deleted file mode 100644 index 924cb76..0000000 --- a/.vex/CVE-2026-4046.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc iconv() denial of service on specific character sets. -# 5-Spot never converts between character encodings: no iconv_open, iconv, -# or locale-dependent conversion is invoked directly, and no dependency in -# Cargo.lock links against libiconv. All string data handled by the -# controller is UTF-8 produced by serde_json / serde_yaml / TOML, which do -# not route through glibc's iconv. -cve = "CVE-2026-4046" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot performs no character-set conversions; the glibc iconv() path exercised by this CVE is unreachable from the controller binary." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2026-4437.json b/.vex/CVE-2026-4437.json new file mode 100644 index 0000000..0ae97e9 --- /dev/null +++ b/.vex/CVE-2026-4437.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-4437", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot only resolves names against the in-cluster DNS resolver configured by the platform operator; no adversary-controlled DNS path reaches the glibc resolver.", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-4437" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-4437.toml b/.vex/CVE-2026-4437.toml deleted file mode 100644 index c0f9a95..0000000 --- a/.vex/CVE-2026-4437.toml +++ /dev/null @@ -1,14 +0,0 @@ -# glibc DNS resolver parses a crafted server response incorrectly. -# 5-Spot only resolves DNS names supplied via its kubeconfig (typically the -# in-cluster KUBERNETES_SERVICE_HOST env var or a service DNS name -# controlled by the cluster operator). A cluster-internal kube-dns / -# CoreDNS server is by definition not attacker-controlled in the threat -# model, so an adversary cannot supply the malformed response required to -# trigger this bug. -cve = "CVE-2026-4437" -status = "not_affected" -justification = "vulnerable_code_cannot_be_controlled_by_adversary" -impact_statement = "5-Spot only resolves names against the in-cluster DNS resolver configured by the platform operator; no adversary-controlled DNS path reaches the glibc resolver." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2026-4438.json b/.vex/CVE-2026-4438.json new file mode 100644 index 0000000..9289d14 --- /dev/null +++ b/.vex/CVE-2026-4438.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-4438", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot performs no reverse DNS lookups; gethostbyaddr is never invoked and no attacker-controlled address can reach the vulnerable code.", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-4438" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-4438.toml b/.vex/CVE-2026-4438.toml deleted file mode 100644 index a9b1af7..0000000 --- a/.vex/CVE-2026-4438.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc gethostbyaddr() returns an invalid hostname on crafted input. -# 5-Spot never calls gethostbyaddr / gethostbyname_r. The hostname crate -# (v0.4.2) uses the gethostname(2) syscall to read the local host name and -# does not perform reverse DNS. No reverse-DNS lookups occur in the -# controller or its dependencies. -cve = "CVE-2026-4438" -status = "not_affected" -justification = "vulnerable_code_cannot_be_controlled_by_adversary" -impact_statement = "5-Spot performs no reverse DNS lookups; gethostbyaddr is never invoked and no attacker-controlled address can reach the vulnerable code." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/CVE-2026-5358.json b/.vex/CVE-2026-5358.json new file mode 100644 index 0000000..6f396cf --- /dev/null +++ b/.vex/CVE-2026-5358.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-5358", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot does not use NIS for identity; the vulnerable nis_local_principal() code path is never called from the controller.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-22T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-5358" + } + } + ], + "timestamp": "2026-04-22T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-5358.toml b/.vex/CVE-2026-5358.toml deleted file mode 100644 index 5a205e9..0000000 --- a/.vex/CVE-2026-5358.toml +++ /dev/null @@ -1,12 +0,0 @@ -# glibc nis_local_principal() buffer overflow via spoofed UDP NIS response. -# NIS is a 1980s identity service deprecated in glibc 2.26. 5-Spot does not -# use NIS for anything: authentication against the Kubernetes API is TLS + -# bearer token via kube-rs; there is no nss-nis configuration, no yp.conf -# lookup, and no Cargo dependency that binds libnsl. -cve = "CVE-2026-5358" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot does not use NIS for identity; the vulnerable nis_local_principal() code path is never called from the controller." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-22T00:00:00Z" diff --git a/.vex/CVE-2026-5450.json b/.vex/CVE-2026-5450.json new file mode 100644 index 0000000..3b00d7b --- /dev/null +++ b/.vex/CVE-2026-5450.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-5450", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot is Rust and never invokes the scanf family; the vulnerable %mc conversion is not reachable from the controller.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-22T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-5450" + } + } + ], + "timestamp": "2026-04-22T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-5450.toml b/.vex/CVE-2026-5450.toml deleted file mode 100644 index e41cc9b..0000000 --- a/.vex/CVE-2026-5450.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc scanf-family heap buffer overflow when the "%mc" conversion is used -# with an explicit width > 1024. 5-Spot is Rust; no 5-Spot source file calls -# scanf(), sscanf(), fscanf() or any C stdio conversion — I/O is via -# Tokio/Hyper with Rust's typed parsers (serde_json, kube-rs decoders). -# scanf lives in the base image's libc6 but is not reached from the -# controller's execute path. -cve = "CVE-2026-5450" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot is Rust and never invokes the scanf family; the vulnerable %mc conversion is not reachable from the controller." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-22T00:00:00Z" diff --git a/.vex/CVE-2026-5928.json b/.vex/CVE-2026-5928.json new file mode 100644 index 0000000..12fc392 --- /dev/null +++ b/.vex/CVE-2026-5928.json @@ -0,0 +1,23 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2026-5928", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "5-Spot uses Rust UTF-8 I/O exclusively and never calls ungetwc(); the vulnerable wide-char stream path is unreachable.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-22T00:00:00Z", + "vulnerability": { + "name": "CVE-2026-5928" + } + } + ], + "timestamp": "2026-04-22T00:00:00Z", + "version": 1 +} diff --git a/.vex/CVE-2026-5928.toml b/.vex/CVE-2026-5928.toml deleted file mode 100644 index e6f3c5e..0000000 --- a/.vex/CVE-2026-5928.toml +++ /dev/null @@ -1,13 +0,0 @@ -# glibc ungetwc() buffer under-read via a pointer-swap bug in -# _IO_wdefault_pbackfail. Only triggers with non-standard multibyte -# encodings that have overlaps between single-byte and multibyte forms; -# NVD explicitly notes it cannot occur with standard Unicode. -# 5-Spot is Rust, uses UTF-8 everywhere, and never calls ungetwc() or the -# wide-char stdio family. Controller I/O is typed Rust, not libio. -cve = "CVE-2026-5928" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot uses Rust UTF-8 I/O exclusively and never calls ungetwc(); the vulnerable wide-char stream path is unreachable." -products = ["pkg:oci/5-spot"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-22T00:00:00Z" diff --git a/.vex/GHSA-cq8v-f236-94qc.json b/.vex/GHSA-cq8v-f236-94qc.json new file mode 100644 index 0000000..73e070a --- /dev/null +++ b/.vex/GHSA-cq8v-f236-94qc.json @@ -0,0 +1,26 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/GHSA-cq8v-f236-94qc", + "author": "erick.bourgeois@gmail.com", + "statements": [ + { + "impact_statement": "Vulnerable rand 0.8.6 is pulled transitively by tungstenite 0.21 for websocket frame masking only. The advisory requires ThreadRng to be invoked from within a custom log::Log implementation during reseed; 5-Spot's tracing-subscriber stack does not call into rand from any logger emit path, so the unsound code is not in the execute path.", + "justification": "vulnerable_code_not_in_execute_path", + "products": [ + { + "@id": "pkg:oci/5-spot" + }, + { + "@id": "pkg:oci/5-spot" + } + ], + "status": "not_affected", + "timestamp": "2026-04-19T00:00:00Z", + "vulnerability": { + "name": "GHSA-cq8v-f236-94qc" + } + } + ], + "timestamp": "2026-04-19T00:00:00Z", + "version": 1 +} diff --git a/.vex/GHSA-cq8v-f236-94qc.toml b/.vex/GHSA-cq8v-f236-94qc.toml deleted file mode 100644 index 40d198a..0000000 --- a/.vex/GHSA-cq8v-f236-94qc.toml +++ /dev/null @@ -1,33 +0,0 @@ -# rand soundness when a custom `log` logger calls rand::rng() and triggers -# ThreadRng reseeding at warn- or trace-level. -# -# Reachable-only-if ALL of: -# 1. the `log` and `thread_rng` rand features are enabled, -# 2. a custom logger implementation is registered, -# 3. that logger itself calls rand::rng() / a TryRng method on ThreadRng, -# 4. ThreadRng reseeds (every 64 kB) while the logger is on the stack, -# 5. trace-level is on OR warn-level is on with getrandom unavailable. -# -# In 5-Spot's dependency graph, the only vulnerable rand 0.8.6 is pulled -# transitively via warp 0.3.7 → tokio-tungstenite 0.21 → tungstenite 0.21, -# where rand is used exclusively for websocket client frame-masking keys -# (tungstenite::protocol::frame::mask). That code path is not invoked -# from, or reachable by, any `log` / `tracing` logger implementation in -# this project — 5-Spot uses tracing-subscriber's stock fmt + json -# layers with env-filter, none of which touch rand from within emit(). -# -# The full fix (rand >= 0.9.3) requires warp → tokio-tungstenite 0.22+, -# which warp 0.3.x does not support. Tracked for removal when the warp -# 0.3 → 0.4 / axum migration lands; until then this statement documents -# the non-exploitability and a Scorecard-side ignore is recorded in -# osv-scanner.toml at the repo root. -cve = "GHSA-cq8v-f236-94qc" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "Vulnerable rand 0.8.6 is pulled transitively by tungstenite 0.21 for websocket frame masking only. The advisory requires ThreadRng to be invoked from within a custom log::Log implementation during reseed; 5-Spot's tracing-subscriber stack does not call into rand from any logger emit path, so the unsound code is not in the execute path." -products = [ - "pkg:oci/5-spot", - "pkg:oci/5-spot", -] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/.vex/README.md b/.vex/README.md index 4e1606c..64a842a 100644 --- a/.vex/README.md +++ b/.vex/README.md @@ -7,9 +7,10 @@ SPDX-License-Identifier: Apache-2.0 This directory is the **human-authored source of truth** for 5-Spot's [VEX (Vulnerability Exploitability eXchange)](https://github.com/openvex/spec) -statements. CI reads every `.vex/.toml` at release time and assembles -a single signed +statements. Each `.vex/.json` is a single-statement [OpenVEX](https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md) +document in the native format. CI merges every file at release time via +[`vexctl`](https://github.com/openvex/vexctl) into a single signed document that is: - attached to the GitHub Release as an asset, @@ -21,13 +22,32 @@ document that is: Downstream scanners (Grype, Trivy, Harbor) consume the OpenVEX document and suppress findings we have already triaged as not applicable. +## Automated statements (roadmap Phase 2) + +On every push + release, CI also produces a `vex-auto-presence` +workflow artifact: a separate OpenVEX document containing +`not_affected + component_not_present` statements for every Grype +finding whose affected package URL is not present in any image SBOM +(and is not already triaged in this directory). This is produced by +the `auto-vex-presence` bin under `src/bin/auto_vex_presence.rs` and +is merged **unconditionally** into the signed release VEX alongside +the hand-authored statements in this directory. + +Verification of the merged document — including the auto-generated +statements — is performed downstream by the Security team, which +re-evaluates the VEX against the attached evidence (SBOMs, Cosign +attestations, SLSA provenance) and counter-signs the document if they +agree. There is no parallel-run gate on our side: our job is to emit +as aggressively as the evidence supports; their job is to verify. + ## When to add a statement -When a scanner (Trivy in CI, or a downstream consumer) flags a CVE on a +When a scanner (Grype in CI, or a downstream consumer) flags a CVE on a 5-Spot release artifact, open a PR adding **one file per CVE** in this directory. Merging is gated by: -1. `tools/validate-vex.sh` (schema + enum + uniqueness checks). +1. `make vex-validate` — parses every `.vex/*.json` via `vexctl merge`; + any malformed file fails the merge and blocks the PR. 2. Human review of the impact statement. No automated "everything is `not_affected`" statements are written. Every @@ -35,41 +55,50 @@ statement is explicitly authored and reviewed. ## File format -One TOML file per advisory, named `.toml` (case-insensitive match -on the `cve` field; file name is informational). Accepted identifier shapes: +One JSON file per advisory, named `.json`. Accepted +identifier shapes for the `vulnerability.name` field: - `CVE-YYYY-NNNN+` — MITRE CVE (the common case). -- `GHSA-xxxx-xxxx-xxxx` — GitHub Security Advisory. Use this when the advisory - has no assigned CVE yet (e.g. `GHSA-cq8v-f236-94qc`). +- `GHSA-xxxx-xxxx-xxxx` — GitHub Security Advisory (used when the + advisory has no assigned CVE yet, e.g. `GHSA-cq8v-f236-94qc`). - `RUSTSEC-YYYY-NNNN` — RustSec advisory DB. -The TOML field is still named `cve` for backward compatibility with the -original file format; the value may be any of the above. - -```toml -cve = "CVE-2025-12345" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot does not parse untrusted XML; the affected libxml2 code path is never invoked." -products = [ - "pkg:oci/5-spot-chainguard", - "pkg:oci/5-spot-distroless", -] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" +Each file is a single-statement OpenVEX v0.2.0 document: + +```json +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2025-12345", + "author": "erick.bourgeois@gmail.com", + "timestamp": "2026-04-19T00:00:00Z", + "version": 1, + "statements": [ + { + "vulnerability": {"name": "CVE-2025-12345"}, + "products": [{"@id": "pkg:oci/5-spot"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "5-Spot does not parse untrusted XML; the affected libxml2 code path is never invoked.", + "timestamp": "2026-04-19T00:00:00Z" + } + ] +} ``` -### Field reference +The document-level `@id`, `author`, and `timestamp` are replaced by +CI at release time; the statement-level fields (inside `statements[]`) +are what ship in the merged release document. + +### Field reference (statement level) | Field | Required | Notes | | ------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `cve` | yes | Canonical CVE identifier, `CVE-YYYY-NNNNN+`. | +| `vulnerability.name` | yes | Canonical identifier: `CVE-YYYY-NNNN+`, `GHSA-xxxx-xxxx-xxxx`, or `RUSTSEC-YYYY-NNNN`. | | `status` | yes | One of: `not_affected`, `affected`, `fixed`, `under_investigation`. | | `justification` | required iff `status = "not_affected"` | OpenVEX enum, see below. | | `impact_statement` | recommended for `not_affected` | Free-form explanation of why the CVE is non-exploitable in 5-Spot. | | `action_statement` | required iff `status = "affected"` or `"under_investigation"` | What a consumer should do until a fix is available (e.g. upgrade path, mitigation). | -| `products` | yes, non-empty | List of product identifiers the statement applies to. Use package URLs (`pkg:oci/...`) or image references. | -| `author` | yes | Email or GitHub handle of the author of the triage. | +| `products` | yes, non-empty | List of product identifiers. Use package URLs (`pkg:oci/...`) or image references. | | `timestamp` | yes | RFC-3339 UTC timestamp. | ### Allowed `justification` values @@ -85,10 +114,22 @@ Per the OpenVEX spec: ## Local validation ```bash -./tools/validate-vex.sh +make vex-validate +``` + +This installs `vexctl` if missing and runs `vexctl merge` over every +`.vex/*.json`. Any malformed file fails the merge — successful +parse = valid structure, valid enum values, and no duplicate +statements for the same (vulnerability, product) pair. + +## Local assembly + +```bash +make vex-assemble ``` -The validator is the same one CI runs on every PR. +Prints the merged OpenVEX document to stdout. Useful for previewing +the release-time artifact locally. ## References diff --git a/Cargo.toml b/Cargo.toml index 7afd885..aa34929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,10 @@ path = "src/bin/crdgen.rs" name = "crddoc" path = "src/bin/crddoc.rs" +[[bin]] +name = "auto-vex-presence" +path = "src/bin/auto_vex_presence.rs" + [dependencies] kube = { version = "3.1", features = ["runtime", "derive", "client", "ws"] } kube-lease-manager = "0.11" diff --git a/Makefile b/Makefile index 21a88e0..8359a49 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Copyright (c) 2025 Erick Bourgeois, RBC Capital Markets # SPDX-License-Identifier: Apache-2.0 -.PHONY: help install build build-debug build-linux-amd64 build-linux-arm64 build-macos-arm64 prepare-binaries-linux-amd64 prepare-binaries-linux-arm64 test test-lib lint format clean crds crddoc docs docs-serve docs-clean docs-rustdoc calm-diagrams calm-validate run-local docker-build docker-build-amd64 docker-build-arm64 docker-build-chainguard docker-push docker-buildx docker-buildx-chainguard gitleaks gitleaks-install install-git-hooks security-scan-local sbom audit kind-install kind-create kind-delete kind-load kind-deploy kind-example kind-setup kind-status +.PHONY: help install build build-debug build-linux-amd64 build-linux-arm64 build-macos-arm64 prepare-binaries-linux-amd64 prepare-binaries-linux-arm64 test test-lib lint format clean crds crddoc docs docs-serve docs-clean docs-rustdoc calm-diagrams calm-validate run-local docker-build docker-build-amd64 docker-build-arm64 docker-build-chainguard docker-push docker-buildx docker-buildx-chainguard gitleaks gitleaks-install install-git-hooks security-scan-local sbom audit vexctl-install vex-validate vex-assemble vex-auto-presence kind-install kind-create kind-delete kind-load kind-deploy kind-example kind-setup kind-status # CALM (FINOS Common Architecture Language Model) configuration CALM_CLI_VERSION ?= 1.37.0 @@ -36,6 +36,7 @@ CONTAINER_TOOL ?= docker # Security tool versions GITLEAKS_VERSION ?= 8.21.2 +VEXCTL_VERSION ?= 0.4.1 # Kind (local Kubernetes) configuration KIND_VERSION ?= 0.24.0 @@ -449,6 +450,84 @@ audit: ## Check dependencies for security vulnerabilities (installs cargo-audit @command -v cargo-audit >/dev/null 2>&1 || { echo "Installing cargo-audit..."; cargo install cargo-audit; } @cargo audit +vexctl-install: ## Install vexctl (brew on macOS, pinned raw binary + sha256 on Linux) + @if command -v vexctl >/dev/null 2>&1; then \ + echo "✓ vexctl already installed: $$(vexctl version 2>&1 | head -1)"; \ + exit 0; \ + fi; \ + echo "Installing vexctl v$(VEXCTL_VERSION)..."; \ + OS=$$(uname -s | tr '[:upper:]' '[:lower:]'); \ + ARCH=$$(uname -m); \ + case "$$ARCH" in \ + x86_64|amd64) ARCH="amd64" ;; \ + aarch64|arm64) ARCH="arm64" ;; \ + esac; \ + case "$$OS" in \ + darwin) \ + if command -v brew >/dev/null 2>&1; then \ + brew install vexctl; \ + else \ + echo "ERROR: Homebrew not found. Install from https://brew.sh or set VEXCTL_VERSION and re-run on a system with brew."; \ + exit 1; \ + fi ;; \ + linux) \ + BINARY="vexctl-linux-$${ARCH}"; \ + BASE_URL="https://github.com/openvex/vexctl/releases/download/v$(VEXCTL_VERSION)"; \ + echo "Downloading $${BINARY}..."; \ + curl -fsSL -o /tmp/$${BINARY} "$${BASE_URL}/$${BINARY}"; \ + curl -fsSL -o /tmp/vexctl_checksums.txt "$${BASE_URL}/vexctl_checksums.txt"; \ + cd /tmp && grep " $${BINARY}$$" vexctl_checksums.txt > vexctl_checksum_file.txt; \ + if command -v sha256sum >/dev/null 2>&1; then \ + sha256sum -c vexctl_checksum_file.txt; \ + elif command -v shasum >/dev/null 2>&1; then \ + shasum -a 256 -c vexctl_checksum_file.txt; \ + else \ + echo "WARNING: No checksum tool found, skipping verification"; \ + fi; \ + sudo install -m 0755 /tmp/$${BINARY} /usr/local/bin/vexctl; \ + rm -f /tmp/$${BINARY} /tmp/vexctl_checksums.txt /tmp/vexctl_checksum_file.txt ;; \ + *) \ + echo "ERROR: Unsupported OS '$$OS'. Install vexctl manually from https://github.com/openvex/vexctl/releases"; \ + exit 1 ;; \ + esac; \ + echo "✓ vexctl installed: $$(vexctl version 2>&1 | head -1)" + +vex-validate: vexctl-install ## Parse every .vex/*.json via vexctl merge (validation = successful parse) + @echo "Validating .vex/*.json..." + @vexctl merge --id "https://5-spot/local/validate" --author "local" .vex/*.json > /dev/null + @echo "✓ all .vex/*.json parsed successfully" + +vex-assemble: vexctl-install ## Assemble a local OpenVEX document from .vex/*.json (prints to stdout) + @vexctl merge \ + --id "https://5-spot/local/assemble" \ + --author "$$(git config user.email 2>/dev/null || echo local)" \ + .vex/*.json + +# Inputs for vex-auto-presence. Override on the command line, e.g. +# make vex-auto-presence GRYPE_JSON=scan.json SBOM_FILES="a.json b.json" +GRYPE_JSON ?= grype.json +SBOM_FILES ?= $(wildcard target/release/*.cdx.json docker-sbom-*.json) +PRODUCT_PURL ?= pkg:oci/5-spot + +vex-auto-presence: ## Run auto-vex-presence bin over $(GRYPE_JSON) + $(SBOM_FILES) (Phase 2) + @if [ ! -f "$(GRYPE_JSON)" ]; then \ + echo "ERROR: $(GRYPE_JSON) not found. Override with: make vex-auto-presence GRYPE_JSON=path/to/grype.json"; \ + exit 1; \ + fi + @if [ -z "$(SBOM_FILES)" ]; then \ + echo "ERROR: no SBOMs found. Override with: make vex-auto-presence SBOM_FILES='a.json b.json'"; \ + exit 1; \ + fi + @cargo run --quiet --bin auto-vex-presence -- \ + --grype-json "$(GRYPE_JSON)" \ + $(foreach s,$(SBOM_FILES),--sbom "$(s)") \ + --vex-dir .vex \ + --product-purl "$(PRODUCT_PURL)" \ + --id "https://5-spot/local/auto-presence" \ + --author "auto-vex-presence" \ + --output vex.auto-presence.json + @echo "✓ wrote vex.auto-presence.json" + # ============================================================ # Kind Cluster (local testing for ScheduledMachine) # ============================================================ diff --git a/README.md b/README.md index 7a6b7c3..3447e97 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A cloud-native Kubernetes controller for managing time-based machine scheduling ### Security & Compliance [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/finos/5-spot/badge)](https://scorecard.dev/viewer/?uri=github.com/finos/5-spot) +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12621/badge)](https://www.bestpractices.dev/projects/12621) [![SPDX](https://img.shields.io/badge/SPDX-License--Identifier-blue)](https://spdx.dev/) [![Gitleaks](https://img.shields.io/badge/Gitleaks-Secret%20Scanning-blue)](https://github.com/gitleaks/gitleaks) [![Semgrep](https://img.shields.io/badge/Semgrep-SAST-1B2F3D?logo=semgrep)](https://semgrep.dev/) diff --git a/docs/src/security/vex.md b/docs/src/security/vex.md index aabbb98..d362d9b 100644 --- a/docs/src/security/vex.md +++ b/docs/src/security/vex.md @@ -27,12 +27,22 @@ independently. On every release of 5-Spot the CI pipeline performs the following steps: -1. **Validate** every file under `.vex/*.toml` (schema, enum values, CVE - uniqueness) with `tools/validate-vex.sh`. -2. **Assemble** a single OpenVEX document (`vex.openvex.json`) with - `tools/assemble_openvex.py`, stamped with a canonical - `@id = https://github.com///releases/tag//vex`. -3. **Cross-check** the output with `vexctl validate`. +1. **Parse** every file under `.vex/*.json` (each is a single-statement + native OpenVEX document) via `vexctl merge`. Malformed input fails + the merge — there is no separate validator to keep in sync. +2. **Generate presence-based auto-VEX** (roadmap Phase 2): the + `auto-vex-presence` job runs a Grype triage scan on each image + variant without VEX suppression, then emits a `not_affected + + component_not_present` statement for every finding whose affected + package URL is absent from the image SBOM and not already covered + by a hand-authored statement. The result is uploaded as the + `vex-auto-presence` workflow artifact for review on every build. +3. **Assemble** a single OpenVEX document (`vex.openvex.json`) with + `vexctl merge`, stamped with a canonical + `@id = https://github.com///releases/tag//vex` + and the release actor as the document-level author. The + auto-presence document is included in the merge on every build — + there is no feature-flag gate. 4. **Cosign-attest** the document to *both* image digests (Chainguard and Distroless). The attestation lands in the Sigstore transparency log and is pushed to the registry alongside the image. @@ -91,27 +101,40 @@ release (for example, `finos`). ## How a 5-Spot maintainer adds a statement When a new CVE surfaces on a release artifact, open a PR adding a -single file to [`.vex/`](https://github.com/finos/5-spot/tree/main/.vex): - -```toml -# .vex/CVE-2025-12345.toml -cve = "CVE-2025-12345" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "5-Spot does not parse untrusted XML; the affected libxml2 code path is never invoked." -products = [ - "pkg:oci/5-spot-chainguard", - "pkg:oci/5-spot-distroless", -] -author = "maintainer@example" -timestamp = "2026-04-19T00:00:00Z" +single file to [`.vex/`](https://github.com/finos/5-spot/tree/main/.vex). +Each file is a native OpenVEX v0.2.0 single-statement document: + +```json +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/finos/5-spot/.vex/CVE-2025-12345", + "author": "maintainer@example", + "timestamp": "2026-04-19T00:00:00Z", + "version": 1, + "statements": [ + { + "vulnerability": {"name": "CVE-2025-12345"}, + "products": [{"@id": "pkg:oci/5-spot"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "5-Spot does not parse untrusted XML; the affected libxml2 code path is never invoked.", + "timestamp": "2026-04-19T00:00:00Z" + } + ] +} ``` -The PR gate on `build.yaml` (`validate-vex` job) blocks malformed files -from merging. The same validator is re-run on release for +The document-level `@id`, `author`, and `timestamp` fields are replaced +by CI at release time with a canonical release-tag `@id`, the release +actor, and the release timestamp. Only the statement contents (inside +`statements[]`) carry forward into the merged release document. + +The PR gate on `build.yaml` (`validate-vex` job) runs `vexctl merge` +over every `.vex/*.json` file; any malformed file fails the merge and +blocks the PR. The same tool runs again on release for belt-and-suspenders. -### Required fields per status +### Required fields per status (statement level) | `status` | Extra required field | Notes | | --------------------- | -------------------- | ------------------------------------------------------------------ | @@ -120,21 +143,81 @@ belt-and-suspenders. | `under_investigation` | `action_statement` | Same — give consumers something actionable. | | `fixed` | — | Just declares the CVE no longer applies to this release. | -All four statuses additionally require `cve`, `products`, `author`, and -`timestamp` (RFC-3339 UTC). +All four statuses additionally require `vulnerability.name`, `products`, +and `timestamp` (RFC-3339 UTC). + +Validate locally with `make vex-validate`. --- -## Why we did not auto-generate statements from scanner output +## What we automate, and what stays human The VEX document is a trust claim, not a compliance artifact. If 5-Spot automatically emitted `not_affected` for every Grype finding, the document would be worthless the moment Grype missed a true positive. +That constraint rules out "auto-triage everything" but not all +automation — specifically, the `component_not_present` justification +has a purely mechanical definition ("the vulnerable component is not +in the product"), and the SBOM is the authoritative definition of +what's in the product. + +The split is therefore: + +- **Automated** (roadmap Phase 2, active on every build): if Grype + flags a CVE on a package whose `purl` is not in any image SBOM, and + the CVE isn't already triaged in `.vex/`, the `auto-vex-presence` + job emits a `not_affected + component_not_present` statement. The + SBOM digest is the evidence backing the claim. The resulting + statements are merged unconditionally into the signed VEX document. +- **Hand-authored** (everything else): `not_affected` with any + justification other than `component_not_present`, plus all + `affected`, `fixed`, and `under_investigation` statements, stay in + `.vex/*.json` and go through PR review. Grype findings drive + maintainers to write statements; the statements themselves are + deliberate human decisions. + +Reachability-based auto-VEX (justification +`vulnerable_code_not_in_execute_path`) is Phase 3 of the roadmap and +is not yet in production. + +--- -The `.vex/` directory is therefore **hand-authored and PR-reviewed**. -Grype findings drive maintainers to write statements; the statements -themselves are deliberate human decisions. This keeps the audit trail -honest. +## Trust model + +5-Spot operates a **two-signature** trust model for VEX: + +1. **CI emits and Cosign-attests** the merged VEX document + (hand-authored + auto-presence) against both image digests on every + push + release. The attestation is keyless via GitHub OIDC and lands + in the Sigstore transparency log alongside the SBOM attestations, + SLSA provenance, and GitHub build-provenance bundle. +2. **The Security team independently verifies and counter-signs**. On + each release they: + - Re-run `vexctl merge` over the committed `.vex/*.json` and the + uploaded `vex-auto-presence` artifact, diffing against the signed + `vex.openvex.json` attached to the release. + - For each auto-generated `component_not_present` statement, + re-derive presence by inspecting the signed SBOM attestations + (`cosign download attestation --predicate-type cyclonedx.json`). + - For each hand-authored statement, review the `impact_statement` + against the release source tree at the tagged commit. + - Apply their own Cosign attestation to the same image digests if + they agree (`cosign attest --type openvex` under the Security + team's OIDC identity), adding a second signature to the + transparency log. + +Downstream consumers can require both attestations via +`cosign verify-attestation` with two `--certificate-identity-regexp` +invocations (one matching the CI identity, one matching the Security +team's). The design keeps the VEX document auditable even when the +5-Spot team automates its generation aggressively: if the CI emits a +claim the Security team cannot substantiate, the image ships with +only one signature on the VEX — which is a discoverable condition for +any downstream gate. + +This is why it is safe for 5-Spot CI to auto-generate +`component_not_present` statements by default: incorrect suppressions +are caught at verification time, not at emission time. --- diff --git a/src/auto_vex_presence.rs b/src/auto_vex_presence.rs new file mode 100644 index 0000000..23df425 --- /dev/null +++ b/src/auto_vex_presence.rs @@ -0,0 +1,225 @@ +// Copyright (c) 2025 Erick Bourgeois, firestoned +// SPDX-License-Identifier: Apache-2.0 +//! Presence-based auto-VEX generation (roadmap Phase 2). +//! +//! Given a Grype JSON report, one or more CycloneDX SBOMs, and the set of +//! already-triaged CVE identifiers from `.vex/*.json`, compute an OpenVEX +//! document containing `not_affected + component_not_present` statements +//! for every Grype finding whose affected purl is absent from every SBOM. +//! +//! The rule is deliberately narrow: `component_not_present` is the one +//! OpenVEX justification with a purely mechanical definition — the SBOM +//! is the definition of what's in the product. Everything else stays +//! human-authored. +//! +//! This module contains pure logic only; I/O is driven by the thin CLI +//! wrapper in `src/bin/auto_vex_presence.rs`. + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +// ───────────────────────────────────────────────────────────────────────── +// Grype JSON schema (only the fields this module needs) +// ───────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct GrypeReport { + pub matches: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct GrypeMatch { + pub vulnerability: GrypeVuln, + pub artifact: GrypeArtifact, +} + +#[derive(Debug, Deserialize)] +pub struct GrypeVuln { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct GrypeArtifact { + pub purl: Option, +} + +// ───────────────────────────────────────────────────────────────────────── +// CycloneDX SBOM schema (only the fields this module needs) +// ───────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct Sbom { + pub components: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct SbomComponent { + pub purl: Option, +} + +// ───────────────────────────────────────────────────────────────────────── +// OpenVEX document schema (subset we emit — same shape vexctl merge reads) +// ───────────────────────────────────────────────────────────────────────── + +/// OpenVEX v0.2.0 context URI. +const OPENVEX_CONTEXT: &str = "https://openvex.dev/ns/v0.2.0"; + +/// OpenVEX document spec version. +const OPENVEX_VERSION: u32 = 1; + +/// Fixed impact-statement attached to every auto-generated statement. The +/// string is audit-visible so reviewers can distinguish machine-authored +/// suppressions from human-authored ones at a glance. +const AUTO_IMPACT_STATEMENT: &str = + "Auto-derived by auto-vex-presence: the vulnerable component's package \ + URL is not present in any release SBOM, so the CVE cannot be exploited \ + against this product."; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Document { + #[serde(rename = "@context")] + pub context: String, + #[serde(rename = "@id")] + pub id: String, + pub author: String, + pub timestamp: String, + pub version: u32, + pub statements: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Statement { + pub vulnerability: Vuln, + pub products: Vec, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub justification: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub impact_statement: Option, + pub timestamp: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Vuln { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Product { + #[serde(rename = "@id")] + pub id: String, +} + +// ───────────────────────────────────────────────────────────────────────── +// Core logic +// ───────────────────────────────────────────────────────────────────────── + +/// Compute the set of auto-VEX statements to emit. +/// +/// Emits one `not_affected + component_not_present` statement per unique +/// Grype CVE whose affected `purl` is not found in any SBOM and is not +/// already covered by a hand-authored `.vex/*.json` statement. +/// +/// Output is sorted by CVE identifier so byte-identical inputs produce +/// byte-identical outputs (important for diffable CI artifacts). +pub fn compute_presence_vex( + grype: &GrypeReport, + sboms: &[Sbom], + already_triaged: &HashSet, + product_purl: &str, + statement_timestamp: &str, +) -> Vec { + let sbom_purls: HashSet<&str> = sboms + .iter() + .flat_map(|s| s.components.iter().flatten()) + .filter_map(|c| c.purl.as_deref()) + .collect(); + + // BTreeMap keyed on CVE id → deterministic sorted output + implicit de-dup. + let mut emitted: BTreeMap = BTreeMap::new(); + for m in &grype.matches { + let cve = &m.vulnerability.id; + if already_triaged.contains(cve) { + continue; + } + if emitted.contains_key(cve) { + continue; + } + let artifact_purl = match m.artifact.purl.as_deref() { + Some(p) if !p.is_empty() => p, + _ => continue, + }; + if sbom_purls.contains(artifact_purl) { + continue; + } + emitted.insert( + cve.clone(), + Statement { + vulnerability: Vuln { name: cve.clone() }, + products: vec![Product { + id: product_purl.to_string(), + }], + status: "not_affected".to_string(), + justification: Some("component_not_present".to_string()), + impact_statement: Some(AUTO_IMPACT_STATEMENT.to_string()), + timestamp: statement_timestamp.to_string(), + }, + ); + } + emitted.into_values().collect() +} + +/// Wrap a set of statements in the OpenVEX envelope. `@id`, `author`, and +/// `timestamp` are supplied by the caller (typically from the CI context). +pub fn build_document( + statements: Vec, + id: &str, + author: &str, + timestamp: &str, +) -> Document { + Document { + context: OPENVEX_CONTEXT.to_string(), + id: id.to_string(), + author: author.to_string(), + timestamp: timestamp.to_string(), + version: OPENVEX_VERSION, + statements, + } +} + +/// Read every `*.json` file in `vex_dir` and return the set of +/// `vulnerability.name` values across all statements. Non-existent +/// directories are treated as "nothing triaged yet" (permissive) so that +/// fresh repos work without special-casing. Malformed JSON surfaces as an +/// error so bad files don't silently become unsuppressed findings. +pub fn load_triaged_from_vex_dir(vex_dir: &Path) -> std::io::Result> { + let mut triaged = HashSet::new(); + let entries = match std::fs::read_dir(vex_dir) { + Ok(e) => e, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(triaged), + Err(err) => return Err(err), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let bytes = std::fs::read(&path)?; + let doc: Document = serde_json::from_slice(&bytes).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{}: {}", path.display(), e), + ) + })?; + for stmt in doc.statements { + triaged.insert(stmt.vulnerability.name); + } + } + Ok(triaged) +} + +#[cfg(test)] +#[path = "auto_vex_presence_tests.rs"] +mod tests; diff --git a/src/auto_vex_presence_tests.rs b/src/auto_vex_presence_tests.rs new file mode 100644 index 0000000..fcaf7b8 --- /dev/null +++ b/src/auto_vex_presence_tests.rs @@ -0,0 +1,477 @@ +// Copyright (c) 2025 Erick Bourgeois, firestoned +// SPDX-License-Identifier: Apache-2.0 +//! Unit tests for the `auto_vex_presence` module. +//! +//! Coverage obligations per project rule (100% positive / negative / exception): +//! - Happy path: a Grype finding whose purl is absent from every SBOM is +//! emitted as `not_affected + component_not_present`. +//! - Negative path: a Grype finding whose purl IS in the SBOM is skipped. +//! - Negative path: a Grype finding whose CVE ID is already covered by a +//! hand-authored `.vex/*.json` statement is skipped. +//! - Negative path: a Grype finding with a missing/empty purl is skipped. +//! - Multiple-SBOM union: a purl present in ANY SBOM excludes the finding. +//! - De-duplication: if the same CVE appears multiple times in Grype output +//! (e.g., matched against multiple artifacts), only one statement is +//! emitted. +//! - Determinism: the output is sorted so identical inputs always produce +//! identical bytes. +//! - Malformed inputs: Grype JSON without `matches`, SBOM without +//! `components`, and `.vex/` with malformed JSON all surface as typed +//! errors. +//! - I/O: missing `.vex/` directory is treated as an empty triage set +//! (permissive); missing Grype / SBOM file is an error. + +#[cfg(test)] +#[allow(clippy::module_inception)] +mod tests { + use super::super::*; + use serde_json::json; + use std::collections::HashSet; + + // ──────────────────────────────────────────────────────────────────── + // compute_presence_vex — core pure logic + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn empty_grype_yields_empty_statements() { + let grype = GrypeReport { matches: vec![] }; + let statements = compute_presence_vex( + &grype, + &[], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert!(statements.is_empty()); + } + + #[test] + fn cve_not_in_any_sbom_is_emitted() { + let grype = GrypeReport { + matches: vec![GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00001".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:deb/debian/libfoo@1.0".to_string()), + }, + }], + }; + let statements = compute_presence_vex( + &grype, + &[], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert_eq!(statements.len(), 1); + assert_eq!(statements[0].vulnerability.name, "CVE-2026-00001"); + assert_eq!(statements[0].status, "not_affected"); + assert_eq!( + statements[0].justification.as_deref(), + Some("component_not_present") + ); + assert_eq!(statements[0].products.len(), 1); + assert_eq!(statements[0].products[0].id, "pkg:oci/5-spot"); + } + + #[test] + fn cve_with_purl_in_sbom_is_skipped() { + let grype = GrypeReport { + matches: vec![GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00002".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:cargo/serde@1.0".to_string()), + }, + }], + }; + let sbom = Sbom { + components: Some(vec![SbomComponent { + purl: Some("pkg:cargo/serde@1.0".to_string()), + }]), + }; + let statements = compute_presence_vex( + &grype, + std::slice::from_ref(&sbom), + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert!(statements.is_empty()); + } + + #[test] + fn cve_already_triaged_is_skipped() { + let grype = GrypeReport { + matches: vec![GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00003".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:deb/debian/libbar@1.0".to_string()), + }, + }], + }; + let mut triaged = HashSet::new(); + triaged.insert("CVE-2026-00003".to_string()); + let statements = compute_presence_vex( + &grype, + &[], + &triaged, + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert!(statements.is_empty()); + } + + #[test] + fn match_without_purl_is_skipped() { + let grype = GrypeReport { + matches: vec![GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00004".to_string(), + }, + artifact: GrypeArtifact { purl: None }, + }], + }; + let statements = compute_presence_vex( + &grype, + &[], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert!(statements.is_empty()); + } + + #[test] + fn purl_in_any_sbom_excludes_finding() { + let grype = GrypeReport { + matches: vec![GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00005".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:cargo/tokio@1.0".to_string()), + }, + }], + }; + let binary_sbom = Sbom { + components: Some(vec![SbomComponent { + purl: Some("pkg:cargo/serde@1.0".to_string()), + }]), + }; + let docker_sbom = Sbom { + components: Some(vec![SbomComponent { + purl: Some("pkg:cargo/tokio@1.0".to_string()), + }]), + }; + let statements = compute_presence_vex( + &grype, + &[binary_sbom, docker_sbom], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert!(statements.is_empty()); + } + + #[test] + fn duplicate_cve_emits_only_once() { + let grype = GrypeReport { + matches: vec![ + GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00006".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:deb/debian/libx@1".to_string()), + }, + }, + GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00006".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:deb/debian/libx@2".to_string()), + }, + }, + ], + }; + let statements = compute_presence_vex( + &grype, + &[], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + assert_eq!(statements.len(), 1); + } + + #[test] + fn output_is_sorted_by_cve_id() { + let grype = GrypeReport { + matches: vec![ + GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00009".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:x/a".to_string()), + }, + }, + GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00007".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:x/b".to_string()), + }, + }, + GrypeMatch { + vulnerability: GrypeVuln { + id: "CVE-2026-00008".to_string(), + }, + artifact: GrypeArtifact { + purl: Some("pkg:x/c".to_string()), + }, + }, + ], + }; + let statements = compute_presence_vex( + &grype, + &[], + &HashSet::new(), + "pkg:oci/5-spot", + "2026-04-22T00:00:00Z", + ); + let ids: Vec<&str> = statements + .iter() + .map(|s| s.vulnerability.name.as_str()) + .collect(); + assert_eq!( + ids, + vec!["CVE-2026-00007", "CVE-2026-00008", "CVE-2026-00009"] + ); + } + + // ──────────────────────────────────────────────────────────────────── + // build_document — wraps statements in the OpenVEX envelope + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn document_envelope_fields_are_set() { + let statements = vec![Statement { + vulnerability: Vuln { + name: "CVE-2026-10000".to_string(), + }, + products: vec![Product { + id: "pkg:oci/5-spot".to_string(), + }], + status: "not_affected".to_string(), + justification: Some("component_not_present".to_string()), + impact_statement: Some("derived".to_string()), + timestamp: "2026-04-22T00:00:00Z".to_string(), + }]; + let doc = build_document( + statements, + "https://5-spot/auto-presence/run-1", + "auto-vex-presence", + "2026-04-22T00:00:00Z", + ); + assert_eq!(doc.context, "https://openvex.dev/ns/v0.2.0"); + assert_eq!(doc.id, "https://5-spot/auto-presence/run-1"); + assert_eq!(doc.author, "auto-vex-presence"); + assert_eq!(doc.timestamp, "2026-04-22T00:00:00Z"); + assert_eq!(doc.version, 1); + assert_eq!(doc.statements.len(), 1); + } + + #[test] + fn empty_statements_still_produces_valid_envelope() { + let doc = build_document( + vec![], + "https://5-spot/auto-presence/empty", + "auto-vex-presence", + "2026-04-22T00:00:00Z", + ); + assert!(doc.statements.is_empty()); + // Serde round-trip: an empty doc is still valid JSON that vexctl can consume. + let json = serde_json::to_string(&doc).unwrap(); + let back: Document = serde_json::from_str(&json).unwrap(); + assert!(back.statements.is_empty()); + } + + // ──────────────────────────────────────────────────────────────────── + // parse_grype / parse_sbom — deserialization contracts + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn parse_grype_from_valid_json() { + let doc = json!({ + "matches": [ + { + "vulnerability": {"id": "CVE-2026-20000"}, + "artifact": {"purl": "pkg:deb/debian/libfoo@1.0"} + } + ] + }) + .to_string(); + let parsed: GrypeReport = serde_json::from_str(&doc).unwrap(); + assert_eq!(parsed.matches.len(), 1); + assert_eq!(parsed.matches[0].vulnerability.id, "CVE-2026-20000"); + } + + #[test] + fn parse_grype_tolerates_extra_fields() { + let doc = json!({ + "matches": [ + { + "vulnerability": {"id": "CVE-2026-20001", "severity": "High"}, + "artifact": {"purl": "pkg:x/y@1", "name": "y", "version": "1"}, + "relatedVulnerabilities": [] + } + ], + "source": {"type": "image"}, + "descriptor": {"name": "grype"} + }) + .to_string(); + let parsed: GrypeReport = serde_json::from_str(&doc).unwrap(); + assert_eq!(parsed.matches.len(), 1); + } + + #[test] + fn parse_grype_empty_matches_is_valid() { + let doc = json!({"matches": []}).to_string(); + let parsed: GrypeReport = serde_json::from_str(&doc).unwrap(); + assert!(parsed.matches.is_empty()); + } + + #[test] + fn parse_grype_missing_matches_errors() { + let doc = json!({"not_matches": []}).to_string(); + let result: Result = serde_json::from_str(&doc); + assert!(result.is_err()); + } + + #[test] + fn parse_sbom_without_components_is_empty() { + let doc = json!({"bomFormat": "CycloneDX", "specVersion": "1.4"}).to_string(); + let parsed: Sbom = serde_json::from_str(&doc).unwrap(); + assert!(parsed.components.is_none()); + } + + #[test] + fn parse_sbom_component_without_purl_is_ignored() { + let doc = json!({ + "components": [ + {"name": "no-purl-component", "type": "library"}, + {"purl": "pkg:cargo/serde@1.0"} + ] + }) + .to_string(); + let parsed: Sbom = serde_json::from_str(&doc).unwrap(); + let comps = parsed.components.as_ref().unwrap(); + assert_eq!(comps.len(), 2); + assert!(comps[0].purl.is_none()); + assert_eq!(comps[1].purl.as_deref(), Some("pkg:cargo/serde@1.0")); + } + + // ──────────────────────────────────────────────────────────────────── + // load_triaged_from_vex_dir — reads hand-authored statements + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn load_triaged_from_directory_extracts_all_cve_ids() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("CVE-2026-30000.json"), + json!({ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example/1", + "author": "test", + "timestamp": "2026-04-22T00:00:00Z", + "version": 1, + "statements": [{ + "vulnerability": {"name": "CVE-2026-30000"}, + "products": [{"@id": "pkg:oci/5-spot"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "timestamp": "2026-04-22T00:00:00Z" + }] + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + tmp.path().join("GHSA-abcd-efgh-ijkl.json"), + json!({ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example/2", + "author": "test", + "timestamp": "2026-04-22T00:00:00Z", + "version": 1, + "statements": [{ + "vulnerability": {"name": "GHSA-abcd-efgh-ijkl"}, + "products": [{"@id": "pkg:oci/5-spot"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "timestamp": "2026-04-22T00:00:00Z" + }] + }) + .to_string(), + ) + .unwrap(); + let triaged = load_triaged_from_vex_dir(tmp.path()).unwrap(); + assert!(triaged.contains("CVE-2026-30000")); + assert!(triaged.contains("GHSA-abcd-efgh-ijkl")); + assert_eq!(triaged.len(), 2); + } + + #[test] + fn load_triaged_missing_directory_is_permissive() { + let triaged = load_triaged_from_vex_dir(std::path::Path::new( + "/nonexistent/vex-dir-that-does-not-exist", + )) + .unwrap(); + assert!(triaged.is_empty()); + } + + #[test] + fn load_triaged_malformed_json_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bad.json"), "{ not valid json").unwrap(); + let result = load_triaged_from_vex_dir(tmp.path()); + assert!(result.is_err()); + } + + #[test] + fn load_triaged_ignores_non_json_files() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("README.md"), "not a statement").unwrap(); + std::fs::write( + tmp.path().join("CVE-2026-30001.json"), + json!({ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://example/3", + "author": "test", + "timestamp": "2026-04-22T00:00:00Z", + "version": 1, + "statements": [{ + "vulnerability": {"name": "CVE-2026-30001"}, + "products": [{"@id": "pkg:oci/5-spot"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "timestamp": "2026-04-22T00:00:00Z" + }] + }) + .to_string(), + ) + .unwrap(); + let triaged = load_triaged_from_vex_dir(tmp.path()).unwrap(); + assert_eq!(triaged.len(), 1); + assert!(triaged.contains("CVE-2026-30001")); + } +} diff --git a/src/bin/auto_vex_presence.rs b/src/bin/auto_vex_presence.rs new file mode 100644 index 0000000..657253b --- /dev/null +++ b/src/bin/auto_vex_presence.rs @@ -0,0 +1,142 @@ +// Copyright (c) 2025 Erick Bourgeois, firestoned +// SPDX-License-Identifier: Apache-2.0 +//! # Presence-based auto-VEX generator (CI tool, roadmap Phase 2) +//! +//! Reads a Grype JSON report and one or more CycloneDX SBOMs, cross-checks +//! the set of hand-authored statements in `.vex/*.json`, and emits an +//! OpenVEX document containing `not_affected + component_not_present` +//! statements for every Grype finding whose affected `purl` is absent from +//! every SBOM and whose CVE identifier is not already triaged. +//! +//! ## Usage +//! +//! ```bash +//! cargo run --bin auto-vex-presence -- \ +//! --grype-json grype.json \ +//! --sbom docker-sbom-Chainguard.json \ +//! --sbom docker-sbom-Distroless.json \ +//! --vex-dir .vex \ +//! --product-purl pkg:oci/5-spot \ +//! --id "https://github.com/finos/5-spot/actions/runs/123/auto-vex-presence" \ +//! --author auto-vex-presence \ +//! --output vex.auto-presence.json +//! ``` +//! +//! All logic lives in [`five_spot::auto_vex_presence`]; this binary is +//! strictly a clap-driven CLI + file I/O wrapper. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use five_spot::auto_vex_presence::{ + build_document, compute_presence_vex, load_triaged_from_vex_dir, GrypeReport, Sbom, +}; + +#[derive(Parser, Debug)] +#[command( + name = "auto-vex-presence", + about = "Emit OpenVEX component_not_present statements for Grype findings absent from SBOMs" +)] +struct Cli { + /// Grype JSON report (produced by `grype --output json`). + #[arg(long)] + grype_json: PathBuf, + + /// One or more CycloneDX SBOM JSON files. A purl present in ANY SBOM + /// counts as "component present" and excludes the finding. + #[arg(long, required = true)] + sbom: Vec, + + /// Directory of hand-authored `.vex/*.json` statements; CVEs already + /// covered by one of these are skipped. Missing directory is OK. + #[arg(long)] + vex_dir: PathBuf, + + /// Product purl to attach to every emitted statement (e.g. + /// `pkg:oci/5-spot`). + #[arg(long)] + product_purl: String, + + /// Canonical `@id` for the document (typically a URL of the CI run). + #[arg(long)] + id: String, + + /// Document-level author (typically `auto-vex-presence`). + #[arg(long, default_value = "auto-vex-presence")] + author: String, + + /// RFC-3339 UTC timestamp. Defaults to now() at process start. + #[arg(long)] + timestamp: Option, + + /// Output path. Defaults to stdout. + #[arg(long)] + output: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("auto-vex-presence: {err:#}"); + ExitCode::FAILURE + } + } +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let grype_bytes = std::fs::read(&cli.grype_json).map_err(|e| { + anyhow::anyhow!( + "failed to read --grype-json {}: {}", + cli.grype_json.display(), + e + ) + })?; + let grype: GrypeReport = serde_json::from_slice(&grype_bytes).map_err(|e| { + anyhow::anyhow!( + "failed to parse --grype-json {}: {}", + cli.grype_json.display(), + e + ) + })?; + + let mut sboms: Vec = Vec::with_capacity(cli.sbom.len()); + for path in &cli.sbom { + let bytes = std::fs::read(path) + .map_err(|e| anyhow::anyhow!("failed to read --sbom {}: {}", path.display(), e))?; + let sbom: Sbom = serde_json::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("failed to parse --sbom {}: {}", path.display(), e))?; + sboms.push(sbom); + } + + let triaged: HashSet = load_triaged_from_vex_dir(&cli.vex_dir).map_err(|e| { + anyhow::anyhow!("failed to load --vex-dir {}: {}", cli.vex_dir.display(), e) + })?; + + let timestamp = cli + .timestamp + .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()); + + let statements = compute_presence_vex(&grype, &sboms, &triaged, &cli.product_purl, ×tamp); + let doc = build_document(statements, &cli.id, &cli.author, ×tamp); + let rendered = serde_json::to_string_pretty(&doc)? + "\n"; + + match &cli.output { + Some(path) => std::fs::write(path, &rendered) + .map_err(|e| anyhow::anyhow!("failed to write --output {}: {}", path.display(), e))?, + None => print!("{rendered}"), + } + + eprintln!( + "auto-vex-presence: {} grype match(es) -> {} statement(s) emitted ({} already triaged in {})", + grype.matches.len(), + doc.statements.len(), + triaged.len(), + cli.vex_dir.display(), + ); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 6f86a5f..2b72633 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! Public API surface for the 5-Spot `ScheduledMachine` controller. //! //! Modules: +//! - [`auto_vex_presence`] — presence-based auto-VEX generation (CI-only, roadmap Phase 2) //! - [`constants`] — all named constants (timing, labels, phases, CAPI API strings) //! - [`crd`] — `ScheduledMachine` CRD type definitions (source of truth for YAML generation) //! - [`health`] — HTTP health and readiness server @@ -12,6 +13,7 @@ //! - [`metrics`] — Prometheus metric definitions and recording helpers //! - [`reconcilers`] — reconciliation logic and controller context +pub mod auto_vex_presence; pub mod constants; pub mod crd; pub mod health; diff --git a/tools/assemble_openvex.py b/tools/assemble_openvex.py deleted file mode 100644 index f5d8a0f..0000000 --- a/tools/assemble_openvex.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright (c) 2025 Erick Bourgeois, firestoned -# SPDX-License-Identifier: Apache-2.0 -# -# Assembles a single OpenVEX document from every .vex/.toml file. -# -# Output is written to stdout (or --output) as pretty-printed JSON with sorted -# keys, so the content is deterministic and diffable. Statement fields track -# the OpenVEX v0.2.0 spec (https://github.com/openvex/spec). -# -# Runs validate_vex first so the assembler never emits a document from a -# malformed source tree. - -from __future__ import annotations - -import argparse -import datetime as _dt -import json -import sys -import tomllib -from pathlib import Path - -# Keep validate_vex reachable when this file is executed directly. -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import validate_vex # noqa: E402 - -OPENVEX_CONTEXT = "https://openvex.dev/ns/v0.2.0" -OPENVEX_VERSION = 1 - - -def _ts_to_str(value: object) -> str: - if isinstance(value, _dt.datetime): - # Normalize to RFC-3339 UTC with a Z suffix regardless of source tz. - if value.tzinfo is None: - value = value.replace(tzinfo=_dt.timezone.utc) - return ( - value.astimezone(_dt.timezone.utc) - .strftime("%Y-%m-%dT%H:%M:%SZ") - ) - return str(value) - - -def _load_statement(path: Path) -> dict: - with path.open("rb") as fh: - doc = tomllib.load(fh) - - # Preserve the identifier verbatim — CVE-YYYY-NNNN+ is uppercase by - # MITRE convention, but GHSA-xxxx-xxxx-xxxx segments are lowercase, - # and upper-casing them breaks round-tripping against osv.dev and - # github.com/advisories (which treat GHSA IDs case-insensitively for - # matching but render them in their canonical lowercase form). - statement: dict = { - "vulnerability": {"name": doc["cve"]}, - "products": [{"@id": product} for product in doc["products"]], - "status": doc["status"], - "timestamp": _ts_to_str(doc["timestamp"]), - } - - for optional in ("justification", "impact_statement", "action_statement"): - if optional in doc and doc[optional]: - statement[optional] = doc[optional] - - return statement - - -def build_document( - vex_dir: Path, - doc_id: str, - author: str, - timestamp: str, -) -> dict: - statements = [_load_statement(p) for p in sorted(vex_dir.glob("*.toml"))] - return { - "@context": OPENVEX_CONTEXT, - "@id": doc_id, - "author": author, - "timestamp": timestamp, - "version": OPENVEX_VERSION, - "statements": statements, - } - - -def _now_utc_rfc3339() -> str: - return _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--vex-dir", required=True, type=Path) - parser.add_argument( - "--id", - required=True, - help="Canonical @id URI, e.g. " - "https://github.com///releases/tag//vex", - ) - parser.add_argument( - "--author", - required=True, - help="Document author (release actor / signer).", - ) - parser.add_argument( - "--timestamp", - default=None, - help="Override document timestamp (RFC-3339). Defaults to now().", - ) - parser.add_argument( - "--output", - default=None, - type=Path, - help="Output file path. Defaults to stdout.", - ) - args = parser.parse_args(argv[1:]) - - errors = validate_vex.validate_dir(args.vex_dir) - if errors: - for err in errors: - print(err, file=sys.stderr) - print( - f"\nassemble-openvex: refusing to emit — {len(errors)} " - f"validation error(s) in {args.vex_dir}", - file=sys.stderr, - ) - return 1 - - timestamp = args.timestamp or _now_utc_rfc3339() - doc = build_document(args.vex_dir, args.id, args.author, timestamp) - rendered = json.dumps(doc, indent=2, sort_keys=True) + "\n" - - if args.output: - args.output.write_text(rendered) - else: - sys.stdout.write(rendered) - - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/tools/tests/assemble-openvex-tests.sh b/tools/tests/assemble-openvex-tests.sh deleted file mode 100755 index 19d7ee8..0000000 --- a/tools/tests/assemble-openvex-tests.sh +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2025 Erick Bourgeois, firestoned -# SPDX-License-Identifier: Apache-2.0 -# -# Tests for tools/assemble_openvex.py. Covers the happy path (multiple valid -# statements), the validator-gate negative path, CLI argument errors, the -# --output flag, and normalization of TOML datetimes to RFC-3339 UTC strings. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -ASSEMBLER="$REPO_ROOT/tools/assemble_openvex.py" -FIXTURES="$SCRIPT_DIR/fixtures" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -PASS=0 -FAIL=0 - -fail_case() { - echo "FAIL: $1" >&2 - sed 's/^/ /' "$2" >&2 || true - FAIL=$((FAIL + 1)) -} - -pass_case() { - echo "PASS: $1" - PASS=$((PASS + 1)) -} - -# ── Happy path: assemble from valid-multiple ──────────────────────────────── -out="$TMP/happy.json" -python3 "$ASSEMBLER" \ - --vex-dir "$FIXTURES/valid-multiple" \ - --id "https://github.com/owner/repo/releases/tag/v1.2.3/vex" \ - --author "erick.bourgeois@gmail.com" \ - --timestamp "2026-04-19T12:00:00Z" \ - --output "$out" >/dev/null 2>"$TMP/err" - -if [ -s "$out" ] && - grep -q '"@context": "https://openvex.dev/ns/v0.2.0"' "$out" && - grep -q '"@id": "https://github.com/owner/repo/releases/tag/v1.2.3/vex"' "$out" && - grep -q '"author": "erick.bourgeois@gmail.com"' "$out" && - grep -q '"version": 1' "$out" && - grep -q '"CVE-2025-0001"' "$out" && - grep -q '"CVE-2025-0002"' "$out" && - grep -q '"pkg:oci/5-spot-chainguard"' "$out"; then - pass_case "happy-path produces well-formed OpenVEX" -else - fail_case "happy-path produces well-formed OpenVEX" "$out" -fi - -# JSON must parse cleanly. -if python3 -c "import json,sys; json.loads(open('$out').read())" 2>/dev/null; then - pass_case "happy-path output is valid JSON" -else - fail_case "happy-path output is valid JSON" "$out" -fi - -# ── Happy path via stdout (no --output) ───────────────────────────────────── -if python3 "$ASSEMBLER" \ - --vex-dir "$FIXTURES/valid-single" \ - --id "urn:vex:test" \ - --author "ci@example" \ - --timestamp "2026-04-19T00:00:00Z" > "$TMP/stdout.json" 2>"$TMP/err"; then - if grep -q '"CVE-2025-0001"' "$TMP/stdout.json"; then - pass_case "stdout output captures document" - else - fail_case "stdout output captures document" "$TMP/stdout.json" - fi -else - fail_case "stdout output exits 0" "$TMP/err" -fi - -# ── Negative path: validator rejects malformed input ──────────────────────── -set +e -python3 "$ASSEMBLER" \ - --vex-dir "$FIXTURES/invalid-status" \ - --id "urn:vex:test" \ - --author "ci@example" \ - --timestamp "2026-04-19T00:00:00Z" \ - --output "$TMP/bad.json" >/dev/null 2>"$TMP/err" -rc=$? -set -e -if [ "$rc" -eq 1 ] && [ ! -f "$TMP/bad.json" ]; then - pass_case "invalid input rejected with exit=1 and no output written" -else - fail_case "invalid input rejected with exit=1 and no output written" "$TMP/err" -fi - -# ── Negative path: missing required CLI args ──────────────────────────────── -set +e -python3 "$ASSEMBLER" --vex-dir "$FIXTURES/valid-single" >/dev/null 2>"$TMP/err" -rc=$? -set -e -if [ "$rc" -ne 0 ]; then - pass_case "missing --id / --author rejected" -else - fail_case "missing --id / --author rejected" "$TMP/err" -fi - -# ── GHSA identifier case is preserved (not upper-cased) ──────────────────── -# GHSA IDs are conventionally lowercase alphanumeric segments; upper-casing -# them changes the on-wire identifier and breaks a round-trip against -# osv.dev / github.com/advisories. Regression guard for the rand soundness -# advisory (GHSA-cq8v-f236-94qc) which was the first non-CVE ID we shipped. -out_ghsa="$TMP/ghsa.json" -python3 "$ASSEMBLER" \ - --vex-dir "$FIXTURES/valid-ghsa" \ - --id "urn:vex:ghsa-test" \ - --author "ci@example" \ - --timestamp "2026-04-19T00:00:00Z" \ - --output "$out_ghsa" >/dev/null 2>"$TMP/err" -if grep -q '"GHSA-cq8v-f236-94qc"' "$out_ghsa" && - ! grep -q '"GHSA-CQ8V-F236-94QC"' "$out_ghsa"; then - pass_case "GHSA identifier case is preserved verbatim" -else - fail_case "GHSA identifier case is preserved verbatim" "$out_ghsa" -fi - -# ── Default timestamp (no --timestamp) produces RFC-3339 UTC ─────────────── -out_dt="$TMP/dt.json" -python3 "$ASSEMBLER" \ - --vex-dir "$FIXTURES/valid-single" \ - --id "urn:vex:test" \ - --author "ci@example" \ - --output "$out_dt" >/dev/null 2>"$TMP/err" -if grep -Eq '"timestamp": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"' "$out_dt"; then - pass_case "default timestamp is RFC-3339 UTC" -else - fail_case "default timestamp is RFC-3339 UTC" "$out_dt" -fi - -echo "" -echo "Results: $PASS passed, $FAIL failed" -[ "$FAIL" -eq 0 ] diff --git a/tools/tests/fixtures/README.md b/tools/tests/fixtures/README.md deleted file mode 100644 index 5c2901a..0000000 --- a/tools/tests/fixtures/README.md +++ /dev/null @@ -1,58 +0,0 @@ - - -# VEX validator test fixtures - -Each directory here is an independent input corpus for -`tools/tests/validate-vex-tests.sh`. The test driver runs -`tools/validate-vex.sh ` and asserts the expected exit code. - -> **Do not "fix" fixtures that look malformed.** Several directories hold -> files that are wrong on purpose — they exist to exercise the validator's -> negative and exception paths. - -## Positive-path fixtures (exit 0) - -| Directory | Purpose | -| ----------------- | ------------------------------------------------------------------ | -| `empty-dir/` | No TOML files; validator must treat an empty corpus as valid. | -| `valid-single/` | One fully-populated `not_affected` statement. | -| `valid-multiple/` | Two valid files with different statuses (`not_affected`, `fixed`). | -| `valid-affected/` | Valid `status = "affected"` with the required `action_statement`. | - -## Negative-path fixtures (exit 1) - -Each of these exercises exactly one validation rule, so failures point at a -specific defect if one is introduced. - -| Directory | Rule under test | -| --------------------------- | ---------------------------------------------------------------------------------------- | -| `malformed-toml/` | `tomllib.TOMLDecodeError` branch (unterminated string, missing `]`, unquoted enum). | -| `missing-cve/` | Required field `cve` absent. | -| `missing-status/` | Required field `status` absent. | -| `missing-products/` | Required field `products` absent. | -| `missing-author/` | Required field `author` absent. | -| `missing-timestamp/` | Required field `timestamp` absent. | -| `invalid-cve-format/` | `cve` does not match `CVE-YYYY-NNNN+`. | -| `invalid-status/` | `status` is not one of the four OpenVEX enum values. | -| `empty-products/` | `products = []` — must be a non-empty list. | -| `bad-timestamp/` | `timestamp` is not an RFC-3339 UTC string (`yesterday`). | -| `missing-justification/` | `status = "not_affected"` without a `justification`. | -| `invalid-justification/` | `justification` value is outside the OpenVEX enum (`nah_its_fine`). | -| `missing-action-statement/` | `status = "under_investigation"` without an `action_statement`. | -| `duplicate-cve/` | Two files declaring the same CVE — cross-file uniqueness check. | - -The test driver also runs a `missing-dir` case against a temp path that does -not exist (no fixture needed for that one — it is constructed at runtime). - -## Adding a new case - -1. Create `tools/tests/fixtures//` with the TOML file(s) that - reproduce the condition. -2. Add one `run_case` line to `tools/tests/validate-vex-tests.sh` with the - expected exit code. -3. Run `./tools/tests/validate-vex-tests.sh` and confirm it passes. -4. If the new rule also affects the assembler, add a matching case to - `tools/tests/assemble-openvex-tests.sh`. diff --git a/tools/tests/fixtures/bad-timestamp/CVE-2025-0016.toml b/tools/tests/fixtures/bad-timestamp/CVE-2025-0016.toml deleted file mode 100644 index a7b668a..0000000 --- a/tools/tests/fixtures/bad-timestamp/CVE-2025-0016.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0016" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "yesterday" diff --git a/tools/tests/fixtures/duplicate-cve/first.toml b/tools/tests/fixtures/duplicate-cve/first.toml deleted file mode 100644 index 467adee..0000000 --- a/tools/tests/fixtures/duplicate-cve/first.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0020" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/duplicate-cve/second.toml b/tools/tests/fixtures/duplicate-cve/second.toml deleted file mode 100644 index 72fdbfd..0000000 --- a/tools/tests/fixtures/duplicate-cve/second.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "CVE-2025-0020" -status = "not_affected" -justification = "component_not_present" -products = ["pkg:oci/5-spot-distroless"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T12:00:00Z" diff --git a/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc-dup.toml b/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc-dup.toml deleted file mode 100644 index 726fa9a..0000000 --- a/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc-dup.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "GHSA-CQ8V-F236-94QC" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc.toml b/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc.toml deleted file mode 100644 index 47f4749..0000000 --- a/tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "GHSA-cq8v-f236-94qc" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/empty-dir/.gitkeep b/tools/tests/fixtures/empty-dir/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tools/tests/fixtures/empty-products/CVE-2025-0015.toml b/tools/tests/fixtures/empty-products/CVE-2025-0015.toml deleted file mode 100644 index 0a651a0..0000000 --- a/tools/tests/fixtures/empty-products/CVE-2025-0015.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0015" -status = "fixed" -products = [] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/invalid-cve-format/CVE-bad.toml b/tools/tests/fixtures/invalid-cve-format/CVE-bad.toml deleted file mode 100644 index 58832bd..0000000 --- a/tools/tests/fixtures/invalid-cve-format/CVE-bad.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "NOT-A-CVE" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/invalid-ghsa-format/GHSA-bad.toml b/tools/tests/fixtures/invalid-ghsa-format/GHSA-bad.toml deleted file mode 100644 index 681dc23..0000000 --- a/tools/tests/fixtures/invalid-ghsa-format/GHSA-bad.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "GHSA-not-right" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/invalid-justification/CVE-2025-0018.toml b/tools/tests/fixtures/invalid-justification/CVE-2025-0018.toml deleted file mode 100644 index 8002eb1..0000000 --- a/tools/tests/fixtures/invalid-justification/CVE-2025-0018.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "CVE-2025-0018" -status = "not_affected" -justification = "nah_its_fine" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/invalid-rustsec-format/RUSTSEC-bad.toml b/tools/tests/fixtures/invalid-rustsec-format/RUSTSEC-bad.toml deleted file mode 100644 index dcc7be1..0000000 --- a/tools/tests/fixtures/invalid-rustsec-format/RUSTSEC-bad.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "RUSTSEC-2026-97" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/invalid-status/CVE-2025-0014.toml b/tools/tests/fixtures/invalid-status/CVE-2025-0014.toml deleted file mode 100644 index 31b6e92..0000000 --- a/tools/tests/fixtures/invalid-status/CVE-2025-0014.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0014" -status = "totally-not-a-status" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/malformed-toml/CVE-2025-9999.toml b/tools/tests/fixtures/malformed-toml/CVE-2025-9999.toml deleted file mode 100644 index 76148c7..0000000 --- a/tools/tests/fixtures/malformed-toml/CVE-2025-9999.toml +++ /dev/null @@ -1,7 +0,0 @@ -# INTENTIONALLY MALFORMED — do not "fix" this file. -# Fixture for the `malformed-toml` test case: exercises the -# tomllib.TOMLDecodeError branch in validate_file(). The unterminated string, -# unquoted enum, and missing `]` are all deliberate. -cve = "CVE-2025-9999 -status = not_affected -products = [broken diff --git a/tools/tests/fixtures/missing-action-statement/CVE-2025-0019.toml b/tools/tests/fixtures/missing-action-statement/CVE-2025-0019.toml deleted file mode 100644 index 0541144..0000000 --- a/tools/tests/fixtures/missing-action-statement/CVE-2025-0019.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0019" -status = "under_investigation" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-author/CVE-2025-0012.toml b/tools/tests/fixtures/missing-author/CVE-2025-0012.toml deleted file mode 100644 index a31b181..0000000 --- a/tools/tests/fixtures/missing-author/CVE-2025-0012.toml +++ /dev/null @@ -1,4 +0,0 @@ -cve = "CVE-2025-0012" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-cve/case.toml b/tools/tests/fixtures/missing-cve/case.toml deleted file mode 100644 index ee88117..0000000 --- a/tools/tests/fixtures/missing-cve/case.toml +++ /dev/null @@ -1,5 +0,0 @@ -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-justification/CVE-2025-0017.toml b/tools/tests/fixtures/missing-justification/CVE-2025-0017.toml deleted file mode 100644 index 1a08fee..0000000 --- a/tools/tests/fixtures/missing-justification/CVE-2025-0017.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0017" -status = "not_affected" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-products/CVE-2025-0011.toml b/tools/tests/fixtures/missing-products/CVE-2025-0011.toml deleted file mode 100644 index ae375db..0000000 --- a/tools/tests/fixtures/missing-products/CVE-2025-0011.toml +++ /dev/null @@ -1,4 +0,0 @@ -cve = "CVE-2025-0011" -status = "fixed" -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-status/CVE-2025-0010.toml b/tools/tests/fixtures/missing-status/CVE-2025-0010.toml deleted file mode 100644 index 21f8af5..0000000 --- a/tools/tests/fixtures/missing-status/CVE-2025-0010.toml +++ /dev/null @@ -1,4 +0,0 @@ -cve = "CVE-2025-0010" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/missing-timestamp/CVE-2025-0013.toml b/tools/tests/fixtures/missing-timestamp/CVE-2025-0013.toml deleted file mode 100644 index 38f7694..0000000 --- a/tools/tests/fixtures/missing-timestamp/CVE-2025-0013.toml +++ /dev/null @@ -1,4 +0,0 @@ -cve = "CVE-2025-0013" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" diff --git a/tools/tests/fixtures/valid-affected/CVE-2025-0003.toml b/tools/tests/fixtures/valid-affected/CVE-2025-0003.toml deleted file mode 100644 index cf4bd49..0000000 --- a/tools/tests/fixtures/valid-affected/CVE-2025-0003.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "CVE-2025-0003" -status = "affected" -action_statement = "Upgrade to 5-Spot v1.2.3 which ships the patched dependency." -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/valid-ghsa/GHSA-cq8v-f236-94qc.toml b/tools/tests/fixtures/valid-ghsa/GHSA-cq8v-f236-94qc.toml deleted file mode 100644 index 472702e..0000000 --- a/tools/tests/fixtures/valid-ghsa/GHSA-cq8v-f236-94qc.toml +++ /dev/null @@ -1,7 +0,0 @@ -cve = "GHSA-cq8v-f236-94qc" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "The affected code path is never invoked by 5-Spot." -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/valid-multiple/CVE-2025-0001.toml b/tools/tests/fixtures/valid-multiple/CVE-2025-0001.toml deleted file mode 100644 index 04912a9..0000000 --- a/tools/tests/fixtures/valid-multiple/CVE-2025-0001.toml +++ /dev/null @@ -1,6 +0,0 @@ -cve = "CVE-2025-0001" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/valid-multiple/CVE-2025-0002.toml b/tools/tests/fixtures/valid-multiple/CVE-2025-0002.toml deleted file mode 100644 index b0f906a..0000000 --- a/tools/tests/fixtures/valid-multiple/CVE-2025-0002.toml +++ /dev/null @@ -1,5 +0,0 @@ -cve = "CVE-2025-0002" -status = "fixed" -products = ["pkg:oci/5-spot-chainguard", "pkg:oci/5-spot-distroless"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T12:30:00Z" diff --git a/tools/tests/fixtures/valid-rustsec/RUSTSEC-2026-0097.toml b/tools/tests/fixtures/valid-rustsec/RUSTSEC-2026-0097.toml deleted file mode 100644 index a6cdb79..0000000 --- a/tools/tests/fixtures/valid-rustsec/RUSTSEC-2026-0097.toml +++ /dev/null @@ -1,7 +0,0 @@ -cve = "RUSTSEC-2026-0097" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "The affected code path is never invoked by 5-Spot." -products = ["pkg:oci/5-spot-chainguard"] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/fixtures/valid-single/CVE-2025-0001.toml b/tools/tests/fixtures/valid-single/CVE-2025-0001.toml deleted file mode 100644 index 7631f13..0000000 --- a/tools/tests/fixtures/valid-single/CVE-2025-0001.toml +++ /dev/null @@ -1,10 +0,0 @@ -cve = "CVE-2025-0001" -status = "not_affected" -justification = "vulnerable_code_not_in_execute_path" -impact_statement = "The affected code path is never invoked by 5-Spot." -products = [ - "pkg:oci/5-spot-chainguard", - "pkg:oci/5-spot-distroless", -] -author = "erick.bourgeois@gmail.com" -timestamp = "2026-04-19T00:00:00Z" diff --git a/tools/tests/validate-vex-tests.sh b/tools/tests/validate-vex-tests.sh deleted file mode 100755 index 804e32b..0000000 --- a/tools/tests/validate-vex-tests.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2025 Erick Bourgeois, firestoned -# SPDX-License-Identifier: Apache-2.0 -# -# End-to-end tests for tools/validate-vex.sh. Each fixture directory represents -# one schema or uniqueness case; we assert the expected exit code. Run directly -# or from CI: -# -# ./tools/tests/validate-vex-tests.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -VALIDATE="$REPO_ROOT/tools/validate-vex.sh" -FIXTURES="$SCRIPT_DIR/fixtures" -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -PASS=0 -FAIL=0 - -run_case() { - local name="$1" - local dir="$2" - local expected_exit="$3" - local actual_exit=0 - "$VALIDATE" "$dir" >"$TMP/out" 2>&1 || actual_exit=$? - - if [ "$actual_exit" -eq "$expected_exit" ]; then - echo "PASS: $name (exit=$actual_exit)" - PASS=$((PASS + 1)) - return 0 - fi - - echo "FAIL: $name (expected exit=$expected_exit, got $actual_exit)" >&2 - sed 's/^/ /' "$TMP/out" >&2 - FAIL=$((FAIL + 1)) - return 0 -} - -# Missing-dir path: exercises `directory does not exist` branch. -run_case "missing-dir" "$TMP/does-not-exist" 1 - -# Happy paths. -run_case "empty-dir" "$FIXTURES/empty-dir" 0 -run_case "valid-single" "$FIXTURES/valid-single" 0 -run_case "valid-multiple" "$FIXTURES/valid-multiple" 0 -run_case "valid-affected" "$FIXTURES/valid-affected" 0 -# Non-CVE identifiers (GHSA + RUSTSEC) accepted since 2026-04-20 — -# first real encounter was GHSA-cq8v-f236-94qc (rand soundness) which -# ships without a CVE ID. -run_case "valid-ghsa" "$FIXTURES/valid-ghsa" 0 -run_case "valid-rustsec" "$FIXTURES/valid-rustsec" 0 - -# Negative paths — one per validation rule. -run_case "malformed-toml" "$FIXTURES/malformed-toml" 1 -run_case "missing-cve" "$FIXTURES/missing-cve" 1 -run_case "missing-status" "$FIXTURES/missing-status" 1 -run_case "missing-products" "$FIXTURES/missing-products" 1 -run_case "missing-author" "$FIXTURES/missing-author" 1 -run_case "missing-timestamp" "$FIXTURES/missing-timestamp" 1 -run_case "invalid-cve-format" "$FIXTURES/invalid-cve-format" 1 -run_case "invalid-ghsa-format" "$FIXTURES/invalid-ghsa-format" 1 -run_case "invalid-rustsec-format" "$FIXTURES/invalid-rustsec-format" 1 -run_case "invalid-status" "$FIXTURES/invalid-status" 1 -run_case "empty-products" "$FIXTURES/empty-products" 1 -run_case "bad-timestamp" "$FIXTURES/bad-timestamp" 1 -run_case "missing-justification" "$FIXTURES/missing-justification" 1 -run_case "invalid-justification" "$FIXTURES/invalid-justification" 1 -run_case "missing-action-statement" "$FIXTURES/missing-action-statement" 1 -run_case "duplicate-cve" "$FIXTURES/duplicate-cve" 1 -run_case "duplicate-ghsa" "$FIXTURES/duplicate-ghsa" 1 - -echo "" -echo "Results: $PASS passed, $FAIL failed" -[ "$FAIL" -eq 0 ] diff --git a/tools/validate-vex.sh b/tools/validate-vex.sh deleted file mode 100755 index b9a087d..0000000 --- a/tools/validate-vex.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) 2025 Erick Bourgeois, firestoned -# SPDX-License-Identifier: Apache-2.0 -# -# validate-vex.sh — schema, enum, and uniqueness gate for `.vex/*.toml`. -# -# Exits 0 if every VEX statement file in the target directory is well-formed -# and the set of CVE IDs is unique across files. Exits 1 on any violation and -# prints a line per failure to stderr. -# -# Usage: -# tools/validate-vex.sh # validate the repo's .vex/ dir -# tools/validate-vex.sh # validate an alternate directory (tests) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -TARGET_DIR="${1:-$REPO_ROOT/.vex}" - -if ! command -v python3 >/dev/null 2>&1; then - echo "validate-vex: python3 is required (needs tomllib, Python >= 3.11)" >&2 - exit 2 -fi - -exec python3 "$SCRIPT_DIR/validate_vex.py" "$TARGET_DIR" diff --git a/tools/validate_vex.py b/tools/validate_vex.py deleted file mode 100644 index dd17005..0000000 --- a/tools/validate_vex.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) 2025 Erick Bourgeois, firestoned -# SPDX-License-Identifier: Apache-2.0 -# -# Schema + enum + uniqueness validator for .vex/.toml files. -# Invoked by tools/validate-vex.sh; kept as a separate file so the test driver -# can run it directly too. Uses only the Python standard library (tomllib is -# stdlib as of 3.11). - -from __future__ import annotations - -import datetime as _dt -import re -import sys -import tomllib -from pathlib import Path - -VALID_STATUSES = frozenset( - {"not_affected", "affected", "fixed", "under_investigation"} -) - -# Per OpenVEX spec: https://github.com/openvex/spec -VALID_JUSTIFICATIONS = frozenset( - { - "component_not_present", - "vulnerable_code_not_present", - "vulnerable_code_not_in_execute_path", - "vulnerable_code_cannot_be_controlled_by_adversary", - "inline_mitigations_already_exist", - } -) - -# Fields required on every file regardless of status. -REQUIRED_ALWAYS = ("cve", "status", "products", "author", "timestamp") - -# Accepted vulnerability identifiers. The TOML field is still named `cve` -# for backward compatibility with the existing .vex/ files, but the value -# may be any of: -# - CVE-YYYY-NNNN+ (MITRE CVE) -# - GHSA-xxxx-xxxx-xxxx (GitHub Security Advisory, case-insensitive) -# - RUSTSEC-YYYY-NNNN (RustSec advisory DB) -# First real non-CVE encounter was GHSA-cq8v-f236-94qc (rand soundness) -# which ships without a CVE ID, so CVE-only would make it unrepresentable. -_CVE_RE = re.compile(r"^CVE-\d{4}-\d{4,}$") -_GHSA_RE = re.compile(r"^GHSA(-[a-z0-9]{4}){3}$", re.IGNORECASE) -_RUSTSEC_RE = re.compile(r"^RUSTSEC-\d{4}-\d{4}$") - - -def _is_accepted_id(value: str) -> bool: - return bool( - _CVE_RE.match(value) - or _GHSA_RE.match(value) - or _RUSTSEC_RE.match(value) - ) - -# RFC-3339 in UTC (Z suffix) — we intentionally keep this narrow so every VEX -# timestamp is directly comparable without timezone arithmetic. -_RFC3339_RE = re.compile( - r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$" -) - - -def _is_rfc3339(value: object) -> bool: - if isinstance(value, _dt.datetime): - return True - if not isinstance(value, str): - return False - return bool(_RFC3339_RE.match(value)) - - -def validate_file(path: Path) -> tuple[str | None, list[str]]: - """Validate one TOML file. Returns (canonical_cve_or_None, error_list).""" - errors: list[str] = [] - try: - with path.open("rb") as fh: - doc = tomllib.load(fh) - except OSError as exc: - return None, [f"{path}: cannot read file: {exc}"] - except tomllib.TOMLDecodeError as exc: - return None, [f"{path}: malformed TOML: {exc}"] - - for field in REQUIRED_ALWAYS: - if field not in doc: - errors.append(f"{path}: missing required field '{field}'") - - # If the core set is incomplete, further checks cascade into noise. - if errors: - return None, errors - - cve = doc["cve"] - status = doc["status"] - products = doc["products"] - timestamp = doc["timestamp"] - author = doc["author"] - - if not isinstance(cve, str) or not _is_accepted_id(cve): - errors.append( - f"{path}: 'cve' must be a CVE-YYYY-NNNN+, " - f"GHSA-xxxx-xxxx-xxxx, or RUSTSEC-YYYY-NNNN identifier, " - f"got {cve!r}" - ) - - if status not in VALID_STATUSES: - errors.append( - f"{path}: 'status' must be one of " - f"{sorted(VALID_STATUSES)}, got {status!r}" - ) - - if not isinstance(products, list) or not products: - errors.append(f"{path}: 'products' must be a non-empty list") - elif not all(isinstance(p, str) and p for p in products): - errors.append( - f"{path}: 'products' entries must be non-empty strings" - ) - - if not isinstance(author, str) or not author.strip(): - errors.append(f"{path}: 'author' must be a non-empty string") - - if not _is_rfc3339(timestamp): - errors.append( - f"{path}: 'timestamp' must be RFC-3339 UTC " - f"(e.g. 2026-04-19T00:00:00Z), got {timestamp!r}" - ) - - if status == "not_affected": - just = doc.get("justification") - if just not in VALID_JUSTIFICATIONS: - errors.append( - f"{path}: status='not_affected' requires 'justification' " - f"in {sorted(VALID_JUSTIFICATIONS)}, got {just!r}" - ) - - if status in ("affected", "under_investigation"): - action = doc.get("action_statement") - if not isinstance(action, str) or not action.strip(): - errors.append( - f"{path}: status='{status}' requires a non-empty " - f"'action_statement'" - ) - - canonical_cve = cve.upper() if isinstance(cve, str) else None - return canonical_cve, errors - - -def validate_dir(target: Path) -> list[str]: - """Validate every *.toml under `target`. Returns a list of error strings.""" - if not target.is_dir(): - return [f"{target}: directory does not exist"] - - errors: list[str] = [] - seen: dict[str, Path] = {} - - # Sorted to make error output deterministic. - for path in sorted(target.glob("*.toml")): - cve, file_errors = validate_file(path) - errors.extend(file_errors) - - if cve is None: - continue - if cve in seen: - errors.append( - f"{path}: duplicate CVE {cve} " - f"already declared in {seen[cve]}" - ) - else: - seen[cve] = path - - return errors - - -def main(argv: list[str]) -> int: - if len(argv) != 2: - print( - f"usage: {argv[0]} ", - file=sys.stderr, - ) - return 2 - - target = Path(argv[1]) - errors = validate_dir(target) - - if errors: - for err in errors: - print(err, file=sys.stderr) - print( - f"\nvalidate-vex: {len(errors)} error(s) in {target}", - file=sys.stderr, - ) - return 1 - - print(f"validate-vex: OK ({target})") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv))