diff --git a/src/cli.rs b/src/cli.rs index e41c5d15..9e0fa2d8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -513,6 +513,24 @@ pub enum Commands { #[arg(long, help_heading = "Dynamic", value_name = "BACKEND")] backend: Option, + /// Process-backend hardening profile applied to every verified finding. + /// + /// `standard` (default): baseline only. Linux runs no-new-privs + + /// memory rlimit; macOS skips the sandbox-exec wrap. + /// `strict`: full lockdown. Linux layers namespaces, chroot to + /// workdir, and a default-deny seccomp filter; macOS wraps the + /// harness with `sandbox-exec -f .sb`. Opt-in because + /// interpreted Linux harnesses may SIGSYS until the per-language + /// seccomp allowlists are expanded. + #[cfg_attr(not(feature = "dynamic"), arg(hide = true))] + #[arg( + long, + help_heading = "Dynamic", + value_name = "PROFILE", + value_parser = ["standard", "strict"], + )] + harden: Option, + // ── Baseline / patch-validation (§M6.5) ──────────────────────── /// Read a previous scan's JSON output (or a stripped .nyx/baseline.json) /// and diff it against the current scan on stable_hash. diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 039876b2..50c0c524 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -104,6 +104,7 @@ pub fn handle_command( verify_all_confidence, unsafe_sandbox, backend, + harden, baseline, baseline_write, gate, @@ -346,9 +347,13 @@ pub fn handle_command( config.scanner.verify_all_confidence = true; } config.scanner.verify_backend = resolved_backend.to_owned(); + // --harden= overrides the config default. + if let Some(ref profile) = harden { + config.scanner.harden_profile = profile.to_owned(); + } } // Without the dynamic feature, --verify / --no-verify / --unsafe-sandbox / - // --backend are silently accepted (no-op). + // --backend / --harden are silently accepted (no-op). #[cfg(not(feature = "dynamic"))] { let _ = verify; @@ -356,6 +361,7 @@ pub fn handle_command( let _ = verify_all_confidence; let _ = unsafe_sandbox; let _ = backend; + let _ = harden; } // ── --explain-engine: print resolved config and exit ──────── diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index d8565bc1..d0657a7b 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -101,7 +101,7 @@ impl VerifyOptions { /// (`src/dynamic/runner.rs` `oob_nonce_slot` branch) while non-OOB /// payloads continue to run against their existing oracle. pub fn from_config(config: &Config) -> Self { - use crate::dynamic::sandbox::{NetworkPolicy, SandboxBackend}; + use crate::dynamic::sandbox::{NetworkPolicy, ProcessHardeningProfile, SandboxBackend}; let backend = match config.scanner.verify_backend.as_str() { "docker" => SandboxBackend::Docker, "process" => SandboxBackend::Process, @@ -116,6 +116,17 @@ impl VerifyOptions { Some(listener) => NetworkPolicy::OobOutbound { listener }, None => NetworkPolicy::None, }; + // Phase 17/18 (Track E.1/E.2): `--harden=strict` (or + // `harden_profile = "strict"` in nyx.toml) opts the verifier into + // the full process-backend lockdown. Linux engages namespace + // unshare + chroot + default-deny seccomp on top of the baseline; + // macOS wraps the harness with `sandbox-exec -f .sb` keyed + // off the per-finding expected cap (set later in `verify_finding` + // because the cap is only known once spec derivation runs). + let process_hardening = match config.scanner.harden_profile.as_str() { + "strict" => ProcessHardeningProfile::Strict, + _ => ProcessHardeningProfile::Standard, + }; // Phase 18 (Track E.2): the macOS process backend depends on // `/usr/bin/sandbox-exec` to confine filesystem reach. When the // binary is absent, surface that up-front so filesystem oracles @@ -135,6 +146,7 @@ impl VerifyOptions { sandbox: SandboxOptions { backend, network_policy, + process_hardening, ..SandboxOptions::default() }, project_root: None, @@ -661,6 +673,18 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { if !stub_harness.is_empty() { sandbox_opts.stub_harness = Some(Arc::clone(&stub_harness)); } + // Phase 17/18: when the operator opted into Strict hardening, seed + // `seccomp_caps` from the spec's expected cap so the Linux process + // backend installs the cap-minimal syscall allowlist and the macOS + // backend picks the matching `.sb` profile (`FILE_IO → + // path_traversal`, `CODE_EXEC → cmdi`, …). Standard runs leave the + // field at 0 (base allowlist / no wrap) for back-compat. + if matches!( + sandbox_opts.process_hardening, + crate::dynamic::sandbox::ProcessHardeningProfile::Strict, + ) { + sandbox_opts.seccomp_caps = spec.expected_cap.bits(); + } // Phase 30: hand the runner an `Arc` clone so it can append // `build_*` / `sandbox_started` / `oracle_*` stages from inside // `run_spec`. The verifier still owns the trace for verdict-stage @@ -1211,6 +1235,46 @@ mod tests { unsafe { std::env::remove_var("NYX_VERIFY_REPLAY_STABLE") }; } + #[test] + fn from_config_defaults_process_hardening_to_standard() { + use crate::dynamic::sandbox::ProcessHardeningProfile; + let opts = VerifyOptions::from_config(&Config::default()); + assert!( + matches!(opts.sandbox.process_hardening, ProcessHardeningProfile::Standard), + "back-compat: missing harden_profile must keep the Standard baseline so \ + existing call sites (process backend without `--harden=strict`) keep \ + their pre-Phase-17 hardening matrix" + ); + } + + #[test] + fn from_config_picks_up_strict_harden_profile() { + use crate::dynamic::sandbox::ProcessHardeningProfile; + let mut config = Config::default(); + config.scanner.harden_profile = "strict".to_owned(); + let opts = VerifyOptions::from_config(&config); + assert!( + matches!(opts.sandbox.process_hardening, ProcessHardeningProfile::Strict), + "harden_profile=strict must engage the full Phase-17/18 lockdown so \ + `--harden=strict` actually wraps the harness with sandbox-exec on macOS \ + and layers chroot + seccomp on Linux" + ); + } + + #[test] + fn from_config_unknown_harden_profile_falls_back_to_standard() { + use crate::dynamic::sandbox::ProcessHardeningProfile; + let mut config = Config::default(); + config.scanner.harden_profile = "lockdown".to_owned(); + let opts = VerifyOptions::from_config(&config); + assert!( + matches!(opts.sandbox.process_hardening, ProcessHardeningProfile::Standard), + "unknown harden_profile values must degrade to Standard so a typo in \ + nyx.toml does not silently leave the operator without the baseline \ + hardening they were already paying for" + ); + } + #[test] fn verdict_cache_round_trip() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/utils/config.rs b/src/utils/config.rs index b956e511..e9ac0338 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -281,6 +281,24 @@ pub struct ScannerConfig { /// `"process"`: in-process runner (same as `--unsafe-sandbox`). #[serde(default = "default_verify_backend")] pub verify_backend: String, + + /// Process-backend hardening profile applied during dynamic verification. + /// + /// `"standard"` (default): the historical baseline. On Linux this + /// engages `prctl(PR_SET_NO_NEW_PRIVS)` plus `setrlimit(RLIMIT_AS)`; + /// on macOS the harness runs without a `sandbox-exec` wrap. + /// `"strict"`: opts into the full Phase 17/18 lockdown. On Linux the + /// process backend layers the namespace unshare, chroot to workdir, + /// and default-deny seccomp filter on top of the baseline. On macOS + /// the harness is wrapped with `sandbox-exec -f .sb` keyed + /// off the finding's expected cap (FILE_IO → `path_traversal.sb`, + /// CODE_EXEC → `cmdi.sb`, SSRF → `ssrf.sb`, …). + /// + /// Opt-in. Interpreted Linux harnesses (python3, node, java) may + /// SIGSYS under strict seccomp until the per-language allowlists are + /// expanded; static native harnesses run unaffected. + #[serde(default = "default_harden_profile")] + pub harden_profile: String, } fn default_verify() -> bool { true @@ -288,6 +306,9 @@ fn default_verify() -> bool { fn default_verify_backend() -> String { "auto".to_owned() } +fn default_harden_profile() -> String { + "standard".to_owned() +} impl Default for ScannerConfig { fn default() -> Self { Self { @@ -327,6 +348,7 @@ impl Default for ScannerConfig { verify: true, verify_all_confidence: false, verify_backend: "auto".to_owned(), + harden_profile: "standard".to_owned(), } } } diff --git a/tests/sandbox_hardening_macos.rs b/tests/sandbox_hardening_macos.rs index 7a343fdc..d1b8755b 100644 --- a/tests/sandbox_hardening_macos.rs +++ b/tests/sandbox_hardening_macos.rs @@ -403,6 +403,131 @@ except Exception as exc: ); } + /// Companion to the test below: the same fixture under the default + /// `harden_profile = "standard"` produces a `Confirmed` verdict + /// (path-of-least-resistance) but does *not* stamp a + /// `hardening_outcome`. Guards against a future regression where + /// `from_config` unconditionally engages Strict — the macOS process + /// backend's wrap is opt-in and the operator's verdict shape must + /// reflect that. + #[test] + fn verify_finding_under_standard_leaves_hardening_outcome_unset() { + use std::path::PathBuf; + 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 config = Config::default(); + 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 the default profile should still confirm: detail={:?}", + result.detail, + ); + assert!( + result.hardening_outcome.is_none(), + "standard profile must not stamp hardening_outcome — the macOS \ + process backend never engaged sandbox-exec, so claiming the run \ + was sandboxed would be a false witness; got: {:?}", + result.hardening_outcome, + ); + } + /// 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