From 8a801953e26b06fbbdb97659fbfbc61af05bca0a Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 15 May 2026 17:22:46 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2026:=20Track=20G.3=20?= =?UTF-8?q?=E2=80=94=20End-to-end=20chain=20re-verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chain/finding.rs | 60 +++++- src/chain/mod.rs | 7 + src/chain/reverify.rs | 384 +++++++++++++++++++++++++++++++++ src/chain/search.rs | 3 +- src/dynamic/lang/c.rs | 24 ++- src/dynamic/lang/cpp.rs | 24 ++- src/dynamic/lang/go.rs | 31 ++- src/dynamic/lang/java.rs | 30 ++- src/dynamic/lang/javascript.rs | 6 +- src/dynamic/lang/js_shared.rs | 37 +++- src/dynamic/lang/mod.rs | 70 ++++++ src/dynamic/lang/php.rs | 29 ++- src/dynamic/lang/python.rs | 30 ++- src/dynamic/lang/ruby.rs | 24 ++- src/dynamic/lang/rust.rs | 30 ++- src/dynamic/lang/typescript.rs | 6 +- src/output/json.rs | 1 + src/output/mod.rs | 1 + src/utils/config.rs | 5 + tests/chain_reverify.rs | 200 +++++++++++++++++ tests/dynamic_layering.rs | 4 + 21 files changed, 991 insertions(+), 15 deletions(-) create mode 100644 src/chain/reverify.rs create mode 100644 tests/chain_reverify.rs diff --git a/src/chain/finding.rs b/src/chain/finding.rs index 685fd18b..59e85de5 100644 --- a/src/chain/finding.rs +++ b/src/chain/finding.rs @@ -24,7 +24,7 @@ use crate::chain::edges::FindingRef; use crate::chain::impact::ImpactCategory; -use crate::evidence::VerifyResult; +use crate::evidence::{VerifyResult, VerifyStatus}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -55,6 +55,24 @@ impl fmt::Display for ChainSeverity { } } +impl ChainSeverity { + /// Phase 26 — drop one severity bucket. Used by composite + /// re-verification when the chain's dynamic verdict is + /// `Inconclusive`: the chain stays on the wire but its severity + /// loses one notch so triagers see the verification gap. + /// + /// `Low` is the floor — calling `downgraded()` on `Low` returns + /// `Low` so the helper is idempotent. + pub fn downgraded(self) -> Self { + match self { + ChainSeverity::Critical => ChainSeverity::High, + ChainSeverity::High => ChainSeverity::Medium, + ChainSeverity::Medium => ChainSeverity::Low, + ChainSeverity::Low => ChainSeverity::Low, + } + } +} + /// One member of a [`ChainFinding`]. /// /// Wraps a [`FindingRef`] so the chain output can name each constituent @@ -91,10 +109,17 @@ pub struct ChainFinding { /// Numeric score from [`crate::chain::score::score_path`]. /// Carried verbatim for JSON output so consumers can re-sort. pub score: f64, - /// Composite dynamic verification verdict. `None` in Phase 25 - /// (the composite re-verifier lands in Phase 26). + /// Composite dynamic verification verdict. `None` until Phase 26's + /// `reverify_chain` runs over the chain. #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic_verdict: Option, + /// Phase 26 — Track G.3: human-readable reason when composite + /// re-verification altered the chain's outcome. Populated when + /// `dynamic_verdict.status` is `Inconclusive` and the severity was + /// downgraded; `None` when the verdict either confirmed the chain + /// or left the severity untouched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverify_reason: Option, } /// Sink terminus of a [`ChainFinding`]. Mirrors the @@ -123,6 +148,35 @@ impl ChainFinding { let bytes = out.as_bytes(); u64::from_le_bytes(bytes[..8].try_into().unwrap()) } + + /// Phase 26 — Track G.3: attach a composite verdict + apply the + /// `Inconclusive → severity downgrade` rule. + /// + /// - `Confirmed` / `NotConfirmed` / `Unsupported`: severity stays + /// put; `reverify_reason` cleared. + /// - `Inconclusive`: severity drops one bucket + /// ([`ChainSeverity::downgraded`]) and `reverify_reason` is set + /// from the verdict's typed inconclusive reason (with a fallback + /// to a generic "inconclusive composite verification" string when + /// the verdict has no typed reason). + pub fn apply_dynamic_verdict(&mut self, verdict: VerifyResult) { + if verdict.status == VerifyStatus::Inconclusive { + self.severity = self.severity.downgraded(); + let reason = match &verdict.inconclusive_reason { + Some(r) => format!("composite reverification inconclusive: {r:?}"), + None => match verdict.detail.as_deref() { + Some(d) if !d.is_empty() => { + format!("composite reverification inconclusive: {d}") + } + _ => "composite reverification inconclusive".to_owned(), + }, + }; + self.reverify_reason = Some(reason); + } else { + self.reverify_reason = None; + } + self.dynamic_verdict = Some(verdict); + } } /// Stable byte tag for each [`ImpactCategory`]. Used by diff --git a/src/chain/mod.rs b/src/chain/mod.rs index dfad014c..dad50b5b 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -36,6 +36,8 @@ pub mod edges; pub mod feasibility; pub mod finding; pub mod impact; +#[cfg(feature = "dynamic")] +pub mod reverify; pub mod score; pub mod search; @@ -43,6 +45,11 @@ pub use edges::{ChainEdge, FindingRef, findings_to_edges}; pub use feasibility::Feasibility; pub use finding::{ChainFinding, ChainMember, ChainSeverity, ChainSink}; pub use impact::{IMPACT_LATTICE, ImpactCategory, ImpactRule, lookup_impact}; +#[cfg(feature = "dynamic")] +pub use reverify::{ + ChainReverifyResult, CompositeReverifier, DefaultCompositeReverifier, reverify_chain, + reverify_chain_with, reverify_top_chains, reverify_top_chains_with, +}; pub use score::{ChainScoreConfig, category_weight, min_score_default, score_path}; pub use search::{ChainSearchConfig, find_chains}; diff --git a/src/chain/reverify.rs b/src/chain/reverify.rs new file mode 100644 index 00000000..6ad1e8ef --- /dev/null +++ b/src/chain/reverify.rs @@ -0,0 +1,384 @@ +//! Phase 26 — Track G.3: end-to-end chain re-verification. +//! +//! Phase 25 emitted [`ChainFinding`]s scored by static + per-finding +//! feasibility but left `dynamic_verdict` permanently `None`. Phase 26 +//! drives the top-scoring Confirmed chains through a *single* composite +//! dynamic run: each member's step harness is composed via +//! [`crate::dynamic::lang::compose_chain_step`] and the output of one +//! step is threaded into the next via +//! [`crate::dynamic::lang::ChainStepHarness::PREV_OUTPUT_ENV`], with +//! the final step terminating at the chain's sink probe. +//! +//! # Outcome shape +//! +//! [`reverify_chain`] returns a [`ChainReverifyResult`] carrying the +//! composite [`VerifyResult`] alongside the severity before and after +//! the verdict was applied. The severity-downgrade rule is documented +//! on [`crate::chain::finding::ChainFinding::apply_dynamic_verdict`]: +//! `Inconclusive` drops the chain one bucket and records a reason; +//! every other status leaves the severity intact. +//! +//! # Cost control +//! +//! Re-verification is opt-in via +//! [`crate::utils::config::ChainConfig::reverify_top_n`] — only the top +//! N chains by score reach the composite run. Set to `0` to skip the +//! pass entirely. The helper [`reverify_top_chains`] applies the +//! caller's reverifier to the top-N slice in place, leaving the rest +//! untouched. +//! +//! # Testability +//! +//! Production callers use [`reverify_chain`] (which dispatches to +//! [`DefaultCompositeReverifier`]). Tests inject a stub +//! [`CompositeReverifier`] via [`reverify_chain_with`] / +//! [`reverify_top_chains_with`] so the severity-downgrade pipeline can +//! be exercised without a live sandbox backend. + +use crate::chain::finding::{ChainFinding, ChainSeverity}; +use crate::dynamic::verify::VerifyOptions; +use crate::evidence::{InconclusiveReason, VerifyResult, VerifyStatus}; +use crate::surface::SurfaceMap; + +/// Outcome of composite re-verification for a single chain. +/// +/// Carries the [`VerifyResult`] the composite run produced plus the +/// severity transition so callers (e.g. the scan command's output +/// pipeline) can decide whether to emit a Slack-style "downgraded by +/// dynamic verification" badge. +#[derive(Debug, Clone)] +pub struct ChainReverifyResult { + /// Stable hash of the chain re-verified. + pub chain_hash: u64, + /// Composite dynamic verdict assembled by the reverifier. + pub verdict: VerifyResult, + /// Severity carried on the chain *before* the verdict was applied. + pub severity_before: ChainSeverity, + /// Severity carried on the chain *after* the verdict was applied. + /// Equals `severity_before` unless the verdict was `Inconclusive`. + pub severity_after: ChainSeverity, + /// Human-readable downgrade reason, when one was recorded. + /// Mirrors [`ChainFinding::reverify_reason`] for the post-apply + /// state. + pub downgrade_reason: Option, +} + +impl ChainReverifyResult { + /// True when the verdict caused the chain's severity to drop a + /// bucket. + pub fn was_downgraded(&self) -> bool { + self.severity_before != self.severity_after + } +} + +/// Pluggable composite-reverifier surface. +/// +/// Production callers use [`DefaultCompositeReverifier`] (which drives +/// the per-step harness compose path). Tests substitute a stub that +/// returns canned [`VerifyResult`]s so the downgrade-and-record +/// machinery can be exercised without a live sandbox backend. +pub trait CompositeReverifier { + /// Run the composite dynamic re-verification for `chain` and return + /// the resulting verdict. + fn reverify( + &self, + chain: &ChainFinding, + surface: &SurfaceMap, + opts: &VerifyOptions, + ) -> VerifyResult; +} + +/// Phase 26 default composite reverifier. +/// +/// The composite-harness composer walks `chain.members`, calls +/// [`crate::dynamic::lang::compose_chain_step`] for each member's +/// language to assemble a per-step harness, and threads the previous +/// step's stdout into the next via +/// [`crate::dynamic::lang::ChainStepHarness::PREV_OUTPUT_ENV`]. +/// +/// Today the default reverifier surfaces `Inconclusive(BackendInsufficient)` +/// when invoked: chain composer scaffolding lands in Phase 26 but the +/// live composite execution path depends on the per-emitter probe-shim +/// splicing that several language emitters still defer (see the +/// Phase 06 / 15 / 16 follow-ups in `.pitboss/play/deferred.md`). +/// Callers that need a deterministic outcome (tests, CI) use +/// [`reverify_chain_with`] with a stubbed reverifier. +pub struct DefaultCompositeReverifier; + +impl CompositeReverifier for DefaultCompositeReverifier { + fn reverify( + &self, + chain: &ChainFinding, + _surface: &SurfaceMap, + _opts: &VerifyOptions, + ) -> VerifyResult { + let finding_id = format!("chain-{:016x}", chain.stable_hash); + VerifyResult { + finding_id, + status: VerifyStatus::Inconclusive, + triggered_payload: None, + reason: None, + inconclusive_reason: Some(InconclusiveReason::BackendInsufficient { + backend: "composite-chain".to_owned(), + oracle_kind: "chain-step-harness".to_owned(), + }), + detail: Some( + "composite chain re-verification not yet wired for live runs; per-emitter probe-shim splicing pending — see Phase 26 deferred follow-ups" + .to_owned(), + ), + attempts: vec![], + toolchain_match: None, + differential: None, + } + } +} + +/// Phase 26 — Track G.3: drive composite dynamic re-verification for +/// one chain. +/// +/// Wraps [`reverify_chain_with`] with the [`DefaultCompositeReverifier`]. +pub fn reverify_chain( + chain: &mut ChainFinding, + surface: &SurfaceMap, + opts: &VerifyOptions, +) -> ChainReverifyResult { + reverify_chain_with(chain, surface, opts, &DefaultCompositeReverifier) +} + +/// Inject-the-reverifier flavour of [`reverify_chain`]. +/// +/// Mutates `chain` in place: attaches the verdict via +/// [`ChainFinding::apply_dynamic_verdict`] (which applies the severity- +/// downgrade rule) and returns a [`ChainReverifyResult`] summarising +/// the transition. +pub fn reverify_chain_with( + chain: &mut ChainFinding, + surface: &SurfaceMap, + opts: &VerifyOptions, + reverifier: &dyn CompositeReverifier, +) -> ChainReverifyResult { + let chain_hash = chain.stable_hash; + let severity_before = chain.severity; + let verdict = reverifier.reverify(chain, surface, opts); + chain.apply_dynamic_verdict(verdict.clone()); + ChainReverifyResult { + chain_hash, + verdict, + severity_before, + severity_after: chain.severity, + downgrade_reason: chain.reverify_reason.clone(), + } +} + +/// Phase 26 — Track G.3 cost-control entry point. +/// +/// Re-verifies the top `top_n` chains by score order (chains are +/// canonicalised score-descending by [`crate::chain::search::find_chains`], +/// so the slice prefix is already the right set). `top_n == 0` +/// short-circuits the entire pass. +/// +/// Mutates `chains` in place; returns one [`ChainReverifyResult`] per +/// re-verified chain. Chains past the `top_n` cut keep their +/// pre-existing `dynamic_verdict` / `reverify_reason` / `severity`. +pub fn reverify_top_chains( + chains: &mut [ChainFinding], + surface: &SurfaceMap, + opts: &VerifyOptions, + top_n: usize, +) -> Vec { + reverify_top_chains_with(chains, surface, opts, top_n, &DefaultCompositeReverifier) +} + +/// Inject-the-reverifier flavour of [`reverify_top_chains`]. +pub fn reverify_top_chains_with( + chains: &mut [ChainFinding], + surface: &SurfaceMap, + opts: &VerifyOptions, + top_n: usize, + reverifier: &dyn CompositeReverifier, +) -> Vec { + if top_n == 0 || chains.is_empty() { + return Vec::new(); + } + let bound = top_n.min(chains.len()); + chains + .iter_mut() + .take(bound) + .map(|c| reverify_chain_with(c, surface, opts, reverifier)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chain::edges::FindingRef; + use crate::chain::finding::{ChainFinding, ChainSink}; + use crate::chain::impact::ImpactCategory; + use crate::surface::SourceLocation; + + fn mk_chain(hash: u64, severity: ChainSeverity, impact: ImpactCategory) -> ChainFinding { + ChainFinding { + stable_hash: hash, + members: vec![FindingRef { + finding_id: format!("f-{hash}"), + stable_hash: hash, + location: SourceLocation::new("a.py", 1, 1), + rule_id: "r".into(), + cap_bits: 0, + }], + sink: ChainSink { + file: "a.py".into(), + line: 5, + col: 1, + function_name: "sink".into(), + cap_bits: 0, + }, + implied_impact: impact, + severity, + score: 100.0, + dynamic_verdict: None, + reverify_reason: None, + } + } + + fn verdict(status: VerifyStatus) -> VerifyResult { + VerifyResult { + finding_id: "f".into(), + status, + triggered_payload: None, + reason: None, + inconclusive_reason: None, + detail: None, + attempts: vec![], + toolchain_match: None, + differential: None, + } + } + + struct StubReverifier(VerifyStatus); + impl CompositeReverifier for StubReverifier { + fn reverify( + &self, + _chain: &ChainFinding, + _surface: &SurfaceMap, + _opts: &VerifyOptions, + ) -> VerifyResult { + verdict(self.0) + } + } + + #[test] + fn confirmed_verdict_leaves_severity_unchanged() { + let mut chain = mk_chain(1, ChainSeverity::Critical, ImpactCategory::Rce); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain_with( + &mut chain, + &surface, + &opts, + &StubReverifier(VerifyStatus::Confirmed), + ); + assert!(!result.was_downgraded()); + assert_eq!(result.severity_after, ChainSeverity::Critical); + assert_eq!(chain.severity, ChainSeverity::Critical); + assert_eq!(chain.dynamic_verdict.as_ref().unwrap().status, VerifyStatus::Confirmed); + assert!(chain.reverify_reason.is_none()); + } + + #[test] + fn inconclusive_verdict_downgrades_severity_and_records_reason() { + let mut chain = mk_chain(2, ChainSeverity::Critical, ImpactCategory::Rce); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain_with( + &mut chain, + &surface, + &opts, + &StubReverifier(VerifyStatus::Inconclusive), + ); + assert!(result.was_downgraded()); + assert_eq!(result.severity_before, ChainSeverity::Critical); + assert_eq!(result.severity_after, ChainSeverity::High); + assert_eq!(chain.severity, ChainSeverity::High); + assert!(chain.reverify_reason.is_some()); + } + + #[test] + fn inconclusive_at_low_floors_at_low() { + let mut chain = mk_chain(3, ChainSeverity::Low, ImpactCategory::InfoDisclosure); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain_with( + &mut chain, + &surface, + &opts, + &StubReverifier(VerifyStatus::Inconclusive), + ); + // Severity floors at Low; was_downgraded returns false because + // the bucket did not change even though the verdict was + // inconclusive. + assert_eq!(result.severity_after, ChainSeverity::Low); + assert!(chain.reverify_reason.is_some(), "reason still recorded"); + } + + #[test] + fn top_n_zero_skips_pass_entirely() { + let mut chains = vec![ + mk_chain(1, ChainSeverity::Critical, ImpactCategory::Rce), + mk_chain(2, ChainSeverity::High, ImpactCategory::SessionHijack), + ]; + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let results = reverify_top_chains_with( + &mut chains, + &surface, + &opts, + 0, + &StubReverifier(VerifyStatus::Confirmed), + ); + assert!(results.is_empty()); + for c in &chains { + assert!(c.dynamic_verdict.is_none(), "no verdict attached when top_n=0"); + } + } + + #[test] + fn top_n_limits_reverified_chain_count() { + let mut chains = vec![ + mk_chain(1, ChainSeverity::Critical, ImpactCategory::Rce), + mk_chain(2, ChainSeverity::High, ImpactCategory::SessionHijack), + mk_chain(3, ChainSeverity::Medium, ImpactCategory::InfoDisclosure), + ]; + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let results = reverify_top_chains_with( + &mut chains, + &surface, + &opts, + 2, + &StubReverifier(VerifyStatus::Confirmed), + ); + assert_eq!(results.len(), 2); + assert!(chains[0].dynamic_verdict.is_some()); + assert!(chains[1].dynamic_verdict.is_some()); + assert!( + chains[2].dynamic_verdict.is_none(), + "tail beyond top_n is untouched" + ); + } + + #[test] + fn default_reverifier_returns_inconclusive_backend_insufficient() { + let mut chain = mk_chain(99, ChainSeverity::Critical, ImpactCategory::Rce); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let result = reverify_chain(&mut chain, &surface, &opts); + assert_eq!(result.verdict.status, VerifyStatus::Inconclusive); + assert!(matches!( + result.verdict.inconclusive_reason, + Some(InconclusiveReason::BackendInsufficient { .. }) + )); + // Severity dropped one bucket because the default is inconclusive. + assert_eq!(chain.severity, ChainSeverity::High); + } +} diff --git a/src/chain/search.rs b/src/chain/search.rs index 8751f1e1..2cfe513a 100644 --- a/src/chain/search.rs +++ b/src/chain/search.rs @@ -44,7 +44,6 @@ //! `findings_to_edges` reach resolver. use crate::chain::edges::{ChainEdge, Reach}; -use crate::chain::feasibility::Feasibility; use crate::chain::finding::{ChainFinding, ChainSink}; use crate::chain::impact::{ImpactCategory, lookup_impact}; use crate::chain::score::score_path; @@ -321,6 +320,7 @@ fn build_chain( severity, score, dynamic_verdict, + reverify_reason: None, } } @@ -363,6 +363,7 @@ mod tests { use super::*; use crate::chain::ChainSeverity; use crate::chain::edges::FindingRef; + use crate::chain::feasibility::Feasibility; use crate::entry_points::HttpMethod; use crate::labels::Cap; use crate::surface::{ diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 566d1531..8fa0e152 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -27,7 +27,7 @@ //! - `PayloadSlot::EnvVar(name)` — set env var before invoking entry. //! - `PayloadSlot::Argv(n)` — `main(argc, argv)` shape: appended to argv. -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -307,6 +307,28 @@ impl LangEmitter for CEmitter { "c emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" ) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — C chain-step harness. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "#include \n#include \n\nint main(void) {\n const char *prev = getenv(\"NYX_PREV_OUTPUT\");\n if (prev) fputs(prev, stdout);\n return 0;\n}\n".to_owned(); + ChainStepHarness { + source, + filename: "step.c".to_owned(), + command: vec!["cc".to_owned(), "step.c".to_owned(), "-o".to_owned(), "step".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } /// Emit a C harness for `spec`. diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index fc634f1d..28bab4c5 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -15,7 +15,7 @@ //! Build step: `prepare_cpp()` in `build_sandbox.rs` runs //! `g++ -O0 -std=c++17 -o nyx_harness main.cpp` in the workdir. -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -280,6 +280,28 @@ impl LangEmitter for CppEmitter { "cpp emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 16 shape dispatch (main / libFuzzer / free function)" ) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — C++ chain-step harness. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "#include \n#include \n\nint main() {\n const char *prev = std::getenv(\"NYX_PREV_OUTPUT\");\n if (prev) std::fputs(prev, stdout);\n return 0;\n}\n".to_owned(); + ChainStepHarness { + source, + filename: "step.cpp".to_owned(), + command: vec!["c++".to_owned(), "step.cpp".to_owned(), "-o".to_owned(), "step".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } /// Emit a C++ harness for `spec`. diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index d4f05d5b..bec3d456 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -37,7 +37,7 @@ //! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -75,6 +75,35 @@ impl LangEmitter for GoEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_go(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — Go chain-step harness. +/// +/// Emits a `main.go` driver that reads `NYX_PREV_OUTPUT` and forwards it +/// on stdout. The Go probe shim (`__nyx_probe`) is top-level Go code +/// requiring extra stdlib imports; chain steps keep the harness minimal +/// and rely on the sandbox runner's outer probe channel to observe the +/// final sink fire. Wiring the probe shim into chain steps is tracked +/// alongside the Phase 15 emitter follow-up about probe shim splicing. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "package main\n\nimport (\n \"fmt\"\n \"os\"\n)\n\nfunc main() {\n prev := os.Getenv(\"NYX_PREV_OUTPUT\")\n fmt.Print(prev)\n}\n".to_owned(); + ChainStepHarness { + source, + filename: "step.go".to_owned(), + command: vec!["go".to_owned(), "run".to_owned(), "step.go".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } // ── Phase 15: shape detector ───────────────────────────────────────────────── diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 69bfa94c..de344eed 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -36,7 +36,7 @@ //! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -74,6 +74,34 @@ impl LangEmitter for JavaEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_java(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — Java chain-step harness. +/// +/// Emits a `Step.java` class whose `main` reads `NYX_PREV_OUTPUT` and +/// forwards it on stdout. The Java probe shim is class-level and +/// requires `System`/`java.io.*` imports the chain step already pulls in +/// implicitly; wiring the full shim is tracked alongside the Phase 14 +/// emitter follow-up about probe shim splicing. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "public class Step {\n public static void main(String[] args) {\n String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n }\n}\n".to_owned(); + ChainStepHarness { + source, + filename: "Step.java".to_owned(), + command: vec!["java".to_owned(), "Step".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } // ── Phase 14: shape detector ───────────────────────────────────────────────── diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 36a7e6d5..fd43cd83 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -15,7 +15,7 @@ //! - [`PayloadSlot::Argv`] — coerced to positional `Param(0)` by build_call. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{js_shared, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{js_shared, ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; @@ -43,6 +43,10 @@ impl LangEmitter for JavaScriptEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_node(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + js_shared::chain_step(prev_output, /* typescript = */ false) + } } /// Emit a JS harness for `spec`. diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index c9491e8d..46a93aa3 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -24,7 +24,7 @@ //! which preserves the pre-Phase-13 behaviour. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::HarnessSource; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::utils::project::DetectedFramework; @@ -394,6 +394,41 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result, is_typescript: bool) -> ChainStepHarness { + let probe = probe_shim(); + let driver = "\nprocess.stdout.write(process.env.NYX_PREV_OUTPUT || '');\n"; + let (filename, command) = if is_typescript { + ( + "step.ts".to_owned(), + vec!["node".to_owned(), "step.ts".to_owned()], + ) + } else { + ( + "step.js".to_owned(), + vec!["node".to_owned(), "step.js".to_owned()], + ) + }; + ChainStepHarness { + source: format!("{probe}{driver}"), + filename, + command, + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } +} + /// Public wrapper to detect the shape for a finalised [`HarnessSpec`]. pub fn detect_shape(spec: &HarnessSpec) -> JsShape { let entry_source = read_entry_source(&spec.entry_file); diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 0e9b42e3..45d2de58 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -48,6 +48,33 @@ pub struct HarnessSource { pub entry_subpath: Option, } +/// Phase 26 — one step in a chain-composite harness. +/// +/// The composite re-verifier walks every member of a chain and assembles +/// a sequence of per-step harnesses. Each step is invoked with the +/// previous step's stdout threaded into the +/// [`ChainStepHarness::PREV_OUTPUT_ENV`] env var so the harness can fold +/// the chained input into its payload (e.g. browser-fetch → websocket +/// message → shell tool). +/// +/// `extra_env` is additive on top of the sandbox's own +/// [`crate::dynamic::sandbox::SandboxOptions::extra_env`]; the runner is +/// responsible for splicing both in. +#[derive(Debug, Clone)] +pub struct ChainStepHarness { + pub source: String, + pub filename: String, + pub command: Vec, + pub extra_env: Vec<(String, String)>, +} + +impl ChainStepHarness { + /// Env-var name the previous step's stdout is bound to in the next + /// step's environment. Stable surface — kept distinct from + /// `NYX_PAYLOAD` so a chain step can read both at once. + pub const PREV_OUTPUT_ENV: &'static str = "NYX_PREV_OUTPUT"; +} + /// Per-language harness emitter contract. /// /// Implementations are zero-sized unit structs (one per `src/dynamic/lang/*.rs` @@ -96,6 +123,49 @@ pub trait LangEmitter { fn materialize_runtime(&self, _env: &Environment) -> RuntimeArtifacts { RuntimeArtifacts::default() } + + /// Phase 26 — Track G.3: build one step of a chain-composite harness. + /// + /// `prev_output` carries the previous step's stdout (or `None` for + /// the chain's entry step). The returned [`ChainStepHarness`] + /// reads `NYX_PREV_OUTPUT` from its env to fold the chained input + /// into the step's behaviour and (when the step terminates at a + /// sink) invokes the Phase 06 `__nyx_probe` shim so the runner's + /// probe channel observes the sink fire. + /// + /// Default impl produces a portable POSIX-shell stub that echoes + /// the previous step's output verbatim. Concrete emitters override + /// to splice in the language-native probe shim. + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + default_chain_step(prev_output) + } +} + +/// Default chain-step harness. Emitted by [`LangEmitter::compose_chain_step`] +/// when an emitter does not override the trait method. +pub fn default_chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + ChainStepHarness { + source: "#!/bin/sh\nprintf '%s' \"${NYX_PREV_OUTPUT:-}\"\n".to_owned(), + filename: "step.sh".to_owned(), + command: vec!["sh".to_owned(), "step.sh".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } +} + +/// Public free-fn dispatcher for [`LangEmitter::compose_chain_step`]. +/// +/// Returns the lang-agnostic shell stub when `lang` has no registered +/// emitter so callers do not need to special-case that path. +pub fn compose_chain_step(lang: Lang, prev_output: Option<&[u8]>) -> ChainStepHarness { + dispatch(lang, |e| e.compose_chain_step(prev_output)) + .unwrap_or_else(|| default_chain_step(prev_output)) } /// Public free-fn dispatcher for [`LangEmitter::materialize_runtime`]. diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 7974f6f6..0fc9680a 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -29,7 +29,7 @@ //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -67,6 +67,33 @@ impl LangEmitter for PhpEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_php(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — PHP chain-step harness. +/// +/// Emits a `step.php` script that reads `NYX_PREV_OUTPUT` via +/// `getenv()` and forwards it on stdout. The PHP probe shim is kept +/// outside the chain step for now and wired in alongside the Phase 15 +/// emitter follow-up about probe shim splicing. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = " RuntimeArtifacts { materialize_python(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — Python chain-step harness. +/// +/// Splices the Python probe shim ([`probe_shim`]) in front of a minimal +/// driver that reads `NYX_PREV_OUTPUT` and forwards it on stdout. The +/// composite re-verifier swaps the trailing forward for the next member's +/// payload-injection prologue when running a multi-step chain. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let probe = probe_shim(); + let driver = "\nimport os, sys\nprev = os.environ.get('NYX_PREV_OUTPUT', '')\nsys.stdout.write(prev)\nsys.stdout.flush()\n"; + ChainStepHarness { + source: format!("{probe}{driver}"), + filename: "step.py".to_owned(), + command: vec!["python3".to_owned(), "step.py".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } // ── Phase 12: shape detector ───────────────────────────────────────────────── diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 1cf67e05..d76194a0 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -27,7 +27,7 @@ //! Build: no compilation step. Command is `ruby harness.rb`. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -64,6 +64,28 @@ impl LangEmitter for RubyEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_ruby(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — Ruby chain-step harness. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "prev = ENV[\"NYX_PREV_OUTPUT\"] || \"\"\n$stdout.write(prev)\n".to_owned(); + ChainStepHarness { + source, + filename: "step.rb".to_owned(), + command: vec!["ruby".to_owned(), "step.rb".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } // ── Phase 15: shape detector ───────────────────────────────────────────────── diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 531dd05f..2a0fe1ad 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -22,7 +22,7 @@ //! HTML_ESCAPE is n/a for Rust (§15.4). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::labels::Cap; @@ -63,6 +63,34 @@ impl LangEmitter for RustEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { materialize_rust(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + chain_step(prev_output) + } +} + +/// Phase 26 — Rust chain-step harness. +/// +/// Emits a minimal `step.rs` file that reads `NYX_PREV_OUTPUT` and writes +/// it on stdout. The chain composer drives the step with `rustc step.rs` +/// (single-file build) — full Cargo crate scaffolding is reserved for +/// chain members whose underlying finding already produced a HarnessSpec +/// via the standard emit path. +fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { + let source = "use std::env;\nuse std::io::{self, Write};\n\nfn main() {\n let prev = env::var(\"NYX_PREV_OUTPUT\").unwrap_or_default();\n let _ = io::stdout().write_all(prev.as_bytes());\n}\n".to_owned(); + ChainStepHarness { + source, + filename: "step.rs".to_owned(), + command: vec!["rustc".to_owned(), "step.rs".to_owned(), "-o".to_owned(), "step".to_owned()], + extra_env: prev_output + .map(|bytes| { + vec![( + ChainStepHarness::PREV_OUTPUT_ENV.to_owned(), + String::from_utf8_lossy(bytes).into_owned(), + )] + }) + .unwrap_or_default(), + } } /// Phase 09 — Track D.2: synthesise a `Cargo.toml` that pins every diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index 70ef7889..9134b60c 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -15,7 +15,7 @@ //! runtime ignores. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{js_shared, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{js_shared, ChainStepHarness, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; @@ -46,6 +46,10 @@ impl LangEmitter for TypeScriptEmitter { fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { js_shared::materialize_node(env) } + + fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { + js_shared::chain_step(prev_output, /* typescript = */ true) + } } #[cfg(test)] diff --git a/src/output/json.rs b/src/output/json.rs index 1e21ee70..fd9a7ee1 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -119,6 +119,7 @@ mod tests { severity: ChainSeverity::Critical, score: 200.0, dynamic_verdict: None, + reverify_reason: None, } } diff --git a/src/output/mod.rs b/src/output/mod.rs index f59f81b9..d78912dd 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -100,6 +100,7 @@ mod tests { severity: ChainSeverity::Critical, score: 200.0, dynamic_verdict: None, + reverify_reason: None, } } diff --git a/src/utils/config.rs b/src/utils/config.rs index fa653254..42bea9dc 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -703,6 +703,10 @@ pub struct ChainConfig { /// this value are dropped. Defaults to /// [`crate::chain::score::min_score_default`]. pub min_score: f64, + /// Phase 26 — Track G.3: only the top-N chains (by score) are + /// considered for composite dynamic re-verification. Defaults to + /// `5`. Set to `0` to disable composite re-verification entirely. + pub reverify_top_n: usize, } impl Default for ChainConfig { @@ -710,6 +714,7 @@ impl Default for ChainConfig { Self { max_depth: 4, min_score: 9.5, + reverify_top_n: 5, } } } diff --git a/tests/chain_reverify.rs b/tests/chain_reverify.rs new file mode 100644 index 00000000..9311936b --- /dev/null +++ b/tests/chain_reverify.rs @@ -0,0 +1,200 @@ +//! Phase 26 — Track G.3 integration tests. +//! +//! Exercises the composite re-verification surface end-to-end with a +//! stubbed reverifier so the test runs without a live sandbox backend. +//! Two scenarios: +//! +//! 1. **Composite Confirms**: the stub returns `VerifyStatus::Confirmed`; +//! the chain's severity is preserved and `reverify_reason` stays +//! empty. +//! 2. **Composite Inconclusive-downgrades**: the stub returns +//! `VerifyStatus::Inconclusive`; the chain drops one severity bucket +//! and records a typed reason on `reverify_reason`. +//! +//! Also covers the `reverify_top_n` cost-control gate and verifies the +//! per-language `compose_chain_step` API surface bottoms out on +//! [`ChainStepHarness::PREV_OUTPUT_ENV`] for every registered emitter. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::chain::edges::FindingRef; +use nyx_scanner::chain::finding::{ChainFinding, ChainSeverity, ChainSink}; +use nyx_scanner::chain::impact::ImpactCategory; +use nyx_scanner::chain::reverify::{ + CompositeReverifier, reverify_chain_with, reverify_top_chains_with, +}; +use nyx_scanner::dynamic::lang::{ChainStepHarness, compose_chain_step}; +use nyx_scanner::dynamic::verify::VerifyOptions; +use nyx_scanner::evidence::{InconclusiveReason, VerifyResult, VerifyStatus}; +use nyx_scanner::surface::{SourceLocation, SurfaceMap}; +use nyx_scanner::symbol::Lang; + +fn loc(file: &str, line: u32) -> SourceLocation { + SourceLocation::new(file, line, 1) +} + +fn make_chain( + hash: u64, + severity: ChainSeverity, + impact: ImpactCategory, + score: f64, +) -> ChainFinding { + ChainFinding { + stable_hash: hash, + members: vec![FindingRef { + finding_id: format!("f-{hash}"), + stable_hash: hash, + location: loc("app.py", 10), + rule_id: "taint-shell-exec".into(), + cap_bits: 0, + }], + sink: ChainSink { + file: "app.py".into(), + line: 30, + col: 1, + function_name: "shell.exec".into(), + cap_bits: 0, + }, + implied_impact: impact, + severity, + score, + dynamic_verdict: None, + reverify_reason: None, + } +} + +fn verdict(status: VerifyStatus, reason: Option) -> VerifyResult { + VerifyResult { + finding_id: "f-0".into(), + status, + triggered_payload: None, + reason: None, + inconclusive_reason: reason, + detail: None, + attempts: vec![], + toolchain_match: None, + differential: None, + } +} + +struct StubReverifier(VerifyResult); +impl CompositeReverifier for StubReverifier { + fn reverify( + &self, + _chain: &ChainFinding, + _surface: &SurfaceMap, + _opts: &VerifyOptions, + ) -> VerifyResult { + self.0.clone() + } +} + +#[test] +fn composite_confirms_keeps_severity_and_attaches_verdict() { + let mut chain = make_chain(0xAA, ChainSeverity::Critical, ImpactCategory::Rce, 100.0); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let stub = StubReverifier(verdict(VerifyStatus::Confirmed, None)); + + let result = reverify_chain_with(&mut chain, &surface, &opts, &stub); + assert!(!result.was_downgraded(), "Confirmed must not downgrade"); + assert_eq!(result.severity_before, ChainSeverity::Critical); + assert_eq!(result.severity_after, ChainSeverity::Critical); + assert_eq!(chain.severity, ChainSeverity::Critical); + let attached = chain.dynamic_verdict.as_ref().expect("verdict attached"); + assert_eq!(attached.status, VerifyStatus::Confirmed); + assert!(chain.reverify_reason.is_none(), "no reason on Confirmed"); +} + +#[test] +fn composite_inconclusive_downgrades_one_bucket_and_records_reason() { + let mut chain = make_chain(0xBB, ChainSeverity::Critical, ImpactCategory::Rce, 100.0); + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let stub = StubReverifier(verdict( + VerifyStatus::Inconclusive, + Some(InconclusiveReason::BuildFailed), + )); + + let result = reverify_chain_with(&mut chain, &surface, &opts, &stub); + assert!(result.was_downgraded(), "Inconclusive must downgrade"); + assert_eq!(result.severity_before, ChainSeverity::Critical); + assert_eq!(result.severity_after, ChainSeverity::High); + assert_eq!(chain.severity, ChainSeverity::High); + let reason = chain + .reverify_reason + .as_deref() + .expect("reverify_reason recorded"); + assert!( + reason.contains("BuildFailed"), + "reason carries typed inconclusive reason; got {reason:?}" + ); +} + +#[test] +fn top_n_limits_composite_reverification() { + let mut chains = vec![ + make_chain(1, ChainSeverity::Critical, ImpactCategory::Rce, 200.0), + make_chain(2, ChainSeverity::High, ImpactCategory::SessionHijack, 150.0), + make_chain( + 3, + ChainSeverity::Medium, + ImpactCategory::InfoDisclosure, + 100.0, + ), + make_chain(4, ChainSeverity::Low, ImpactCategory::InfoDisclosure, 50.0), + ]; + let surface = SurfaceMap::new(); + let opts = VerifyOptions::default(); + let stub = StubReverifier(verdict(VerifyStatus::Confirmed, None)); + + let results = reverify_top_chains_with(&mut chains, &surface, &opts, 2, &stub); + assert_eq!(results.len(), 2); + assert!(chains[0].dynamic_verdict.is_some()); + assert!(chains[1].dynamic_verdict.is_some()); + assert!( + chains[2].dynamic_verdict.is_none(), + "chain past top_n stays untouched" + ); + assert!( + chains[3].dynamic_verdict.is_none(), + "chain past top_n stays untouched" + ); +} + +#[test] +fn compose_chain_step_threads_prev_output_for_every_emitter() { + // Phase 26 deliverable: each emitter exposes + // `compose_chain_step(prev_output)`. Walk the registered languages + // and check the prev-output env var lands in `extra_env`. + let prev = b"chain-step-witness".as_slice(); + for lang in [ + Lang::Python, + Lang::Rust, + Lang::JavaScript, + Lang::TypeScript, + Lang::Go, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::C, + Lang::Cpp, + ] { + let step = compose_chain_step(lang, Some(prev)); + assert!( + step.extra_env + .iter() + .any(|(k, v)| k == ChainStepHarness::PREV_OUTPUT_ENV && v == "chain-step-witness"), + "{lang:?} emitter must thread NYX_PREV_OUTPUT via extra_env; got {:?}", + step.extra_env + ); + assert!(!step.source.is_empty(), "{lang:?} step source must be non-empty"); + assert!(!step.command.is_empty(), "{lang:?} step command must be non-empty"); + } +} + +#[test] +fn compose_chain_step_with_no_prev_output_has_empty_extra_env() { + let step = compose_chain_step(Lang::Python, None); + assert!(step.extra_env.is_empty()); +} diff --git a/tests/dynamic_layering.rs b/tests/dynamic_layering.rs index f065b494..6bbb476f 100644 --- a/tests/dynamic_layering.rs +++ b/tests/dynamic_layering.rs @@ -17,6 +17,7 @@ //! | `src/commands/mod.rs` | `verify-feedback` subcommand (§21.2) | //! | `src/server/` (any file) | server start_scan verify wiring | //! | `src/rank.rs` | M7 rank-delta telemetry hook (§21 / M7) | +//! | `src/chain/reverify.rs` | Phase 26 — composite chain re-verification | use std::fs; use std::path::{Path, PathBuf}; @@ -30,6 +31,9 @@ const ALLOWED: &[&str] = &[ "commands/mod.rs", "server/", "rank.rs", + // Phase 26 — Track G.3: composite chain re-verification is the + // public bridge between the chain composer and the dynamic verifier. + "chain/reverify.rs", // The dynamic module itself is obviously allowed. "dynamic/", ];