diff --git a/agent/common/src/lib.rs b/agent/common/src/lib.rs index 0736d9c..859e043 100644 --- a/agent/common/src/lib.rs +++ b/agent/common/src/lib.rs @@ -163,6 +163,30 @@ impl WriteKey { } } +/// Dedup key for the credential-basename read gate (JEF-320 security rework): the +/// `(pid, inode)` tuple, same shape as [`WriteKey`] but a distinct type (its own LRU map, +/// its own gate) so the two dedup domains can't be mixed up at a call site. Bounds a HIGH +/// finding from security review: `try_file_open`'s widening past `is_tmpfs` to a small +/// basename allowlist (`SENSITIVE_CREDENTIAL_BASENAMES` in the eBPF crate) had no dedup, so +/// an attacker with code-exec in any pod could spam reads of a matched basename (e.g. +/// `/etc/shadow`, a `credentials` file) to exhaust the single shared ring buffer and force +/// `record_drop()` to silently drop real exec/priv-change/connect signals node-wide — a +/// sensor-blinding / detection-evasion primitive. Coalescing on `(pid, inode)` collapses +/// exactly that chatty-reader case at the source, same as [`WriteKey`] does for writes. +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct ReadKey { + pub pid: u32, + pub ino: u64, +} + +impl ReadKey { + /// Build the dedup key for a read by `pid` of the file with inode `ino`. + pub fn new(pid: u32, ino: u64) -> Self { + Self { pid, ino } + } +} + /// Whether a repeat event keyed at `last_ns` should be coalesced (suppressed) at `now_ns`, /// given the dedup `window_ns` (JEF-65). The single source of truth for the dedup /// decision, shared verbatim by the kernel probe and the userspace tests so the two can't @@ -235,4 +259,15 @@ mod tests { assert_ne!(base, WriteKey::new(9999, 42)); assert_ne!(base, WriteKey::new(1234, 43)); } + + #[test] + fn read_key_distinguishes_pid_and_inode() { + // The credential-basename-read dedup key (JEF-320 security rework) mirrors + // WriteKey's equality shape: same (pid, inode) pair compares equal, either field + // differing does not. + let base = ReadKey::new(1234, 42); + assert_eq!(base, ReadKey::new(1234, 42)); + assert_ne!(base, ReadKey::new(9999, 42)); + assert_ne!(base, ReadKey::new(1234, 43)); + } } diff --git a/agent/protector-agent-ebpf/src/main.rs b/agent/protector-agent-ebpf/src/main.rs index 25f366f..10d0c99 100644 --- a/agent/protector-agent-ebpf/src/main.rs +++ b/agent/protector-agent-ebpf/src/main.rs @@ -35,7 +35,7 @@ use aya_ebpf::{ // dedup key/window/decision (JEF-65) live here too so the kernel probe and the userspace // tests share one definition and can't drift. use protector_agent_common::{ - should_coalesce, ConnEvent, ConnKey, EventHeader, FileEvent, PrivEvent, WriteKey, + should_coalesce, ConnEvent, ConnKey, EventHeader, FileEvent, PrivEvent, ReadKey, WriteKey, DEDUP_MAP_CAP, DEDUP_WINDOW_NS, KIND_CONNECT, KIND_EXEC, KIND_FILE_OPEN, KIND_FILE_WRITE, KIND_LIBRARY_LOAD, KIND_PRIV_CHANGE, PATH_CAP, }; @@ -163,6 +163,40 @@ fn allow_write(key: &WriteKey) -> bool { true } +/// In-kernel dedup map for the credential-basename read gate (JEF-320 security rework): +/// `(pid, inode)` → last-emit time (ns). Bounds a HIGH finding from security review: the +/// `try_file_open` widening past `is_tmpfs` to `SENSITIVE_CREDENTIAL_BASENAMES` had no +/// dedup, so a chatty reader of a matched basename (e.g. repeatedly opening `/etc/shadow` +/// or a `credentials` file) could flood the single shared ring and starve real exec/priv- +/// change/connect signals via `record_drop()` — a sensor-blinding primitive. Same shape, +/// sizing, and eviction style as [`WRITE_SEEN`]. +#[map] +static CREDENTIAL_READ_SEEN: LruHashMap = + LruHashMap::with_max_entries(DEDUP_MAP_CAP, 0); + +/// The credential-basename-read dedup gate (JEF-320 security rework), mirroring +/// [`allow_write`]. Returns `true` if this read of `key` should be emitted, `false` if +/// it's a repeat inside [`DEDUP_WINDOW_NS`] and was coalesced (the shared [`COALESCED`] +/// counter is bumped here). On emit, stamps `now` so the next repeat is measured from it. +/// Fail open: an insert that never fails falls through to emit, so a bookkeeping error +/// never silently loses a real signal. The first sighting of a key (no entry) always emits. +fn allow_credential_read(key: &ReadKey) -> bool { + let now = unsafe { bpf_ktime_get_ns() }; + if let Some(last) = CREDENTIAL_READ_SEEN.get_ptr_mut(key) { + // SAFETY: `last` points at this key's live slot; we read then overwrite it. + let last_ns = unsafe { *last }; + if should_coalesce(last_ns, now, DEDUP_WINDOW_NS) { + record_coalesced(); + return false; + } + unsafe { *last = now }; + return true; + } + // First time we've seen this key (or it was LRU-evicted): record and emit. + let _ = CREDENTIAL_READ_SEEN.insert(key, &now, 0); + true +} + // Minimal kernel sockaddr layout for the IPv4 case. We only touch the family and the // `sockaddr_in` address/port; reads are bounds-checked by `bpf_probe_read_kernel`. const AF_INET: u16 = 2; @@ -248,6 +282,55 @@ fn try_connect(ctx: &ProbeContext) -> Result<(), i64> { /// no universal secret marker (see docs/ebpf-testing-on-nodes.md). const TMPFS_MAGIC: u64 = 0x0102_1994; +/// A small, fixed allowlist of on-host credential-file BASENAMES (JEF-320, Retire-Falco +/// G3) — the cheap in-kernel volume gate that lets `try_file_open` widen past `is_tmpfs` +/// for a read that might be the host shadow/gshadow/sudoers file, an SSH private key, or a +/// cloud-provider credential file. These live on the container's ordinary rootfs +/// (overlayfs), not tmpfs, so `is_tmpfs` alone never sees them — but letting EVERY +/// non-tmpfs read through would reopen the full file-open firehose `is_tmpfs` exists to +/// avoid. This basename check is deliberately short and distinctive (unlike `is_tmpfs`, +/// which is broad by design) so it stays nowhere near that volume. The volume this list +/// lets past `is_tmpfs` is additionally bounded in-kernel by [`allow_credential_read`] (a +/// second HIGH finding: an unbounded matched-basename read let an attacker flood the +/// shared ring — see its doc comment). +/// +/// This is NOT the security classification — same division of labor as the existing +/// tmpfs-scoped probe: the agent stays pure data (JEF-113), and the engine +/// (`engine::observe::host_credential_class`) makes the real "is this path a known +/// on-host credential path" call from the FULL path `bpf_d_path` returns below. +/// +/// **KEEP IN SYNC with `engine::observe::host_credential_class`** (security rework, HIGH +/// finding): this list is the coarse in-kernel PRE-filter, that module makes the precise +/// security decision — a basename here that the engine no longer classifies as a +/// credential is pure ring pressure with zero detection value. `passwd` and `known_hosts` +/// were REMOVED (world-readable / hold no secret material, matching +/// `host_credential_class::EXACT_HOST_PATHS` dropping `passwd` and +/// `SSH_NON_CREDENTIAL_BASENAME` excluding `known_hosts`); `gshadow` was ADDED (matches +/// `EXACT_HOST_PATHS`). The coarse basename `credentials` deliberately stays even though +/// it's shared by every cloud provider's config dir — the engine does the precise +/// directory-paired path check (`CLOUD_CREDENTIAL_FILES`); the dedup gate above bounds how +/// often a match on it can cost a ring slot. +const SENSITIVE_CREDENTIAL_BASENAMES: &[&[u8]] = &[ + b"shadow", + b"gshadow", + b"sudoers", + b"authorized_keys", + b"id_rsa", + b"id_dsa", + b"id_ecdsa", + b"id_ed25519", + b"credentials", + b"application_default_credentials.json", + b"azureProfile.json", + b"accessTokens.json", + b"msal_token_cache.json", +]; + +/// Read buffer for [`is_sensitive_credential_basename`] — sized to the longest entry in +/// [`SENSITIVE_CREDENTIAL_BASENAMES`] (`application_default_credentials.json`, 38 bytes) +/// plus room for the NUL terminator. +const CREDENTIAL_BASENAME_CAP: usize = 48; + /// `PROT_EXEC` — an executable memory mapping. The dynamic linker mmaps shared objects /// (and the main binary) executable, so this distinguishes a code load from a data mmap. const PROT_EXEC: u64 = 0x4; @@ -273,7 +356,12 @@ fn is_write_open(flags: u64) -> bool { /// fentry on `security_file_open(struct file *file)` — the secret-read probe (ADR-0014). /// For a tmpfs read, emits a [`FileEvent`] with the container-relative path via /// `bpf_d_path`; the engine maps it to a SecretRead (or drops it). Filtering to tmpfs -/// in-kernel keeps the (very high) file-open volume off the ring buffer. Observe-only. +/// in-kernel keeps the (very high) file-open volume off the ring buffer. JEF-320 widens +/// this past tmpfs for a small, fixed allowlist of on-host credential-file basenames (see +/// [`SENSITIVE_CREDENTIAL_BASENAMES`]), bounded by the [`allow_credential_read`] dedup gate +/// (security rework) so a chatty reader of a matched basename can't flood the ring — ON-NODE +/// LOAD VALIDATION PENDING for that widening (docs/ebpf-testing-on-nodes.md: this crate +/// can't be compiled or verifier-tested off the fleet). Observe-only. #[fentry(function = "security_file_open")] pub fn file_open(ctx: FEntryContext) -> u32 { let _ = try_file_open(&ctx); @@ -283,10 +371,27 @@ pub fn file_open(ctx: FEntryContext) -> u32 { fn try_file_open(ctx: &FEntryContext) -> Result<(), i64> { // security_file_open's first argument is `struct file *file`. let file: *const vmlinux::file = unsafe { ctx.arg(0) }; - if file.is_null() || !is_tmpfs(file) { + if file.is_null() { + return Ok(()); + } + if is_tmpfs(file) { + emit_file_path(file, KIND_FILE_OPEN); return Ok(()); } - emit_file_path(file, KIND_FILE_OPEN); + if is_sensitive_credential_basename(file) { + // JEF-320 security rework: dedup gate on (pid, inode) — a chatty reader of a + // matched basename (e.g. hammering `/etc/shadow` or a `credentials` file) must not + // be able to flood the single shared ring and starve real exec/priv-change/connect + // signals. A missing inode still emits (fail open, mirrors `try_file_write`): the + // dedup is a volume optimization, not a correctness gate. + let pid = (aya_ebpf::helpers::bpf_get_current_pid_tgid() >> 32) as u32; + if let Some(ino) = inode_ino(file) { + if !allow_credential_read(&ReadKey::new(pid, ino)) { + return Ok(()); + } + } + emit_file_path(file, KIND_FILE_OPEN); + } Ok(()) } @@ -567,6 +672,48 @@ fn emit_lib_name(file: *const vmlinux::file) { } } +/// Whether `file`'s leaf dentry name is one of [`SENSITIVE_CREDENTIAL_BASENAMES`] +/// (JEF-320) — the cheap volume gate for `try_file_open`'s past-tmpfs widening. Reads the +/// dentry's `d_name` directly rather than `bpf_d_path`ing every non-tmpfs open, the same +/// allowed-anywhere pattern as [`emit_lib_name`]. A failed read = "not sensitive" (drop, +/// never a false allow). +fn is_sensitive_credential_basename(file: *const vmlinux::file) -> bool { + let mut dentry: *mut vmlinux::dentry = core::ptr::null_mut(); + let mut name_ptr: *const u8 = core::ptr::null(); + unsafe { + if read_kernel(&mut dentry, core::ptr::addr_of!((*file).f_path.dentry)) != 0 + || dentry.is_null() + { + return false; + } + if read_kernel( + &mut name_ptr, + core::ptr::addr_of!((*dentry).d_name.name).cast(), + ) != 0 + || name_ptr.is_null() + { + return false; + } + } + let mut buf = [0u8; CREDENTIAL_BASENAME_CAP]; + let n = unsafe { + bpf_probe_read_kernel_str( + buf.as_mut_ptr() as *mut core::ffi::c_void, + CREDENTIAL_BASENAME_CAP as u32, + name_ptr as *const core::ffi::c_void, + ) + }; + if n <= 0 { + return false; + } + // `n` counts the trailing NUL (see emit_lib_name); clamp defensively before slicing so + // a bigger-than-expected return can never index out of `buf`. + let len = (n as usize).min(CREDENTIAL_BASENAME_CAP).saturating_sub(1); + SENSITIVE_CREDENTIAL_BASENAMES + .iter() + .any(|&want| want == &buf[..len]) +} + /// Whether `file` lives on a tmpfs — `file->f_inode->i_sb->s_magic == TMPFS_MAGIC`. The /// pointer chase uses bpf_probe_read_kernel (fixed offsets from the node-BTF vmlinux), /// the same safe pattern as the connect probe. A failed read = "not tmpfs" (drop). diff --git a/behavior/src/lib.rs b/behavior/src/lib.rs index ee30d75..c107cc6 100644 --- a/behavior/src/lib.rs +++ b/behavior/src/lib.rs @@ -27,11 +27,14 @@ pub enum Behavior { /// An outbound connection the workload made; `internet` if it left the cluster. NetworkConnection { peer: String, internet: bool }, /// A read of a secret. `source` distinguishes *how* it was read: a mounted-file read - /// (the eBPF agent's on-disk path) or a Kubernetes API GET/LIST/WATCH via the + /// (the eBPF agent's on-disk path), a Kubernetes API GET/LIST/WATCH via the /// workload's ServiceAccount RBAC (observed engine-side from the apiserver audit log, - /// JEF-269) — two genuinely different runtime facts that both reach the same secret. - /// Older sensors omit `source`, which defaults to [`SecretReadSource::Mounted`] (the - /// only kind eBPF can see), preserving the pre-existing wire shape. + /// JEF-269), or a well-known ON-HOST credential path — the host shadow file, an SSH + /// private-key dir, a cloud-credential file (observed engine-side from the path alone, + /// JEF-320) — three genuinely different runtime facts that all reach credential + /// material. Older sensors omit `source`, which defaults to + /// [`SecretReadSource::Mounted`] (the only kind eBPF originally saw), preserving the + /// pre-existing wire shape. SecretRead { secret: String, #[serde(default, skip_serializing_if = "SecretReadSource::is_mounted")] @@ -100,6 +103,12 @@ pub enum SecretReadSource { /// `secrets`) via the workload's ServiceAccount RBAC — a TLS call to the apiserver /// eBPF cannot attribute as a secret read. Observed engine-side from the audit log. Api, + /// The path read is a well-known ON-HOST sensitive credential path — the host + /// password/shadow file, a per-user SSH private-key directory, or a cloud-provider + /// credential file — outside any k8s Secret mount (JEF-320, Retire-Falco G3). The + /// eBPF agent still only emits a path (pure data, JEF-113); the engine classifies it + /// (`engine::observe::host_credential_class`), same division of labor as `Mounted`. + HostPath, } impl SecretReadSource { @@ -181,6 +190,9 @@ impl Behavior { Behavior::SecretRead { secret, source } => match source { SecretReadSource::Mounted => format!("reads secret {secret}"), SecretReadSource::Api => format!("reads secret {secret} (via Kubernetes API)"), + SecretReadSource::HostPath => { + format!("reads secret {secret} (on-host filesystem)") + } }, Behavior::LibraryLoaded { name } => format!("loaded library {name}"), Behavior::FileRead { path } => format!("opened file {path}"), @@ -231,6 +243,10 @@ impl Behavior { secret, source: SecretReadSource::Api, } => format!("read-api:{secret}"), + Behavior::SecretRead { + secret, + source: SecretReadSource::HostPath, + } => format!("read-host:{secret}"), Behavior::LibraryLoaded { name } => format!("lib:{name}"), Behavior::FileRead { path } => format!("file:{path}"), // Keyed on the gained UID only (always 0 today, but stable if the escalation @@ -415,525 +431,4 @@ pub struct RuntimeReport { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn behavior_serializes_to_the_kind_tagged_contract() { - let v = serde_json::to_value(Behavior::NetworkConnection { - peer: "1.2.3.4:443".into(), - internet: true, - }) - .unwrap(); - assert_eq!( - v, - serde_json::json!({"kind": "network_connection", "peer": "1.2.3.4:443", "internet": true}) - ); - } - - #[test] - fn resolves_in_applies_the_attribution_resolution_rule() { - // A namespace/name attribution always resolves — even when the lookup - // would reject everything. - assert!(Attribution::by_namespaced_name("app", "web").resolves_in(|_| false)); - // A cgroup-UID attribution (the eBPF agent) resolves iff the UID is known. - assert!(Attribution::by_pod_uid("uid-1").resolves_in(|uid| uid == "uid-1")); - assert!(!Attribution::by_pod_uid("uid-unknown").resolves_in(|uid| uid == "uid-1")); - } - - #[test] - fn observation_roundtrips_and_omits_absent_optionals() { - // An eBPF-agent observation: attributed by uid, source + time set. - let obs = RuntimeObservation { - attribution: Attribution::by_pod_uid("uid"), - source: Some("protector-agent".into()), - observed_at_ms: Some(1_710_000_000_000), - node: None, - behavior: Behavior::SecretRead { - secret: "app/session-key".into(), - source: SecretReadSource::Mounted, - }, - }; - let v = serde_json::to_value(&obs).unwrap(); - assert_eq!( - v, - serde_json::json!({ - "pod_uid": "uid", - "source": "protector-agent", - "observed_at_ms": 1_710_000_000_000u64, - "behavior": {"kind": "secret_read", "secret": "app/session-key"} - }) - ); - assert_eq!( - serde_json::from_value::(v).unwrap(), - obs - ); - } - - #[test] - fn secret_read_source_distinguishes_mounted_from_api() { - // Mounted is the default and is OMITTED on the wire, so the eBPF agent's existing - // `{"kind":"secret_read","secret":"..."}` contract is byte-for-byte unchanged. - let mounted = Behavior::SecretRead { - secret: "app/db".into(), - source: SecretReadSource::Mounted, - }; - assert_eq!( - serde_json::to_value(&mounted).unwrap(), - serde_json::json!({"kind": "secret_read", "secret": "app/db"}) - ); - // An absent `source` deserializes back to Mounted (older sensors). - let from_legacy: Behavior = - serde_json::from_value(serde_json::json!({"kind": "secret_read", "secret": "app/db"})) - .unwrap(); - assert_eq!(from_legacy, mounted); - - // An API read serializes its source explicitly and round-trips. - let api = Behavior::SecretRead { - secret: "app/db".into(), - source: SecretReadSource::Api, - }; - let v = serde_json::to_value(&api).unwrap(); - assert_eq!( - v, - serde_json::json!({"kind": "secret_read", "secret": "app/db", "source": "api"}) - ); - assert_eq!(serde_json::from_value::(v).unwrap(), api); - - // The two are distinguishable everywhere it matters: summary prose and the - // verdict-cache fingerprint. The metric label stays the coarse shared token. - assert_eq!(mounted.summary(), "reads secret app/db"); - assert_eq!(api.summary(), "reads secret app/db (via Kubernetes API)"); - assert_eq!(mounted.fingerprint_key(), "read:app/db"); - assert_eq!(api.fingerprint_key(), "read-api:app/db"); - assert_ne!(mounted.fingerprint_key(), api.fingerprint_key()); - assert_eq!(mounted.variant_label(), api.variant_label()); - } - - #[test] - fn namespaced_observation_deserializes_from_namespace_pod() { - // A metadata-attributed observation: ns/pod set, no uid/source/time. - let obs: RuntimeObservation = serde_json::from_value(serde_json::json!({ - "namespace": "app", "pod": "web", - "behavior": {"kind": "alert", "rule": "Terminal shell in container"} - })) - .unwrap(); - assert_eq!( - obs.attribution, - Attribution::by_namespaced_name("app", "web") - ); - assert!(obs.behavior.is_alert()); - } - - #[test] - fn process_exec_fingerprint_coarsens_to_basename() { - // Different absolute paths to the same binary must collapse to one stable key so - // exec churn doesn't bust the verdict cache (mirrors LibraryLoaded's basename key). - let a = Behavior::ProcessExec { - path: "/usr/bin/bash".into(), - }; - let b = Behavior::ProcessExec { - path: "/bin/bash".into(), - }; - assert_eq!(a.fingerprint_key(), "exec:bash"); - assert_eq!(a.fingerprint_key(), b.fingerprint_key()); - // The wire type's summary is the bare path; *classification* of a notable exec - // (shell / package manager) is engine policy (engine::observe::exec_class, JEF-113), - // so it's not annotated here. - assert_eq!(a.summary(), "executed /usr/bin/bash"); - } - - #[test] - fn process_exec_summary_is_the_bare_path() { - // The shared wire type emits only the path — engine policy decides if it's notable - // (a shell / package manager) and annotates the prompt/output line (JEF-113). - let shell = Behavior::ProcessExec { - path: "/bin/bash".into(), - }; - let normal = Behavior::ProcessExec { - path: "/app/server".into(), - }; - assert_eq!(shell.summary(), "executed /bin/bash"); - assert_eq!(normal.summary(), "executed /app/server"); - // Classification is engine evidence, NOT action-bar corroboration — only Alerts - // corroborate from the wire type's perspective. - assert!(!shell.is_alert()); - } - - #[test] - fn variant_label_is_a_stable_low_cardinality_token() { - // Each variant maps to a fixed token carrying NO per-instance payload (no peer, - // path, or secret name) — so it's safe as a metric label without cardinality blow-up. - let cases: [(Behavior, &str); 9] = [ - (Behavior::Alert { rule: "x".into() }, "alert"), - ( - Behavior::NetworkConnection { - peer: "1.2.3.4:443".into(), - internet: true, - }, - "connection", - ), - ( - Behavior::SecretRead { - secret: "s".into(), - source: SecretReadSource::Mounted, - }, - "secret-read", - ), - (Behavior::LibraryLoaded { name: "l".into() }, "library-load"), - (Behavior::FileRead { path: "/p".into() }, "file-read"), - ( - Behavior::PrivilegeChange { - from_uid: 1000, - to_uid: 0, - }, - "priv-change", - ), - ( - Behavior::ProcessExec { - path: "/bin/bash".into(), - }, - "exec", - ), - ( - Behavior::FileWrite { - path: "/etc/cron.d/x".into(), - }, - "file-write", - ), - ( - Behavior::ImageLinkage { - static_linkage: true, - }, - "image-linkage", - ), - ]; - for (behavior, want) in cases { - assert_eq!(behavior.variant_label(), want, "{behavior:?}"); - } - } - - #[test] - fn file_write_fingerprint_coarsens_to_the_dirname() { - // Per-file write churn within a directory must collapse to one stable key so a - // burst of writes (drop-and-execute, a config dir rewritten file-by-file) doesn't - // bust the verdict cache — the write signal is high-frequency (JEF-306). - let a = Behavior::FileWrite { - path: "/etc/cron.d/dropper".into(), - }; - let b = Behavior::FileWrite { - path: "/etc/cron.d/other".into(), - }; - assert_eq!(a.fingerprint_key(), "write:/etc/cron.d"); - assert_eq!(a.fingerprint_key(), b.fingerprint_key()); - // A top-level path and a bare filename coarsen to `/` (low cardinality, never panics). - assert_eq!( - Behavior::FileWrite { - path: "/passwd".into() - } - .fingerprint_key(), - "write:/" - ); - assert_eq!( - Behavior::FileWrite { - path: "relative".into() - } - .fingerprint_key(), - "write:/" - ); - } - - #[test] - fn file_write_summary_is_the_bare_path_and_never_corroborates() { - // The shared wire type emits only the path — whether the write is *sensitive* - // (container drift / config tampering) is engine corroboration policy (JEF-306 F3), - // so it's pure data here and, like other mundane behaviors, never an alert. - let w = Behavior::FileWrite { - path: "/etc/ssh/sshd_config".into(), - }; - assert_eq!(w.summary(), "wrote file /etc/ssh/sshd_config"); - assert!(!w.is_alert()); - } - - #[test] - fn file_write_serializes_to_the_kind_tagged_contract() { - // Pure-data wire shape: `{"kind":"file_write","path":"..."}`, round-trips (JEF-306). - let w = Behavior::FileWrite { - path: "/etc/cron.d/x".into(), - }; - let v = serde_json::to_value(&w).unwrap(); - assert_eq!( - v, - serde_json::json!({"kind": "file_write", "path": "/etc/cron.d/x"}) - ); - assert_eq!(serde_json::from_value::(v).unwrap(), w); - } - - #[test] - fn observation_carries_the_node_and_omits_it_when_absent() { - // JEF-308: the agent stamps its node so coverage is derivable PER NODE. When present it - // rides the wire; when absent (a node-agnostic sensor, older agents) it is omitted — never guessed. - let with_node = RuntimeObservation { - attribution: Attribution::by_pod_uid("uid"), - source: Some("protector-agent".into()), - observed_at_ms: None, - node: Some("node-a".into()), - behavior: Behavior::ProcessExec { - path: "/bin/sh".into(), - }, - }; - let v = serde_json::to_value(&with_node).unwrap(); - assert_eq!(v["node"], serde_json::json!("node-a")); - assert_eq!( - serde_json::from_value::(v).unwrap(), - with_node - ); - - // Absent node ⇒ the key is omitted (byte-stable for node-agnostic sensors), and a legacy - // observation with no `node` deserializes back to `None`. - let no_node: RuntimeObservation = serde_json::from_value(serde_json::json!({ - "namespace": "app", "pod": "web", - "behavior": {"kind": "alert", "rule": "shell"} - })) - .unwrap(); - assert_eq!(no_node.node, None); - let reser = serde_json::to_value(&no_node).unwrap(); - assert!( - reser.get("node").is_none(), - "absent node is omitted on the wire" - ); - } - - #[test] - fn agent_report_round_trips_and_classifies_blind_vs_partial() { - // A healthy report: all probes loaded, some signals — round-trips. - let healthy = AgentReport { - node: "node-a".into(), - probes_loaded: 6, - probes_total: 6, - signals_emitted: 12, - observed_at_ms: Some(1_710_000_000_000), - }; - let v = serde_json::to_value(&healthy).unwrap(); - assert_eq!(serde_json::from_value::(v).unwrap(), healthy); - assert!(!healthy.is_blind()); - assert!(!healthy.is_partial()); - - // Quiet but healthy: probes loaded, zero signals — NOT blind, NOT partial (quiet≠blind). - let quiet = AgentReport { - signals_emitted: 0, - ..healthy.clone() - }; - assert!( - !quiet.is_blind(), - "a quiet node with probes loaded is not blind" - ); - assert!(!quiet.is_partial()); - - // Ready but blind: the agent is up but no probe attached — blind despite pod-Ready. - let blind = AgentReport { - probes_loaded: 0, - ..healthy.clone() - }; - assert!(blind.is_blind()); - assert!( - !blind.is_partial(), - "zero probes reads as blind, not partial" - ); - - // Partial: some but not all probes attached — degraded coverage. - let partial = AgentReport { - probes_loaded: 4, - ..healthy - }; - assert!(!partial.is_blind()); - assert!(partial.is_partial()); - } - - #[test] - fn agent_report_observed_at_ms_is_omitted_when_absent() { - let report = AgentReport { - node: "n".into(), - probes_loaded: 1, - probes_total: 1, - signals_emitted: 0, - observed_at_ms: None, - }; - let v = serde_json::to_value(&report).unwrap(); - assert!(v.get("observed_at_ms").is_none()); - assert_eq!(serde_json::from_value::(v).unwrap(), report); - } - - #[test] - fn runtime_report_round_trips_with_observations_and_liveness() { - // JEF-336: the unified envelope carries the window's observations AND the per-node - // liveness beacon in one shape, and round-trips byte-for-byte. - let report = RuntimeReport { - observations: vec![RuntimeObservation { - attribution: Attribution::by_pod_uid("uid"), - source: Some("protector-agent".into()), - observed_at_ms: None, - node: Some("node-a".into()), - behavior: Behavior::Alert { - rule: "Terminal shell in container".into(), - }, - }], - liveness: Some(AgentReport { - node: "node-a".into(), - probes_loaded: 6, - probes_total: 6, - signals_emitted: 1, - observed_at_ms: None, - }), - }; - let v = serde_json::to_value(&report).unwrap(); - assert!(v.get("observations").is_some()); - assert_eq!(v["liveness"]["node"], serde_json::json!("node-a")); - assert_eq!(serde_json::from_value::(v).unwrap(), report); - } - - #[test] - fn runtime_report_omits_empty_observations_and_absent_liveness() { - // A quiet node's envelope: no observations, liveness present — `observations` is omitted - // (skip_serializing_if empty) so the wire is just `{"liveness":{...}}`. - let quiet = RuntimeReport { - observations: Vec::new(), - liveness: Some(AgentReport { - node: "node-a".into(), - probes_loaded: 6, - probes_total: 6, - signals_emitted: 0, - observed_at_ms: None, - }), - }; - let v = serde_json::to_value(&quiet).unwrap(); - assert!( - v.get("observations").is_none(), - "empty observations omitted from the wire" - ); - assert!(v.get("liveness").is_some()); - assert_eq!(serde_json::from_value::(v).unwrap(), quiet); - - // A third-party observations-only envelope: liveness absent → `liveness` omitted, and it - // deserializes back with `liveness: None` (the ADR-0003 tool-agnostic path). - let obs_only = RuntimeReport { - observations: vec![RuntimeObservation { - attribution: Attribution::by_namespaced_name("app", "web"), - source: None, - observed_at_ms: None, - node: None, - behavior: Behavior::LibraryLoaded { - name: "openssl".into(), - }, - }], - liveness: None, - }; - let v = serde_json::to_value(&obs_only).unwrap(); - assert!( - v.get("liveness").is_none(), - "absent liveness omitted from the wire" - ); - assert_eq!( - serde_json::from_value::(v).unwrap(), - obs_only - ); - } - - #[test] - fn image_linkage_serializes_to_the_kind_tagged_contract_and_round_trips() { - // JEF-407: the linkage signal rides the same `{"kind": "...", ...}` behavioral wire. - // A static-linkage report and a dynamic one both round-trip byte-for-byte. - let stat = Behavior::ImageLinkage { - static_linkage: true, - }; - let v = serde_json::to_value(&stat).unwrap(); - assert_eq!( - v, - serde_json::json!({"kind": "image_linkage", "static_linkage": true}) - ); - assert_eq!(serde_json::from_value::(v).unwrap(), stat); - - let dynm = Behavior::ImageLinkage { - static_linkage: false, - }; - let v = serde_json::to_value(&dynm).unwrap(); - assert_eq!( - v, - serde_json::json!({"kind": "image_linkage", "static_linkage": false}) - ); - assert_eq!(serde_json::from_value::(v).unwrap(), dynm); - } - - #[test] - fn image_linkage_is_context_not_corroboration() { - // A structural fact about the image, never an "attack is happening now" signal — - // only Alerts corroborate the action bar (else linkage would fire it, which is wrong). - assert!( - !Behavior::ImageLinkage { - static_linkage: true - } - .is_alert() - ); - // Distinct summaries and fingerprints for the two linkage states. - assert_eq!( - Behavior::ImageLinkage { - static_linkage: true - } - .summary(), - "entrypoint is a statically linked binary" - ); - assert_eq!( - Behavior::ImageLinkage { - static_linkage: false - } - .summary(), - "entrypoint is a dynamically linked binary" - ); - assert_ne!( - Behavior::ImageLinkage { - static_linkage: true - } - .fingerprint_key(), - Behavior::ImageLinkage { - static_linkage: false - } - .fingerprint_key() - ); - } - - #[test] - fn image_linkage_observation_round_trips_over_the_wire() { - // The full RuntimeObservation the agent POSTs for a static entrypoint — attributed by - // pod UID (the eBPF agent's path), source + node stamped — round-trips. - let obs = RuntimeObservation { - attribution: Attribution::by_pod_uid("uid"), - source: Some("protector-agent".into()), - observed_at_ms: None, - node: Some("node-a".into()), - behavior: Behavior::ImageLinkage { - static_linkage: true, - }, - }; - let v = serde_json::to_value(&obs).unwrap(); - assert_eq!( - v["behavior"], - serde_json::json!({"kind": "image_linkage", "static_linkage": true}) - ); - assert_eq!( - serde_json::from_value::(v).unwrap(), - obs - ); - } - - #[test] - fn only_alert_corroborates() { - assert!(Behavior::Alert { rule: "x".into() }.is_alert()); - assert!( - !Behavior::NetworkConnection { - peer: "p".into(), - internet: true - } - .is_alert() - ); - } -} +mod tests; diff --git a/behavior/src/tests.rs b/behavior/src/tests.rs new file mode 100644 index 0000000..85fd1ad --- /dev/null +++ b/behavior/src/tests.rs @@ -0,0 +1,560 @@ +//! Unit tests for the behavioral wire contract. Moved out of `lib.rs`'s +//! `#[cfg(test)] mod tests` block into its own file (JEF-320) per the repo's 1,000-line +//! file cap — `lib.rs` was approaching it. No test content changed by the move. + +use super::*; + +#[test] +fn behavior_serializes_to_the_kind_tagged_contract() { + let v = serde_json::to_value(Behavior::NetworkConnection { + peer: "1.2.3.4:443".into(), + internet: true, + }) + .unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "network_connection", "peer": "1.2.3.4:443", "internet": true}) + ); +} + +#[test] +fn resolves_in_applies_the_attribution_resolution_rule() { + // A namespace/name attribution always resolves — even when the lookup + // would reject everything. + assert!(Attribution::by_namespaced_name("app", "web").resolves_in(|_| false)); + // A cgroup-UID attribution (the eBPF agent) resolves iff the UID is known. + assert!(Attribution::by_pod_uid("uid-1").resolves_in(|uid| uid == "uid-1")); + assert!(!Attribution::by_pod_uid("uid-unknown").resolves_in(|uid| uid == "uid-1")); +} + +#[test] +fn observation_roundtrips_and_omits_absent_optionals() { + // An eBPF-agent observation: attributed by uid, source + time set. + let obs = RuntimeObservation { + attribution: Attribution::by_pod_uid("uid"), + source: Some("protector-agent".into()), + observed_at_ms: Some(1_710_000_000_000), + node: None, + behavior: Behavior::SecretRead { + secret: "app/session-key".into(), + source: SecretReadSource::Mounted, + }, + }; + let v = serde_json::to_value(&obs).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "pod_uid": "uid", + "source": "protector-agent", + "observed_at_ms": 1_710_000_000_000u64, + "behavior": {"kind": "secret_read", "secret": "app/session-key"} + }) + ); + assert_eq!( + serde_json::from_value::(v).unwrap(), + obs + ); +} + +#[test] +fn secret_read_source_distinguishes_mounted_from_api() { + // Mounted is the default and is OMITTED on the wire, so the eBPF agent's existing + // `{"kind":"secret_read","secret":"..."}` contract is byte-for-byte unchanged. + let mounted = Behavior::SecretRead { + secret: "app/db".into(), + source: SecretReadSource::Mounted, + }; + assert_eq!( + serde_json::to_value(&mounted).unwrap(), + serde_json::json!({"kind": "secret_read", "secret": "app/db"}) + ); + // An absent `source` deserializes back to Mounted (older sensors). + let from_legacy: Behavior = + serde_json::from_value(serde_json::json!({"kind": "secret_read", "secret": "app/db"})) + .unwrap(); + assert_eq!(from_legacy, mounted); + + // An API read serializes its source explicitly and round-trips. + let api = Behavior::SecretRead { + secret: "app/db".into(), + source: SecretReadSource::Api, + }; + let v = serde_json::to_value(&api).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "secret_read", "secret": "app/db", "source": "api"}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), api); + + // The two are distinguishable everywhere it matters: summary prose and the + // verdict-cache fingerprint. The metric label stays the coarse shared token. + assert_eq!(mounted.summary(), "reads secret app/db"); + assert_eq!(api.summary(), "reads secret app/db (via Kubernetes API)"); + assert_eq!(mounted.fingerprint_key(), "read:app/db"); + assert_eq!(api.fingerprint_key(), "read-api:app/db"); + assert_ne!(mounted.fingerprint_key(), api.fingerprint_key()); + assert_eq!(mounted.variant_label(), api.variant_label()); +} + +#[test] +fn secret_read_source_distinguishes_host_path_from_mounted_and_api() { + // An on-host credential read (JEF-320) serializes its source explicitly, just like + // `Api`, and round-trips — it is a genuinely distinct runtime fact from a k8s + // Secret-mount read even though both reach the same `Behavior::SecretRead` shape. + let host = Behavior::SecretRead { + secret: "/etc/shadow".into(), + source: SecretReadSource::HostPath, + }; + let v = serde_json::to_value(&host).unwrap(); + assert_eq!( + v, + serde_json::json!({ + "kind": "secret_read", + "secret": "/etc/shadow", + "source": "host_path" + }) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), host); + + // Distinguishable everywhere it matters: summary prose and the verdict-cache + // fingerprint, distinct from BOTH other sources. The metric label stays the coarse + // shared token across all three. + let mounted = Behavior::SecretRead { + secret: "/etc/shadow".into(), + source: SecretReadSource::Mounted, + }; + assert_eq!( + host.summary(), + "reads secret /etc/shadow (on-host filesystem)" + ); + assert_eq!(host.fingerprint_key(), "read-host:/etc/shadow"); + assert_ne!(host.fingerprint_key(), mounted.fingerprint_key()); + assert_eq!(host.variant_label(), mounted.variant_label()); +} + +#[test] +fn namespaced_observation_deserializes_from_namespace_pod() { + // A metadata-attributed observation: ns/pod set, no uid/source/time. + let obs: RuntimeObservation = serde_json::from_value(serde_json::json!({ + "namespace": "app", "pod": "web", + "behavior": {"kind": "alert", "rule": "Terminal shell in container"} + })) + .unwrap(); + assert_eq!( + obs.attribution, + Attribution::by_namespaced_name("app", "web") + ); + assert!(obs.behavior.is_alert()); +} + +#[test] +fn process_exec_fingerprint_coarsens_to_basename() { + // Different absolute paths to the same binary must collapse to one stable key so + // exec churn doesn't bust the verdict cache (mirrors LibraryLoaded's basename key). + let a = Behavior::ProcessExec { + path: "/usr/bin/bash".into(), + }; + let b = Behavior::ProcessExec { + path: "/bin/bash".into(), + }; + assert_eq!(a.fingerprint_key(), "exec:bash"); + assert_eq!(a.fingerprint_key(), b.fingerprint_key()); + // The wire type's summary is the bare path; *classification* of a notable exec + // (shell / package manager) is engine policy (engine::observe::exec_class, JEF-113), + // so it's not annotated here. + assert_eq!(a.summary(), "executed /usr/bin/bash"); +} + +#[test] +fn process_exec_summary_is_the_bare_path() { + // The shared wire type emits only the path — engine policy decides if it's notable + // (a shell / package manager) and annotates the prompt/output line (JEF-113). + let shell = Behavior::ProcessExec { + path: "/bin/bash".into(), + }; + let normal = Behavior::ProcessExec { + path: "/app/server".into(), + }; + assert_eq!(shell.summary(), "executed /bin/bash"); + assert_eq!(normal.summary(), "executed /app/server"); + // Classification is engine evidence, NOT action-bar corroboration — only Alerts + // corroborate from the wire type's perspective. + assert!(!shell.is_alert()); +} + +#[test] +fn variant_label_is_a_stable_low_cardinality_token() { + // Each variant maps to a fixed token carrying NO per-instance payload (no peer, + // path, or secret name) — so it's safe as a metric label without cardinality blow-up. + let cases: [(Behavior, &str); 9] = [ + (Behavior::Alert { rule: "x".into() }, "alert"), + ( + Behavior::NetworkConnection { + peer: "1.2.3.4:443".into(), + internet: true, + }, + "connection", + ), + ( + Behavior::SecretRead { + secret: "s".into(), + source: SecretReadSource::Mounted, + }, + "secret-read", + ), + (Behavior::LibraryLoaded { name: "l".into() }, "library-load"), + (Behavior::FileRead { path: "/p".into() }, "file-read"), + ( + Behavior::PrivilegeChange { + from_uid: 1000, + to_uid: 0, + }, + "priv-change", + ), + ( + Behavior::ProcessExec { + path: "/bin/bash".into(), + }, + "exec", + ), + ( + Behavior::FileWrite { + path: "/etc/cron.d/x".into(), + }, + "file-write", + ), + ( + Behavior::ImageLinkage { + static_linkage: true, + }, + "image-linkage", + ), + ]; + for (behavior, want) in cases { + assert_eq!(behavior.variant_label(), want, "{behavior:?}"); + } +} + +#[test] +fn file_write_fingerprint_coarsens_to_the_dirname() { + // Per-file write churn within a directory must collapse to one stable key so a + // burst of writes (drop-and-execute, a config dir rewritten file-by-file) doesn't + // bust the verdict cache — the write signal is high-frequency (JEF-306). + let a = Behavior::FileWrite { + path: "/etc/cron.d/dropper".into(), + }; + let b = Behavior::FileWrite { + path: "/etc/cron.d/other".into(), + }; + assert_eq!(a.fingerprint_key(), "write:/etc/cron.d"); + assert_eq!(a.fingerprint_key(), b.fingerprint_key()); + // A top-level path and a bare filename coarsen to `/` (low cardinality, never panics). + assert_eq!( + Behavior::FileWrite { + path: "/passwd".into() + } + .fingerprint_key(), + "write:/" + ); + assert_eq!( + Behavior::FileWrite { + path: "relative".into() + } + .fingerprint_key(), + "write:/" + ); +} + +#[test] +fn file_write_summary_is_the_bare_path_and_never_corroborates() { + // The shared wire type emits only the path — whether the write is *sensitive* + // (container drift / config tampering) is engine corroboration policy (JEF-306 F3), + // so it's pure data here and, like other mundane behaviors, never an alert. + let w = Behavior::FileWrite { + path: "/etc/ssh/sshd_config".into(), + }; + assert_eq!(w.summary(), "wrote file /etc/ssh/sshd_config"); + assert!(!w.is_alert()); +} + +#[test] +fn file_write_serializes_to_the_kind_tagged_contract() { + // Pure-data wire shape: `{"kind":"file_write","path":"..."}`, round-trips (JEF-306). + let w = Behavior::FileWrite { + path: "/etc/cron.d/x".into(), + }; + let v = serde_json::to_value(&w).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "file_write", "path": "/etc/cron.d/x"}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), w); +} + +#[test] +fn observation_carries_the_node_and_omits_it_when_absent() { + // JEF-308: the agent stamps its node so coverage is derivable PER NODE. When present it + // rides the wire; when absent (a node-agnostic sensor, older agents) it is omitted — never guessed. + let with_node = RuntimeObservation { + attribution: Attribution::by_pod_uid("uid"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: Some("node-a".into()), + behavior: Behavior::ProcessExec { + path: "/bin/sh".into(), + }, + }; + let v = serde_json::to_value(&with_node).unwrap(); + assert_eq!(v["node"], serde_json::json!("node-a")); + assert_eq!( + serde_json::from_value::(v).unwrap(), + with_node + ); + + // Absent node ⇒ the key is omitted (byte-stable for node-agnostic sensors), and a legacy + // observation with no `node` deserializes back to `None`. + let no_node: RuntimeObservation = serde_json::from_value(serde_json::json!({ + "namespace": "app", "pod": "web", + "behavior": {"kind": "alert", "rule": "shell"} + })) + .unwrap(); + assert_eq!(no_node.node, None); + let reser = serde_json::to_value(&no_node).unwrap(); + assert!( + reser.get("node").is_none(), + "absent node is omitted on the wire" + ); +} + +#[test] +fn agent_report_round_trips_and_classifies_blind_vs_partial() { + // A healthy report: all probes loaded, some signals — round-trips. + let healthy = AgentReport { + node: "node-a".into(), + probes_loaded: 6, + probes_total: 6, + signals_emitted: 12, + observed_at_ms: Some(1_710_000_000_000), + }; + let v = serde_json::to_value(&healthy).unwrap(); + assert_eq!(serde_json::from_value::(v).unwrap(), healthy); + assert!(!healthy.is_blind()); + assert!(!healthy.is_partial()); + + // Quiet but healthy: probes loaded, zero signals — NOT blind, NOT partial (quiet≠blind). + let quiet = AgentReport { + signals_emitted: 0, + ..healthy.clone() + }; + assert!( + !quiet.is_blind(), + "a quiet node with probes loaded is not blind" + ); + assert!(!quiet.is_partial()); + + // Ready but blind: the agent is up but no probe attached — blind despite pod-Ready. + let blind = AgentReport { + probes_loaded: 0, + ..healthy.clone() + }; + assert!(blind.is_blind()); + assert!( + !blind.is_partial(), + "zero probes reads as blind, not partial" + ); + + // Partial: some but not all probes attached — degraded coverage. + let partial = AgentReport { + probes_loaded: 4, + ..healthy + }; + assert!(!partial.is_blind()); + assert!(partial.is_partial()); +} + +#[test] +fn agent_report_observed_at_ms_is_omitted_when_absent() { + let report = AgentReport { + node: "n".into(), + probes_loaded: 1, + probes_total: 1, + signals_emitted: 0, + observed_at_ms: None, + }; + let v = serde_json::to_value(&report).unwrap(); + assert!(v.get("observed_at_ms").is_none()); + assert_eq!(serde_json::from_value::(v).unwrap(), report); +} + +#[test] +fn runtime_report_round_trips_with_observations_and_liveness() { + // JEF-336: the unified envelope carries the window's observations AND the per-node + // liveness beacon in one shape, and round-trips byte-for-byte. + let report = RuntimeReport { + observations: vec![RuntimeObservation { + attribution: Attribution::by_pod_uid("uid"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: Some("node-a".into()), + behavior: Behavior::Alert { + rule: "Terminal shell in container".into(), + }, + }], + liveness: Some(AgentReport { + node: "node-a".into(), + probes_loaded: 6, + probes_total: 6, + signals_emitted: 1, + observed_at_ms: None, + }), + }; + let v = serde_json::to_value(&report).unwrap(); + assert!(v.get("observations").is_some()); + assert_eq!(v["liveness"]["node"], serde_json::json!("node-a")); + assert_eq!(serde_json::from_value::(v).unwrap(), report); +} + +#[test] +fn runtime_report_omits_empty_observations_and_absent_liveness() { + // A quiet node's envelope: no observations, liveness present — `observations` is omitted + // (skip_serializing_if empty) so the wire is just `{"liveness":{...}}`. + let quiet = RuntimeReport { + observations: Vec::new(), + liveness: Some(AgentReport { + node: "node-a".into(), + probes_loaded: 6, + probes_total: 6, + signals_emitted: 0, + observed_at_ms: None, + }), + }; + let v = serde_json::to_value(&quiet).unwrap(); + assert!( + v.get("observations").is_none(), + "empty observations omitted from the wire" + ); + assert!(v.get("liveness").is_some()); + assert_eq!(serde_json::from_value::(v).unwrap(), quiet); + + // A third-party observations-only envelope: liveness absent → `liveness` omitted, and it + // deserializes back with `liveness: None` (the ADR-0003 tool-agnostic path). + let obs_only = RuntimeReport { + observations: vec![RuntimeObservation { + attribution: Attribution::by_namespaced_name("app", "web"), + source: None, + observed_at_ms: None, + node: None, + behavior: Behavior::LibraryLoaded { + name: "openssl".into(), + }, + }], + liveness: None, + }; + let v = serde_json::to_value(&obs_only).unwrap(); + assert!( + v.get("liveness").is_none(), + "absent liveness omitted from the wire" + ); + assert_eq!( + serde_json::from_value::(v).unwrap(), + obs_only + ); +} + +#[test] +fn image_linkage_serializes_to_the_kind_tagged_contract_and_round_trips() { + // JEF-407: the linkage signal rides the same `{"kind": "...", ...}` behavioral wire. + // A static-linkage report and a dynamic one both round-trip byte-for-byte. + let stat = Behavior::ImageLinkage { + static_linkage: true, + }; + let v = serde_json::to_value(&stat).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "image_linkage", "static_linkage": true}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), stat); + + let dynm = Behavior::ImageLinkage { + static_linkage: false, + }; + let v = serde_json::to_value(&dynm).unwrap(); + assert_eq!( + v, + serde_json::json!({"kind": "image_linkage", "static_linkage": false}) + ); + assert_eq!(serde_json::from_value::(v).unwrap(), dynm); +} + +#[test] +fn image_linkage_is_context_not_corroboration() { + // A structural fact about the image, never an "attack is happening now" signal — + // only Alerts corroborate the action bar (else linkage would fire it, which is wrong). + assert!( + !Behavior::ImageLinkage { + static_linkage: true + } + .is_alert() + ); + // Distinct summaries and fingerprints for the two linkage states. + assert_eq!( + Behavior::ImageLinkage { + static_linkage: true + } + .summary(), + "entrypoint is a statically linked binary" + ); + assert_eq!( + Behavior::ImageLinkage { + static_linkage: false + } + .summary(), + "entrypoint is a dynamically linked binary" + ); + assert_ne!( + Behavior::ImageLinkage { + static_linkage: true + } + .fingerprint_key(), + Behavior::ImageLinkage { + static_linkage: false + } + .fingerprint_key() + ); +} + +#[test] +fn image_linkage_observation_round_trips_over_the_wire() { + // The full RuntimeObservation the agent POSTs for a static entrypoint — attributed by + // pod UID (the eBPF agent's path), source + node stamped — round-trips. + let obs = RuntimeObservation { + attribution: Attribution::by_pod_uid("uid"), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: Some("node-a".into()), + behavior: Behavior::ImageLinkage { + static_linkage: true, + }, + }; + let v = serde_json::to_value(&obs).unwrap(); + assert_eq!( + v["behavior"], + serde_json::json!({"kind": "image_linkage", "static_linkage": true}) + ); + assert_eq!( + serde_json::from_value::(v).unwrap(), + obs + ); +} + +#[test] +fn only_alert_corroborates() { + assert!(Behavior::Alert { rule: "x".into() }.is_alert()); + assert!( + !Behavior::NetworkConnection { + peer: "p".into(), + internet: true + } + .is_alert() + ); +} diff --git a/engine/src/engine/observe/adapter/enrich.rs b/engine/src/engine/observe/adapter/enrich.rs index ee6227a..f527ef0 100644 --- a/engine/src/engine/observe/adapter/enrich.rs +++ b/engine/src/engine/observe/adapter/enrich.rs @@ -3,6 +3,7 @@ use petgraph::visit::EdgeRef; use super::*; use crate::engine::graph::{Behavior, Reachability}; use crate::engine::observe::Attribution; +use crate::engine::observe::host_credential_class::host_credential_path; use crate::engine::observe::ip_index::{IpIndex, PeerResolutionMemo}; /// Annotates Image nodes with vulnerability findings (Vulnerability port). Like @@ -150,28 +151,44 @@ impl Adapter for RuntimeAdapter { .or_insert(*static_linkage); continue; } - // Refine a raw FileRead (a tmpfs open the credential-free agent couldn't - // classify) into a SecretRead using the pod's secret volumeMounts — or drop - // it if the path isn't under a Secret mount (most tmpfs reads aren't). Other - // behaviors pass through unchanged. + // Refine a raw FileRead (a file open the wire-pure agent couldn't classify + // itself) into a SecretRead — either a k8s-mounted Secret (the pod's + // volumeMounts) or a well-known ON-HOST credential path (JEF-320, e.g. the host + // shadow file, an SSH private-key dir, a cloud-credential file — see + // `host_credential_class`) — or drop it if neither classifier claims it (most + // reads are neither). Other behaviors pass through unchanged. let behavior = match &event.behavior { Behavior::FileRead { path } => match pod.and_then(|p| secret_for_path(p, path)) { Some(secret) => { // Real secret reads are sparse — log each at info (operability + // confirms the secret-read probe end-to-end on the nodes). tracing::info!(%secret, namespace = %ns, pod = %name, "secret read"); - // A refined FileRead is always a mounted-file read — the only kind - // eBPF observes. The API secret-read path is the audit adapter's - // (JEF-269), never this one. Behavior::SecretRead { secret, source: crate::engine::graph::SecretReadSource::Mounted, } } - None => { - filtered += 1; - continue; - } + None => match host_credential_path(path) { + Some(secret) => { + // Same operability signal as a mounted secret read, distinct + // log message so an operator can tell a k8s-mount read from an + // on-host credential-path read at a glance. + tracing::info!( + %secret, + namespace = %ns, + pod = %name, + "on-host credential read" + ); + Behavior::SecretRead { + secret, + source: crate::engine::graph::SecretReadSource::HostPath, + } + } + None => { + filtered += 1; + continue; + } + }, }, // Resolve a connection peer's cluster IP to the workload/service it // belongs to (JEF: resolve-connection-peers), stably across a transient @@ -575,3 +592,7 @@ fn under<'a>(path: &'a str, mount_path: &str) -> Option<&'a str> { #[cfg(test)] mod tests; +// JEF-320's on-host credential-path tests live in their own file rather than growing +// `tests.rs` (already large) further, per the repo's file-size convention. +#[cfg(test)] +mod host_credential_tests; diff --git a/engine/src/engine/observe/adapter/enrich/host_credential_tests.rs b/engine/src/engine/observe/adapter/enrich/host_credential_tests.rs new file mode 100644 index 0000000..1059dcc --- /dev/null +++ b/engine/src/engine/observe/adapter/enrich/host_credential_tests.rs @@ -0,0 +1,141 @@ +//! JEF-320, end-to-end through the [`super::RuntimeAdapter`]: a raw `FileRead` of a +//! well-known on-host credential path attaches to the workload as a `SecretRead` with +//! [`crate::engine::graph::SecretReadSource::HostPath`], and the FP-scoping this ticket +//! relies on (container/entry context only — no bare host process) holds through the +//! real attribution-resolution pipeline, not just the classifier in isolation (see +//! `engine::observe::host_credential_class`'s own unit tests for the path-matching +//! table). Kept in its own file rather than growing `enrich/tests.rs` further (repo file- +//! size convention). + +use super::*; +use crate::engine::observe::{RuntimeObservation, Snapshot}; +use serde_json::json; + +fn pod(value: serde_json::Value) -> Pod { + serde_json::from_value(value).expect("valid Pod fixture") +} + +/// `app/web`, with a stable UID, and no Secret volume mounts — the fixture for these +/// tests (a k8s-mounted secret would already be covered by `secret_for_path`; these +/// paths are deliberately NOT under any mount). +fn web_pod_with_uid(uid: &str) -> Pod { + pod(json!({ + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "web", "namespace": "app", "uid": uid}, + "spec": {"containers": [{"name": "web", "image": "web:1"}]} + })) +} + +/// A `FileRead` of `path`, attributed to a pod UID (the eBPF agent's real attribution +/// path — the FP-scoping mechanism this ticket relies on). +fn file_read_by_uid(uid: &str, path: &str) -> RuntimeObservation { + RuntimeObservation { + attribution: Attribution::by_pod_uid(uid), + source: Some("protector-agent".into()), + observed_at_ms: None, + node: None, + behavior: Behavior::FileRead { path: path.into() }, + } +} + +/// The `SecretRead` behaviors attached to the (single) workload in `graph`, as +/// `(secret, source)` pairs. +fn secret_reads_of( + graph: &crate::engine::graph::SecurityGraph, +) -> Vec<(String, crate::engine::graph::SecretReadSource)> { + graph + .inner() + .node_weights() + .find_map(|n| match n { + Node::Workload(w) => Some( + w.runtime + .iter() + .filter_map(|o| match &o.behavior { + Behavior::SecretRead { secret, source } => Some((secret.clone(), *source)), + _ => None, + }) + .collect::>(), + ), + _ => None, + }) + .expect("workload node exists") +} + +#[test] +fn on_host_credential_read_attaches_as_secret_read_with_host_path_source() { + // The host shadow file, read by a process inside app/web (attributed by cgroup UID — + // the agent's real path) → a SecretRead with SecretReadSource::HostPath, corroborating + // CredentialAccess (JEF-320). This is the ticket's core end-to-end wire. + let snap = Snapshot { + pods: vec![web_pod_with_uid("uid-1")], + runtime_events: vec![file_read_by_uid("uid-1", "/etc/shadow")], + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + assert_eq!( + secret_reads_of(&graph), + vec![( + "/etc/shadow".to_string(), + crate::engine::graph::SecretReadSource::HostPath + )] + ); +} + +#[test] +fn ssh_and_cloud_credential_reads_also_attach_end_to_end() { + let snap = Snapshot { + pods: vec![web_pod_with_uid("uid-1")], + runtime_events: vec![ + file_read_by_uid("uid-1", "/root/.ssh/id_rsa"), + file_read_by_uid("uid-1", "/root/.aws/credentials"), + ], + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + let mut got = secret_reads_of(&graph); + got.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + got, + vec![ + ( + "/root/.aws/credentials".to_string(), + crate::engine::graph::SecretReadSource::HostPath + ), + ( + "/root/.ssh/id_rsa".to_string(), + crate::engine::graph::SecretReadSource::HostPath + ), + ] + ); +} + +#[test] +fn a_benign_file_read_is_dropped_not_a_secret_read() { + // A read that is neither a k8s Secret mount nor a known on-host credential path is + // dropped entirely — it must not silently attach as SOME evidence. + let snap = Snapshot { + pods: vec![web_pod_with_uid("uid-1")], + runtime_events: vec![file_read_by_uid("uid-1", "/etc/hosts")], + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + assert!(secret_reads_of(&graph).is_empty()); +} + +#[test] +fn a_credential_read_with_no_resolvable_pod_attribution_never_attaches() { + // FP-scoping (JEF-320): a `FileRead` attributed by a pod UID the engine has never + // observed — the shape a genuine host-system daemon's event would have, since it + // isn't in any pod's cgroup — resolves to nothing and is dropped upstream, before the + // host-credential classifier ever runs. No workload exists to attach it to either. + let snap = Snapshot { + pods: vec![web_pod_with_uid("uid-1")], + runtime_events: vec![file_read_by_uid("uid-unknown-host-process", "/etc/shadow")], + ..Default::default() + }; + let graph = super::super::build_graph(&snap, &super::super::default_adapters()); + assert_eq!( + secret_reads_of(&graph), + Vec::<(String, crate::engine::graph::SecretReadSource)>::new() + ); +} diff --git a/engine/src/engine/observe/host_credential_class.rs b/engine/src/engine/observe/host_credential_class.rs new file mode 100644 index 0000000..d6449cd --- /dev/null +++ b/engine/src/engine/observe/host_credential_class.rs @@ -0,0 +1,376 @@ +//! On-host credential-path classification policy (JEF-320, Retire-Falco G3). +//! +//! The agent's `SecretRead` (via `security_file_open`) started out scoped to the tmpfs +//! superblock — the k8s Secret/ConfigMap/projected-volume mount point — so a read of a +//! **host-filesystem** credential file (the host shadow file, a user's SSH private-key +//! directory, a cloud-provider credential file) produced no CredentialAccess +//! corroboration, even though it lives on the container's ordinary rootfs and Falco's +//! "Read sensitive file untrusted" / "Read ssh information" rules fire on exactly this. +//! This module is the engine-side classifier that closes that gap. +//! +//! Authoritative source for the path list: the F0 Falco-parity audit +//! (`docs/falco-parity-audit.md` §3 Class E / §7-8 G3; removed from the tree in the F8 +//! retirement commit but preserved in history — `git show a2c7620:docs/falco-parity-audit.md`, +//! landed in PR #169) plus the well-known on-disk conventions for the AWS/GCP/Azure CLIs. +//! +//! ENGINE-SIDE policy, not wire data (JEF-113, mirroring `exec_class`): the shared +//! [`crate::engine::graph::Behavior`] type stays pure — the agent emits a +//! `FileRead { path }` for a file it can't classify itself, unchanged wire shape. The +//! probe was minimally widened (JEF-320) to also emit a `FileRead` for a small, fixed +//! allowlist of on-host credential-file BASENAMES outside tmpfs (a cheap in-kernel volume +//! gate — see `is_sensitive_credential_basename` in +//! `agent/protector-agent-ebpf/src/main.rs` — NOT the security decision). This module makes +//! the actual "is this path a known on-host credential path" call, from the full path +//! alone, exactly like `adapter::enrich::secret_for_path` does for k8s Secret mounts. +//! +//! **Security rework (JEF-320 follow-up, four fixes from a HELD security review):** +//! 1. `/etc/passwd` and `~/.ssh/known_hosts` are dropped from the allowlist — both are +//! world-readable / hold no secret material (`passwd` has no password hashes since +//! shadow passwords; `known_hosts` holds OTHER hosts' PUBLIC keys), and both are read +//! constantly by benign processes (`id`, `ls -l`, NSS, PAM, any SSH client). Matching +//! them produced a standing false CredentialAccess corroboration on ~every workload. +//! Falco itself fires on `shadow`, never `passwd`, for exactly this reason. `gshadow` +//! (the group-password shadow file) and `sudoers` (root-equivalent grant list) are +//! added — both hold or gate genuine credential material. +//! 2. The `.ssh` match is now ANCHORED: a matched path must be a non-empty file directly +//! under a REAL home root (`/root/.ssh/` or `/home//.ssh/`), not a +//! `.ssh` segment at any depth (which let a world-writable `/tmp/.../.ssh/…` or +//! `/dev/shm/.../.ssh/…` path forge an SSH-key corroboration) and not the bare `.ssh` +//! directory itself (a directory open, not a key read). +//! 3. The untrusted path is lexically NORMALIZED first (`.`/`..`/`//` collapsed via +//! [`std::path::Path::components`], never `std::fs::canonicalize` — that would touch +//! the ENGINE HOST's disk, violating zero-egress) before every check, closing a +//! path-traversal gap that could both forge a match (an unrelated basename reached via +//! a `..` that walks back out of a matched segment) and evade one (a dot/double-slash +//! variant of an exact path like `/etc/./shadow` or `/etc//shadow`). +//! +//! (The fourth fix — foothold-gating the resulting `SecretReadSource::HostPath` +//! corroboration — lives in `engine::reason::proof::corroborate`, not here.) +//! +//! **FP-scoping (container/entry context only):** every `RuntimeSignal` that reaches a +//! Workload node in `RuntimeAdapter::contribute` is already attributed to a *Pod* — a +//! genuine host-system daemon (e.g. the node's real `sshd`, running outside any container +//! cgroup) has neither a resolvable `ByPodUid` nor a `ByNamespacedName` attribution, so its +//! events are dropped upstream (`unresolved`) before this classifier ever runs. That gives +//! "scope to container/entry context" for free from the existing attribution mechanism — no +//! extra check needed here. What this does NOT filter out: a *legitimate* in-container auth +//! process (e.g. a bastion/jump pod's own `sshd` reading its own `/etc/shadow` for PAM, or +//! its own `authorized_keys`) still matches. That residual false-positive is accepted +//! deliberately: this signal is CORROBORATION ONLY (`corroborates()` only ever sets +//! `corroborated`, never actuates — shadow-only, ADR-0014), it is combined with the model's +//! own reasoning and every other piece of evidence on the chain, and (post security-rework) +//! it is additionally gated to a proven internet-facing foothold entry +//! (`engine::reason::proof::corroborate::host_credential_read_on_foothold`) rather than +//! corroborating context-free on any workload. + +/// Absolute on-host paths matched **exactly** (against the NORMALIZED form of the observed +/// path — see [`host_credential_path`]) — the host shadow/gshadow/sudoers files (F0 §3 +/// Class E1, "Read sensitive file untrusted": `/etc/shadow` et al.). Deliberately a short, +/// exact list (not a directory-prefix match on all of `/etc`) so an unrelated `/etc/*` +/// config read never false-positives. +/// +/// `/etc/passwd` is deliberately NOT here: it is world-readable and holds no secret +/// material (password hashes live in `/etc/shadow`), and is read constantly by benign +/// processes (`id`, `ls -l`, NSS, PAM) — including it produced a standing false +/// CredentialAccess corroboration on ~every workload (HIGH finding, security rework). +/// Falco's own rule set matches `shadow`, never `passwd`, for the same reason. +const EXACT_HOST_PATHS: &[&str] = &["/etc/shadow", "/etc/gshadow", "/etc/sudoers"]; + +/// The path SEGMENT (not substring) that marks a per-user SSH directory (F0 §3 Class E1, +/// "Read ssh information"). See [`ssh_key_material_path`] for the ANCHORED match this +/// segment feeds — a bare segment-anywhere match was a security finding (security rework): +/// it let a `.ssh` directory under an attacker-writable temp/shm path forge a match. +const SSH_DIR_SEGMENT: &str = ".ssh"; + +/// The one basename under a `.ssh` directory that is deliberately EXCLUDED from a match: +/// `known_hosts` holds the PUBLIC keys of remote hosts the user has connected to — no +/// secret material — and is rewritten by an ordinary SSH client on every new connection. +/// Matching it (security rework finding 1) produced routine false CredentialAccess +/// corroborations indistinguishable from an actual private-key read. +const SSH_NON_CREDENTIAL_BASENAME: &str = "known_hosts"; + +/// Cloud-provider credential files for the major providers (AWS/GCP/Azure), each paired +/// with the provider's conventional config-directory SEGMENT so a same-named file in an +/// unrelated directory (e.g. some app's own `credentials` file) does not match. `file` +/// must be the exact basename; `dir_segment` must appear as a whole path segment (of the +/// NORMALIZED path — see [`host_credential_path`]). +const CLOUD_CREDENTIAL_FILES: &[(&str, &str)] = &[ + // AWS CLI / SDKs: ~/.aws/credentials. + (".aws", "credentials"), + // gcloud CLI application-default credentials and the legacy credentials DB. + ("gcloud", "application_default_credentials.json"), + ("gcloud", "credentials.db"), + // Azure CLI: ~/.azure/{azureProfile.json,accessTokens.json,msal_token_cache.json}. + (".azure", "azureProfile.json"), + (".azure", "accessTokens.json"), + (".azure", "msal_token_cache.json"), +]; + +/// Lexically normalize `path` into its `/`-separated NON-EMPTY components, collapsing +/// `.`/`..`/duplicate-`/` forms via [`std::path::Path::components`] — WITHOUT touching disk +/// (deliberately NOT `std::fs::canonicalize`, which would read the **engine host's** own +/// filesystem and resolve symlinks there, violating zero-egress: the path under +/// classification describes a file on a remote workload's rootfs, not anything that exists +/// on this host). +/// +/// This closes a path-traversal finding (security rework): matching the raw, unnormalized +/// path let a crafted path both FORGE a match (e.g. `/tmp/.aws/../credentials` textually +/// contains the `.aws` segment and the `credentials` basename, but lexically resolves to +/// `/tmp/credentials` — nowhere near `.aws`) and EVADE one (e.g. `/etc/./shadow` or +/// `/etc//shadow` resolve to the real `/etc/shadow` but never matched the exact-string +/// list). Every check below runs against this normalized form. +fn normalized_components(path: &str) -> Vec<&str> { + use std::path::{Component, Path}; + let mut out: Vec<&str> = Vec::new(); + for component in Path::new(path).components() { + match component { + Component::Normal(segment) => { + out.push(segment.to_str().expect("path is valid UTF-8 str")); + } + Component::ParentDir => { + out.pop(); + } + Component::CurDir | Component::RootDir | Component::Prefix(_) => {} + } + } + out +} + +/// Whether the normalized path `components` is a non-empty FILE directly under a real +/// home directory's `.ssh` — `/root/.ssh/` or `/home//.ssh/` — the +/// ANCHORED form of F0's "Read ssh information" rule (security rework finding 2). +/// +/// Anchoring to a real home root (rather than a `.ssh` segment at any depth) means a +/// `.ssh` directory nested under an attacker-writable path — a world-writable temp or +/// `/dev/shm` directory, or any other non-home location — does NOT match: such a path can +/// be planted by an unprivileged attacker precisely to forge this corroboration, so it +/// carries none of the "this is really a user's SSH key material" signal a home-rooted +/// `.ssh` does. Requiring a non-empty component AFTER `.ssh` means a read/open of the +/// `.ssh` directory itself — not a file inside it — does not match either. `known_hosts` +/// directly under `.ssh` is excluded (finding 1: no secret material). +fn ssh_key_material_path(components: &[&str]) -> bool { + let rest = match components { + [root, seg, rest @ ..] if *root == "root" && *seg == SSH_DIR_SEGMENT => rest, + [home, _user, seg, rest @ ..] if *home == "home" && *seg == SSH_DIR_SEGMENT => rest, + _ => return false, + }; + !rest.is_empty() && rest != [SSH_NON_CREDENTIAL_BASENAME] +} + +/// Whether the normalized path `components` end in a known cloud-provider credential +/// basename AND contain that provider's config-directory segment somewhere earlier in the +/// (normalized) path — see [`CLOUD_CREDENTIAL_FILES`]. +fn cloud_credential_path(components: &[&str]) -> bool { + let Some(&name) = components.last() else { + return false; + }; + CLOUD_CREDENTIAL_FILES + .iter() + .any(|&(dir, file)| file == name && components.contains(&dir)) +} + +/// Classify `path` (a container-relative path the agent observed, from a `FileRead`) as a +/// well-known **on-host** sensitive credential path (JEF-320). Returns the NORMALIZED path +/// (see [`normalized_components`]) — used as the [`crate::engine::graph::Behavior::SecretRead`] +/// `secret` identifier (mirrors how a k8s-mounted secret is named by +/// `adapter::enrich::secret_for_path`) — or `None` for anything else, including deliberate +/// near-misses: a look-alike basename outside its expected directory, a backup/rotated file +/// (`shadow.bak`), a directory read of `.ssh` itself rather than a file inside it, or a +/// `.ssh` outside a real home root. +pub fn host_credential_path(path: &str) -> Option { + let components = normalized_components(path); + let normalized = format!("/{}", components.join("/")); + + let is_credential = EXACT_HOST_PATHS.contains(&normalized.as_str()) + || ssh_key_material_path(&components) + || cloud_credential_path(&components); + is_credential.then_some(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_shadow_gshadow_sudoers_match_exactly() { + for path in EXACT_HOST_PATHS { + assert_eq!( + host_credential_path(path), + Some((*path).to_string()), + "{path}" + ); + } + } + + #[test] + fn host_shadow_near_misses_do_not_match() { + // A backup/rotated copy, a different directory, and a substring look-alike must + // all stay unclassified — this is an exact-path list, not a prefix match. + let near_misses = [ + "/etc/shadow.bak", + "/etc/shadow-", + "/mnt/host/etc/shadow", // a bind-mounted copy at a different absolute path + "/etc/shadowdir/x", + "/app/etc/passwd", + "/etc/passwd.orig", + ]; + for path in near_misses { + assert_eq!(host_credential_path(path), None, "{path}"); + } + } + + #[test] + fn passwd_no_longer_matches() { + // HIGH finding (security rework): world-readable, no secret material, read + // constantly by benign processes (id / ls -l / NSS / PAM) — must NOT corroborate. + assert_eq!(host_credential_path("/etc/passwd"), None); + } + + #[test] + fn known_hosts_no_longer_matches() { + // Finding 1 (security rework): other hosts' PUBLIC keys, no secret material, and + // rewritten by any ordinary SSH client — must NOT corroborate, unlike a real key. + assert_eq!(host_credential_path("/root/.ssh/known_hosts"), None); + assert_eq!(host_credential_path("/home/app/.ssh/known_hosts"), None); + } + + #[test] + fn ssh_key_material_matches_a_file_under_a_real_home_dot_ssh() { + let matches = [ + "/root/.ssh/id_rsa", + "/root/.ssh/id_ed25519", + "/home/app/.ssh/authorized_keys", + ]; + for path in matches { + assert_eq!(host_credential_path(path), Some(path.to_string()), "{path}"); + } + } + + #[test] + fn ssh_anchored_match_rejects_a_temp_or_shm_dot_ssh_path() { + // MEDIUM finding (security rework): an attacker-writable temp/shm `.ssh` directory + // must NOT forge an SSH-key corroboration — only a REAL home root anchors a match. + let near_misses = [ + "/tmp/.ssh/id_rsa", + "/dev/shm/.ssh/id_rsa", + "/tmp/fake/.ssh/id_rsa", + "/var/lib/jenkins/.ssh/id_rsa.pub", // not /root or /home/ + ]; + for path in near_misses { + assert_eq!(host_credential_path(path), None, "{path}"); + } + } + + #[test] + fn ssh_anchored_match_rejects_the_bare_dot_ssh_directory() { + // MEDIUM finding (security rework): the `.ssh` directory ITSELF (no file + // component beneath it) must NOT match — only a file inside it does. + assert_eq!(host_credential_path("/root/.ssh"), None); + assert_eq!(host_credential_path("/home/app/.ssh"), None); + assert_eq!(host_credential_path("/root/.ssh/"), None); + } + + #[test] + fn ssh_look_alikes_do_not_match() { + // A `.ssh`-PREFIXED name is a different, unrelated file/dir — substring + // containment must never fire. + let near_misses = [ + "/home/app/.sshrc", + "/home/app/.ssh-backup/id_rsa", + "/home/app/sshconfig", + "/home/app/ssh/id_rsa", // "ssh", not ".ssh" + ]; + for path in near_misses { + assert_eq!(host_credential_path(path), None, "{path}"); + } + } + + #[test] + fn cloud_credential_files_match_under_their_provider_directory() { + let matches = [ + "/root/.aws/credentials", + "/home/app/.aws/credentials", + "/root/.config/gcloud/application_default_credentials.json", + "/root/.config/gcloud/credentials.db", + "/home/app/.azure/azureProfile.json", + "/home/app/.azure/accessTokens.json", + "/home/app/.azure/msal_token_cache.json", + ]; + for path in matches { + assert_eq!(host_credential_path(path), Some(path.to_string()), "{path}"); + } + } + + #[test] + fn cloud_credential_look_alikes_do_not_match() { + // The right basename in the WRONG directory, and the right directory with a + // benign file, must both stay unclassified — the pairing is required, not either + // half alone. + let near_misses = [ + "/app/credentials", // "credentials" but no .aws segment + "/root/.aws/config", // .aws segment but not the credentials file + "/root/.aws/credentials.bak", + "/root/.config/gcloud/logs/credentials.db.old", + "/home/app/.azure/clouds.config", // .azure segment, unrelated file + ]; + for path in near_misses { + assert_eq!(host_credential_path(path), None, "{path}"); + } + } + + #[test] + fn ordinary_application_paths_never_match() { + // The general "don't false-positive on normal app I/O" sanity sweep. + let benign = [ + "/app/config.yaml", + "/app/data/users.db", + "/var/log/app.log", + "/tmp/scratch", + "/usr/lib/libssl.so.3", + "/etc/resolv.conf", + "/etc/hosts", + ]; + for path in benign { + assert_eq!(host_credential_path(path), None, "{path}"); + } + } + + #[test] + fn dot_and_double_slash_variants_of_shadow_still_match_normalized() { + // MEDIUM finding (security rework), the EVADE case: `.`/`..`/`//` noise in the + // observed path must not let a real `/etc/shadow` read slip past an exact-string + // check — normalization must resolve it to the canonical form first. + assert_eq!( + host_credential_path("/etc/./shadow"), + Some("/etc/shadow".to_string()) + ); + assert_eq!( + host_credential_path("/etc//shadow"), + Some("/etc/shadow".to_string()) + ); + assert_eq!( + host_credential_path("/etc/foo/../shadow"), + Some("/etc/shadow".to_string()) + ); + } + + #[test] + fn traversal_past_a_matched_segment_does_not_forge_a_cloud_credential_match() { + // MEDIUM finding (security rework), the FORGE case: a path that textually + // contains a matched dir segment (`.aws`) and a matched basename + // (`credentials`), but whose `..` walks back OUT of that directory before + // reaching the file, must NOT match — it never actually reads through `.aws`. + assert_eq!(host_credential_path("/tmp/.aws/../credentials"), None); + assert_eq!( + host_credential_path("/root/.aws/subdir/../../credentials"), + None + ); + } + + #[test] + fn traversal_past_a_matched_ssh_segment_does_not_forge_a_match() { + // The same FORGE shape against the anchored `.ssh` rule: `..` walks back out of + // `/root/.ssh` before reaching a sibling file, so it must not match either. + assert_eq!(host_credential_path("/root/.ssh/../id_rsa"), None); + } +} diff --git a/engine/src/engine/observe/mod.rs b/engine/src/engine/observe/mod.rs index 1675d67..3a53895 100644 --- a/engine/src/engine/observe/mod.rs +++ b/engine/src/engine/observe/mod.rs @@ -19,6 +19,7 @@ pub mod exec_class; pub mod exploit_intel; pub mod feed_reload; pub mod health; +pub mod host_credential_class; pub mod ingest_guard; pub mod ip_index; pub mod linkerd; diff --git a/engine/src/engine/reason/proof/corroborate.rs b/engine/src/engine/reason/proof/corroborate.rs index 0b2667d..33a6ac0 100644 --- a/engine/src/engine/reason/proof/corroborate.rs +++ b/engine/src/engine/reason/proof/corroborate.rs @@ -9,7 +9,7 @@ use std::time::Duration; use petgraph::stable_graph::NodeIndex; use crate::engine::graph::attack::AttackRef; -use crate::engine::graph::{Behavior, Node, RuntimeSignal, SecurityGraph}; +use crate::engine::graph::{Behavior, Node, RuntimeSignal, SecretReadSource, SecurityGraph}; /// The context the entry workload provides to the corroboration predicate (JEF-319, JEF-314). /// The flat per-behavior [`corroborates`] relation is context-free on purpose @@ -80,9 +80,20 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { || (attack.tactic == Tactic::InitialAccess && crate::engine::observe::peer_class::foothold_peer(behavior).is_some()) } - // A read of a mounted secret corroborates a CREDENTIAL_ACCESS objective (T1552): - // the workload is actually touching the credential the chain reaches. - Behavior::SecretRead { .. } => attack.tactic == Tactic::CredentialAccess, + // A read of a k8s-mounted or API-fetched secret corroborates a CREDENTIAL_ACCESS + // objective (T1552) context-free: the workload is actually touching a credential + // the chain reaches, unambiguous regardless of foothold status. + // + // `SecretReadSource::HostPath` (JEF-320) is DELIBERATELY EXCLUDED here (security + // rework): an on-host credential path can be read by an ordinary, legitimate + // in-container process unrelated to the chain (a bastion pod's own `sshd` reading + // its own `/etc/shadow` for PAM), so it needs the proven-foothold gate the flat, + // context-free relation can't apply — that shape lives at the entry-scoped seam + // ([`host_credential_read_on_foothold`]), mirroring how `PrivilegeChange` defers to + // [`privilege_escalation_on_foothold`]. + Behavior::SecretRead { source, .. } => { + attack.tactic == Tactic::CredentialAccess && *source != SecretReadSource::HostPath + } // A library load corroborates a FOOTHOLD (Initial Access / Exploit Public-Facing, // T1190): after JEF-75 a LibraryLoaded surviving on a workload is already pruned // to a *vulnerable* library, so its presence is the runtime foothold signal. @@ -150,14 +161,17 @@ pub(super) fn corroborates(behavior: &Behavior, attack: &AttackRef) -> bool { /// entry-scoped shapes fires: **cross-tenant lateral** (JEF-319) — a connection from the /// entry to a peer in a DIFFERENT namespace ([`cross_tenant_lateral`]) — **privilege /// escalation on the foothold** (JEF-314) — a root escalation on the entry itself -/// ([`privilege_escalation_on_foothold`]) — or **drop-then-execute** (JEF-321) — a -/// `ProcessExec` of a path a RECENT `FileWrite` dropped ([`drop_then_execute`]). All three -/// are scoped to a proven foothold entry. +/// ([`privilege_escalation_on_foothold`]) — **drop-then-execute** (JEF-321) — a +/// `ProcessExec` of a path a RECENT `FileWrite` dropped ([`drop_then_execute`]) — or **an +/// on-host credential read on the foothold** (JEF-320 security rework) — a `SecretRead` with +/// [`SecretReadSource::HostPath`] on the entry itself +/// ([`host_credential_read_on_foothold`]). All four are scoped to a proven foothold entry. /// /// None of these shapes widens the flat predicates it sits beside: ordinary internet egress, -/// ordinary in-cluster traffic, an ordinary setuid, and an ordinary write-then-run of a -/// benign path all still corroborate nothing (ADR-0011). Like every arm here this only sets -/// `corroborated`; it never actuates +/// ordinary in-cluster traffic, an ordinary setuid, an ordinary write-then-run of a benign +/// path, and an ordinary in-container process reading a host credential path off a +/// non-foothold pod all still corroborate nothing (ADR-0011). Like every arm here this only +/// sets `corroborated`; it never actuates /// (shadow-gated, ADR-0014). pub(super) fn corroborated_for( runtime: &[RuntimeSignal], @@ -170,6 +184,7 @@ pub(super) fn corroborated_for( }) || cross_tenant_lateral(runtime, entry) || privilege_escalation_on_foothold(runtime, attack, entry) || drop_then_execute(runtime, entry) + || host_credential_read_on_foothold(runtime, attack, entry) } /// The cross-tenant lateral-movement shape (JEF-319): a `NetworkConnection` from the entry to @@ -275,6 +290,40 @@ pub(super) fn privilege_escalation_on_foothold( }) } +/// The on-host-credential-path-read-on-foothold shape (JEF-320 security rework, HIGH/MEDIUM +/// findings from a HELD security review): a `SecretRead` with [`SecretReadSource::HostPath`] +/// — a read of a well-known on-host credential path +/// (`crate::engine::observe::host_credential_class`) OUTSIDE any k8s Secret mount — on the +/// entry itself corroborates a CredentialAccess-tactic objective. +/// +/// Conservative scoping (ADR-0011 / ADR-0014), mirroring [`cross_tenant_lateral`] / +/// [`privilege_escalation_on_foothold`] / [`drop_then_execute`]: corroborates ONLY when the +/// entry is a proven internet-facing foothold (`entry.is_foothold`) AND `attack.tactic` is +/// `CredentialAccess`. This is deliberately NOT in the flat, context-free [`corroborates`] +/// relation the way a `Mounted`/`Api` `SecretRead` is: reading the pod's OWN declared k8s +/// Secret is unambiguous credential access, but an on-host credential path can be read by an +/// ordinary, legitimate in-container process that has nothing to do with any chain (a +/// bastion pod's own `sshd` reading its own `/etc/shadow` for PAM, an init process reading +/// `/etc/sudoers`) — gating on the proven foothold is what keeps that the same ADR-0011 +/// on-call-engineer false positive the sibling shapes guard against, rather than turning +/// every ordinary pod's mundane host-file access into a standing corroboration source. +pub(super) fn host_credential_read_on_foothold( + runtime: &[RuntimeSignal], + attack: &AttackRef, + entry: EntryContext<'_>, +) -> bool { + use crate::engine::graph::attack::Tactic; + if !entry.is_foothold || attack.tactic != Tactic::CredentialAccess { + return false; + } + runtime.iter().any(|s| { + matches!( + &s.behavior, + Behavior::SecretRead { source, .. } if *source == SecretReadSource::HostPath + ) + }) +} + /// The entry workload's runtime signals (empty for a non-workload node), resolved once /// per entry so [`corroborated_for`] doesn't re-look-up the constant entry node on every /// objective in the per-objective loop. diff --git a/engine/src/engine/reason/proof/corroborate_host_credential_tests.rs b/engine/src/engine/reason/proof/corroborate_host_credential_tests.rs new file mode 100644 index 0000000..a967582 --- /dev/null +++ b/engine/src/engine/reason/proof/corroborate_host_credential_tests.rs @@ -0,0 +1,147 @@ +//! Tests for the JEF-320 security-rework entry-scoped corroboration shape — an on-host +//! credential-path `SecretRead` on the foothold — kept in its own `*_tests.rs` file (repo +//! CLAUDE.md: tests count toward the 1,000-line cap). `super` resolves to the proof module, +//! so these exercise the `pub(super)` `corroborate` seam directly. +//! +//! Finding 4 (MEDIUM, broken access control) from the HELD security review: unlike its +//! siblings [`cross_tenant_lateral`](super::corroborate::cross_tenant_lateral) / +//! [`privilege_escalation_on_foothold`](super::corroborate::privilege_escalation_on_foothold) +//! / [`drop_then_execute`](super::corroborate::drop_then_execute), the `SecretRead { source: +//! HostPath, .. }` arm was NOT gated on `entry.is_foothold` — it corroborated on ANY +//! workload. This module tests the fix both ways: the flat `corroborates()` relation must +//! now stay silent for a `HostPath` read (only `Mounted`/`Api` stay context-free), and the +//! new entry-scoped shape must fire ONLY on a proven foothold. + +use std::time::{Duration, SystemTime}; + +use super::corroborate::{ + EntryContext, corroborated_for, corroborates, host_credential_read_on_foothold, +}; +use crate::engine::graph::Provenance; +use crate::engine::graph::attack::{CREDENTIAL_ACCESS, EXFILTRATION}; +use crate::engine::graph::{Behavior, RuntimeSignal, SecretReadSource}; + +/// A base time all `at()` offsets are relative to, so timing is exact regardless of clock. +fn base() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000) +} + +/// A `RuntimeSignal` for `behavior` observed `secs` after [`base`]. +fn sig(behavior: Behavior, secs: u64) -> RuntimeSignal { + RuntimeSignal { + behavior, + provenance: Provenance::new("test", base() + Duration::from_secs(secs)), + } +} + +fn host_path_read(path: &str) -> Behavior { + Behavior::SecretRead { + secret: path.into(), + source: SecretReadSource::HostPath, + } +} + +/// The entry is a proven internet-facing foothold in namespace `ns`. +fn foothold_entry(ns: &str) -> EntryContext<'_> { + EntryContext { + source_ns: ns, + is_foothold: true, + } +} + +/// The entry is an ordinary (non-foothold) workload in namespace `ns`. +fn ordinary_entry(ns: &str) -> EntryContext<'_> { + EntryContext { + source_ns: ns, + is_foothold: false, + } +} + +// ---- Positive: on-host credential read on the foothold entry — end to end ---------------- + +#[test] +fn on_host_credential_read_on_the_foothold_entry_corroborates_credential_access() { + let runtime = [sig(host_path_read("/etc/shadow"), 0)]; + assert!(corroborated_for( + &runtime, + &CREDENTIAL_ACCESS, + None, + foothold_entry("frontend"), + )); + // And the predicate directly. + assert!(host_credential_read_on_foothold( + &runtime, + &CREDENTIAL_ACCESS, + foothold_entry("frontend"), + )); +} + +// ---- Negative: same read, non-foothold entry (the security-review finding) --------------- + +#[test] +fn on_host_credential_read_on_a_non_foothold_entry_does_not_corroborate() { + // MEDIUM finding (broken access control, security rework): the SAME read, but the + // entry is an ordinary pod — a bastion pod's own `sshd` reading its own `/etc/shadow` + // for PAM must NOT corroborate on an unrelated, non-foothold workload (ADR-0011). + let runtime = [sig(host_path_read("/etc/shadow"), 0)]; + assert!(!corroborated_for( + &runtime, + &CREDENTIAL_ACCESS, + None, + ordinary_entry("frontend"), + )); + assert!(!host_credential_read_on_foothold( + &runtime, + &CREDENTIAL_ACCESS, + ordinary_entry("frontend"), + )); +} + +// ---- Regression guard: the flat, context-free relation must NOT corroborate HostPath ----- + +#[test] +fn host_path_secret_read_does_not_flatly_corroborate_without_the_foothold_gate() { + // The context-free `corroborates()` seam must stay silent for a `HostPath` read — only + // the entry-scoped, foothold-gated shape above may promote it. A `Mounted`/`Api` read + // (the pod's OWN declared k8s Secret) is unambiguous and stays context-free — see + // `corroborate_objective_tests::secret_read_corroborates_credential_access`. + let behavior = host_path_read("/etc/shadow"); + assert!(!corroborates(&behavior, &CREDENTIAL_ACCESS)); +} + +// ---- Regression guard: doesn't widen past CredentialAccess or corroborate an unrelated --- + +#[test] +fn on_host_credential_read_on_the_foothold_does_not_corroborate_an_unrelated_objective() { + let runtime = [sig(host_path_read("/etc/shadow"), 0)]; + assert!(!corroborated_for( + &runtime, + &EXFILTRATION, + None, + foothold_entry("frontend"), + )); + assert!(!host_credential_read_on_foothold( + &runtime, + &EXFILTRATION, + foothold_entry("frontend"), + )); +} + +#[test] +fn a_mounted_secret_read_on_the_foothold_still_corroborates_via_the_flat_relation() { + // Sanity: this fix must not regress the unrelated, unchanged Mounted/Api path — those + // stay context-free and corroborate even without invoking the new entry-scoped shape. + let runtime = [sig( + Behavior::SecretRead { + secret: "db-creds".into(), + source: SecretReadSource::Mounted, + }, + 0, + )]; + assert!(corroborated_for( + &runtime, + &CREDENTIAL_ACCESS, + None, + ordinary_entry("frontend"), + )); +} diff --git a/engine/src/engine/reason/proof/mod.rs b/engine/src/engine/reason/proof/mod.rs index 6bce6b8..81019d2 100644 --- a/engine/src/engine/reason/proof/mod.rs +++ b/engine/src/engine/reason/proof/mod.rs @@ -355,6 +355,8 @@ mod corroborate_context_tests; #[cfg(test)] mod corroborate_drop_exec_tests; #[cfg(test)] +mod corroborate_host_credential_tests; +#[cfg(test)] mod corroborate_objective_tests; #[cfg(test)] mod corroborate_privesc_tests;