mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 26: Track G.3 — End-to-end chain re-verification
This commit is contained in:
parent
4228be2db6
commit
8a801953e2
21 changed files with 991 additions and 15 deletions
|
|
@ -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<VerifyResult>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
384
src/chain/reverify.rs
Normal file
384
src/chain/reverify.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<ChainReverifyResult> {
|
||||
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<ChainReverifyResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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 <stdio.h>\n#include <stdlib.h>\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`.
|
||||
|
|
|
|||
|
|
@ -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 <cstdio>\n#include <cstdlib>\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`.
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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<HarnessSource, Un
|
|||
})
|
||||
}
|
||||
|
||||
/// Phase 26 — Node chain-step harness (shared between JS + TS emitters).
|
||||
///
|
||||
/// Splices the Node 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.
|
||||
pub fn chain_step(prev_output: Option<&[u8]>, 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);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,33 @@ pub struct HarnessSource {
|
|||
pub entry_subpath: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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`].
|
||||
|
|
|
|||
|
|
@ -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 = "<?php\n$prev = getenv(\"NYX_PREV_OUTPUT\");\nif ($prev === false) { $prev = \"\"; }\necho $prev;\n".to_owned();
|
||||
ChainStepHarness {
|
||||
source,
|
||||
filename: "step.php".to_owned(),
|
||||
command: vec!["php".to_owned(), "step.php".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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
//! - Other slots produce [`UnsupportedReason::PayloadSlotUnsupported`].
|
||||
|
||||
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::utils::project::DetectedFramework;
|
||||
|
|
@ -65,6 +65,34 @@ impl LangEmitter for PythonEmitter {
|
|||
fn materialize_runtime(&self, env: &Environment) -> 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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ mod tests {
|
|||
severity: ChainSeverity::Critical,
|
||||
score: 200.0,
|
||||
dynamic_verdict: None,
|
||||
reverify_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ mod tests {
|
|||
severity: ChainSeverity::Critical,
|
||||
score: 200.0,
|
||||
dynamic_verdict: None,
|
||||
reverify_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
tests/chain_reverify.rs
Normal file
200
tests/chain_reverify.rs
Normal file
|
|
@ -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<InconclusiveReason>) -> 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());
|
||||
}
|
||||
|
|
@ -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/",
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue