diff --git a/crates/flowproof-adapters/src/agent_runner.rs b/crates/flowproof-adapters/src/agent_runner.rs index ea6743ca..dce9dde7 100644 --- a/crates/flowproof-adapters/src/agent_runner.rs +++ b/crates/flowproof-adapters/src/agent_runner.rs @@ -668,6 +668,10 @@ pub fn run_against( /// other platform it is exactly [`run_against`] with an empty egress log, /// since the mechanism is Linux-only and the tier is reported "not /// contained" independently. +/// +/// `egress_engaged` says whether the FLOW declared an egress policy, or is +/// supervised for side-effect observation only under an allow-all set nobody +/// declared - and the latter must never report `Enforced`. #[cfg(target_os = "linux")] pub fn run_against_contained( proxy: &AgentProxy, @@ -675,6 +679,7 @@ pub fn run_against_contained( env: &BTreeMap, timeout: Duration, allow: &AllowSet, + egress_engaged: bool, ) -> Result { let base = proxy.base_url(); let mut cmd = configure(command, &base, env)?; @@ -722,10 +727,16 @@ pub fn run_against_contained( // The filter that enforced is the filter that watched, so `fs` // above is evidence here and silence everywhere else. observed: true, - // Reaching here means the filter installed: it goes in via `pre_exec` - // and a failure aborts the spawn, so there is no path to a finished - // run with no filter behind it. - containment: Some(Containment::Enforced), + containment: Some(if egress_engaged { + // Reaching here means the filter installed (it goes in via + // `pre_exec`; a failure aborts the spawn) - and it enforced the + // DECLARED policy. + Containment::Enforced + } else { + // An allow-all policy nobody declared: observing side effects is + // not containing egress, and the tier must not blur the two. + Containment::observation_only() + }), }; drop(log); Ok(run) @@ -745,7 +756,13 @@ pub fn run_against_contained( env: &BTreeMap, timeout: Duration, allow: &AllowSet, + egress_engaged: bool, ) -> Result { + // Defense in depth: no caller passes `false` here today, but a future + // one must get the plain path, never WFP filters from a wildcard set. + if !egress_engaged { + return run_against(proxy, command, env, timeout); + } let command = command.trim(); if command.is_empty() { return Err(RunError::NoCommand); @@ -774,6 +791,7 @@ pub fn run_against_contained( egress: EgressLog { blocked: outcome.blocked, faults: outcome.faults, + observed: Vec::new(), }, // Filesystem observation is a seccomp mechanism; Windows has none. fs: FsLog::default(), @@ -794,9 +812,13 @@ pub fn run_against_contained( env: &BTreeMap, timeout: Duration, _allow: &AllowSet, + egress_engaged: bool, ) -> Result { - // No mechanism on this platform; the plain path, and the tier line says - // "not contained". + // Defense in depth, mirroring the Windows variant. + if !egress_engaged { + return run_against(proxy, command, env, timeout); + } + // No mechanism here; the plain path, and the tier says "not contained". run_against(proxy, command, env, timeout) } diff --git a/crates/flowproof-adapters/src/egress.rs b/crates/flowproof-adapters/src/egress.rs index fb47b130..3b4ceaf1 100644 --- a/crates/flowproof-adapters/src/egress.rs +++ b/crates/flowproof-adapters/src/egress.rs @@ -56,6 +56,16 @@ impl Containment { ) } + /// The tier for a run supervised for side-effect OBSERVATION only: an + /// allow-all policy nobody declared contains nothing. + pub fn observation_only() -> Self { + Containment::NotContained( + "flow engages side-effect observation only; no egress policy declared, \ + nothing contained" + .to_string(), + ) + } + /// The tier for a `url:` flow: a service flowproof did not start cannot /// be contained. pub fn url_flow() -> Self { @@ -124,6 +134,11 @@ impl Containment { pub struct EgressLog { /// Every denied attempt, in order - retries included. pub blocked: Vec, + /// Every ALLOWED non-loopback destination the supervisor performed a + /// connect/send to, in order - the `http_request` half of the side-effect + /// lane. Never folded into `blocked`: that list's emptiness is half the + /// `assert_no_egress` predicate. + pub observed: Vec, /// Every supervisor FAULT, in order. A fault is not a policy denial: it /// is a trapped syscall the supervisor could not adjudicate at all, /// because the mechanism it needs was refused (`process_vm_readv` or @@ -190,6 +205,9 @@ pub struct AllowSet { /// Host entries are pre-resolved into `Ip` entries; only `Ip`/`Cidr` /// remain, each keeping its own optional port constraint. entries: Vec, + /// The spec spelling each entry came from, index-parallel with `entries`, + /// so [`AllowSet::allowed_as`] can name a destination by the spec's text. + spellings: Vec, } impl AllowSet { @@ -199,22 +217,43 @@ impl AllowSet { /// exempt). A name that does not resolve contributes no IPs, so its /// traffic is denied - the safe default. pub fn resolve(entries: &[String]) -> Result { - let mut out = Vec::new(); - for raw in entries { + // The entry text is its own spelling when no unresolved form exists. + let pairs: Vec<(&str, &str)> = entries.iter().map(|e| (e.as_str(), e.as_str())).collect(); + Self::resolve_spelled(&pairs) + } + + /// `resolve`, from `(spelling, resolved)` pairs: the resolved text is + /// parsed and enforced; the spelling - the spec's own, possibly `${VAR}`, + /// text - is retained, so a destination such an entry admits can be + /// recorded by spelling rather than by the value it held. + pub fn resolve_spelled(pairs: &[(&str, &str)]) -> Result { + let mut set = Self::default(); + for (spelling, raw) in pairs { let parsed = egress::parse_allow_entry(raw)?; match parsed.host { HostMatch::Host(name) => { for ip in resolve_host(&name) { - out.push(AllowEntry { + set.entries.push(AllowEntry { host: HostMatch::Ip(ip), port: parsed.port, }); } } - HostMatch::Ip(_) | HostMatch::Cidr(_, _) => out.push(parsed), + HostMatch::Ip(_) | HostMatch::Cidr(_, _) => set.entries.push(parsed), } + // One spelling per entry the pair contributed. + set.spellings + .resize(set.entries.len(), spelling.to_string()); } - Ok(Self { entries: out }) + Ok(set) + } + + /// The observation-only policy: wildcard v4 + v6 CIDRs, so the POLICY + /// path admits every parseable inet destination. (The structural + /// refusals are independent of any set.) + pub fn allow_all() -> Self { + let all = ["0.0.0.0/0".to_string(), "::/0".to_string()]; + Self::resolve(&all).expect("the wildcard CIDRs parse") } /// Is `(ip, port)` allowed? Loopback is allowed wholesale, independent of @@ -229,6 +268,22 @@ impl AllowSet { .any(|entry| entry.port_ok(port) && host_matches(&entry.host, ip)) } + /// The spec spelling under which `(ip, port)` is admitted, `None` when no + /// entry matches (loopback is exempt without consulting any entry). When + /// SEVERAL admit it, a `${VAR}` spelling wins over a concrete one. + pub fn allowed_as(&self, ip: IpAddr, port: u16) -> Option<&str> { + let mut concrete = None; + for (entry, spelling) in self.entries.iter().zip(&self.spellings) { + if entry.port_ok(port) && host_matches(&entry.host, ip) { + if spelling.contains("${") { + return Some(spelling); + } + concrete.get_or_insert(spelling.as_str()); + } + } + concrete + } + /// No declared destinations: a contained run with an empty allow-set /// permits only loopback. pub fn is_empty(&self) -> bool { @@ -336,6 +391,7 @@ mod tests { fn undeclared_destinations_dedupe_by_destination() { let log = EgressLog { faults: Vec::new(), + observed: Vec::new(), blocked: vec![ EgressEvent { destination: "198.51.100.9:443".into(), @@ -367,6 +423,7 @@ mod tests { fn a_faulted_log_is_not_clean_even_with_nothing_blocked() { let log = EgressLog { blocked: Vec::new(), + observed: Vec::new(), faults: vec!["connect: process_vm_readv: Operation not permitted".into()], }; // The old predicate - "nothing was blocked" - still reads as empty. @@ -379,6 +436,7 @@ mod tests { fn repeated_faults_dedupe_but_keep_first_seen_order() { let log = EgressLog { blocked: Vec::new(), + observed: Vec::new(), faults: vec![ "connect: process_vm_readv: EPERM".into(), "sendto: pidfd_getfd: EPERM".into(), diff --git a/crates/flowproof-adapters/src/egress_linux.rs b/crates/flowproof-adapters/src/egress_linux.rs index 6d922e64..000bab2c 100644 --- a/crates/flowproof-adapters/src/egress_linux.rs +++ b/crates/flowproof-adapters/src/egress_linux.rs @@ -768,6 +768,7 @@ fn handle_connect( let ip = normalize(ip); if allow.allows(ip, port) { dbg_egress(&format!("connect: ALLOWED {ip}:{port}, performing")); + record_observed(log, spawn, ip, port, "tcp"); perform_connect(req.pid, sockfd, &buf[..n], log) } else { dbg_egress(&format!("connect: DENIED by policy {ip}:{port}")); @@ -830,6 +831,7 @@ fn handle_sendto( record(log, spawn, &format!("{ip}:{port}"), "udp"); return errno_resp(libc::ECONNREFUSED); } + record_observed(log, spawn, ip, port, "udp"); Some((addr, n)) } Some(Dest::Unix) => Some((addr, n)), @@ -875,6 +877,7 @@ fn handle_sendmsg( } let (name_ptr, name_len, control_len) = msghdr_fields(&hdr); + let mut vetted = None; if name_ptr != 0 && name_len != 0 { let mut addr = [0u8; 128]; let read = read_child(req.pid, name_ptr, &mut addr, name_len as usize); @@ -894,6 +897,7 @@ fn handle_sendmsg( record(log, spawn, &format!("{ip}:{port}"), "udp"); return errno_resp(libc::ECONNREFUSED); } + vetted = Some((ip, port)); } } // Control data (e.g. SCM_RIGHTS) is not marshalled in v1: refuse rather @@ -901,6 +905,10 @@ fn handle_sendmsg( if control_len != 0 { return errno_resp(libc::EPERM); } + // AFTER the refusal, so a refused message is never reported as performed. + if let Some((ip, port)) = vetted { + record_observed(log, spawn, ip, port, "udp"); + } // On-host / connected: re-perform on the child's own socket. sendmmsg is // serviced as its first message; the child sees one message sent. perform_sendmsg(req.pid, sockfd, msg_ptr, log) @@ -1518,6 +1526,26 @@ fn record(log: &Mutex, spawn: Instant, destination: &str, protocol: & .push(event); } +/// Record an ALLOWED destination into the shared log's `observed` list - the +/// `http_request` half of the side-effect lane. Loopback is filtered HERE, at +/// the capture point: connects to flowproof's own proxy and MCP listeners are +/// already first-class trace data, and re-emitting them would double-count +/// flowproof's boundaries as agent behavior (and pay a push per model call). +fn record_observed(log: &Mutex, spawn: Instant, ip: IpAddr, port: u16, protocol: &str) { + if is_loopback(ip) { + return; + } + let event = EgressEvent { + destination: format!("{ip}:{port}"), + protocol: protocol.to_string(), + at_ms: spawn.elapsed().as_millis() as u64, + }; + log.lock() + .unwrap_or_else(|e| e.into_inner()) + .observed + .push(event); +} + /// Record a supervisor FAULT and deny the syscall. /// /// A fault is not a denial. It is the supervisor admitting it could not @@ -1804,4 +1832,46 @@ mod tests { assert_eq!(ok_resp(5).val, 5); assert_eq!(ok_resp(5).error, 0); } + + /// The narrowed observation-only pin: the POLICY path under allow-all + /// admits every parseable inet destination - deliberately NOT "denies + /// nothing", since the structural refusals are independent of any + /// `AllowSet`. The capture point records an allowed destination into + /// `observed` - never `blocked` - loopback filtered wholesale, and the + /// tier such a run reports never says enforced. + #[test] + fn allow_all_admits_every_parseable_inet_destination_and_observation_records_it() { + let all = AllowSet::allow_all(); + assert!(!all.is_empty()); + let destinations: [(IpAddr, u16); 4] = [ + ("198.51.100.9".parse().expect("ipv4"), 443), + ("10.1.2.3".parse().expect("ipv4"), 8080), + ("2001:db8::1".parse().expect("ipv6"), 65535), + // v4-mapped, as `normalize` would hand it to the policy. + (normalize("::ffff:203.0.113.9".parse().expect("ipv6")), 53), + ]; + for (ip, port) in destinations { + assert!(all.allows(ip, port), "{ip}:{port} must be admitted"); + } + + let log = Mutex::new(EgressLog::default()); + let spawn = Instant::now(); + let tcp_dest: IpAddr = "198.51.100.9".parse().expect("ipv4"); + record_observed(&log, spawn, tcp_dest, 443, "tcp"); + record_observed(&log, spawn, "203.0.113.9".parse().expect("ipv4"), 53, "udp"); + for loopback in ["127.0.0.1", "127.9.9.9", "::1"] { + record_observed(&log, spawn, loopback.parse().expect("loopback"), 443, "tcp"); + } + let log = log.into_inner().expect("unpoisoned"); + assert!(log.blocked.is_empty(), "observation is not a denial"); + assert_eq!(log.observed.len(), 2, "loopback never appears"); + assert_eq!(log.observed[0].destination, "198.51.100.9:443"); + assert_eq!(log.observed[0].protocol, "tcp"); + assert_eq!(log.observed[1].destination, "203.0.113.9:53"); + assert_eq!(log.observed[1].protocol, "udp"); + + let line = Containment::observation_only().report_line(); + assert!(line.contains("not contained"), "{line}"); + assert!(!line.contains("enforced"), "{line}"); + } } diff --git a/crates/flowproof-adapters/tests/side_effect_http_e2e.rs b/crates/flowproof-adapters/tests/side_effect_http_e2e.rs new file mode 100644 index 00000000..6c454e9b --- /dev/null +++ b/crates/flowproof-adapters/tests/side_effect_http_e2e.rs @@ -0,0 +1,92 @@ +//! The `http_request` capture against a real kernel: an ALLOWED non-loopback +//! connect must land in the egress log's `observed` list, and observation-only +//! supervision must report its own tier, never `enforced`. The unit tests in +//! `egress_linux` prove the capture point as data; only a live filter proves +//! `handle_connect` reaches it. + +#![cfg(all(target_os = "linux", feature = "agent"))] + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr, TcpListener, UdpSocket}; +use std::time::Duration; + +use flowproof_adapters::agent_runner::run_against_contained; +use flowproof_adapters::egress::{AllowSet, Containment}; +use flowproof_adapters::AgentProxy; + +/// Points the CHILD half of the re-exec below at the listener; unset on an +/// ordinary test run, so the child test passes empty there. +const CHILD_CONNECT_VAR: &str = "FLOWPROOF_SIDE_EFFECT_E2E_CONNECT"; + +#[test] +fn the_child_half_connects_where_the_env_points() { + if let Ok(addr) = std::env::var(CHILD_CONNECT_VAR) { + let _ = std::net::TcpStream::connect(addr.as_str()); + } +} + +/// This host's primary non-loopback IPv4, via the packet-free UDP-connect +/// trick; loopback would not exercise the capture. +fn host_ipv4() -> Option { + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + match sock.local_addr().ok()?.ip() { + IpAddr::V4(ip) if !ip.is_loopback() => Some(ip), + _ => None, + } +} + +#[test] +fn an_allowed_connect_is_observed_and_the_tier_stays_honest() { + let Some(ip) = host_ipv4() else { + eprintln!("skipping: this host has no non-loopback IPv4 interface"); + return; + }; + let listener = TcpListener::bind((ip, 0)).expect("bind non-loopback listener"); + let addr = listener.local_addr().expect("addr").to_string(); + std::thread::spawn(move || { + for stream in listener.incoming().take(4).flatten() { + drop(stream); + } + }); + + // The full observation-only runner path, this test binary re-exec'd as + // the agent. + let exe = std::env::current_exe().expect("test binary path"); + let command = format!( + "{} the_child_half_connects_where_the_env_points --exact", + exe.display() + ); + let env = BTreeMap::from([(CHILD_CONNECT_VAR.to_string(), addr.clone())]); + let proxy = AgentProxy::start(Default::default(), BTreeMap::new(), 0).expect("proxy"); + let run = run_against_contained( + &proxy, + &command, + &env, + Duration::from_secs(60), + &AllowSet::allow_all(), + /* egress_engaged: */ false, + ) + .expect("run"); + + // The tier pin: the filter watched (`observed`), but a wildcard policy + // nobody declared must never read as enforcement. + assert!(run.observed, "the filter watched, so the run was observed"); + assert_eq!(run.containment, Some(Containment::observation_only())); + + let egress = &run.egress; + assert!( + egress.blocked.is_empty(), + "allow-all denies nothing on the policy path: {:?}", + egress.blocked + ); + let event = (egress.observed.iter()) + .find(|e| e.destination == addr) + .unwrap_or_else(|| { + panic!( + "the allowed connect must be observed; log: {:?} / faults: {:?}", + egress.observed, egress.faults + ) + }); + assert_eq!(event.protocol, "tcp"); +} diff --git a/crates/flowproof-cli/src/agent_flow.rs b/crates/flowproof-cli/src/agent_flow.rs index 455ee473..b8c234e8 100644 --- a/crates/flowproof-cli/src/agent_flow.rs +++ b/crates/flowproof-cli/src/agent_flow.rs @@ -27,7 +27,7 @@ use flowproof_adapters::FsEvent; use flowproof_agent::{FlowSpec, SpecStep}; use flowproof_trace::cassette::Cassette; use flowproof_trace::egress::EgressEvent; -use flowproof_trace::side_effect::{SideEffect, KIND_FS_WRITE}; +use flowproof_trace::side_effect::{SideEffect, KIND_FS_WRITE, KIND_HTTP_REQUEST}; use flowproof_trace::substitution::Mocks; use flowproof_trace::toolcalls::{self, ToolCallExpectation}; @@ -654,10 +654,15 @@ impl Plan { /// service flowproof did not start is never contained. fn drive(&self, proxy: &AgentProxy) -> Result { match &self.driver { - Driver::Command(command) if self.engages_egress => { - run_against_contained(proxy, command, &self.env, AGENT_TIMEOUT, &self.allow) - .map_err(|e| e.to_string()) - } + Driver::Command(command) if self.engages_egress => run_against_contained( + proxy, + command, + &self.env, + AGENT_TIMEOUT, + &self.allow, + /* egress_engaged: */ true, + ) + .map_err(|e| e.to_string()), Driver::Command(command) => { run_against(proxy, command, &self.env, AGENT_TIMEOUT).map_err(|e| e.to_string()) } @@ -733,7 +738,13 @@ fn plan(spec: &FlowSpec) -> Result { resolved_allow .push(flowproof_trace::secret::resolve_refs(entry).map_err(|e| e.to_string())?); } - let allow = AllowSet::resolve(&resolved_allow)?; + // The set keeps each entry's UNRESOLVED spelling beside its resolved + // form, so an observed destination a `${VAR}` entry admits is recorded + // by spelling - the value never reaches the trace. + let spelled: Vec<(&str, &str)> = (allow_unresolved.iter().map(String::as_str)) + .zip(resolved_allow.iter().map(String::as_str)) + .collect(); + let allow = AllowSet::resolve_spelled(&spelled)?; let assert_no_egress = spec .steps .iter() @@ -1064,10 +1075,36 @@ fn fs_effect(event: &FsEvent, workspace: &Path) -> SideEffect { } } +/// One observed egress event as the trace record. The target is value-free +/// (`ip:port`) unless a `${VAR}`-bearing allow entry admits it - then the +/// UNRESOLVED spelling is recorded, since the resolved address would +/// disclose what the variable held. The port sits after the LAST colon. +fn http_effect(event: &EgressEvent, allow: &AllowSet) -> SideEffect { + let target = (event.destination.rsplit_once(':')) + .and_then(|(ip, port)| allow.allowed_as(ip.parse().ok()?, port.parse().ok()?)) + .filter(|spelling| spelling.contains("${")) + .map_or_else(|| event.destination.clone(), str::to_string); + SideEffect { + kind: KIND_HTTP_REQUEST.into(), + target: Some(target), + target_note: None, + op: Some(event.protocol.clone()), + flags: None, + at_ms: event.at_ms, + before: None, + after: None, + diff: None, + } +} + /// The trace's side-effect lane, or `None` when the observation mechanism /// never ran - absence keeps meaning "not observed", and a present lane /// with no effects stays positive evidence: observed, and clean. -fn side_effects_lane(run: &AgentRun, workspace: &Path) -> Option { +fn side_effects_lane( + run: &AgentRun, + allow: &AllowSet, + workspace: &Path, +) -> Option { if !run.observed { return None; } @@ -1076,12 +1113,24 @@ fn side_effects_lane(run: &AgentRun, workspace: &Path) -> Option = (run.fs.destructive.iter()) + .map(|e| fs_effect(e, &root)) + .chain(run.egress.observed.iter().map(|e| http_effect(e, allow))) + .collect(); + // One lane, one clock: merged by when it happened (a stable sort). + effects.sort_by_key(|e| e.at_ms); + // Either half going blind makes an empty effects list silence rather + // than evidence, so the faults are the UNION of both, deduped. + let mut faults = run.fs.distinct_faults(); + for fault in run.egress.distinct_faults() { + if !faults.contains(&fault) { + faults.push(fault); + } + } Some(SideEffectsTrace { observation: "observed (linux seccomp)".into(), - effects: (run.fs.destructive.iter()) - .map(|e| fs_effect(e, &root)) - .collect(), - faults: run.fs.distinct_faults(), + effects, + faults, }) } @@ -1526,7 +1575,8 @@ fn record_inner( // The side-effect lane, built whenever the run was observed (the // workspace root is the agent's spawn cwd) and scanned WITH the // cassette below, BEFORE the trace is minted - the same store-guard. - let side_effects = side_effects_lane(&run, &std::env::current_dir().unwrap_or_default()); + let workspace = std::env::current_dir().unwrap_or_default(); + let side_effects = side_effects_lane(&run, &plan.allow, &workspace); check_secret_leak(&plan, &cassette, &mcp_trace, side_effects.as_ref())?; let trace = AgentTrace { @@ -2114,7 +2164,8 @@ mod tests { #[test] fn the_lane_records_hygiene_outputs_never_raw_paths() { let mut run = egress_run(vec![]); - assert!(side_effects_lane(&run, ws()).is_none(), "not observed"); + let unobserved = side_effects_lane(&run, &AllowSet::default(), ws()); + assert!(unobserved.is_none(), "not observed"); run.observed = true; run.fs.faults = vec!["openat2: EPERM".into(), "openat2: EPERM".into()]; @@ -2131,7 +2182,8 @@ mod tests { event("renameat2", "/ws/a", Some("/home/u/b.csv")), event("rename", "/home/u/a", Some("/home/u/b")), ]; - let lane = side_effects_lane(&run, ws()).expect("observed mints the lane"); + let lane = + side_effects_lane(&run, &AllowSet::default(), ws()).expect("observed mints the lane"); assert_eq!(lane.observation, "observed (linux seccomp)"); assert_eq!(lane.faults.len(), 1, "one mechanism, one finding"); assert_eq!(lane.effects[0].target.as_deref(), Some("./gone.csv")); @@ -2147,6 +2199,53 @@ mod tests { assert!(corpus.iter().any(|(n, _)| n == "the side-effects lane")); } + /// The http half: observed egress joins the lane ordered by `at_ms` (the + /// clock the fs records share), a `${VAR}`-admitted destination is named + /// by its UNRESOLVED spelling, and the faults are the UNION of both + /// supervisor halves - egress faults must not be silently dropped. + #[cfg(unix)] + #[test] + fn the_lane_merges_observed_egress_and_folds_in_its_faults() { + let observed = |destination: &str, protocol: &str, at_ms: u64| EgressEvent { + destination: destination.into(), + protocol: protocol.into(), + at_ms, + }; + let mut run = egress_run(vec![]); + run.observed = true; + run.fs.faults = vec!["openat2: EPERM".into()]; + run.egress.observed = vec![ + observed("198.51.100.9:443", "tcp", 610), + observed("203.0.113.9:53", "udp", 100), + ]; + run.egress.faults = vec!["sendto: pidfd_getfd: EPERM".into(), "openat2: EPERM".into()]; + // BOTH a concrete and a `${VAR}` entry admit the tcp destination: + // redaction-first, the unresolved spelling must win. + let allow = AllowSet::resolve_spelled(&[ + ("198.51.100.9:443", "198.51.100.9:443"), + ("${SERVICE_HOST}:443", "198.51.100.9:443"), + ]) + .expect("resolves"); + let lane = side_effects_lane(&run, &allow, ws()).expect("observed mints the lane"); + + // Sorted by at_ms: the udp send first, keeping its `ip:port`. + assert_eq!(lane.effects[0].kind, "http_request"); + assert_eq!(lane.effects[0].target.as_deref(), Some("203.0.113.9:53")); + assert_eq!(lane.effects[0].op.as_deref(), Some("udp")); + let spelled = lane.effects[1].target.as_deref(); + assert_eq!(spelled, Some("${SERVICE_HOST}:443")); + let json = serde_json::to_string(&lane).expect("serialize"); + assert!( + !json.contains("198.51.100.9"), + "resolved value leaked: {json}" + ); + // The union, deduped: the shared fault is one finding. + assert_eq!( + lane.faults, + ["openat2: EPERM", "sendto: pidfd_getfd: EPERM"] + ); + } + // ---- egress containment verdict (cross-platform) ---- /// A minimal `command:` Plan for exercising `check_egress` directly. @@ -2184,7 +2283,11 @@ mod tests { stdout: String::new(), stderr: String::new(), upstream_error: None, - egress: flowproof_adapters::egress::EgressLog { blocked, faults }, + egress: flowproof_adapters::egress::EgressLog { + blocked, + faults, + observed: Vec::new(), + }, fs: flowproof_adapters::FsLog::default(), observed: false, containment: None,