feat(reachability): distinguish present-static-binary from not-observed (JEF-404)#229
Conversation
…ed so a Go/musl CVE on a hot path doesn't read as safe (JEF-404) Reachability is proven by correlating a CVE's package against runtime LibraryLoaded events. Statically linked binaries (Go, CGO-disabled/musl-static) compile everything into one executable — no per-.so loads — so LibraryLoaded never names the vulnerable package and every CVE renders `not-observed` even on a hot path. Worse, `not-observed` reads to the adjudicator as "not running", so absence-of-evidence became evidence-of-absence for a whole image class. - Add Reachability::PresentStaticBinary (label `present-static-binary`): the library-load correlation CANNOT fire for a static binary, so a would-be `not-observed` CVE on such an image is indeterminate, not observed-absent. - Add a minimal, zero-dependency ELF static-linkage classifier (engine::observe::elf): a static ELF carries no PT_INTERP program header. Pure, byte-only, bounds-checked, honestly returns None on any malformed/unknown input — no new heavy crate, no egress (it only inspects bytes already in hand). - Add Image::static_binary: Option<bool> as the per-image signal the classifier populates; CveReachabilityAdapter tags PresentStaticBinary when the image is static and no load matched (a real LoadedAtRuntime match still wins). - Feed it into the prompt honestly: the tag is CONTEXT, never exploitation evidence (only loaded-at-runtime is), but explicitly NOT reassurance either — its lack of a runtime load must not be read as safety. JEF-402/405 evidence rules are untouched (loaded-at-runtime remains the sole CVE evidence). DECISION NEEDED (architect): the engine has no in-cluster access to the entrypoint binary bytes today (zero-egress, node-local agent), so static_binary stays None in production until an ELF-byte signal is plumbed in (e.g. the agent POSTing the entrypoint's linkage on the behavioral wire, or a scanner field). The classifier + enum + adapter + prompt are all wired and tested end-to-end; only the byte source is pending. Tests: ELF fixtures (static vs dynamic classify differently, 32/64-bit, big-endian, malformed→None); a static image tags PresentStaticBinary while the same dynamic image stays NotObserved and a real load still yields LoadedAtRuntime; label round-trip; prompt renders the tag as unknowable, not reassurance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
|
Architect ratification (INTEGRATE / JEF-404 DECISION NEEDED) RATIFIED: land-dormant is the correct call. The classifier +
Independently re-verified against the repo invariants:
FOLLOW-UP (required, human): file a ticket to plumb an ELF-byte source so HARDENING NIT (non-blocking, not reachable today): in the read helpers, |
…statically-linked-binaries-gorustmusl
…bility activates in prod (JEF-407) (#235) JEF-404 landed the static-linkage machinery but it was DORMANT: the engine has no in-cluster access to a workload's entrypoint bytes, so `Image::static_binary` was `None` at every prod construction site — the ELF classifier never ran outside tests and Go/musl-static CVEs still rendered `not-observed`. This wires the byte SOURCE. Signal source (DECISION): the node-local agent reports linkage over the EXISTING behavioral wire (ADR-0014) — the ticket's preferred path. The agent already sees `/proc/<pid>/exe`; on an exec it reads only the leading ELF header and classifies linkage, emitting a new `Behavior::ImageLinkage { static_linkage }` over the same `/behavior` channel (no new egress — zero-egress holds; no heavy dep). The engine's RuntimeAdapter diverts that signal onto `Image::static_binary` (walking RunsImage edges) before CveReachabilityAdapter reads it, so a static image's would-be `not-observed` CVE now tags `present-static-binary`. Shared classifier: the ELF parser moved from `engine::observe::elf` into the shared `protector-behavior` crate (std-only, dependency-free) as the single source of truth, so engine and agent classify identically and can't drift; the engine module keeps a thin re-export. Pre-activation hardening (architect-flagged on #229): the ELF read helpers now form every `off + N` slice bound with `checked_add`, so a crafted `e_phoff` near `usize::MAX` can't overflow-panic on a debug build — it returns `None`. The parser stays allocation-free and returns `None` on malformed/truncated input. Backward-tolerant: an older agent that doesn't send `ImageLinkage` simply leaves `static_binary == None` — the exact prior behavior. `ImageLinkage` is CONTEXT only: it never corroborates and is diverted before it becomes workload runtime state, so the JEF-402/405 rule that only `loaded-at-runtime` is CVE evidence is untouched. Tests: agent classifies static vs dynamic ELF (and drops unknown); the engine maps the agent's report into `Image::static_binary` and an end-to-end pipeline test shows a static report → `present-static-binary`, a dynamic report → `not-observed`, and a real load stays `loaded-at-runtime`; the `checked_add` change is covered by a crafted `u64::MAX` offset returning `None` with no panic; `ImageLinkage` serde round-trips and is proven not to land on workload runtime. Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes JEF-404
Problem
Reachability is proven by correlating a CVE's package against runtime
LibraryLoadedevents. Statically linked binaries (Go, CGO-disabled/musl-static) compile everything into one executable — there are no per-.soloads — soLibraryLoadednever names the vulnerable package and every CVE rendersnot-observed, even when the vulnerable code is genuinely on a hot path. Two harms: (a) false negatives on reachability for a whole image class; (b)not-observedreads to the adjudicator as "not running", so absence-of-evidence was being read as evidence-of-absence.What changed
Reachability::PresentStaticBinary(labelpresent-static-binary): distinct fromnot-observed("observed absent"). The library-load correlation cannot fire for a static binary, so a would-be-not-observedCVE on such an image is indeterminate, not absent.engine::observe::elf): a static ELF carries noPT_INTERPprogram header. Pure, byte-only, bounds-checked; honestly returnsNoneon any malformed/unknown input. No new heavy crate, and it only inspects bytes already in hand — the zero-egress invariant holds.Image::static_binary: Option<bool>— the per-image signal the classifier populates.CveReachabilityAdaptertagsPresentStaticBinarywhen the image is static and no load matched; a realLoadedAtRuntimematch still wins (some static binaries dlopen a plugin).loaded-at-runtimeis), but explicitly NOT reassurance — its lack of a runtime load must not be read as safety. The JEF-402/405 evidence rules are untouched (loaded-at-runtime remains the sole CVE evidence).Static-linkage signal chosen (and why)
The cheapest reliable signal is the ELF header (no
PT_INTERP⇒ static). I implemented a minimal, well-scoped ELF-header parser rather than pulling ingoblin/object(zero-egress + no heavy dep). It reads only the ELF header and program-header table, short-circuits at the firstPT_INTERP, and is fully unit-tested with tiny synthetic byte fixtures.DECISION NEEDED (architect): the engine has no in-cluster access to the entrypoint binary bytes today (zero-egress, node-local agent), so
static_binarystaysNonein production until an ELF-byte signal is plumbed in — e.g. the agent POSTing the entrypoint's linkage over the behavioral wire (ADR-0014), or a scanner-reported field. The enum + classifier + adapter + prompt are all wired and tested end-to-end; only the byte source is pending.Nonelinkage means reachability behaves exactly as before this change, so nothing regresses in the interim.Tests
None(never guess, never panic).PresentStaticBinarywhile the same image dynamically-linked staysNotObserved, and a real matching load still yieldsLoadedAtRuntime.Reachabilitylabel round-trip (distinct token fromnot-observed).Checks
cargo fmtclean;cargo clippy --all-targetsclean (warnings as errors);cargo test -p protector→ 772 passed, 0 failed. All touched files under the 1000-line cap (tests split intoelf/tests.rs).🤖 Generated with Claude Code
https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP