mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0002 (20260517T044708Z-e058)
This commit is contained in:
parent
3d51a3d8ae
commit
6698eb96eb
5 changed files with 237 additions and 2 deletions
18
src/cli.rs
18
src/cli.rs
|
|
@ -513,6 +513,24 @@ pub enum Commands {
|
|||
#[arg(long, help_heading = "Dynamic", value_name = "BACKEND")]
|
||||
backend: Option<String>,
|
||||
|
||||
/// 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 <cap>.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<String>,
|
||||
|
||||
// ── 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.
|
||||
|
|
|
|||
|
|
@ -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=<standard|strict> 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 ────────
|
||||
|
|
|
|||
|
|
@ -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 <cap>.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();
|
||||
|
|
|
|||
|
|
@ -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 <profile>.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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue