diff --git a/src/dynamic/sandbox/process_macos.rs b/src/dynamic/sandbox/process_macos.rs index 2856c361..faf194f6 100644 --- a/src/dynamic/sandbox/process_macos.rs +++ b/src/dynamic/sandbox/process_macos.rs @@ -208,9 +208,12 @@ pub fn profile_path(name: &str) -> Option { } let dir = profile_dir()?; let path = dir.join(format!("{key}.sb")); - if !path.exists() { - std::fs::write(&path, source).ok()?; - } + // Always overwrite on first miss in this process so an upgraded nyx + // binary picks up new profile content even when a previous version + // left a stale `.sb` file under `std::env::temp_dir()`. The in-process + // `PROFILE_PATHS` cache then short-circuits subsequent lookups so the + // write happens at most once per profile per process lifetime. + std::fs::write(&path, source).ok()?; let mut cache = profile_paths().lock().ok()?; cache.insert(*key, path.clone()); Some(path) diff --git a/src/dynamic/sandbox_profiles/cmdi.sb b/src/dynamic/sandbox_profiles/cmdi.sb index 4053ad6e..7f8d9dc3 100644 --- a/src/dynamic/sandbox_profiles/cmdi.sb +++ b/src/dynamic/sandbox_profiles/cmdi.sb @@ -9,6 +9,13 @@ (version 1) (allow default) +;; The `/Users` denylist uses regex matches on specific secret-bearing +;; subpaths instead of a blanket `(subpath "/Users")` deny. The blanket +;; form blocks every interpreter cold-start (python3 / node / java) at +;; `_path_importer_cache` because Hombrew / Anaconda / pyenv / nvm all +;; install under `/Users//...`. Narrowing to a specific secret +;; set keeps the harness loadable while still blocking credential +;; exfiltration via a tainted-argv command. (deny file-read* (literal "/etc/passwd") (literal "/etc/master.passwd") @@ -18,7 +25,21 @@ (literal "/private/etc/master.passwd") (literal "/private/etc/shadow") (literal "/private/etc/sudoers") - (subpath "/Users") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/\.zsh_history$") + (regex #"^/Users/[^/]+/\.bash_history$") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Code/User(/|$)") (subpath "/var/db") (subpath "/private/var/db") (subpath "/Library/Keychains")) diff --git a/src/dynamic/sandbox_profiles/deserialize.sb b/src/dynamic/sandbox_profiles/deserialize.sb index 39c85120..45d45016 100644 --- a/src/dynamic/sandbox_profiles/deserialize.sb +++ b/src/dynamic/sandbox_profiles/deserialize.sb @@ -9,6 +9,9 @@ (version 1) (allow default) +;; The `/Users` denylist uses regex matches on specific secret-bearing +;; subpaths instead of a blanket `(subpath "/Users")` deny. See the +;; matching comment in `cmdi.sb` for the cold-start rationale. (deny file-read* (literal "/etc/passwd") (literal "/etc/master.passwd") @@ -18,5 +21,16 @@ (literal "/private/etc/master.passwd") (literal "/private/etc/shadow") (literal "/private/etc/sudoers") - (subpath "/Users") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") (subpath "/Library/Keychains")) diff --git a/src/dynamic/sandbox_profiles/path_traversal.sb b/src/dynamic/sandbox_profiles/path_traversal.sb index 6d7eb3d8..2f8ab8c6 100644 --- a/src/dynamic/sandbox_profiles/path_traversal.sb +++ b/src/dynamic/sandbox_profiles/path_traversal.sb @@ -21,6 +21,13 @@ (version 1) (allow default) +;; The `/Users` denylist uses regex matches on specific secret-bearing +;; subpaths instead of a blanket `(subpath "/Users")` deny. See the +;; matching comment in `cmdi.sb` for the cold-start rationale. The +;; FILE_IO profile is the strictest of the cap profiles so the regex +;; set is wider than the CMDI / SSRF profiles: every credential file +;; under `~` plus per-app secret stores (Slack tokens, VS Code user +;; settings, Mail database) are denied. (deny file-read* (literal "/etc/passwd") (literal "/etc/master.passwd") @@ -30,7 +37,21 @@ (literal "/private/etc/master.passwd") (literal "/private/etc/shadow") (literal "/private/etc/sudoers") - (subpath "/Users") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/\.zsh_history$") + (regex #"^/Users/[^/]+/\.bash_history$") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Code/User(/|$)") (subpath "/var/db") (subpath "/private/var/db") (subpath "/var/log") diff --git a/src/dynamic/sandbox_profiles/ssrf.sb b/src/dynamic/sandbox_profiles/ssrf.sb index d09b47af..7ed90af5 100644 --- a/src/dynamic/sandbox_profiles/ssrf.sb +++ b/src/dynamic/sandbox_profiles/ssrf.sb @@ -9,6 +9,9 @@ (version 1) (allow default) +;; The `/Users` denylist uses regex matches on specific secret-bearing +;; subpaths instead of a blanket `(subpath "/Users")` deny. See the +;; matching comment in `cmdi.sb` for the cold-start rationale. (deny file-read* (literal "/etc/passwd") (literal "/etc/master.passwd") @@ -18,5 +21,16 @@ (literal "/private/etc/master.passwd") (literal "/private/etc/shadow") (literal "/private/etc/sudoers") - (subpath "/Users") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") (subpath "/Library/Keychains")) diff --git a/src/dynamic/sandbox_profiles/xxe.sb b/src/dynamic/sandbox_profiles/xxe.sb index f344e3e6..5e4bd4f7 100644 --- a/src/dynamic/sandbox_profiles/xxe.sb +++ b/src/dynamic/sandbox_profiles/xxe.sb @@ -30,6 +30,11 @@ ;; 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. +;; The `/Users` denylist uses regex matches on specific secret-bearing +;; subpaths instead of a blanket `(subpath "/Users")` deny. See the +;; matching comment in `cmdi.sb` for the cold-start rationale. XXE +;; payloads that resolve `file:///Users//.ssh/id_rsa` still hit +;; EPERM at parser fetch time. (deny file-read* (literal "/etc/passwd") (literal "/etc/master.passwd") @@ -39,5 +44,16 @@ (literal "/private/etc/master.passwd") (literal "/private/etc/shadow") (literal "/private/etc/sudoers") - (subpath "/Users") + (regex #"^/Users/[^/]+/\.ssh(/|$)") + (regex #"^/Users/[^/]+/\.aws(/|$)") + (regex #"^/Users/[^/]+/\.gnupg(/|$)") + (regex #"^/Users/[^/]+/\.netrc$") + (regex #"^/Users/[^/]+/\.docker(/|$)") + (regex #"^/Users/[^/]+/\.kube(/|$)") + (regex #"^/Users/[^/]+/\.config/gh(/|$)") + (regex #"^/Users/[^/]+/Library/Keychains(/|$)") + (regex #"^/Users/[^/]+/Library/Cookies(/|$)") + (regex #"^/Users/[^/]+/Library/Mail(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/com\.apple\.TCC(/|$)") + (regex #"^/Users/[^/]+/Library/Application Support/Slack(/|$)") (subpath "/Library/Keychains")) diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index d1b8755b..e7f2510a 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -115,8 +115,9 @@ except Exception as exc: /// 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. + /// the harness workdir at run time so the sandbox-exec narrow + /// `/Users//Library/...` denies cannot accidentally shadow a + /// home-relative script-load path. const XXE_PROBE_SOURCE: &str = include_str!("dynamic_fixtures/hardening/xxe_probe.py"); @@ -528,6 +529,163 @@ except Exception as exc: ); } + /// Phase 18 acceptance (d): Strict-profile run of the cmdi positive + /// fixture confirms AND stamps `VerifyResult::hardening_outcome`. + /// Mirrors `verify_finding_under_standard_leaves_hardening_outcome_unset` + /// with `harden_profile = "strict"` so the macOS process backend + /// engages `sandbox-exec -f cmdi.sb -D WORKDIR=...` end-to-end. + /// The cmdi.sb profile's narrowed `/Users` deny (regex-matched + /// secret subpaths only, not a blanket `(subpath "/Users")` deny) + /// keeps `_path_importer_cache` reachable so the python harness + /// cold-starts; the `subprocess.run("echo NYX_PWN_CMDI", shell=True)` + /// invocation in the auto-emitted harness is the sink probe and + /// fires under the cmdi profile (process-exec is allowed; filesystem + /// reads of host secrets are denied via the inherited denylist). + #[test] + fn verify_finding_under_strict_stamps_hardening_outcome() { + use std::path::PathBuf; + unsafe { std::env::remove_var(SANDBOX_EXEC_BIN_ENV) }; + if !sandbox_exec_available() { + eprintln!("SKIP: /usr/bin/sandbox-exec missing — cannot exercise wrap"); + return; + } + let python3_available = std::process::Command::new("/usr/bin/python3") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !python3_available { + eprintln!("SKIP: /usr/bin/python3 missing — cannot run python harness"); + return; + } + + use nyx_scanner::commands::scan::Diag; + use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions}; + use nyx_scanner::evidence::{ + Confidence, Evidence, FlowStep, FlowStepKind, VerifyStatus, + }; + use nyx_scanner::labels::Cap; + use nyx_scanner::patterns::{FindingCategory, Severity}; + use nyx_scanner::utils::config::Config; + + let fixture_src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/dynamic_fixtures/python/cmdi_positive.py"); + + let tmp = tempfile::TempDir::new().expect("create tempdir"); + let dst = tmp.path().join("cmdi_positive.py"); + std::fs::copy(&fixture_src, &dst).expect("stage fixture into tempdir"); + + unsafe { + std::env::set_var( + "NYX_REPRO_BASE", + tmp.path().join("repro").to_str().unwrap(), + ); + std::env::set_var( + "NYX_TELEMETRY_PATH", + tmp.path().join("events.jsonl").to_str().unwrap(), + ); + } + + let path_str = dst.to_string_lossy().into_owned(); + let evidence = Evidence { + flow_steps: vec![ + FlowStep { + step: 1, + kind: FlowStepKind::Source, + file: path_str.clone(), + line: 1, + col: 0, + snippet: None, + variable: Some("host".into()), + callee: None, + function: Some("run_ping".into()), + is_cross_file: false, + }, + FlowStep { + step: 2, + kind: FlowStepKind::Sink, + file: path_str.clone(), + line: 13, + col: 4, + snippet: None, + variable: None, + callee: None, + function: None, + is_cross_file: false, + }, + ], + sink_caps: Cap::CODE_EXEC.bits(), + ..Default::default() + }; + let diag = Diag { + path: path_str, + line: 13, + col: 0, + severity: Severity::High, + id: "taint-unsanitised-flow".into(), + category: FindingCategory::Security, + path_validated: false, + guard_kind: None, + message: None, + labels: vec![], + confidence: Some(Confidence::High), + evidence: Some(evidence), + rank_score: None, + rank_reason: None, + suppressed: false, + suppression: None, + rollup: None, + finding_id: String::new(), + alternative_finding_ids: vec![], + stable_hash: 0, + }; + + let mut config = Config::default(); + config.scanner.harden_profile = "strict".to_owned(); + // Force the process backend: the macOS sandbox-exec wrap is gated + // on `SandboxBackend::Process`, and `SandboxBackend::Auto` would + // route the python harness to docker when docker is reachable + // (the common CI shape). Docker ignores `process_hardening`, so + // running under `Auto` would leave `hardening_outcome` unset + // regardless of `--harden=strict`, masking the wiring this test + // is asserting. + config.scanner.verify_backend = "process".to_owned(); + let opts = VerifyOptions::from_config(&config); + let result = verify_finding(&diag, &opts); + + unsafe { + std::env::remove_var("NYX_REPRO_BASE"); + std::env::remove_var("NYX_TELEMETRY_PATH"); + } + + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "cmdi_positive.py under --harden=strict should confirm: detail={:?}", + result.detail, + ); + let summary = result + .hardening_outcome + .as_ref() + .expect("Strict run must stamp hardening_outcome"); + assert_eq!( + summary.backend, "macos-process", + "macOS host should produce a macos-process backend stamp", + ); + assert_eq!( + summary.level, "sandboxed", + "Strict-engaged sandbox-exec wrap should record level=sandboxed", + ); + assert_eq!( + summary.profile, "cmdi", + "CODE_EXEC-cap finding should land the cmdi profile", + ); + assert!( + summary.primitives.is_empty(), + "macOS backend records no per-primitive entries", + ); + } + /// Round-trip the portable summary through JSON to lock in the /// repro-bundle wire shape: `VerifyResult::hardening_outcome` lands /// on `expected/verdict.json` so the eval-corpus tabulator and any