From 1d1975a2ea11d3eceb3590ba9ed2b5e8a9597496 Mon Sep 17 00:00:00 2001 From: pitboss Date: Sat, 16 May 2026 12:28:01 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0022 (20260516T052512Z-20f8) --- src/dynamic/sandbox/process_macos.rs | 62 ++++++++--- src/dynamic/sandbox_profiles/xxe.sb | 43 ++++++++ tests/dynamic_fixtures/hardening/xxe_probe.py | 73 +++++++++++++ tests/sandbox_hardening_macos.rs | 100 ++++++++++++++++++ 4 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 src/dynamic/sandbox_profiles/xxe.sb create mode 100644 tests/dynamic_fixtures/hardening/xxe_probe.py diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index 4a708bfd..2856c361 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -125,18 +125,21 @@ const PROFILE_SOURCES: &[(&str, &str)] = &[ ), ("ssrf", include_str!("../sandbox_profiles/ssrf.sb")), ("deserialize", include_str!("../sandbox_profiles/deserialize.sb")), + ("xxe", include_str!("../sandbox_profiles/xxe.sb")), ]; /// Cap → profile-name dispatch. The most restrictive matching profile /// wins: filesystem caps outrank network caps outrank CODE_EXEC outranks -/// DESERIALIZE. Filesystem-shaped caps (`FILE_IO`, `SQL_QUERY` — DBs are -/// files in WORKDIR) map to `path_traversal`; outbound-network-shaped caps -/// (`SSRF`, `HEADER_INJECTION`, `OPEN_REDIRECT`, `UNVALIDATED_REDIRECT`, -/// `LDAP_INJECTION`, `XPATH_INJECTION`) map to `ssrf` since they share the -/// "outbound allowed; host secrets denied" shape. Caps with no shared -/// shape (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS, XXE) fall back to `base` -/// — XXE in particular would want a network-deny profile for entity -/// resolution, which the bundled `.sb` set does not yet ship. +/// DESERIALIZE outranks XXE. Filesystem-shaped caps (`FILE_IO`, +/// `SQL_QUERY` — DBs are files in WORKDIR) map to `path_traversal`; +/// outbound-network-shaped caps (`SSRF`, `HEADER_INJECTION`, +/// `OPEN_REDIRECT`, `UNVALIDATED_REDIRECT`, `LDAP_INJECTION`, +/// `XPATH_INJECTION`) map to `ssrf` since they share the "outbound +/// allowed; host secrets denied" shape. `XXE` maps to its own profile +/// which denies non-loopback outbound (entity fetch) on top of the +/// shared secret-file denylist. Remaining caps with no shared shape +/// (CRYPTO, AUTH, RACE, MEMORY_SAFETY, XSS) fall back to `base` because +/// they are code-path bugs rather than sandbox-boundary sinks. pub fn profile_for_caps(caps: u32) -> &'static str { // Mirror the bit positions declared in `src/labels/mod.rs`. const FILE_IO: u32 = 1 << 5; @@ -149,6 +152,7 @@ pub fn profile_for_caps(caps: u32) -> &'static str { const HEADER_INJECTION: u32 = 1 << 16; const OPEN_REDIRECT: u32 = 1 << 17; const UNVALIDATED_REDIRECT: u32 = 1 << 18; + const XXE: u32 = 1 << 19; const FS_SHAPED: u32 = FILE_IO | SQL_QUERY; const NET_SHAPED: u32 = @@ -162,6 +166,8 @@ pub fn profile_for_caps(caps: u32) -> &'static str { "cmdi" } else if caps & DESERIALIZE != 0 { "deserialize" + } else if caps & XXE != 0 { + "xxe" } else { "base" } @@ -371,14 +377,42 @@ mod tests { #[test] fn profile_for_caps_falls_back_to_base_for_unmapped_caps() { - // CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS / XXE do not yet - // have a cap-specific .sb profile. XXE in particular would want - // a network-deny profile (entity resolution), but the bundled .sb - // set does not ship one — track in deferred.md. + // CRYPTO / AUTH / RACE / MEMORY_SAFETY / XSS are code-path bugs + // without a sandbox-boundary kill path, so they fall back to the + // baseline secret-file denylist. const CRYPTO: u32 = 1 << 11; - const XXE: u32 = 1 << 19; + const AUTH: u32 = 1 << 12; + const RACE: u32 = 1 << 20; + const MEMORY_SAFETY: u32 = 1 << 21; + const XSS: u32 = 1 << 6; assert_eq!(profile_for_caps(CRYPTO), "base"); - assert_eq!(profile_for_caps(XXE), "base"); + assert_eq!(profile_for_caps(AUTH), "base"); + assert_eq!(profile_for_caps(RACE), "base"); + assert_eq!(profile_for_caps(MEMORY_SAFETY), "base"); + assert_eq!(profile_for_caps(XSS), "base"); + } + + #[test] + fn profile_for_caps_routes_xxe_to_xxe_profile() { + // XXE entity resolution kills via an outbound HTTP / DNS fetch + // against an attacker-controlled SYSTEM URL. The dedicated + // profile denies non-loopback outbound so the entity fetch faults + // before the parser hands the leaked data back. + const XXE: u32 = 1 << 19; + const DESERIALIZE: u32 = 1 << 8; + assert_eq!(profile_for_caps(XXE), "xxe"); + // DESERIALIZE outranks XXE in the dispatch chain (gadget chains + // commonly subsume entity-style payloads). + assert_eq!(profile_for_caps(XXE | DESERIALIZE), "deserialize"); + } + + #[test] + fn profile_path_materialises_xxe_profile_source() { + let path = profile_path("xxe").expect("xxe profile"); + let contents = std::fs::read_to_string(&path).expect("read .sb"); + assert!(contents.contains("(version 1)")); + assert!(contents.contains("(deny network-outbound)")); + assert!(contents.contains("/etc/passwd")); } #[test] diff --git a/src/dynamic/sandbox_profiles/xxe.sb b/src/dynamic/sandbox_profiles/xxe.sb new file mode 100644 index 00000000..f344e3e6 --- /dev/null +++ b/src/dynamic/sandbox_profiles/xxe.sb @@ -0,0 +1,43 @@ +;; Phase 18 (Track E.2) — XXE profile. +;; +;; XML eXternal Entity (XXE) payloads ship malicious DOCTYPE blocks +;; that declare a parameter entity whose SYSTEM identifier points at +;; an attacker-controlled URL (`http://attacker.example/leak.dtd`) or +;; a host secret (`file:///etc/passwd`). When the parser resolves the +;; entity it issues an outbound HTTP request or opens the local file, +;; either of which surfaces the leak. This profile blocks both +;; kill paths while keeping the harness itself reachable: +;; +;; * Outbound non-loopback network is denied so the entity fetch +;; against `http://attacker.example/...` cannot leave the host. +;; Loopback stays open so `StubHarness` endpoints bound on +;; 127.0.0.1 / ::1 / localhost remain reachable from the harness. +;; * `file://` reads of host secrets (`/etc/passwd` etc.) are +;; denied via the standard filesystem denylist. WORKDIR-local +;; reads stay open so the harness can read its own XML input. +;; +;; The denylist mirrors the other per-cap profiles' shape; only the +;; `(deny network-outbound)` block is XXE-specific. + +(version 1) +(allow default) + +;; Outbound network: deny by default, re-allow loopback so the +;; harness ↔ stub IPC over 127.0.0.1 / ::1 keeps working. +(deny network-outbound) +(allow network-outbound (remote ip "localhost:*")) + +;; Standard filesystem-escape denylist — shared shape with the other +;; per-cap profiles. `file://`-scheme entity reads of these paths +;; will fault out before the parser hands the contents back. +(deny file-read* + (literal "/etc/passwd") + (literal "/etc/master.passwd") + (literal "/etc/shadow") + (literal "/etc/sudoers") + (literal "/private/etc/passwd") + (literal "/private/etc/master.passwd") + (literal "/private/etc/shadow") + (literal "/private/etc/sudoers") + (subpath "/Users") + (subpath "/Library/Keychains")) diff --git a/tests/dynamic_fixtures/hardening/xxe_probe.py b/tests/dynamic_fixtures/hardening/xxe_probe.py new file mode 100644 index 00000000..f0613c3a --- /dev/null +++ b/tests/dynamic_fixtures/hardening/xxe_probe.py @@ -0,0 +1,73 @@ +"""Phase 18 (Track E.2) — XXE sandbox-profile probe. + +Simulates the kill path of an XML external-entity payload: the parser +sees a SYSTEM identifier pointing at an attacker-controlled URL and +issues an outbound HTTP fetch to resolve it. Under the dedicated +`xxe.sb` profile the outbound connect is denied at the kernel level +and surfaces as `EPERM` (errno=1); under the baseline `(allow +default)` the connect proceeds (and times out or hits the reserved +TEST-NET-1 unreachable, which is a distinct error class). + +The probe deliberately targets `http://192.0.2.1/leak.dtd` so DNS is +out of the picture — `192.0.2.1` is part of TEST-NET-1 (RFC 5737) +and never has a route on a real network, so the failure mode is the +sandbox EPERM vs. an OS-level connect-fail rather than a DNS lookup +quirk. + +Markers printed on stdout: + + xxe:network-denied errno=1 … ← sandbox-exec EPERM (acceptance) + xxe:network-attempted ← sandbox allowed the connect + xxe:probe-error ← probe-internal failure + +Exit codes: + + 0 — outbound attempt was permitted by the sandbox layer + 7 — outbound attempt was denied at the kernel (acceptance) + 9 — probe-internal error before a marker could be emitted +""" + +from __future__ import annotations + +import errno +import socket +import sys + +TEST_NET_HOST = "192.0.2.1" # RFC 5737 TEST-NET-1 — never routed. +TEST_NET_PORT = 80 + + +def main() -> int: + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2.0) + try: + sock.connect((TEST_NET_HOST, TEST_NET_PORT)) + except OSError as exc: + code = getattr(exc, "errno", None) + if code == errno.EPERM: + print(f"xxe:network-denied errno={code} {exc}") + return 7 + print( + f"xxe:network-attempted errno={code} {type(exc).__name__} {exc}" + ) + return 0 + # The connect actually succeeded — extraordinarily unlikely on + # an unrouted host, but treat it as `network-attempted` too: + # the sandbox did not short-circuit the outbound. + print(f"xxe:network-attempted connect-succeeded {TEST_NET_HOST}") + return 0 + except Exception as exc: + print(f"xxe:probe-error {type(exc).__name__} {exc}") + return 9 + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index 40729f50..7cb64971 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -107,6 +107,39 @@ except Exception as exc: // ── Tests ───────────────────────────────────────────────────────────────── + /// XXE probe: simulates an XML parser issuing the outbound HTTP + /// fetch for an external SYSTEM entity. Targets TEST-NET-1 so the + /// DNS layer is sidestepped; under the `xxe.sb` profile the + /// outbound connect is denied with EPERM and the probe exits 7. + /// Under a default-allow sandbox the connect attempt proceeds and + /// the probe exits 0 with the `network-attempted` marker. + /// + /// The probe source is read in at compile time and written into + /// the harness workdir at run time so the sandbox-exec + /// `(subpath "/Users")` deny does not block the script load. + const XXE_PROBE_SOURCE: &str = + include_str!("dynamic_fixtures/hardening/xxe_probe.py"); + + fn write_xxe_probe(workdir: &Path) -> PathBuf { + let path = workdir.join("xxe_probe.py"); + std::fs::write(&path, XXE_PROBE_SOURCE).expect("write xxe probe"); + path + } + + fn build_xxe_harness(workdir: &Path) -> BuiltHarness { + let probe = write_xxe_probe(workdir); + BuiltHarness { + workdir: workdir.to_path_buf(), + command: vec![ + "/usr/bin/python3".to_owned(), + probe.to_string_lossy().into_owned(), + ], + env: vec![], + source: String::new(), + entry_source: String::new(), + } + } + /// Profile selection: `FILE_IO` selects `path_traversal`, etc. #[test] fn profile_for_caps_matches_phase18_table() { @@ -114,9 +147,11 @@ except Exception as exc: const DESERIALIZE: u32 = 1 << 8; const SSRF: u32 = 1 << 9; const CODE_EXEC: u32 = 1 << 10; + const XXE: u32 = 1 << 19; assert_eq!(profile_for_caps(FILE_IO), "path_traversal"); assert_eq!(profile_for_caps(SSRF), "ssrf"); assert_eq!(profile_for_caps(CODE_EXEC), "cmdi"); + assert_eq!(profile_for_caps(XXE), "xxe"); assert_eq!(profile_for_caps(DESERIALIZE), "deserialize"); assert_eq!(profile_for_caps(0), "base"); } @@ -233,6 +268,71 @@ except Exception as exc: unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; } + /// Phase 18 acceptance (c): the XXE entity-resolution kill path + /// runs the probe under the `xxe.sb` profile and asserts the + /// outbound TCP connect against TEST-NET-1 is denied at the + /// kernel layer (EPERM). Sanity-cross-checked against the + /// `standard` profile run: without the wrap, the same probe gets + /// a non-EPERM error class (or a stub-loopback connect succeeds) + /// and exits 0 with the `network-attempted` marker. + #[test] + fn xxe_outbound_blocked_under_strict_xxe_profile() { + unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; + if !sandbox_exec_available() { + eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise xxe profile"); + return; + } + const XXE: u32 = 1 << 19; + let tmp = workdir(); + let harness = build_xxe_harness(tmp.path()); + let opts = strict_opts(XXE); + let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); + let stdout = stdout_string(&result); + eprintln!("stdout under xxe profile:\n{stdout}"); + let outcome = macos_outcome(&result).expect("hardening outcome recorded"); + assert_eq!(outcome.level, HardeningLevel::Sandboxed); + assert_eq!(outcome.profile, "xxe"); + assert!( + stdout.contains("xxe:network-denied"), + "expected sandbox-exec to deny outbound connect with EPERM; stdout:\n{stdout}" + ); + assert_eq!( + result.exit_code, + Some(7), + "probe should exit 7 on EPERM-denied connect; stdout:\n{stdout}" + ); + } + + /// Cross-check: the same probe under the `standard` profile (no + /// sandbox-exec wrap) does not receive EPERM on the outbound + /// connect. This guards against a future regression where every + /// fixture starts surfacing EPERM and the `xxe` test passes + /// vacuously. + #[test] + fn xxe_probe_under_standard_does_not_surface_eperm() { + unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; + let tmp = workdir(); + let harness = build_xxe_harness(tmp.path()); + let opts = standard_opts(); + let result = sandbox::run(&harness, b"", &opts).expect("sandbox::run"); + let stdout = stdout_string(&result); + eprintln!("stdout under standard:\n{stdout}"); + assert!( + result.hardening_outcome.is_none(), + "standard profile should not produce a hardening outcome", + ); + // The probe should NOT report EPERM under the unwrapped run — + // it should report `network-attempted` (typical) or + // `probe-error` (extremely unlikely). EPERM here would mean + // a host-level firewall is independently denying the syscall, + // which would mask the sandbox effect. + assert!( + !stdout.contains("xxe:network-denied"), + "standard profile produced an EPERM signal — host firewall \ + may be masking the sandbox effect; stdout:\n{stdout}" + ); + } + /// Companion to the case above: with `sandbox-exec` reachable the /// flag stays `false` so filesystem oracles run normally. #[test]