diff --git a/.claude/CHANGELOG.md b/.claude/CHANGELOG.md index 649e506..82d3879 100644 --- a/.claude/CHANGELOG.md +++ b/.claude/CHANGELOG.md @@ -9,7 +9,196 @@ The format is based on the regulated environment requirements: --- -## [2026-05-02 12:00] - warp 0.4.2 migration (PR #52 follow-up) +## [2026-05-02 10:00] - Symbol-import auto-VEX (roadmap Phase 3, scope-revised) + +**Author:** Erick Bourgeois + +### Added +- `src/auto_vex_reachability.rs` (library module, ~190 LOC) + + `src/auto_vex_reachability_tests.rs` (20 unit tests, 100% + positive/negative/exception coverage per project rule): + - `parse_nm_output` — parses `nm -D --undefined-only` text into a + set of imported symbol names; strips `@GLIBC_x.y` version + suffixes; defensively skips lines whose type column isn't `U`. + - `load_affected_functions_from_path` — loads a JSON map of CVE id + → [public-API function names]. Underscore-prefixed keys + (`_comment`, `_meta`) are sidecar metadata and skipped silently. + - `compute_reachability_vex` — pure function that, given a Grype + report, the imported-symbol set, the affected-functions mapping, + and the already-triaged CVE set, emits one `not_affected + + vulnerable_code_not_in_execute_path` statement per CVE whose + listed functions are *all absent* from the imports. + - Re-exports `Document`, `Statement`, `Vuln`, `Product`, + `GrypeReport`, `GrypeMatch`, `GrypeVuln`, `GrypeArtifact`, + `build_document`, `load_triaged_from_vex_dir` from + `auto_vex_presence` so callers can import the OpenVEX shape from + one place. +- `src/bin/auto_vex_reachability.rs` — thin clap-driven CLI wrapping + the library. Reads `--grype-json`, `--binary-symbols` (the path to + an `nm` output text file), `--affected-functions`, `--vex-dir`, + `--product-purl`, `--id`, optional `--author` and `--timestamp`. +- `Cargo.toml`: `[[bin]] auto-vex-reachability` entry. +- `.vex/.affected-functions.json` (new, dot-prefixed): curated CVE → + [function names] mapping covering 9 of the 15 hand-authored + statements in `.vex/`. Filename is dot-prefixed so default shell + globs (`vexctl merge .vex/*.json`) skip it. The 6 unmapped CVEs + (`CVE-2019-1010022/3/4/5`, `CVE-2026-4437`, `GHSA-cq8v-f236-94qc`) + are not function-bounded — preconditions / adversary-model claims + / Rust-crate-only — and stay hand-authored. One mapped CVE + (`CVE-2026-4438`, gethostbyaddr) is *also* retained hand-authored + because its statement carries the stronger + `vulnerable_code_cannot_be_controlled_by_adversary` justification; + the mapping entry stays as a safety net so auto-reachability would + jump in with the weaker `vulnerable_code_not_in_execute_path` if + the hand-authored file is ever removed. +- `Makefile`: new `vex-auto-reachability` target that defaults + `RELEASE_BINARY=target/release/5spot` and + `AFFECTED_FUNCTIONS=.vex/.affected-functions.json`. macOS Mach-O + fallback to `nm -gU` since `-D --undefined-only` is ELF-only. + `.PHONY` extended. +- `.github/workflows/build.yaml`: new `auto-vex-reachability` job + gated on `github.event_name != 'pull_request'`. `needs: [build, + extract-version, grype-triage]`. Builds the bin (cached cargo), + downloads the `5spot-linux-amd64` artifact + the `grype-triage-*` + artifacts, runs `nm -D --undefined-only` on the binary, runs the + bin once per variant, merges per-variant outputs via `vexctl + merge`, uploads `vex-auto-reachability` (the merged VEX doc) and + `vex-auto-reachability-evidence` (the raw `nm` output, for the + Security team to re-derive against). + +### Changed +- `.github/workflows/build.yaml`: + - `build-vex` job: `needs` extended with `auto-vex-reachability`. + Added a download step for the `vex-auto-reachability` artifact + and an explicit `test -f` guard. `vexctl merge` invocation now + includes `auto/vex.auto-reachability.json` alongside + `.vex/*.json` and `auto/vex.auto-presence.json`. + - Header comments on `build-vex` updated to describe both + automated phases. +- `src/auto_vex_presence.rs`: `load_triaged_from_vex_dir` now skips + dot-prefixed `*.json` files, so `.vex/.affected-functions.json` + isn't mis-parsed as an OpenVEX statement when `auto-vex-reachability` + loads `--vex-dir`. Added `load_triaged_skips_dotfiles` test in + `auto_vex_presence_tests.rs`. +- `src/lib.rs`: re-export `pub mod auto_vex_reachability`. +- `.vex/README.md`: rewritten "Automated statements" section to + describe both Phase 2 (presence) and Phase 3 (reachability) + artifacts. New sub-section documenting the + `.vex/.affected-functions.json` mapping format. +- `docs/src/security/vex.md`: + - CI flow steps renumbered to 7 — added step 3 for + auto-reachability between presence (step 2) and assemble (step 4). + - "What we automate" section: added the symbol-import-reachability + bullet and a new "Why symbol-imports, not LLVM-IR call graphs" + sub-section explaining the scope revision. +- Roadmap (`~/dev/roadmaps/5spot-automated-vex-generation.md`): + status line updated to "Phase 3 ✅ shipped (symbol-import variant)". + Phase 3 section rewritten to document the scope revision with + rationale. Rollout section updated. + +### Removed +- `.vex/CVE-2010-4756.json` (glob/fnmatch — superseded by auto-reachability) +- `.vex/CVE-2018-20796.json` (regcomp/regexec) +- `.vex/CVE-2019-9192.json` (regcomp/regexec) +- `.vex/CVE-2026-27171.json` (crc32_combine[64]) +- `.vex/CVE-2026-4046.json` (iconv/iconv_open/iconv_close) +- `.vex/CVE-2026-5358.json` (NIS / yp_*) +- `.vex/CVE-2026-5450.json` (scanf family) +- `.vex/CVE-2026-5928.json` (ungetwc) + +These 8 statements all asserted `vulnerable_code_not_in_execute_path` +and are now produced by `auto-vex-reachability` from the curated +mapping + the `nm -D --undefined-only` symbol-import evidence +uploaded by the new CI job. The Security team verifies them by +re-running the same `nm` invocation against the release-attested +binary. `CVE-2026-4438.json` is intentionally retained because its +hand-authored statement carries the stronger +`vulnerable_code_cannot_be_controlled_by_adversary` justification — +see Why below. + +### Why +Phase 3 of the automated VEX generation roadmap, with a scope +revision. The original plan targeted Rust LLVM-IR call-graph +reachability against RustSec advisories carrying `affected.functions`. +At implementation time `cargo audit` reports zero open Rust-level +vulnerabilities for this codebase — every CVE in `.vex/` is a +base-image glibc/zlib finding from Grype scanning the Docker image, +and RustSec doesn't track those. The Rust call-graph approach +addressed zero current findings. + +The mechanical equivalent for our actual data is symbol-import +absence: if the Rust binary's dynamic-symbol-import table doesn't +contain any of the documented public-API entry points for an +affected glibc function, the affected code path cannot be reached +through documented APIs. This is exactly what the existing 14 +hand-authored impact statements claim in prose ("5-Spot performs no +character-set conversions; the glibc iconv() path exercised by this +CVE is unreachable from the controller binary"). Auto-reachability +turns that prose into a verifiable check that the Security team +re-derives during counter-signing. + +The shipped variant addresses 9 of the 15 hand-authored statements +through the curated mapping. Of those 9, **8 hand-authored +statements were deleted** in this same change (their +`vulnerable_code_not_in_execute_path` claim is now produced by +auto-reachability and re-derivable from the uploaded `nm` evidence). +The 9th mapped CVE (`CVE-2026-4438`) was retained because its +hand-authored claim is the stronger +`vulnerable_code_cannot_be_controlled_by_adversary` — the auto-VEX +would replace it with the weaker function-absence claim. The +remaining 6 hand-authored statements are not function-bounded +(adversary-model preconditions, ASLR bypasses, +missing-shell-at-runtime, Rust-crate combo) and stay hand-authored. +The original LLVM-IR call-graph approach is deferred to when (a) +`cargo audit` starts returning Rust-side findings with +function-level data, or (b) we adopt a Rust analyzer that exposes +call-graph data without LTO loss. + +### Verification +- `cargo fmt --all -- --check`: clean. +- `cargo clippy --all-targets --all-features -- -D warnings`: clean. +- `cargo test --lib`: 418 tests pass (398 pre-existing + 20 new for + `auto_vex_reachability`). +- Smoke tests: + - 10 Grype matches (9 mapped + 1 unmapped) + an empty `.vex/` dir + + nm output without any of the affected glibc symbols → 9 + statements emitted in deterministic CVE-id order. Unmapped CVE + correctly skipped. + - Same input but with `glob` added to nm output → 8 statements + emitted (CVE-2010-4756 correctly excluded because `glob` is + importable). + - Same input against the real `.vex/` directory (all 9 mapped CVEs + are hand-authored there) → 0 statements emitted because + `load_triaged_from_vex_dir` correctly skips re-deriving + already-triaged CVEs (and correctly skips + `.affected-functions.json` thanks to the new dotfile filter). +- `vexctl merge` of the auto-reachability output + `.vex/*.json` + + vex.auto-presence.json produces a well-formed merged document + parseable by `vexctl` with no errors. + +### Trust model (unchanged from Phase 2) +The merged VEX (hand-authored + auto-presence + auto-reachability) +is Cosign-attested by CI keyless against both image digests. The +Security team independently re-runs `nm -D --undefined-only` against +the release-attested binary and checks each auto-reachability +statement's claim, then counter-signs with their own OIDC identity +if they agree. Downstream consumers gate on both attestations via +`cosign verify-attestation` with twin +`--certificate-identity-regexp` invocations. + +### Impact +- [ ] Breaking change +- [ ] Requires cluster rollout +- [ ] Config change only +- [x] Documentation only + +*(Not really "documentation only" — net +700 LOC of Rust + tests + a +new CI job — but no runtime, CRD, or API-surface change. Closest +pre-existing box.)* + +--- + +## [2026-04-30 14:00] - Phases 5 + 6 of security audit: RBAC tightening + defence-in-depth docs **Author:** Erick Bourgeois diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9cf974b..227bbdc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -649,6 +649,135 @@ jobs: path: vex.auto-presence.json if-no-files-found: error + # ───────────────────────────────────────────────────────────────────────────── + # Auto-VEX (symbol-import reachability) — push + release. Roadmap Phase 3. + # + # For each Grype triage finding whose CVE id maps to a list of + # affected library function names in `.vex/.affected-functions.json`, + # check whether the release binary's dynamic symbol-import table + # contains any of those names. If none → emit a `not_affected + + # vulnerable_code_not_in_execute_path` statement. The result is + # uploaded as the `vex-auto-reachability` artifact and merged + # unconditionally into the signed VEX document by `build-vex`. + # + # Why "symbol-import reachability" instead of full LLVM-IR call graph: + # cargo audit reports zero open Rust-level vulnerabilities for this + # codebase; every CVE we triage is a base-image glibc/zlib finding + # surfaced by Grype. Those CVEs are not tracked in RustSec, so the + # original roadmap's RustSec-affected-functions approach addresses + # zero current findings. Symbol-import absence is the mechanical + # equivalent for our actual data: if our Rust binary doesn't link + # against `glob`/`scanf`/`iconv`, the affected glibc code paths + # cannot be reached through documented entry points. + # ───────────────────────────────────────────────────────────────────────────── + auto-vex-reachability: + name: 🤖 Auto-VEX (reachability) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + needs: [build, 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-reachability + run: cargo build --release --bin auto-vex-reachability + + - name: Download release binary (5spot-linux-amd64) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: 5spot-linux-amd64 + path: ./bin + + - name: Download triage scan artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: grype-triage-* + path: ./triage + merge-multiple: true + + # Capture the binary's dynamic symbol-import table. This is the + # ELF view; on Linux runners we can use nm -D --undefined-only + # directly. The output becomes the evidence the Security team + # re-derives downstream during counter-signing. + - name: Capture binary symbol imports + run: | + set -euo pipefail + chmod +x bin/5spot + nm -D --undefined-only bin/5spot > symbols.txt + echo "── imported symbol count ──" + wc -l symbols.txt + echo "── first 20 symbols ──" + head -20 symbols.txt + + # Run once per variant (binary symbols are identical across + # variants but Grype findings differ), merge per-variant outputs + # at the end. + - name: Run auto-vex-reachability + run: | + set -euo pipefail + vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + per_variant=() + for variant in Chainguard Distroless; do + triage="triage/grype-triage-${variant}.json" + if [ ! -f "$triage" ]; then + echo "missing $triage; skipping variant $variant" + continue + fi + out="vex.auto-reachability-${variant}.json" + ./target/release/auto-vex-reachability \ + --grype-json "$triage" \ + --binary-symbols symbols.txt \ + --affected-functions .vex/.affected-functions.json \ + --vex-dir .vex \ + --product-purl "pkg:oci/5-spot" \ + --id "${vex_id}/${variant}" \ + --author "auto-vex-reachability" \ + --timestamp "$ts" \ + --output "$out" + per_variant+=("$out") + done + if [ ${#per_variant[@]} -eq 0 ]; then + echo "ERROR: no variant produced an auto-reachability document" + exit 1 + fi + + - name: Merge per-variant auto-reachability documents + run: | + make vexctl-install + vexctl merge \ + --id "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability" \ + --author "auto-vex-reachability" \ + vex.auto-reachability-*.json > vex.auto-reachability.json + echo "── vex.auto-reachability.json ─────────────────────────────────" + cat vex.auto-reachability.json + + - name: Upload auto-reachability artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vex-auto-reachability + path: vex.auto-reachability.json + if-no-files-found: error + + # The symbols.txt evidence is uploaded separately so the Security + # team can re-run the same reachability analysis against the + # release-attested binary digest and confirm the auto-emitted + # statements. + - name: Upload symbol-imports evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vex-auto-reachability-evidence + path: symbols.txt + if-no-files-found: error + # ───────────────────────────────────────────────────────────────────────────── # VEX document — push to main + release. # @@ -666,19 +795,20 @@ jobs: # 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 + # auto-presence (Phase 2) and auto-reachability (Phase 3) artifacts are + # 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, symbol-imports, + # 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, auto-vex-presence] + needs: [docker, extract-version, auto-vex-presence, auto-vex-reachability] permissions: contents: read id-token: write @@ -697,9 +827,15 @@ jobs: name: vex-auto-presence path: ./auto + - name: Download auto-reachability artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: vex-auto-reachability + 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 + # Both auto-* documents are merged alongside hand-authored # statements on every build. - name: Assemble OpenVEX document id: assemble @@ -717,10 +853,11 @@ jobs: ;; esac test -f auto/vex.auto-presence.json || { echo "ERROR: auto-presence artifact missing"; exit 1; } + test -f auto/vex.auto-reachability.json || { echo "ERROR: auto-reachability artifact missing"; exit 1; } vexctl merge \ --id "$vex_id" \ --author "${{ github.event.release.author.login || github.actor }}" \ - .vex/*.json auto/vex.auto-presence.json > vex.openvex.json + .vex/*.json auto/vex.auto-presence.json auto/vex.auto-reachability.json > vex.openvex.json echo "── vex.openvex.json ─────────────────────────────────────────" cat vex.openvex.json diff --git a/.vex/.affected-functions.json b/.vex/.affected-functions.json new file mode 100644 index 0000000..2569727 --- /dev/null +++ b/.vex/.affected-functions.json @@ -0,0 +1,60 @@ +{ + "_comment": [ + "Curated CVE → [function-symbol-name] mapping consumed by", + "auto-vex-reachability (roadmap Phase 3). Each entry names the", + "public C-library entry points whose absence from the Rust", + "binary's dynamic symbol-import table proves the CVE's affected", + "code path is unreachable. Internal helpers (e.g.", + "check_dst_limits_calc_pos_1 inside regexec) are intentionally", + "NOT listed — we want the public API the affected code is reached", + "through, not the internal frames of glibc itself.", + "", + "CVEs whose affected code is not function-bounded (adversary-model", + "preconditions, ASLR bypasses, missing-shell-at-runtime, etc.)", + "are deliberately omitted from this map and stay hand-authored", + "in .vex/.json. The bin treats 'no entry in this map' as 'do", + "not auto-derive', not as 'reachable'.", + "", + "Filename is dot-prefixed so default shell globs (.vex/*.json) do", + "not pick it up — vexctl merge would otherwise try to parse it as", + "an OpenVEX statement and fail." + ], + "CVE-2010-4756": ["glob", "fnmatch"], + "CVE-2018-20796": ["regcomp", "regexec"], + "CVE-2019-9192": ["regcomp", "regexec"], + "CVE-2026-27171": ["crc32_combine", "crc32_combine64"], + "CVE-2026-4046": ["iconv", "iconv_open", "iconv_close"], + "_CVE-2026-4438_note": [ + "CVE-2026-4438 has a hand-authored .vex/CVE-2026-4438.json that", + "carries the stronger vulnerable_code_cannot_be_controlled_by_adversary", + "justification (5-Spot performs no reverse DNS lookups so no", + "attacker-controlled address can reach gethostbyaddr). The mapping", + "entry below stays as a safety net: if the hand-authored file is", + "ever removed, auto-reachability will jump in with the weaker", + "vulnerable_code_not_in_execute_path claim — still correct, just", + "less informative. Underscore-prefixed key, so the bin's loader", + "skips this metadata block." + ], + "CVE-2026-4438": ["gethostbyaddr", "gethostbyaddr_r"], + "CVE-2026-5358": [ + "nis_local_principal", + "nis_local_directory", + "yp_match", + "yp_first", + "yp_next", + "yp_get_default_domain", + "ypbind" + ], + "CVE-2026-5450": [ + "scanf", + "sscanf", + "fscanf", + "vscanf", + "vsscanf", + "vfscanf", + "__isoc99_scanf", + "__isoc99_sscanf", + "__isoc99_fscanf" + ], + "CVE-2026-5928": ["ungetwc"] +} diff --git a/.vex/CVE-2010-4756.json b/.vex/CVE-2010-4756.json deleted file mode 100644 index 9e0b68f..0000000 --- a/.vex/CVE-2010-4756.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-2018-20796.json b/.vex/CVE-2018-20796.json deleted file mode 100644 index 882c413..0000000 --- a/.vex/CVE-2018-20796.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-2019-9192.json b/.vex/CVE-2019-9192.json deleted file mode 100644 index e89c40f..0000000 --- a/.vex/CVE-2019-9192.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-2026-27171.json b/.vex/CVE-2026-27171.json deleted file mode 100644 index e03f9c5..0000000 --- a/.vex/CVE-2026-27171.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-4046.json b/.vex/CVE-2026-4046.json deleted file mode 100644 index a332a96..0000000 --- a/.vex/CVE-2026-4046.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-5358.json b/.vex/CVE-2026-5358.json deleted file mode 100644 index 6f396cf..0000000 --- a/.vex/CVE-2026-5358.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-5450.json b/.vex/CVE-2026-5450.json deleted file mode 100644 index 3b00d7b..0000000 --- a/.vex/CVE-2026-5450.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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-5928.json b/.vex/CVE-2026-5928.json deleted file mode 100644 index 12fc392..0000000 --- a/.vex/CVE-2026-5928.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "@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/README.md b/.vex/README.md index 64a842a..5dae418 100644 --- a/.vex/README.md +++ b/.vex/README.md @@ -22,23 +22,59 @@ 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. +## Automated statements + +On every push + release, CI produces two automated VEX artifacts that +are merged **unconditionally** into the signed release VEX alongside +the hand-authored statements in this directory: + +1. **`vex-auto-presence`** (roadmap Phase 2, + `src/auto_vex_presence.rs`). Emits `not_affected + + component_not_present` statements for every Grype finding whose + affected package URL is absent from every image SBOM and is not + already triaged here. The SBOM digest is the evidence. +2. **`vex-auto-reachability`** (roadmap Phase 3, + `src/auto_vex_reachability.rs`). Emits `not_affected + + vulnerable_code_not_in_execute_path` statements for every Grype + finding whose CVE id is present in the curated + `.affected-functions.json` mapping (see below) **and** whose + listed library functions are *all absent* from the release + binary's dynamic symbol-import table. The accompanying + `vex-auto-reachability-evidence` artifact is the raw `nm -D + --undefined-only` output the analyzer used. + +Verification of the merged document — including both auto-generated +sets — is performed downstream by the Security team, which +re-evaluates each claim against the attached evidence (SBOMs, +symbol-imports, 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. + +### `.affected-functions.json` + +A curated CVE → list-of-public-API-function-names mapping consumed +by the Phase 3 reachability check. The file is dot-prefixed so +default shell globs (e.g., `vexctl merge .vex/*.json`) skip it. +Underscore-prefixed keys (`_comment`, `_meta`) are sidecar metadata +and are ignored by the parser. + +Example entry: + +```json +"CVE-2010-4756": ["glob", "fnmatch"] +``` + +The list names the **public C library entry points** the affected +code path is reached through. Internal helpers (e.g. glibc's +`check_dst_limits_calc_pos_1` inside `regexec`) are intentionally +*not* listed — the auto-reachability check inspects the binary's +dynamic-symbol-import table, which only sees public APIs. CVEs +whose affected behaviour is not function-bounded (adversary-model +preconditions, ASLR bypasses, missing-shell-at-runtime, etc.) are +deliberately omitted from the map and stay hand-authored in their +respective `.vex/.json` files. The bin treats "no entry" as "do +not auto-derive", not as "reachable". ## When to add a statement diff --git a/Cargo.toml b/Cargo.toml index faa9611..9c40551 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,10 @@ path = "src/bin/crddoc.rs" name = "auto-vex-presence" path = "src/bin/auto_vex_presence.rs" +[[bin]] +name = "auto-vex-reachability" +path = "src/bin/auto_vex_reachability.rs" + [dependencies] kube = { version = "3.1", features = ["runtime", "derive", "client", "ws"] } kube-lease-manager = "0.11" diff --git a/Makefile b/Makefile index e749ccc..b33341a 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 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 +.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 vex-auto-reachability 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 @@ -558,6 +558,46 @@ vex-auto-presence: ## Run auto-vex-presence bin over $(GRYPE_JSON) + $(SBOM_FILE --output vex.auto-presence.json @echo "✓ wrote vex.auto-presence.json" +# Inputs for vex-auto-reachability (Phase 3). +# RELEASE_BINARY defaults to the local release build under +# target/release/5spot. AFFECTED_FUNCTIONS is the curated CVE → fns map. +RELEASE_BINARY ?= target/release/5spot +AFFECTED_FUNCTIONS ?= .vex/.affected-functions.json + +vex-auto-reachability: ## Run auto-vex-reachability over $(GRYPE_JSON) + symbols of $(RELEASE_BINARY) (Phase 3) + @if [ ! -f "$(GRYPE_JSON)" ]; then \ + echo "ERROR: $(GRYPE_JSON) not found. Override with: make vex-auto-reachability GRYPE_JSON=path/to/grype.json"; \ + exit 1; \ + fi + @if [ ! -f "$(RELEASE_BINARY)" ]; then \ + echo "ERROR: $(RELEASE_BINARY) not found. Build with: cargo build --release"; \ + exit 1; \ + fi + @if [ ! -f "$(AFFECTED_FUNCTIONS)" ]; then \ + echo "ERROR: $(AFFECTED_FUNCTIONS) not found"; \ + exit 1; \ + fi + @# nm -D --undefined-only is the ELF dynamic-symbol-imports view. + @# On macOS Mach-O, nm reports "no dynamic symbol table" — use + @# -gU (global undefined) as the closest equivalent. + @if [ "$$(uname -s)" = "Darwin" ]; then \ + nm -gU "$(RELEASE_BINARY)" > /tmp/avr-symbols.txt 2>/dev/null || \ + nm -D --undefined-only "$(RELEASE_BINARY)" > /tmp/avr-symbols.txt; \ + else \ + nm -D --undefined-only "$(RELEASE_BINARY)" > /tmp/avr-symbols.txt; \ + fi + @cargo run --quiet --bin auto-vex-reachability -- \ + --grype-json "$(GRYPE_JSON)" \ + --binary-symbols /tmp/avr-symbols.txt \ + --affected-functions "$(AFFECTED_FUNCTIONS)" \ + --vex-dir .vex \ + --product-purl "$(PRODUCT_PURL)" \ + --id "https://5-spot/local/auto-reachability" \ + --author "auto-vex-reachability" \ + --output vex.auto-reachability.json + @rm -f /tmp/avr-symbols.txt + @echo "✓ wrote vex.auto-reachability.json" + # ============================================================ # Kind Cluster (local testing for ScheduledMachine) # ============================================================ diff --git a/docs/src/security/vex.md b/docs/src/security/vex.md index d362d9b..609e7ce 100644 --- a/docs/src/security/vex.md +++ b/docs/src/security/vex.md @@ -36,19 +36,28 @@ On every release of 5-Spot the CI pipeline performs the following steps: 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 + `vex-auto-presence` workflow artifact. +3. **Generate symbol-import reachability auto-VEX** (roadmap Phase 3): + the `auto-vex-reachability` job inspects the release binary's + dynamic symbol-import table (`nm -D --undefined-only`) and emits a + `not_affected + vulnerable_code_not_in_execute_path` statement for + every Grype finding whose CVE id is present in the curated + `.vex/.affected-functions.json` mapping **and** whose listed + library functions are *all absent* from the imports. The result + is uploaded as the `vex-auto-reachability` artifact, with the raw + symbol-import dump alongside as `vex-auto-reachability-evidence`. +4. **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 — + and the release actor as the document-level author. Both + auto-* documents are included in the merge on every build — there is no feature-flag gate. -4. **Cosign-attest** the document to *both* image digests (Chainguard +5. **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. -5. **GitHub attest** the document with `actions/attest-build-provenance` +6. **GitHub attest** the document with `actions/attest-build-provenance` so `gh attestation verify` works for downstream pulls. -6. **Attach** `vex.openvex.json` and its `.bundle` to the GitHub +7. **Attach** `vex.openvex.json` and its `.bundle` to the GitHub Release as assets and register them in `checksums.sha256`. No new GitHub secrets are required — all signing is keyless via the @@ -163,22 +172,51 @@ 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. +- **Automated — presence** (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. +- **Automated — symbol-import reachability** (roadmap Phase 3, active + on every build): if Grype flags a CVE that's listed in the curated + `.vex/.affected-functions.json` mapping, and **none** of the listed + public-API function names appear in the release binary's dynamic + symbol-import table, the `auto-vex-reachability` job emits a + `not_affected + vulnerable_code_not_in_execute_path` statement. + The raw `nm -D --undefined-only` output is uploaded as the + `vex-auto-reachability-evidence` artifact and is the evidence + backing the claim. - **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. + justification other than `component_not_present` / + `vulnerable_code_not_in_execute_path`, 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. + +Both automated paths are merged unconditionally into the signed VEX +document — there is no feature-flag gate. The Security team's +downstream re-verification (see "Trust model" below) is the safety +net. + +### Why symbol-imports, not LLVM-IR call graphs + +The original Phase 3 plan targeted Rust LLVM-IR call-graph +reachability against RustSec advisories carrying `affected.functions`. +At implementation time, `cargo audit` reported zero open Rust-level +vulnerabilities for this codebase — every CVE in `.vex/` is a +base-image glibc/zlib finding from Grype scanning the Docker image, +none of which RustSec tracks. The Rust call-graph approach addressed +zero current findings. + +The mechanical equivalent for our actual data is symbol-import +absence: if the Rust binary doesn't link against `glob` / `scanf` / +`iconv` / etc., the affected glibc code paths cannot be reached +through their documented entry points. The `nm -D --undefined-only` +output is auditable and the Security team re-derives the same +conclusion by running `nm` against the release-attested binary +themselves. --- diff --git a/src/auto_vex_presence.rs b/src/auto_vex_presence.rs index 23df425..71e2a06 100644 --- a/src/auto_vex_presence.rs +++ b/src/auto_vex_presence.rs @@ -188,11 +188,17 @@ pub fn build_document( } } -/// 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. +/// Read every non-dotfile `*.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. +/// +/// Dot-prefixed files (e.g., `.affected-functions.json`, the curated +/// CVE → function mapping consumed by Phase 3) are skipped — they +/// match the `*.json` extension but are sidecar config, not OpenVEX +/// statements. Same convention shell globs use. 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) { @@ -206,6 +212,14 @@ pub fn load_triaged_from_vex_dir(vex_dir: &Path) -> std::io::Result` text output into a set of +/// imported symbol names, with `@VERSION` GLIBC suffixes stripped. +/// +/// Defensively skips any line whose type column isn't `U`, in case the +/// caller passed full `nm -D` output instead of `--undefined-only`. +pub fn parse_nm_output(input: &str) -> HashSet { + let mut symbols = HashSet::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // Find the type column. nm output is ` ` + // where is a single character ('U' = undefined). Tokenize + // and look for a 1-char token followed by the symbol name. + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let (type_char_idx, _) = match tokens + .iter() + .enumerate() + .find(|(_, t)| t.len() == 1 && t.chars().next().unwrap().is_ascii_alphabetic()) + { + Some(p) => p, + None => continue, + }; + if tokens[type_char_idx] != "U" { + continue; + } + let name = match tokens.get(type_char_idx + 1) { + Some(n) => n, + None => continue, + }; + // Strip `@VERSION` suffix (e.g., `scanf@GLIBC_2.7` → `scanf`). + let bare = match name.find('@') { + Some(idx) => &name[..idx], + None => name, + }; + if !bare.is_empty() { + symbols.insert(bare.to_string()); + } + } + symbols +} + +/// Read the CVE → [function names] mapping from a JSON file. Keys whose +/// value is not an array of strings are silently skipped, so the file +/// can carry sidecar metadata (`_comment: [...]`, `_meta: "..."`) next +/// to real entries without breaking parsing. +pub fn load_affected_functions_from_path( + path: &Path, +) -> std::io::Result>> { + let bytes = std::fs::read(path)?; + let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{}: {}", path.display(), e), + ) + })?; + let obj = match raw.as_object() { + Some(o) => o, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{}: top level must be a JSON object", path.display()), + )); + } + }; + let mut out = BTreeMap::new(); + for (k, v) in obj { + // Underscore-prefixed keys are metadata (`_comment`, `_meta`, etc.) + // and never identifier names — skip them. Even if such a key's + // value happens to be an array of strings, those strings are + // free-form prose, not function names. + if k.starts_with('_') { + continue; + } + let arr = match v.as_array() { + Some(a) => a, + None => continue, + }; + let fns: Option> = arr + .iter() + .map(|item| item.as_str().map(|s| s.to_string())) + .collect(); + if let Some(fns) = fns { + out.insert(k.clone(), fns); + } + } + Ok(out) +} + +/// Core reachability check. Emits one statement per unique Grype CVE +/// where (a) the CVE is in the mapping, (b) the mapping's function +/// list is non-empty, (c) NONE of the listed functions appear in +/// `imported_symbols`, and (d) the CVE isn't already in +/// `already_triaged`. Output is sorted by CVE id for deterministic +/// CI artifacts. +pub fn compute_reachability_vex( + grype: &GrypeReport, + imported_symbols: &HashSet, + affected_functions: &BTreeMap>, + already_triaged: &HashSet, + product_purl: &str, + statement_timestamp: &str, +) -> Vec { + let mut emitted: BTreeSet = BTreeSet::new(); + let mut statements: BTreeMap = BTreeMap::new(); + for m in &grype.matches { + let cve = &m.vulnerability.id; + if already_triaged.contains(cve) { + continue; + } + if emitted.contains(cve) { + continue; + } + let fns = match affected_functions.get(cve) { + Some(f) if !f.is_empty() => f, + _ => continue, + }; + // If ANY listed function is imported, abandon — the binary may + // reach the affected code path, leave for human triage. + if fns.iter().any(|f| imported_symbols.contains(f)) { + continue; + } + emitted.insert(cve.clone()); + let impact = format!( + "{} ({}). The dynamic symbol-import table was inspected via \ + `nm -D --undefined-only` against the release artifact; \ + none of the listed entry points were found.", + AUTO_IMPACT_PREFIX, + fns.join(", ") + ); + statements.insert( + cve.clone(), + Statement { + vulnerability: Vuln { name: cve.clone() }, + products: vec![Product { + id: product_purl.to_string(), + }], + status: "not_affected".to_string(), + justification: Some("vulnerable_code_not_in_execute_path".to_string()), + impact_statement: Some(impact), + timestamp: statement_timestamp.to_string(), + }, + ); + } + statements.into_values().collect() +} + +#[cfg(test)] +#[path = "auto_vex_reachability_tests.rs"] +mod tests; diff --git a/src/auto_vex_reachability_tests.rs b/src/auto_vex_reachability_tests.rs new file mode 100644 index 0000000..1dcdcf6 --- /dev/null +++ b/src/auto_vex_reachability_tests.rs @@ -0,0 +1,362 @@ +// Copyright (c) 2025 Erick Bourgeois, firestoned +// SPDX-License-Identifier: Apache-2.0 +//! Unit tests for the `auto_vex_reachability` module. +//! +//! Coverage obligations per project rule (100% positive / negative / +//! exception): +//! - parse_nm_output: empty input, single-line, multi-line, lines with +//! GLIBC version suffixes, mixed whitespace, blank/comment lines. +//! - load_affected_functions_from_path: valid file, empty `{}`, file with +//! non-array values (e.g., `_comment` arrays / strings) silently +//! ignored, missing file errors, malformed JSON errors. +//! - compute_reachability_vex: empty grype, CVE not in mapping skipped, +//! CVE with all affected fns absent from symbols emitted, +//! CVE with any affected fn imported NOT emitted, +//! CVE already triaged skipped, +//! duplicate CVE de-duped, deterministic sort by CVE id. + +#[cfg(test)] +#[allow(clippy::module_inception)] +mod tests { + use super::super::*; + use serde_json::json; + use std::collections::HashSet; + + // ──────────────────────────────────────────────────────────────────── + // parse_nm_output + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn parse_nm_empty_input_yields_empty_set() { + let symbols = parse_nm_output(""); + assert!(symbols.is_empty()); + } + + #[test] + fn parse_nm_single_undefined_symbol() { + let symbols = parse_nm_output(" U malloc\n"); + assert!(symbols.contains("malloc")); + assert_eq!(symbols.len(), 1); + } + + #[test] + fn parse_nm_strips_glibc_version_suffix() { + // Real `nm -D --undefined-only` output suffixes symbols with + // @GLIBC_x.y when the binary requests a specific glibc minimum. + // We strip that — the affected-functions mapping uses bare + // function names. + let raw = " U __libc_start_main@GLIBC_2.34\n\ + U scanf@GLIBC_2.7\n"; + let symbols = parse_nm_output(raw); + assert!(symbols.contains("__libc_start_main")); + assert!(symbols.contains("scanf")); + assert!(!symbols.iter().any(|s| s.contains('@'))); + } + + #[test] + fn parse_nm_handles_blank_and_comment_lines() { + let raw = "\n\ + # comment line\n\ + \n\ + U malloc\n\ + \n"; + let symbols = parse_nm_output(raw); + assert_eq!(symbols.len(), 1); + assert!(symbols.contains("malloc")); + } + + #[test] + fn parse_nm_handles_mixed_whitespace() { + // Tabs, multiple spaces, leading column spacings — all tolerated. + let raw = "\t\tU\tabort\n U free\n U calloc\n"; + let symbols = parse_nm_output(raw); + assert!(symbols.contains("abort")); + assert!(symbols.contains("free")); + assert!(symbols.contains("calloc")); + assert_eq!(symbols.len(), 3); + } + + #[test] + fn parse_nm_skips_non_undefined_lines() { + // Belt-and-suspenders: even though we'd invoke nm with + // --undefined-only, a defensive parser ignores any line whose + // type column isn't 'U'. + let raw = "0000000000001234 T some_local_function\n\ + 0000000000005678 D some_data\n\ + U malloc\n"; + let symbols = parse_nm_output(raw); + assert_eq!(symbols.len(), 1); + assert!(symbols.contains("malloc")); + } + + // ──────────────────────────────────────────────────────────────────── + // load_affected_functions_from_path + // ──────────────────────────────────────────────────────────────────── + + #[test] + fn load_affected_functions_valid_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("aff.json"); + std::fs::write( + &path, + json!({ + "CVE-2026-00001": ["foo", "bar"], + "CVE-2026-00002": ["baz"] + }) + .to_string(), + ) + .unwrap(); + let m = load_affected_functions_from_path(&path).unwrap(); + assert_eq!(m.len(), 2); + assert_eq!(m.get("CVE-2026-00001").unwrap(), &vec!["foo", "bar"]); + assert_eq!(m.get("CVE-2026-00002").unwrap(), &vec!["baz"]); + } + + #[test] + fn load_affected_functions_empty_object_is_valid() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("aff.json"); + std::fs::write(&path, "{}").unwrap(); + let m = load_affected_functions_from_path(&path).unwrap(); + assert!(m.is_empty()); + } + + #[test] + fn load_affected_functions_ignores_non_array_values() { + // _comment: [...] / _meta: "foo" / unrelated keys are skipped + // silently — the file is allowed to carry sidecar metadata + // alongside CVE entries without breaking parsing. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("aff.json"); + std::fs::write( + &path, + json!({ + "_comment": ["this file is curated by hand"], + "_meta": "version 1", + "CVE-2026-00003": ["fn1"] + }) + .to_string(), + ) + .unwrap(); + let m = load_affected_functions_from_path(&path).unwrap(); + assert_eq!(m.len(), 1); + assert!(m.contains_key("CVE-2026-00003")); + } + + #[test] + fn load_affected_functions_ignores_array_with_non_string_entries() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("aff.json"); + std::fs::write( + &path, + json!({ + "CVE-2026-00004": ["valid"], + "_meta_versions": [1, 2, 3] + }) + .to_string(), + ) + .unwrap(); + let m = load_affected_functions_from_path(&path).unwrap(); + assert_eq!(m.len(), 1); + assert!(m.contains_key("CVE-2026-00004")); + } + + #[test] + fn load_affected_functions_missing_file_errors() { + let result = load_affected_functions_from_path(std::path::Path::new( + "/nonexistent/affected-functions.json", + )); + assert!(result.is_err()); + } + + #[test] + fn load_affected_functions_malformed_json_errors() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("aff.json"); + std::fs::write(&path, "{ invalid").unwrap(); + let result = load_affected_functions_from_path(&path); + assert!(result.is_err()); + } + + // ──────────────────────────────────────────────────────────────────── + // compute_reachability_vex + // ──────────────────────────────────────────────────────────────────── + + fn aff(pairs: &[(&str, &[&str])]) -> std::collections::BTreeMap> { + pairs + .iter() + .map(|(k, v)| { + ( + (*k).to_string(), + v.iter().map(|s| (*s).to_string()).collect(), + ) + }) + .collect() + } + + fn syms(s: &[&str]) -> HashSet { + s.iter().map(|x| (*x).to_string()).collect() + } + + fn grype_with(matches: &[&str]) -> GrypeReport { + GrypeReport { + matches: matches + .iter() + .map(|cve| GrypeMatch { + vulnerability: GrypeVuln { + id: (*cve).to_string(), + }, + artifact: GrypeArtifact { purl: None }, + }) + .collect(), + } + } + + #[test] + fn empty_grype_yields_empty_statements() { + let m = aff(&[("CVE-2026-00001", &["foo"])]); + let s = syms(&["bar"]); + let out = compute_reachability_vex( + &grype_with(&[]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert!(out.is_empty()); + } + + #[test] + fn cve_not_in_mapping_is_skipped() { + // The mapping is the gate: no entry → we don't auto-derive. + let m = aff(&[]); + let s = syms(&[]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-99998"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert!(out.is_empty()); + } + + #[test] + fn cve_with_all_affected_fns_absent_is_emitted() { + let m = aff(&[("CVE-2026-00010", &["glob", "fnmatch"])]); + let s = syms(&["malloc", "free"]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00010"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0].vulnerability.name, "CVE-2026-00010"); + assert_eq!(out[0].status, "not_affected"); + assert_eq!( + out[0].justification.as_deref(), + Some("vulnerable_code_not_in_execute_path") + ); + assert_eq!(out[0].products[0].id, "pkg:oci/5-spot"); + // The impact statement should be machine-derivable: which fns + // were checked and confirmed absent. + let impact = out[0].impact_statement.as_deref().unwrap_or_default(); + assert!(impact.contains("glob")); + assert!(impact.contains("fnmatch")); + } + + #[test] + fn cve_with_any_affected_fn_imported_is_not_emitted() { + let m = aff(&[("CVE-2026-00011", &["glob", "fnmatch"])]); + let s = syms(&["fnmatch", "malloc"]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00011"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + // fnmatch IS imported → reachable (via that entry point) → we + // don't claim not_affected. Stays for human triage. + assert!(out.is_empty()); + } + + #[test] + fn cve_already_triaged_is_skipped() { + let m = aff(&[("CVE-2026-00012", &["nis_local_principal"])]); + let s = syms(&[]); + let mut triaged = HashSet::new(); + triaged.insert("CVE-2026-00012".to_string()); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00012"]), + &s, + &m, + &triaged, + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert!(out.is_empty()); + } + + #[test] + fn duplicate_cve_emits_only_once() { + let m = aff(&[("CVE-2026-00013", &["scanf"])]); + let s = syms(&[]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00013", "CVE-2026-00013"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert_eq!(out.len(), 1); + } + + #[test] + fn output_is_sorted_by_cve_id() { + let m = aff(&[ + ("CVE-2026-00021", &["fn_a"]), + ("CVE-2026-00019", &["fn_b"]), + ("CVE-2026-00020", &["fn_c"]), + ]); + let s = syms(&[]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00021", "CVE-2026-00019", "CVE-2026-00020"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + let names: Vec<&str> = out.iter().map(|s| s.vulnerability.name.as_str()).collect(); + assert_eq!( + names, + vec!["CVE-2026-00019", "CVE-2026-00020", "CVE-2026-00021"] + ); + } + + #[test] + fn empty_affected_functions_array_is_treated_as_no_evidence() { + // If the mapping has CVE-X with an empty function list, that + // means "we don't know which functions are affected" — we must + // NOT emit not_affected, because we have no evidence to back it. + let m = aff(&[("CVE-2026-00022", &[])]); + let s = syms(&[]); + let out = compute_reachability_vex( + &grype_with(&["CVE-2026-00022"]), + &s, + &m, + &HashSet::new(), + "pkg:oci/5-spot", + "2026-05-02T00:00:00Z", + ); + assert!(out.is_empty()); + } +} diff --git a/src/bin/auto_vex_reachability.rs b/src/bin/auto_vex_reachability.rs new file mode 100644 index 0000000..2fdfd80 --- /dev/null +++ b/src/bin/auto_vex_reachability.rs @@ -0,0 +1,163 @@ +// Copyright (c) 2025 Erick Bourgeois, firestoned +// SPDX-License-Identifier: Apache-2.0 +//! # Symbol-import reachability auto-VEX (CI tool, roadmap Phase 3) +//! +//! Reads a Grype JSON report, the `nm -D --undefined-only` output of +//! the release binary, and a curated CVE → [function names] mapping. +//! For each Grype CVE in the mapping whose listed functions are *all +//! absent* from the binary's symbol-import table, emits an OpenVEX +//! `not_affected + vulnerable_code_not_in_execute_path` statement. +//! +//! ## Usage +//! +//! ```bash +//! nm -D --undefined-only target/release/5spot > symbols.txt +//! cargo run --bin auto-vex-reachability -- \ +//! --grype-json grype.json \ +//! --binary-symbols symbols.txt \ +//! --affected-functions .vex/.affected-functions.json \ +//! --vex-dir .vex \ +//! --product-purl pkg:oci/5-spot \ +//! --id "https://github.com/finos/5-spot/actions/runs/123/auto-vex-reachability" \ +//! --author auto-vex-reachability \ +//! --output vex.auto-reachability.json +//! ``` + +use std::collections::HashSet; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::Parser; +use five_spot::auto_vex_reachability::{ + build_document, compute_reachability_vex, load_affected_functions_from_path, + load_triaged_from_vex_dir, parse_nm_output, GrypeReport, +}; + +#[derive(Parser, Debug)] +#[command( + name = "auto-vex-reachability", + about = "Emit OpenVEX vulnerable_code_not_in_execute_path statements when affected functions aren't imported by the release binary" +)] +struct Cli { + /// Grype JSON report (produced by `grype --output json`). + #[arg(long)] + grype_json: PathBuf, + + /// Path to the text output of `nm -D --undefined-only `. + /// Each line is one undefined (imported) symbol. + #[arg(long)] + binary_symbols: PathBuf, + + /// JSON file mapping CVE id → list of public-API function names. + /// Underscore-prefixed keys are treated as metadata and skipped. + #[arg(long)] + affected_functions: PathBuf, + + /// Directory of hand-authored `.vex/*.json`; CVEs already covered + /// by one of these are skipped. Missing dir is OK. + #[arg(long)] + vex_dir: PathBuf, + + /// Product purl to attach to every emitted statement. + #[arg(long)] + product_purl: String, + + /// Canonical `@id` for the document (typically a CI run URL). + #[arg(long)] + id: String, + + /// Document-level author. + #[arg(long, default_value = "auto-vex-reachability")] + 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-reachability: {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 nm_text = std::fs::read_to_string(&cli.binary_symbols).map_err(|e| { + anyhow::anyhow!( + "failed to read --binary-symbols {}: {}", + cli.binary_symbols.display(), + e + ) + })?; + let imported_symbols = parse_nm_output(&nm_text); + + let affected = load_affected_functions_from_path(&cli.affected_functions).map_err(|e| { + anyhow::anyhow!( + "failed to load --affected-functions {}: {}", + cli.affected_functions.display(), + e + ) + })?; + + 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_reachability_vex( + &grype, + &imported_symbols, + &affected, + &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-reachability: {} grype match(es), {} symbol(s) imported, \ + {} mapped CVE(s), {} statement(s) emitted ({} already triaged in {})", + grype.matches.len(), + imported_symbols.len(), + affected.len(), + doc.statements.len(), + triaged.len(), + cli.vex_dir.display(), + ); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 2b72633..a68a374 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! //! Modules: //! - [`auto_vex_presence`] — presence-based auto-VEX generation (CI-only, roadmap Phase 2) +//! - [`auto_vex_reachability`] — symbol-import reachability auto-VEX (CI-only, roadmap Phase 3) //! - [`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 @@ -14,6 +15,7 @@ //! - [`reconcilers`] — reconciliation logic and controller context pub mod auto_vex_presence; +pub mod auto_vex_reachability; pub mod constants; pub mod crd; pub mod health;