diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 2e0f5d4e..df88eafb 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -570,8 +570,22 @@ pub fn handle( opts.callgraph = Some(load_verify_callgraph(s)); } } + // Phase 29 follow-up: resolve the telemetry events log path once + // per scan so the per-finding `wrong:` stamp is a cheap fs read, + // not a directories-crate lookup each iteration. `None` (no + // log path resolvable on this host) leaves every `wrong` as + // `None` — the eval-corpus tabulator treats that as "no signal." + let telemetry_log = crate::dynamic::telemetry::log_path(); for diag in &mut diags { - let result = crate::dynamic::verify::verify_finding(diag, &opts); + let mut result = crate::dynamic::verify::verify_finding(diag, &opts); + if result.status == crate::dynamic::report::VerifyStatus::Confirmed { + if let Some(ref log_path) = telemetry_log { + result.wrong = crate::dynamic::telemetry::feedback_wrong_for_finding( + log_path, + &result.finding_id, + ); + } + } if let Some(ref mut ev) = diag.evidence { ev.dynamic_verdict = Some(result); } diff --git a/src/dynamic/policy.rs b/src/dynamic/policy.rs index a406f98a..7d653b2e 100644 --- a/src/dynamic/policy.rs +++ b/src/dynamic/policy.rs @@ -30,15 +30,25 @@ //! # Phase 28 extension (Track H.5 — PII scrubber) //! //! [`Scrubber`] hashes probe-witness values whose textual shape matches a -//! project secret pattern. The pattern set is the same one -//! [`crate::utils::redact`] already uses for `--show-suppressed` console -//! output and repro `outcome.json` redaction: AWS access key IDs, GitHub / +//! project secret pattern. The pattern set is the one +//! [`crate::utils::redact`] already applies to dynamic sandbox output — +//! repro bundle `outcome.json` redaction and telemetry payload scrubbing +//! before they hit disk. Covered shapes: AWS access key IDs, GitHub / //! Slack / OpenAI tokens, PEM blocks, `password=` / `api_key=` / `secret=` //! query strings, and `Bearer` headers. Re-using the redactor's pattern //! list keeps the rule "what counts as PII" defined in exactly one place //! across the project — adding a new pattern in `redact.rs` also tightens //! probe-witness scrubbing without a second registry to maintain. //! +//! Note on the `--show-suppressed` CLI flag: that flag is a boolean +//! toggle for inline-comment suppression of static findings +//! ([`crate::commands::scan`] `show_suppressed`); it does not consume +//! the secret-pattern set defined here. A future user-configurable +//! "what counts as a secret in this project" regex list (e.g. a +//! `[scrubber]` section in `default-nyx.conf`) would plug into +//! [`Scrubber::project_default`] alongside the static +//! [`crate::utils::redact`] patterns, not the suppression flag. +//! //! The witness scrubber differs from the redactor in one respect: instead //! of erasing the secret behind a `` placeholder it replaces it //! with `>` where the prefix is the first 16 hex diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index 51799dc6..620780c4 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -269,6 +269,23 @@ fn repro_root(spec_hash: &str) -> Result { Ok(root) } +/// Resolve the bundle path for `spec_hash` without creating any directories. +/// +/// Returns the same path [`write`] uses (`~/.cache/nyx/dynamic/repro/{spec_hash}/`) +/// so callers can locate an existing bundle for replay. Respects the +/// `NYX_REPRO_BASE` test override. +/// +/// Returns `None` when the host has no resolvable cache dir. +pub fn bundle_root_for(spec_hash: &str) -> Option { + let base = if let Ok(p) = std::env::var("NYX_REPRO_BASE") { + PathBuf::from(p) + } else { + let dirs = ProjectDirs::from("", "", "nyx")?; + dirs.cache_dir().join("dynamic").join("repro") + }; + Some(base.join(spec_hash)) +} + fn write_json(path: &Path, value: &impl serde::Serialize) -> Result<(), ReproError> { let json = serde_json::to_string_pretty(value)?; fs::write(path, json.as_bytes())?; @@ -835,6 +852,36 @@ mod tests { } } + #[test] + fn bundle_root_for_honours_test_override() { + let dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) }; + let root = bundle_root_for("cafe0001").unwrap(); + assert_eq!(root, dir.path().join("cafe0001")); + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + + #[test] + fn bundle_root_for_matches_write_output_under_override() { + // The path returned by `bundle_root_for` must equal the bundle path + // that `write` produces — replay callers locate the bundle without + // re-creating directories, so a drift between the two helpers would + // silently skip the replay for every Confirmed finding. + let dir = TempDir::new().unwrap(); + unsafe { std::env::set_var("NYX_REPRO_BASE", dir.path().to_str().unwrap()) }; + let spec = make_spec(); + let opts = SandboxOptions::default(); + let outcome = make_outcome(); + let verdict = make_verdict(); + let artifact = write( + &spec, &opts, &outcome, &verdict, + "# harness", "# entry", b"payload", "label", None, + ).unwrap(); + let resolved = bundle_root_for(&spec.spec_hash).unwrap(); + assert_eq!(resolved, artifact.root); + unsafe { std::env::remove_var("NYX_REPRO_BASE") }; + } + #[test] fn outcome_json_redacts_secrets() { let dir = TempDir::new().unwrap(); diff --git a/src/dynamic/verify.rs b/src/dynamic/verify.rs index 85732c75..6db66208 100644 --- a/src/dynamic/verify.rs +++ b/src/dynamic/verify.rs @@ -71,6 +71,18 @@ pub struct VerifyOptions { /// end-of-verify. Wired to the future `--verbose` CLI flag; off by /// default so non-interactive scans stay quiet. pub trace_verbose: bool, + /// Phase 29 follow-up: when `true`, the verifier re-runs + /// `reproduce.sh` against the freshly written repro bundle whenever a + /// finding is `Confirmed` and stamps the typed + /// [`crate::evidence::VerifyResult::replay_stable`] field via + /// [`crate::dynamic::repro::replay_stability`]. Opt-in because + /// invoking `reproduce.sh` per Confirmed finding doubles wall-clock + /// cost — the eval-corpus driver flips it on; interactive `nyx scan` + /// keeps it off and leaves `replay_stable: None`. + /// + /// Default `false`. [`Self::from_config`] honours the + /// `NYX_VERIFY_REPLAY_STABLE` environment variable (`1` / `true`). + pub replay_stable_check: bool, } impl VerifyOptions { @@ -113,6 +125,10 @@ impl VerifyOptions { #[cfg(not(target_os = "macos"))] let refuse_filesystem_confirm = false; + let replay_stable_check = std::env::var("NYX_VERIFY_REPLAY_STABLE") + .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE")) + .unwrap_or(false); + Self { sandbox: SandboxOptions { backend, @@ -127,6 +143,7 @@ impl VerifyOptions { refuse_filesystem_confirm, telemetry_policy: SamplingPolicy::from_config(&config.telemetry), trace_verbose: false, + replay_stable_check, } } } @@ -653,7 +670,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { _ => 1, }; - let verdict = build_verdict( + let mut verdict = build_verdict( &finding_id, &spec, result, @@ -662,6 +679,21 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult { elapsed, ); + // Phase 29 follow-up: stamp `replay_stable` from a `reproduce.sh` rerun + // against the freshly written bundle. Opt-in (see + // `VerifyOptions::replay_stable_check`) because invoking the script + // per Confirmed finding doubles wall-clock cost — the eval-corpus + // driver flips it on so the tabulated `stable_replays` column becomes + // non-vacuous; interactive `nyx scan` keeps `replay_stable: None`. + if verdict.status == VerifyStatus::Confirmed + && opts.replay_stable_check + && let Some(bundle) = crate::dynamic::repro::bundle_root_for(&spec.spec_hash) + && bundle.join("reproduce.sh").exists() + { + let replay = crate::dynamic::repro::replay_bundle(&bundle, &[]); + verdict.replay_stable = crate::dynamic::repro::replay_stability(&replay); + } + // Store result in verdict cache (best-effort; errors are silently ignored). if let Some(ref db_path) = opts.db_path { insert_verdict_cache( @@ -1044,6 +1076,33 @@ mod tests { assert_eq!(transitive_import_digest_placeholder(), ""); } + #[test] + fn from_config_defaults_replay_stable_check_off() { + // Make sure the test is hermetic — `from_config` reads the env + // var, so a stale process-wide setting could mask the default. + unsafe { std::env::remove_var("NYX_VERIFY_REPLAY_STABLE") }; + let opts = VerifyOptions::from_config(&Config::default()); + assert!( + !opts.replay_stable_check, + "NYX_VERIFY_REPLAY_STABLE absent must leave the opt-in off so \ + interactive `nyx scan` does not pay the per-finding reproduce.sh cost" + ); + } + + #[test] + fn from_config_picks_up_replay_stable_env_flag() { + unsafe { std::env::set_var("NYX_VERIFY_REPLAY_STABLE", "1") }; + let opts = VerifyOptions::from_config(&Config::default()); + assert!(opts.replay_stable_check); + unsafe { std::env::set_var("NYX_VERIFY_REPLAY_STABLE", "true") }; + let opts = VerifyOptions::from_config(&Config::default()); + assert!(opts.replay_stable_check); + unsafe { std::env::set_var("NYX_VERIFY_REPLAY_STABLE", "0") }; + let opts = VerifyOptions::from_config(&Config::default()); + assert!(!opts.replay_stable_check); + unsafe { std::env::remove_var("NYX_VERIFY_REPLAY_STABLE") }; + } + #[test] fn verdict_cache_round_trip() { let dir = tempfile::TempDir::new().unwrap();