diff --git a/src/commands/scan.rs b/src/commands/scan.rs index ce29c5d1..6e508feb 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -555,8 +555,12 @@ pub fn handle( } // ── Dynamic verification (feature-gated) ───────────────────────────── + // The constructed `VerifyOptions` is held in an `Option` scoped past + // the per-finding loop so the composite-chain re-verification pass + // below can reuse the same preloaded summaries / callgraph without + // a second SQLite round-trip. #[cfg(feature = "dynamic")] - if config.scanner.verify { + let verify_opts: Option = if config.scanner.verify { let mut opts = crate::dynamic::verify::VerifyOptions::from_config(config); // Phase 30 (Track C observability): surface the per-finding // [`crate::dynamic::trace::VerifyTrace`] on stderr when the @@ -599,7 +603,10 @@ pub fn handle( ev.dynamic_verdict = Some(result); } } - } + Some(opts) + } else { + None + }; // ── Baseline write (§M6.5): persist current findings as stripped baseline if let Some(bw_path) = baseline_write { @@ -645,12 +652,39 @@ pub fn handle( max_depth: config.chain.max_depth, min_score: config.chain.min_score, }; - let chains = crate::chain::find_chains_with_reach( + // `mut` is unused when the `dynamic` feature is off: composite + // chain re-verification is the only mutator and is cfg-gated below. + #[allow(unused_mut)] + let mut chains = crate::chain::find_chains_with_reach( &chain_edges, &surface_map, chain_search_cfg, chain_reach, ); + + // Track G.3: composite chain re-verification. Only the top-N chains + // by score reach the live composite run (cost control via + // `[chain] reverify_top_n` — default 5, `0` to skip). Gated on the + // master dynamic-verification switch (`scanner.verify`) so users who + // skip per-finding verification do not pay the per-chain build / + // sandbox cost. Mutates `chains` in place: each top-N chain's + // `dynamic_verdict` / `severity` / `reverify_reason` flow through to + // every downstream consumer (`filter_constituents`, + // `build_findings_json`, `build_sarif_with_chains`, console + // renderer). + #[cfg(feature = "dynamic")] + if let Some(ref opts) = verify_opts { + if config.chain.reverify_top_n > 0 && !chains.is_empty() { + let _ = crate::chain::reverify::reverify_top_chains( + &mut chains, + &diags, + &surface_map, + opts, + config.chain.reverify_top_n, + ); + } + } + let diags_for_output = crate::output::filter_constituents( diags.clone(), &chains, diff --git a/tests/chain_emission_e2e.rs b/tests/chain_emission_e2e.rs index 42a6fc97..e2cfd630 100644 --- a/tests/chain_emission_e2e.rs +++ b/tests/chain_emission_e2e.rs @@ -155,3 +155,95 @@ fn every_chain_composer_scenario_emits_at_least_one_chain() { } } } + +/// Locks the scan-pipeline wiring contract: when dynamic verification is +/// enabled (default), the composite chain re-verifier runs after the +/// chain-composition pass and stamps each top-N chain's +/// `dynamic_verdict` so downstream consumers (`build_findings_json`, +/// `build_sarif_with_chains`, console renderer) see a populated field. +/// +/// The verdict's *status* depends on the host's Python toolchain: when +/// `python3 -m venv` succeeds and the per-language chain-step harness +/// runs, the verdict resolves to `Confirmed`; when the toolchain is +/// missing it falls through to `Inconclusive(BackendInsufficient)`. +/// This test asserts only the wiring contract — that the field is +/// populated and the detail string reports coverage — so it stays green +/// on any host with a working `nyx` binary. +/// +/// Gated on `feature = "dynamic"` because the reverifier lives behind +/// that flag. +#[cfg(feature = "dynamic")] +#[test] +fn flask_eval_chain_reverify_populates_dynamic_verdict() { + let root = fixture_root("python/flask_eval"); + let value = run_scan_json(&root); + + let chains = value + .get("chains") + .and_then(Value::as_array) + .expect("`chains` array missing from scan output"); + assert!(!chains.is_empty(), "expected at least one composed chain"); + + let top = &chains[0]; + let dv = top + .get("dynamic_verdict") + .expect("`dynamic_verdict` key missing from top chain"); + assert!( + !dv.is_null(), + "top chain `dynamic_verdict` was null; wiring did not fire. Chain:\n{}", + serde_json::to_string_pretty(top).unwrap_or_default() + ); + + let status = dv + .get("status") + .and_then(Value::as_str) + .expect("verdict missing `status`"); + assert!( + matches!(status, "Confirmed" | "Inconclusive" | "Unsupported"), + "unexpected verdict status: {status:?}" + ); + + let detail = dv + .get("detail") + .and_then(Value::as_str) + .expect("verdict missing `detail`"); + for segment in ["derived", "built", "ran"] { + assert!( + detail.contains(segment), + "verdict detail missing `{segment}` coverage segment: {detail:?}" + ); + } +} + +/// Mirror of the above: with `--no-verify` the chain-reverify pass is +/// skipped and `dynamic_verdict` stays `null`. Locks the cost-control +/// contract: users who opt out of dynamic verification do not pay the +/// per-chain build / sandbox cost. +#[cfg(feature = "dynamic")] +#[test] +fn flask_eval_chain_dynamic_verdict_is_null_when_verify_disabled() { + let root = fixture_root("python/flask_eval"); + let assert = Command::cargo_bin("nyx") + .expect("nyx binary") + .args(["scan", "--no-verify", "--format", "json"]) + .arg(&root) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()) + .expect("nyx scan stdout is valid UTF-8"); + let value: Value = serde_json::from_str(&stdout) + .expect("nyx scan --format json produced invalid JSON"); + + let chains = value + .get("chains") + .and_then(Value::as_array) + .expect("`chains` array missing"); + assert!(!chains.is_empty()); + + let top = &chains[0]; + let dv = top.get("dynamic_verdict"); + assert!( + matches!(dv, None | Some(Value::Null)), + "top chain `dynamic_verdict` should be absent or null under --no-verify; got {dv:?}" + ); +}