Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion engine/src/engine/graph/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ pub struct Image {
/// Lives on the Image (not the Workload) so it is shared by every workload running the
/// same digest, exactly like [`Vulnerability`]. Empty when the reports are absent.
pub exposed_secrets: Vec<ScanFinding>,
/// Whether this image's main binary is **statically linked** (JEF-404). `Some(true)` ⇒
/// a static ELF (Go, a CGO-disabled/musl-static build): everything is compiled into one
/// executable, so the runtime library-load correlation can never fire and a `NotObserved`
/// CVE on this image is really [`Reachability::PresentStaticBinary`] — indeterminate, not
/// observed-absent. `Some(false)` ⇒ dynamically linked (a `NotObserved` CVE IS
/// observed-absent). `None` ⇒ linkage unknown (no ELF signal available), the default:
/// reachability then behaves exactly as before this field existed. Populated from an ELF
/// header classification (see `engine::observe::elf`); never egresses, purely a per-image
/// structural fact.
pub static_binary: Option<bool>,
}

/// A cluster node / host.
Expand Down Expand Up @@ -376,8 +386,19 @@ pub enum Reachability {
/// The vulnerable package was observed loaded at runtime (a `LibraryLoaded`
/// signal matched its package name) — the strongest dynamic-reachability signal.
LoadedAtRuntime,
/// The correlation pass ran but no matching runtime load was observed.
/// The correlation pass ran but no matching runtime load was observed — the CVE's
/// package was NOT seen loaded. This is *observed absent*: the workload dynamically
/// links its libraries, so a load would have fired if the vulnerable code ran.
NotObserved,
/// The workload's main binary is **statically linked** (Go, a CGO-disabled/musl-static
/// build, …), so the library-load correlation *cannot* fire — the vulnerable package is
/// compiled INTO the one executable, never dlopen'd, so no per-`.so` `LibraryLoaded`
/// event will ever name it (JEF-404). Reachability is therefore **indeterminate**, NOT
/// observed-absent: the CVE is present in a running static binary and we simply can't
/// observe it this way. It is CONTEXT (never exploitation evidence — only
/// `LoadedAtRuntime` is that), but its absence-of-a-load must NOT be read as
/// evidence-of-absence the way `NotObserved` can be.
PresentStaticBinary,
}

impl Reachability {
Expand All @@ -387,6 +408,7 @@ impl Reachability {
Reachability::Unknown => "unknown",
Reachability::LoadedAtRuntime => "loaded-at-runtime",
Reachability::NotObserved => "not-observed",
Reachability::PresentStaticBinary => "present-static-binary",
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions engine/src/engine/graph/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ fn node_key_is_stable_across_fact_changes() {
trust: Trust::Unknown,
vulnerabilities: vec![],
exposed_secrets: vec![],
static_binary: None,
});
let scanned = Node::Image(Image {
digest: "sha256:abc".into(),
Expand All @@ -90,6 +91,7 @@ fn node_key_is_stable_across_fact_changes() {
..Default::default()
}],
exposed_secrets: vec![],
static_binary: None,
});
// Identity (digest) drives the key; facts (trust, vulns) do not.
assert_eq!(clean.key(), scanned.key());
Expand All @@ -107,6 +109,7 @@ fn node_key_constructors_match_node_key() {
trust: Trust::Untrusted,
vulnerabilities: vec![],
exposed_secrets: vec![],
static_binary: None,
});
assert_eq!(NodeKey::image("sha256:abc"), image.key());

Expand Down Expand Up @@ -134,6 +137,7 @@ fn upsert_replaces_in_place_and_keeps_edges() {
trust: Trust::Unknown,
vulnerabilities: vec![],
exposed_secrets: vec![],
static_binary: None,
}));
let wl = g.upsert_node(Node::Workload(Workload {
namespace: "app".into(),
Expand Down Expand Up @@ -165,6 +169,7 @@ fn upsert_replaces_in_place_and_keeps_edges() {
..Default::default()
}],
exposed_secrets: vec![],
static_binary: None,
}));
assert_eq!(img2, img, "upsert keeps the same index");
assert_eq!(g.node_count(), 2, "no duplicate node");
Expand Down Expand Up @@ -208,3 +213,22 @@ fn relations_map_to_attack_techniques() {
None
);
}

#[test]
fn reachability_labels_are_stable_and_distinct() {
// Low-cardinality labels feed the prompt, the verdict fingerprint, and metrics, so each
// variant must map to a fixed, distinct token (JEF-51 / JEF-404).
assert_eq!(Reachability::Unknown.label(), "unknown");
assert_eq!(Reachability::LoadedAtRuntime.label(), "loaded-at-runtime");
assert_eq!(Reachability::NotObserved.label(), "not-observed");
assert_eq!(
Reachability::PresentStaticBinary.label(),
"present-static-binary"
);
// The static-indeterminate state is a DISTINCT token from observed-absent — that
// distinction is exactly what stops absence-of-load reading as evidence-of-absence.
assert_ne!(
Reachability::PresentStaticBinary.label(),
Reachability::NotObserved.label()
);
}
18 changes: 18 additions & 0 deletions engine/src/engine/observe/adapter/enrich.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@ impl Adapter for RuntimeAdapter {
/// package, else [`Reachability::NotObserved`]. CVEs with no `pkg_name` stay
/// [`Reachability::Unknown`] — we can't correlate what the scanner didn't name. This
/// is evidence for the model only; it never gates or suppresses anything in v1.
///
/// One exception the library-load correlation structurally cannot cover (JEF-404): a
/// **statically linked** image (`Image::static_binary == Some(true)`) has no per-`.so`
/// loads — everything is compiled into one executable — so `LibraryLoaded` will never name
/// the vulnerable package and `NotObserved` here would be a false "observed absent". For
/// such an image a would-be-`NotObserved` CVE is tagged [`Reachability::PresentStaticBinary`]
/// instead: indeterminate, not absent — its lack of a runtime load is expected, not
/// reassurance. A `LoadedAtRuntime` match (some static binaries still dlopen a plugin) still
/// wins, and a dynamically linked image (`Some(false)`) or one with unknown linkage (`None`)
/// keeps the original `NotObserved` behavior.
pub struct CveReachabilityAdapter;

impl Adapter for CveReachabilityAdapter {
Expand Down Expand Up @@ -266,13 +276,21 @@ impl Adapter for CveReachabilityAdapter {
let loads = loads_by_image.get(&key.0).cloned().unwrap_or_default();
graph.update_node(&key, |node| {
if let Node::Image(img) = node {
// A statically linked image (JEF-404) can never emit a per-`.so` load, so
// an unmatched CVE is indeterminate, not observed-absent. Captured before
// the borrow of `img.vulnerabilities` below.
let is_static = img.static_binary == Some(true);
for vuln in &mut img.vulnerabilities {
let Some(pkg) = vuln.pkg_name.as_deref() else {
// No package name to correlate — leave it Unknown.
continue;
};
vuln.reachability = if loads.iter().any(|lib| library_matches(lib, pkg)) {
// A real load still wins even on a static image (a dlopen'd plugin).
Reachability::LoadedAtRuntime
} else if is_static {
// No load AND the correlation cannot fire → indeterminate, not absent.
Reachability::PresentStaticBinary
} else {
Reachability::NotObserved
};
Expand Down
65 changes: 65 additions & 0 deletions engine/src/engine/observe/adapter/enrich/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,71 @@ fn loaded_matching_library_is_loaded_at_runtime() {
);
}

/// As [`reachability_for`], but the image's main binary is statically linked (JEF-404).
/// Builds the graph (structural adapters set `static_binary: None`), flips the Image's
/// `static_binary` flag on, then re-runs ONLY the [`CveReachabilityAdapter`] so the static
/// case is exercised through the real correlation code, not a reimplementation.
fn reachability_for_static(pkg: &str, loaded_lib: Option<&str>) -> Reachability {
let web = pod(json!({
"apiVersion": "v1", "kind": "Pod",
"metadata": {"name": "web", "namespace": "app", "labels": {"app": "web"}},
"spec": {"containers": [{"name": "web", "image": "web:1"}]}
}));
let runtime_events = loaded_lib.map(|name| vec![lib(name)]).unwrap_or_default();
let snap = Snapshot {
pods: vec![web],
image_vulns: vec![ImageVulnerabilities {
image: "web:1".into(),
vulnerabilities: vec![Vulnerability {
id: "CVE-2021-44228".into(),
severity: crate::engine::graph::Severity::Critical,
pkg_name: Some(pkg.into()),
..Default::default()
}],
}],
runtime_events,
..Default::default()
};
let mut graph = super::super::build_graph(&snap, &super::super::default_adapters());
let img_key = NodeKey::image(&canonical_image("web:1"));
// Mark the image's main binary statically linked — the signal an ELF classification
// (engine::observe::elf) would carry — then re-run the reachability correlation.
graph.update_node(&img_key, |node| {
if let Node::Image(img) = node {
img.static_binary = Some(true);
}
});
CveReachabilityAdapter.contribute(&snap, &mut graph);
let idx = graph.index_of(&img_key).expect("image node exists");
match graph.node(idx) {
Some(Node::Image(img)) => img.vulnerabilities[0].reachability,
_ => panic!("expected image node"),
}
}

#[test]
fn static_binary_cve_without_a_load_is_present_static_binary_not_not_observed() {
// JEF-404: a Go / musl-static image whose vulnerable package can never emit a per-`.so`
// load must NOT read as observed-absent — it is indeterminate (PresentStaticBinary).
assert_eq!(
reachability_for_static("openssl", None),
Reachability::PresentStaticBinary
);
// And the SAME image if it were dynamically linked stays NotObserved — the two cases
// classify differently, which is the whole point of the new state.
assert_eq!(reachability_for("openssl", None), Reachability::NotObserved);
}

#[test]
fn static_binary_still_yields_loaded_at_runtime_on_a_real_load() {
// Some static binaries dlopen a plugin: an actual matching load still wins over the
// static-indeterminate tag — real exploitation evidence is never downgraded (JEF-405).
assert_eq!(
reachability_for_static("log4j-core", Some("log4j-core-2.14.jar")),
Reachability::LoadedAtRuntime
);
}

/// A `LibraryLoaded` observation on pod app/web (the fixture these tests use).
fn lib(name: &str) -> RuntimeObservation {
RuntimeObservation {
Expand Down
6 changes: 6 additions & 0 deletions engine/src/engine/observe/adapter/workload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ impl Adapter for WorkloadAdapter {
trust: Trust::Unknown,
vulnerabilities: vec![],
exposed_secrets: vec![],
// Linkage is unknown at structural-build time (JEF-404): the engine
// holds no in-cluster access to the image's entrypoint bytes here, so
// it stays `None` and reachability behaves as before. An ELF-classified
// signal (see `engine::observe::elf`) would populate it once the bytes
// are plumbed in — see the ticket's DECISION NEEDED note.
static_binary: None,
}));
graph.add_edge(wl, img, observed(self.name(), Relation::RunsImage));
}
Expand Down
135 changes: 135 additions & 0 deletions engine/src/engine/observe/elf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Minimal ELF static-linkage classification (JEF-404).
//!
//! Reachability is proven by correlating a CVE's package against runtime
//! [`Behavior::LibraryLoaded`](protector_behavior::Behavior::LibraryLoaded) events: a
//! `.so` the kernel loads names the vulnerable package. **Statically linked binaries have
//! no per-library `.so` loads** — Go compiles everything into one executable, a
//! CGO-disabled / musl-static build does the same — so `LibraryLoaded` never names the
//! package and every CVE renders `not-observed` even when the vulnerable code is genuinely
//! on a hot path. That absence must not read as evidence-of-absence (see
//! [`Reachability::PresentStaticBinary`](crate::engine::graph::Reachability)).
//!
//! The cheapest reliable signal that a binary is static is its ELF header: a dynamically
//! linked executable carries a `PT_INTERP` program header naming its dynamic loader
//! (`/lib64/ld-linux-x86-64.so.2`, …); a fully static one does **not**. This module reads
//! ONLY the ELF header and the program-header table — no sections, no symbols, no dynamic
//! table — so it is a tiny, well-scoped parser rather than a heavyweight ELF dependency
//! (zero new heavy crate; the graph stays zero-egress — this only ever inspects bytes the
//! engine was already handed).
//!
//! It is byte-only and pure: give it the leading bytes of a binary, get back whether it is
//! statically linked. That keeps it fully unit-testable with tiny synthetic fixtures and
//! keeps *where the bytes come from* a separate plumbing concern.

/// The four-byte ELF magic (`0x7f 'E' 'L' 'F'`) every ELF file starts with.
const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];

/// `e_ident[EI_CLASS]` values: 32-bit vs 64-bit. Their layouts differ only in field widths.
const ELFCLASS32: u8 = 1;
const ELFCLASS64: u8 = 2;

/// `e_ident[EI_DATA]` values: little- vs big-endian. We honor the file's own byte order so
/// a cross-compiled (e.g. big-endian) binary classifies correctly.
const ELFDATA2LSB: u8 = 1;
const ELFDATA2MSB: u8 = 2;

/// Program-header type `PT_INTERP` — names the dynamic loader. Its presence is the
/// definitive "this binary is dynamically linked" signal; its absence (in an otherwise
/// valid executable) means static linkage.
const PT_INTERP: u32 = 3;

/// Classify a binary's ELF header as statically vs dynamically linked (JEF-404).
///
/// Returns:
/// - `Some(true)` — a valid ELF with **no** `PT_INTERP` program header: statically linked.
/// - `Some(false)` — a valid ELF that carries a `PT_INTERP` header: dynamically linked.
/// - `None` — not an ELF, truncated, or a header we can't parse: linkage unknown, so
/// the caller must fall back to the pre-existing behavior (never guess "static").
///
/// Deliberately conservative: any malformed or unrecognized field yields `None` rather than
/// a wrong classification — a false "static" would mislabel a `not-observed` CVE as
/// indeterminate, and a false "dynamic" would do the reverse, so an honest "unknown" is the
/// only safe answer when the bytes don't parse cleanly.
///
/// Only the ELF header (`e_phoff`, `e_phentsize`, `e_phnum`) and the program-header table
/// (each entry's `p_type`) are read — bounded, allocation-free, and it stops at the first
/// `PT_INTERP` it finds.
pub fn elf_static_linkage(bytes: &[u8]) -> Option<bool> {
// e_ident is 16 bytes; the smallest header we parse (32-bit) is 52 bytes. Reject
// anything too short to hold even the fields we read.
if bytes.len() < 20 || bytes[..4] != ELF_MAGIC {
return None;
}
let class = bytes[4];
let data = bytes[5];
let le = match data {
ELFDATA2LSB => true,
ELFDATA2MSB => false,
_ => return None,
};

// Field offsets/sizes differ between the 32- and 64-bit ELF header layouts. We need
// e_phoff (program-header table file offset), e_phentsize (per-entry size), and e_phnum
// (entry count) — plus, per entry, the p_type at the entry's start.
let (phoff_off, phentsize_off, phnum_off) = match class {
ELFCLASS64 => (0x20usize, 0x36usize, 0x38usize),
ELFCLASS32 => (0x1Cusize, 0x2Ausize, 0x2Cusize),
_ => return None,
};

let phoff = match class {
ELFCLASS64 => read_u64(bytes, phoff_off, le)? as usize,
_ => read_u32(bytes, phoff_off, le)? as usize,
};
let phentsize = read_u16(bytes, phentsize_off, le)? as usize;
let phnum = read_u16(bytes, phnum_off, le)? as usize;

// No program headers at all (e.g. a relocatable object, or e_phoff == 0): we cannot see
// an interpreter, so we cannot assert static linkage — report unknown.
if phoff == 0 || phnum == 0 || phentsize < 4 {
return None;
}

// p_type is the FIRST 4 bytes of every program-header entry in both ELF classes, so we
// only ever read those 4 bytes per entry regardless of 32/64-bit width.
for i in 0..phnum {
let entry = phoff.checked_add(i.checked_mul(phentsize)?)?;
let p_type = read_u32(bytes, entry, le)?;
if p_type == PT_INTERP {
// A dynamic loader is named → dynamically linked.
return Some(false);
}
}
// A valid ELF whose program headers name no interpreter → statically linked.
Some(true)
}

fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
let b: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
Some(if le {
u16::from_le_bytes(b)
} else {
u16::from_be_bytes(b)
})
}

fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
let b: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
Some(if le {
u32::from_le_bytes(b)
} else {
u32::from_be_bytes(b)
})
}

fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
let b: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
Some(if le {
u64::from_le_bytes(b)
} else {
u64::from_be_bytes(b)
})
}

#[cfg(test)]
mod tests;
Loading
Loading