Skip to content

Commit 608a93c

Browse files
authored
Add simple, new auto-vex reachability: Emit OpenVEX vulnerable_code_not_in_execute_path statements when affected functions aren't imported by the release binary (#55)
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 43e987c commit 608a93c

21 files changed

Lines changed: 1340 additions & 239 deletions

.claude/CHANGELOG.md

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,196 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12-
## [2026-05-02 12:00] - warp 0.4.2 migration (PR #52 follow-up)
12+
## [2026-05-02 10:00] - Symbol-import auto-VEX (roadmap Phase 3, scope-revised)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Added
17+
- `src/auto_vex_reachability.rs` (library module, ~190 LOC) +
18+
`src/auto_vex_reachability_tests.rs` (20 unit tests, 100%
19+
positive/negative/exception coverage per project rule):
20+
- `parse_nm_output` — parses `nm -D --undefined-only` text into a
21+
set of imported symbol names; strips `@GLIBC_x.y` version
22+
suffixes; defensively skips lines whose type column isn't `U`.
23+
- `load_affected_functions_from_path` — loads a JSON map of CVE id
24+
[public-API function names]. Underscore-prefixed keys
25+
(`_comment`, `_meta`) are sidecar metadata and skipped silently.
26+
- `compute_reachability_vex` — pure function that, given a Grype
27+
report, the imported-symbol set, the affected-functions mapping,
28+
and the already-triaged CVE set, emits one `not_affected +
29+
vulnerable_code_not_in_execute_path` statement per CVE whose
30+
listed functions are *all absent* from the imports.
31+
- Re-exports `Document`, `Statement`, `Vuln`, `Product`,
32+
`GrypeReport`, `GrypeMatch`, `GrypeVuln`, `GrypeArtifact`,
33+
`build_document`, `load_triaged_from_vex_dir` from
34+
`auto_vex_presence` so callers can import the OpenVEX shape from
35+
one place.
36+
- `src/bin/auto_vex_reachability.rs` — thin clap-driven CLI wrapping
37+
the library. Reads `--grype-json`, `--binary-symbols` (the path to
38+
an `nm` output text file), `--affected-functions`, `--vex-dir`,
39+
`--product-purl`, `--id`, optional `--author` and `--timestamp`.
40+
- `Cargo.toml`: `[[bin]] auto-vex-reachability` entry.
41+
- `.vex/.affected-functions.json` (new, dot-prefixed): curated CVE →
42+
[function names] mapping covering 9 of the 15 hand-authored
43+
statements in `.vex/`. Filename is dot-prefixed so default shell
44+
globs (`vexctl merge .vex/*.json`) skip it. The 6 unmapped CVEs
45+
(`CVE-2019-1010022/3/4/5`, `CVE-2026-4437`, `GHSA-cq8v-f236-94qc`)
46+
are not function-bounded — preconditions / adversary-model claims
47+
/ Rust-crate-only — and stay hand-authored. One mapped CVE
48+
(`CVE-2026-4438`, gethostbyaddr) is *also* retained hand-authored
49+
because its statement carries the stronger
50+
`vulnerable_code_cannot_be_controlled_by_adversary` justification;
51+
the mapping entry stays as a safety net so auto-reachability would
52+
jump in with the weaker `vulnerable_code_not_in_execute_path` if
53+
the hand-authored file is ever removed.
54+
- `Makefile`: new `vex-auto-reachability` target that defaults
55+
`RELEASE_BINARY=target/release/5spot` and
56+
`AFFECTED_FUNCTIONS=.vex/.affected-functions.json`. macOS Mach-O
57+
fallback to `nm -gU` since `-D --undefined-only` is ELF-only.
58+
`.PHONY` extended.
59+
- `.github/workflows/build.yaml`: new `auto-vex-reachability` job
60+
gated on `github.event_name != 'pull_request'`. `needs: [build,
61+
extract-version, grype-triage]`. Builds the bin (cached cargo),
62+
downloads the `5spot-linux-amd64` artifact + the `grype-triage-*`
63+
artifacts, runs `nm -D --undefined-only` on the binary, runs the
64+
bin once per variant, merges per-variant outputs via `vexctl
65+
merge`, uploads `vex-auto-reachability` (the merged VEX doc) and
66+
`vex-auto-reachability-evidence` (the raw `nm` output, for the
67+
Security team to re-derive against).
68+
69+
### Changed
70+
- `.github/workflows/build.yaml`:
71+
- `build-vex` job: `needs` extended with `auto-vex-reachability`.
72+
Added a download step for the `vex-auto-reachability` artifact
73+
and an explicit `test -f` guard. `vexctl merge` invocation now
74+
includes `auto/vex.auto-reachability.json` alongside
75+
`.vex/*.json` and `auto/vex.auto-presence.json`.
76+
- Header comments on `build-vex` updated to describe both
77+
automated phases.
78+
- `src/auto_vex_presence.rs`: `load_triaged_from_vex_dir` now skips
79+
dot-prefixed `*.json` files, so `.vex/.affected-functions.json`
80+
isn't mis-parsed as an OpenVEX statement when `auto-vex-reachability`
81+
loads `--vex-dir`. Added `load_triaged_skips_dotfiles` test in
82+
`auto_vex_presence_tests.rs`.
83+
- `src/lib.rs`: re-export `pub mod auto_vex_reachability`.
84+
- `.vex/README.md`: rewritten "Automated statements" section to
85+
describe both Phase 2 (presence) and Phase 3 (reachability)
86+
artifacts. New sub-section documenting the
87+
`.vex/.affected-functions.json` mapping format.
88+
- `docs/src/security/vex.md`:
89+
- CI flow steps renumbered to 7 — added step 3 for
90+
auto-reachability between presence (step 2) and assemble (step 4).
91+
- "What we automate" section: added the symbol-import-reachability
92+
bullet and a new "Why symbol-imports, not LLVM-IR call graphs"
93+
sub-section explaining the scope revision.
94+
- Roadmap (`~/dev/roadmaps/5spot-automated-vex-generation.md`):
95+
status line updated to "Phase 3 ✅ shipped (symbol-import variant)".
96+
Phase 3 section rewritten to document the scope revision with
97+
rationale. Rollout section updated.
98+
99+
### Removed
100+
- `.vex/CVE-2010-4756.json` (glob/fnmatch — superseded by auto-reachability)
101+
- `.vex/CVE-2018-20796.json` (regcomp/regexec)
102+
- `.vex/CVE-2019-9192.json` (regcomp/regexec)
103+
- `.vex/CVE-2026-27171.json` (crc32_combine[64])
104+
- `.vex/CVE-2026-4046.json` (iconv/iconv_open/iconv_close)
105+
- `.vex/CVE-2026-5358.json` (NIS / yp_*)
106+
- `.vex/CVE-2026-5450.json` (scanf family)
107+
- `.vex/CVE-2026-5928.json` (ungetwc)
108+
109+
These 8 statements all asserted `vulnerable_code_not_in_execute_path`
110+
and are now produced by `auto-vex-reachability` from the curated
111+
mapping + the `nm -D --undefined-only` symbol-import evidence
112+
uploaded by the new CI job. The Security team verifies them by
113+
re-running the same `nm` invocation against the release-attested
114+
binary. `CVE-2026-4438.json` is intentionally retained because its
115+
hand-authored statement carries the stronger
116+
`vulnerable_code_cannot_be_controlled_by_adversary` justification —
117+
see Why below.
118+
119+
### Why
120+
Phase 3 of the automated VEX generation roadmap, with a scope
121+
revision. The original plan targeted Rust LLVM-IR call-graph
122+
reachability against RustSec advisories carrying `affected.functions`.
123+
At implementation time `cargo audit` reports zero open Rust-level
124+
vulnerabilities for this codebase — every CVE in `.vex/` is a
125+
base-image glibc/zlib finding from Grype scanning the Docker image,
126+
and RustSec doesn't track those. The Rust call-graph approach
127+
addressed zero current findings.
128+
129+
The mechanical equivalent for our actual data is symbol-import
130+
absence: if the Rust binary's dynamic-symbol-import table doesn't
131+
contain any of the documented public-API entry points for an
132+
affected glibc function, the affected code path cannot be reached
133+
through documented APIs. This is exactly what the existing 14
134+
hand-authored impact statements claim in prose ("5-Spot performs no
135+
character-set conversions; the glibc iconv() path exercised by this
136+
CVE is unreachable from the controller binary"). Auto-reachability
137+
turns that prose into a verifiable check that the Security team
138+
re-derives during counter-signing.
139+
140+
The shipped variant addresses 9 of the 15 hand-authored statements
141+
through the curated mapping. Of those 9, **8 hand-authored
142+
statements were deleted** in this same change (their
143+
`vulnerable_code_not_in_execute_path` claim is now produced by
144+
auto-reachability and re-derivable from the uploaded `nm` evidence).
145+
The 9th mapped CVE (`CVE-2026-4438`) was retained because its
146+
hand-authored claim is the stronger
147+
`vulnerable_code_cannot_be_controlled_by_adversary` — the auto-VEX
148+
would replace it with the weaker function-absence claim. The
149+
remaining 6 hand-authored statements are not function-bounded
150+
(adversary-model preconditions, ASLR bypasses,
151+
missing-shell-at-runtime, Rust-crate combo) and stay hand-authored.
152+
The original LLVM-IR call-graph approach is deferred to when (a)
153+
`cargo audit` starts returning Rust-side findings with
154+
function-level data, or (b) we adopt a Rust analyzer that exposes
155+
call-graph data without LTO loss.
156+
157+
### Verification
158+
- `cargo fmt --all -- --check`: clean.
159+
- `cargo clippy --all-targets --all-features -- -D warnings`: clean.
160+
- `cargo test --lib`: 418 tests pass (398 pre-existing + 20 new for
161+
`auto_vex_reachability`).
162+
- Smoke tests:
163+
- 10 Grype matches (9 mapped + 1 unmapped) + an empty `.vex/` dir +
164+
nm output without any of the affected glibc symbols → 9
165+
statements emitted in deterministic CVE-id order. Unmapped CVE
166+
correctly skipped.
167+
- Same input but with `glob` added to nm output → 8 statements
168+
emitted (CVE-2010-4756 correctly excluded because `glob` is
169+
importable).
170+
- Same input against the real `.vex/` directory (all 9 mapped CVEs
171+
are hand-authored there) → 0 statements emitted because
172+
`load_triaged_from_vex_dir` correctly skips re-deriving
173+
already-triaged CVEs (and correctly skips
174+
`.affected-functions.json` thanks to the new dotfile filter).
175+
- `vexctl merge` of the auto-reachability output + `.vex/*.json` +
176+
vex.auto-presence.json produces a well-formed merged document
177+
parseable by `vexctl` with no errors.
178+
179+
### Trust model (unchanged from Phase 2)
180+
The merged VEX (hand-authored + auto-presence + auto-reachability)
181+
is Cosign-attested by CI keyless against both image digests. The
182+
Security team independently re-runs `nm -D --undefined-only` against
183+
the release-attested binary and checks each auto-reachability
184+
statement's claim, then counter-signs with their own OIDC identity
185+
if they agree. Downstream consumers gate on both attestations via
186+
`cosign verify-attestation` with twin
187+
`--certificate-identity-regexp` invocations.
188+
189+
### Impact
190+
- [ ] Breaking change
191+
- [ ] Requires cluster rollout
192+
- [ ] Config change only
193+
- [x] Documentation only
194+
195+
*(Not really "documentation only" — net +700 LOC of Rust + tests + a
196+
new CI job — but no runtime, CRD, or API-surface change. Closest
197+
pre-existing box.)*
198+
199+
---
200+
201+
## [2026-04-30 14:00] - Phases 5 + 6 of security audit: RBAC tightening + defence-in-depth docs
13202

14203
**Author:** Erick Bourgeois
15204

.github/workflows/build.yaml

Lines changed: 146 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,135 @@ jobs:
649649
path: vex.auto-presence.json
650650
if-no-files-found: error
651651

652+
# ─────────────────────────────────────────────────────────────────────────────
653+
# Auto-VEX (symbol-import reachability) — push + release. Roadmap Phase 3.
654+
#
655+
# For each Grype triage finding whose CVE id maps to a list of
656+
# affected library function names in `.vex/.affected-functions.json`,
657+
# check whether the release binary's dynamic symbol-import table
658+
# contains any of those names. If none → emit a `not_affected +
659+
# vulnerable_code_not_in_execute_path` statement. The result is
660+
# uploaded as the `vex-auto-reachability` artifact and merged
661+
# unconditionally into the signed VEX document by `build-vex`.
662+
#
663+
# Why "symbol-import reachability" instead of full LLVM-IR call graph:
664+
# cargo audit reports zero open Rust-level vulnerabilities for this
665+
# codebase; every CVE we triage is a base-image glibc/zlib finding
666+
# surfaced by Grype. Those CVEs are not tracked in RustSec, so the
667+
# original roadmap's RustSec-affected-functions approach addresses
668+
# zero current findings. Symbol-import absence is the mechanical
669+
# equivalent for our actual data: if our Rust binary doesn't link
670+
# against `glob`/`scanf`/`iconv`, the affected glibc code paths
671+
# cannot be reached through documented entry points.
672+
# ─────────────────────────────────────────────────────────────────────────────
673+
auto-vex-reachability:
674+
name: 🤖 Auto-VEX (reachability)
675+
if: github.event_name != 'pull_request'
676+
runs-on: ubuntu-latest
677+
needs: [build, extract-version, grype-triage]
678+
permissions:
679+
contents: read
680+
steps:
681+
- name: Checkout code
682+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
683+
684+
- name: Install Rust toolchain
685+
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (moving ref — re-pin quarterly)
686+
687+
- name: Cache cargo dependencies
688+
uses: firestoned/github-actions/rust/cache-cargo@53b483254bc648903c364ee3c73a546d0936a91e # v1.3.6
689+
690+
- name: Build auto-vex-reachability
691+
run: cargo build --release --bin auto-vex-reachability
692+
693+
- name: Download release binary (5spot-linux-amd64)
694+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
695+
with:
696+
name: 5spot-linux-amd64
697+
path: ./bin
698+
699+
- name: Download triage scan artifacts
700+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
701+
with:
702+
pattern: grype-triage-*
703+
path: ./triage
704+
merge-multiple: true
705+
706+
# Capture the binary's dynamic symbol-import table. This is the
707+
# ELF view; on Linux runners we can use nm -D --undefined-only
708+
# directly. The output becomes the evidence the Security team
709+
# re-derives downstream during counter-signing.
710+
- name: Capture binary symbol imports
711+
run: |
712+
set -euo pipefail
713+
chmod +x bin/5spot
714+
nm -D --undefined-only bin/5spot > symbols.txt
715+
echo "── imported symbol count ──"
716+
wc -l symbols.txt
717+
echo "── first 20 symbols ──"
718+
head -20 symbols.txt
719+
720+
# Run once per variant (binary symbols are identical across
721+
# variants but Grype findings differ), merge per-variant outputs
722+
# at the end.
723+
- name: Run auto-vex-reachability
724+
run: |
725+
set -euo pipefail
726+
vex_id="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability"
727+
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
728+
per_variant=()
729+
for variant in Chainguard Distroless; do
730+
triage="triage/grype-triage-${variant}.json"
731+
if [ ! -f "$triage" ]; then
732+
echo "missing $triage; skipping variant $variant"
733+
continue
734+
fi
735+
out="vex.auto-reachability-${variant}.json"
736+
./target/release/auto-vex-reachability \
737+
--grype-json "$triage" \
738+
--binary-symbols symbols.txt \
739+
--affected-functions .vex/.affected-functions.json \
740+
--vex-dir .vex \
741+
--product-purl "pkg:oci/5-spot" \
742+
--id "${vex_id}/${variant}" \
743+
--author "auto-vex-reachability" \
744+
--timestamp "$ts" \
745+
--output "$out"
746+
per_variant+=("$out")
747+
done
748+
if [ ${#per_variant[@]} -eq 0 ]; then
749+
echo "ERROR: no variant produced an auto-reachability document"
750+
exit 1
751+
fi
752+
753+
- name: Merge per-variant auto-reachability documents
754+
run: |
755+
make vexctl-install
756+
vexctl merge \
757+
--id "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/auto-vex-reachability" \
758+
--author "auto-vex-reachability" \
759+
vex.auto-reachability-*.json > vex.auto-reachability.json
760+
echo "── vex.auto-reachability.json ─────────────────────────────────"
761+
cat vex.auto-reachability.json
762+
763+
- name: Upload auto-reachability artifact
764+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
765+
with:
766+
name: vex-auto-reachability
767+
path: vex.auto-reachability.json
768+
if-no-files-found: error
769+
770+
# The symbols.txt evidence is uploaded separately so the Security
771+
# team can re-run the same reachability analysis against the
772+
# release-attested binary digest and confirm the auto-emitted
773+
# statements.
774+
- name: Upload symbol-imports evidence
775+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
776+
with:
777+
name: vex-auto-reachability-evidence
778+
path: symbols.txt
779+
if-no-files-found: error
780+
652781
# ─────────────────────────────────────────────────────────────────────────────
653782
# VEX document — push to main + release.
654783
#
@@ -666,19 +795,20 @@ jobs:
666795
# the assembly here.
667796
#
668797
# Trust model: this job emits and Cosign-attests the VEX document. The
669-
# auto-presence artifact from the Phase 2 job is merged unconditionally
670-
# alongside the hand-authored `.vex/*.json` statements. Verification of
671-
# the resulting claims is performed downstream by the Security team,
672-
# which independently re-evaluates the VEX against the attached evidence
673-
# (SBOMs, Cosign attestations, SLSA provenance) and counter-signs if
674-
# they agree. There is no feature-flag gate on our side — automation is
798+
# auto-presence (Phase 2) and auto-reachability (Phase 3) artifacts are
799+
# merged unconditionally alongside the hand-authored `.vex/*.json`
800+
# statements. Verification of the resulting claims is performed
801+
# downstream by the Security team, which independently re-evaluates the
802+
# VEX against the attached evidence (SBOMs, symbol-imports,
803+
# Cosign attestations, SLSA provenance) and counter-signs if they
804+
# agree. There is no feature-flag gate on our side — automation is
675805
# aggressive, verification is external.
676806
# ─────────────────────────────────────────────────────────────────────────────
677807
build-vex:
678808
name: 🧾 Assemble OpenVEX
679809
if: github.event_name != 'pull_request'
680810
runs-on: ubuntu-latest
681-
needs: [docker, extract-version, auto-vex-presence]
811+
needs: [docker, extract-version, auto-vex-presence, auto-vex-reachability]
682812
permissions:
683813
contents: read
684814
id-token: write
@@ -697,9 +827,15 @@ jobs:
697827
name: vex-auto-presence
698828
path: ./auto
699829

830+
- name: Download auto-reachability artifact
831+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
832+
with:
833+
name: vex-auto-reachability
834+
path: ./auto
835+
700836
# Canonical @id varies by event so downstream consumers can link back
701837
# to the exact commit or release tag that produced the document.
702-
# The auto-presence document is merged alongside hand-authored
838+
# Both auto-* documents are merged alongside hand-authored
703839
# statements on every build.
704840
- name: Assemble OpenVEX document
705841
id: assemble
@@ -717,10 +853,11 @@ jobs:
717853
;;
718854
esac
719855
test -f auto/vex.auto-presence.json || { echo "ERROR: auto-presence artifact missing"; exit 1; }
856+
test -f auto/vex.auto-reachability.json || { echo "ERROR: auto-reachability artifact missing"; exit 1; }
720857
vexctl merge \
721858
--id "$vex_id" \
722859
--author "${{ github.event.release.author.login || github.actor }}" \
723-
.vex/*.json auto/vex.auto-presence.json > vex.openvex.json
860+
.vex/*.json auto/vex.auto-presence.json auto/vex.auto-reachability.json > vex.openvex.json
724861
echo "── vex.openvex.json ─────────────────────────────────────────"
725862
cat vex.openvex.json
726863

0 commit comments

Comments
 (0)