nyx/src/chain/search.rs

654 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Phase 25 — bounded path search for exploit-chain composition.
//!
//! Path topology:
//!
//! ```text
//! Attacker (virtual) → EntryPoint → Finding* → Sink
//! ```
//!
//! The DFS starts at the implicit attacker node (virtually adjacent to
//! every [`crate::surface::EntryPoint`]), traverses up to [`max_depth`]
//! per-finding hops, and terminates at any
//! [`crate::surface::DangerousLocal`] node. Each emitted
//! [`ChainFinding`] is the deterministic minimum-length path through a
//! given (entry, sink) pair.
//!
//! # Determinism
//!
//! 1. SurfaceMap nodes are canonicalised before search — every input
//! list (entries, sinks) is iterated in `SourceLocation` order.
//! 2. Candidate per-entry findings are sorted by
//! [`crate::chain::edges::FindingRef::stable_hash`] before DFS,
//! breaking ties by `rule_id` so collisions stay reproducible.
//! 3. The emitted chain list is sorted by `score` descending (ties
//! broken by `stable_hash` descending, then `implied_impact`
//! descending) before return.
//!
//! Running the same fixture 10× produces a byte-identical chain list.
//!
//! # Phase 24 follow-ups closed here
//!
//! - `BrowserToLocalRce` auth-gate predicate: when the lattice yields
//! `BrowserToLocalRce` from `HEADER_INJECTION + CODE_EXEC`, the path
//! is only kept when the entry's `auth_required` is `false`. Auth-
//! gated entries downgrade to the closest standalone impact.
//! - SSRF + LocalListener refinement: when the lattice yields
//! `InternalNetworkAccess` and the SurfaceMap exposes a local
//! listener (a [`crate::surface::DataStore`] / [`crate::surface::ExternalService`]
//! bound to a loopback host), the path is preserved; without a local
//! listener the chain is still emitted but scored lower (no boost).
//!
//! The "file-local reach → call-graph-aware reach" upgrade remains
//! deferred (see deferred.md): the DFS still treats two findings as
//! adjacent when they share a source file, mirroring Phase 24's
//! `findings_to_edges` reach resolver.
use crate::chain::edges::{ChainEdge, Reach};
use crate::chain::finding::{ChainFinding, ChainSink};
use crate::chain::impact::{ImpactCategory, lookup_impact};
use crate::chain::score::score_path;
use crate::labels::Cap;
use crate::surface::{DangerousLocal, EntryPoint, SurfaceMap, SurfaceNode};
/// Bounded-DFS search configuration.
#[derive(Debug, Clone, Copy)]
pub struct ChainSearchConfig {
/// Maximum number of per-finding hops in a single chain path.
/// `0` disables search (no chain is ever emitted).
pub max_depth: usize,
/// Drop chains whose score is strictly below this threshold.
pub min_score: f64,
}
impl Default for ChainSearchConfig {
fn default() -> Self {
Self {
max_depth: 4,
min_score: crate::chain::score::min_score_default(),
}
}
}
/// Result of one search pass: every chain whose score cleared
/// `cfg.min_score`, deterministically ordered.
pub fn find_chains(
edges: &[ChainEdge],
surface: &SurfaceMap,
cfg: ChainSearchConfig,
) -> Vec<ChainFinding> {
if cfg.max_depth == 0 || edges.is_empty() {
return Vec::new();
}
let sinks = collect_sinks(surface);
let entries = collect_entries(surface);
let local_listener_present = has_local_listener(surface);
let mut chains: Vec<ChainFinding> = Vec::new();
for entry in &entries {
// Per-entry candidate edge slice: every edge whose reach
// points at this entry, sorted deterministically.
let mut candidates: Vec<&ChainEdge> = edges
.iter()
.filter(|e| edge_reaches_entry(e, entry))
.collect();
candidates.sort_by(|a, b| {
(a.finding.stable_hash, &a.finding.rule_id, &a.finding.location)
.cmp(&(b.finding.stable_hash, &b.finding.rule_id, &b.finding.location))
});
for sink in &sinks {
// Phase 25 limits per-entry-per-sink search to those
// candidates that share a file with the sink. Phase 25's
// deferred call-graph follow-up will widen this.
let scoped: Vec<&ChainEdge> = candidates
.iter()
.filter(|e| {
// Surface DangerousLocal location uses POSIX path;
// the per-finding location is whatever the analyser
// recorded. Match on the trailing path segment so
// a project-relative vs absolute mismatch does not
// gate the chain.
paths_overlap(&e.finding.location.file, &sink.location.file)
})
.copied()
.collect();
if let Some(chain) = compose_chain(
entry,
sink,
&scoped,
cfg.max_depth,
local_listener_present,
) && chain.score >= cfg.min_score
{
chains.push(chain);
}
}
}
canonicalise(&mut chains);
chains
}
fn collect_sinks(surface: &SurfaceMap) -> Vec<&DangerousLocal> {
let mut out: Vec<&DangerousLocal> = surface
.nodes
.iter()
.filter_map(|n| match n {
SurfaceNode::DangerousLocal(d) => Some(d),
_ => None,
})
.collect();
out.sort_by(|a, b| (&a.location, &a.function_name).cmp(&(&b.location, &b.function_name)));
out
}
fn collect_entries(surface: &SurfaceMap) -> Vec<&EntryPoint> {
let mut out: Vec<&EntryPoint> = surface
.nodes
.iter()
.filter_map(|n| match n {
SurfaceNode::EntryPoint(e) => Some(e),
_ => None,
})
.collect();
out.sort_by(|a, b| (&a.location, &a.route).cmp(&(&b.location, &b.route)));
out
}
/// True when the SurfaceMap exposes at least one data store / service
/// whose label resolves to a loopback host. Used by the SSRF +
/// LocalListener refinement in [`compose_chain`].
fn has_local_listener(surface: &SurfaceMap) -> bool {
surface.nodes.iter().any(|n| match n {
SurfaceNode::DataStore(d) => is_loopback_label(&d.label),
SurfaceNode::ExternalService(s) => is_loopback_label(&s.label),
_ => false,
})
}
fn is_loopback_label(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.contains("127.0.0.1")
|| lower.contains("localhost")
|| lower.contains("0.0.0.0")
|| lower.starts_with("unix:")
|| lower.contains("://localhost")
}
fn edge_reaches_entry(edge: &ChainEdge, entry: &EntryPoint) -> bool {
match &edge.reach {
Reach::Reachable { route, method, .. } => *route == entry.route && *method == entry.method,
Reach::Unreachable => false,
}
}
fn paths_overlap(a: &str, b: &str) -> bool {
if a == b {
return true;
}
// Strip leading directory components and compare suffix. Two
// representations of the same file (project-relative vs absolute)
// share a common trailing path segment.
let a_tail = a.rsplit('/').next().unwrap_or(a);
let b_tail = b.rsplit('/').next().unwrap_or(b);
a_tail == b_tail && !a_tail.is_empty()
}
/// Build a single chain for one (entry, sink) pair.
///
/// Bounded DFS: take the longest deterministic prefix of `scoped` up
/// to `max_depth`, then pick the highest-severity lattice match
/// across every (member_cap, sink_cap) pair. Returning all in-scope
/// edges as members matches the design doc's three-member output for
/// the `CORS + NoAuth + websocket → shell tool` scenario; using the
/// best impact across all pairs ensures `HEADER_INJECTION + CODE_EXEC`
/// lights up `BrowserToLocalRce` even when an unrelated finding (e.g.
/// the standalone auth-gap diagnostic) is sorted first.
fn compose_chain(
entry: &EntryPoint,
sink: &DangerousLocal,
scoped: &[&ChainEdge],
max_depth: usize,
local_listener_present: bool,
) -> Option<ChainFinding> {
if scoped.is_empty() {
return None;
}
let bound = scoped.len().min(max_depth);
let path: Vec<&ChainEdge> = scoped[..bound].to_vec();
let sink_cap = sole_cap(sink.cap_bits)?;
let (impact, member_impacts) =
resolve_impact(&path, sink_cap, entry, local_listener_present)?;
let mut chain = build_chain(entry, sink, &path, impact, &member_impacts);
// SSRF + LocalListener refinement (Phase 24 deferred close): when
// the implied impact is `InternalNetworkAccess` AND the SurfaceMap
// exposes a loopback listener, the chain is more concrete than the
// bare lattice match — lift the score so it ranks above SSRF chains
// without a corroborating in-process target.
if impact == ImpactCategory::InternalNetworkAccess && local_listener_present {
chain.score *= LOCAL_LISTENER_BOOST;
}
Some(chain)
}
/// Score multiplier applied when an `InternalNetworkAccess` chain has
/// a corroborating loopback listener in the SurfaceMap. Calibrated to
/// lift the chain above an otherwise-identical SSRF chain that lacks
/// the listener context, without overtaking strictly more severe
/// categories.
const LOCAL_LISTENER_BOOST: f64 = 1.5;
/// Pick the lowest-bit single [`Cap`] from `bits`, or `None` when no
/// bit is set. Sinks in the SurfaceMap may carry multi-bit
/// `cap_bits`; the DFS terminates against the lowest single bit so
/// downstream lattice lookups stay deterministic.
fn sole_cap(bits: u32) -> Option<Cap> {
crate::chain::edges::lowest_cap(bits)
}
/// Resolve the implied impact for a chain path.
///
/// Walks every (member.primary_cap, sink_cap) pair and picks the
/// highest-severity lattice match. Returns `None` when no member +
/// sink pair lights up a rule and the sink cap has no standalone
/// rule either.
///
/// Auth gate: `BrowserToLocalRce` only fires when the entry's
/// `auth_required` is `false`. Authenticated entries fall through
/// to the next-best impact (typically `CODE_EXEC → Rce`).
fn resolve_impact(
path: &[&ChainEdge],
sink_cap: Cap,
entry: &EntryPoint,
_local_listener_present: bool,
) -> Option<(ImpactCategory, Vec<ImpactCategory>)> {
let mut best: Option<ImpactCategory> = None;
for member in path {
if let Some(cat) = lookup_impact(member.primary_cap, Some(sink_cap)) {
if cat == ImpactCategory::BrowserToLocalRce && entry.auth_required {
// Auth gate: this rule cannot fire when the entry is
// authed. Keep walking — another pair may light up
// a different rule.
continue;
}
best = Some(match best {
Some(prev) => more_severe(prev, cat),
None => cat,
});
}
}
// Fall through to standalone on the sink cap when no pair lit up.
if best.is_none() {
best = lookup_impact(sink_cap, None);
}
best.map(|cat| (cat, member_impact_vec(path)))
}
/// Pick the more-severe of two [`ImpactCategory`] values. Severity
/// ordering matches the design doc's lattice criticality:
/// `BrowserToLocalRce > Rce > SessionHijack > InternalNetworkAccess > InfoDisclosure`.
fn more_severe(a: ImpactCategory, b: ImpactCategory) -> ImpactCategory {
if severity_rank(a) >= severity_rank(b) {
a
} else {
b
}
}
fn severity_rank(c: ImpactCategory) -> u8 {
match c {
ImpactCategory::BrowserToLocalRce => 5,
ImpactCategory::Rce => 4,
ImpactCategory::SessionHijack => 3,
ImpactCategory::InternalNetworkAccess => 2,
ImpactCategory::InfoDisclosure => 1,
}
}
fn member_impact_vec(path: &[&ChainEdge]) -> Vec<ImpactCategory> {
path.iter()
.filter_map(|e| crate::chain::standalone_impact(e.primary_cap))
.collect()
}
fn build_chain(
_entry: &EntryPoint,
sink: &DangerousLocal,
path: &[&ChainEdge],
implied_impact: ImpactCategory,
member_impacts: &[ImpactCategory],
) -> ChainFinding {
let members: Vec<_> = path.iter().map(|e| e.finding.clone()).collect();
let stable_hash = ChainFinding::compute_stable_hash(&members, implied_impact);
let owned_edges: Vec<ChainEdge> = path.iter().map(|e| (*e).clone()).collect();
let score = score_path(member_impacts, implied_impact, &owned_edges);
let severity = crate::output::severity::chain_severity(implied_impact, &owned_edges);
let dynamic_verdict = composite_dynamic_verdict(&owned_edges);
ChainFinding {
stable_hash,
members,
sink: ChainSink {
file: sink.location.file.clone(),
line: sink.location.line,
col: sink.location.col,
function_name: sink.function_name.clone(),
cap_bits: sink.cap_bits,
},
implied_impact,
severity,
score,
dynamic_verdict,
reverify_reason: None,
}
}
/// Phase 25 placeholder for composite verification. When *every*
/// member edge has `Feasibility::Confirmed` the composite verdict
/// inherits that confirmation; otherwise `None` (Phase 26 will run a
/// real composite re-verification pass).
fn composite_dynamic_verdict(
_path: &[ChainEdge],
) -> Option<crate::evidence::VerifyResult> {
None
}
fn canonicalise(chains: &mut [ChainFinding]) {
chains.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(b.stable_hash.cmp(&a.stable_hash))
.then(b.implied_impact.cmp(&a.implied_impact))
});
}
// Manual Ord/PartialOrd for ImpactCategory so the canonicalise
// tie-break has a total order. Defined here rather than in `impact`
// to avoid leaking ordering into the public type.
impl PartialOrd for ImpactCategory {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ImpactCategory {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(*self as u8).cmp(&(*other as u8))
}
}
#[cfg(test)]
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::{
DangerousLocal, EntryPoint, Framework, SourceLocation, SurfaceMap, SurfaceNode,
};
fn loc(file: &str, line: u32) -> SourceLocation {
SourceLocation::new(file, line, 1)
}
fn entry(file: &str, route: &str, auth: bool) -> SurfaceNode {
SurfaceNode::EntryPoint(EntryPoint {
location: loc(file, 1),
framework: Framework::Flask,
method: HttpMethod::POST,
route: route.into(),
handler_name: "h".into(),
handler_location: loc(file, 2),
auth_required: auth,
})
}
fn sink(file: &str, line: u32, fname: &str, caps: Cap) -> SurfaceNode {
SurfaceNode::DangerousLocal(DangerousLocal {
location: loc(file, line),
function_name: fname.into(),
cap_bits: caps.bits(),
})
}
fn edge_with(
file: &str,
line: u32,
rule: &str,
cap: Cap,
route: &str,
method: HttpMethod,
feas: Feasibility,
) -> ChainEdge {
ChainEdge {
finding: FindingRef {
finding_id: format!("{rule}-{line}"),
stable_hash: blake3::hash(format!("{rule}:{file}:{line}").as_bytes()).as_bytes()
[..8]
.try_into()
.map(u64::from_le_bytes)
.unwrap(),
location: loc(file, line),
rule_id: rule.into(),
cap_bits: cap.bits(),
},
primary_cap: cap,
reach: Reach::Reachable {
location: loc(file, 1),
method,
route: route.into(),
auth_required: false,
},
feasibility: feas,
}
}
#[test]
fn returns_empty_when_no_findings() {
let surface = SurfaceMap::new();
let result = find_chains(&[], &surface, ChainSearchConfig::default());
assert!(result.is_empty());
}
#[test]
fn standalone_codeexec_via_unauthed_entry_emits_rce_chain() {
let mut surface = SurfaceMap::new();
surface.nodes.push(entry("app.py", "/exec", false));
surface
.nodes
.push(sink("app.py", 20, "os.system", Cap::CODE_EXEC));
let e = edge_with(
"app.py",
10,
"taint-codeexec",
Cap::CODE_EXEC,
"/exec",
HttpMethod::POST,
Feasibility::Confirmed,
);
let chains = find_chains(&[e], &surface, ChainSearchConfig::default());
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].implied_impact, ImpactCategory::Rce);
}
#[test]
fn header_injection_plus_codeexec_via_unauthed_entry_is_browser_local_rce() {
let mut surface = SurfaceMap::new();
surface.nodes.push(entry("app.py", "/ws", false));
surface
.nodes
.push(sink("app.py", 30, "shell.exec", Cap::CODE_EXEC));
let cors = edge_with(
"app.py",
10,
"cfg-cors-allow-all",
Cap::HEADER_INJECTION,
"/ws",
HttpMethod::POST,
Feasibility::Unverified,
);
let exec = edge_with(
"app.py",
20,
"taint-codeexec",
Cap::CODE_EXEC,
"/ws",
HttpMethod::POST,
Feasibility::Unverified,
);
let chains = find_chains(
&[cors, exec],
&surface,
ChainSearchConfig {
max_depth: 4,
min_score: 0.0,
},
);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].implied_impact, ImpactCategory::BrowserToLocalRce);
assert_eq!(chains[0].severity, ChainSeverity::Critical);
}
#[test]
fn authed_entry_downgrades_browser_local_rce_to_rce() {
let mut surface = SurfaceMap::new();
// Same fixture but entry is authed — should NOT light up
// BrowserToLocalRce.
surface.nodes.push(entry("app.py", "/ws", true));
surface
.nodes
.push(sink("app.py", 30, "shell.exec", Cap::CODE_EXEC));
let cors = edge_with(
"app.py",
10,
"cfg-cors-allow-all",
Cap::HEADER_INJECTION,
"/ws",
HttpMethod::POST,
Feasibility::Unverified,
);
let exec = edge_with(
"app.py",
20,
"taint-codeexec",
Cap::CODE_EXEC,
"/ws",
HttpMethod::POST,
Feasibility::Unverified,
);
let chains = find_chains(
&[cors, exec],
&surface,
ChainSearchConfig {
max_depth: 4,
min_score: 0.0,
},
);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].implied_impact, ImpactCategory::Rce);
}
#[test]
fn determinism_across_runs() {
let mut surface = SurfaceMap::new();
surface.nodes.push(entry("app.py", "/exec", false));
surface
.nodes
.push(sink("app.py", 20, "os.system", Cap::CODE_EXEC));
let e = edge_with(
"app.py",
10,
"taint-codeexec",
Cap::CODE_EXEC,
"/exec",
HttpMethod::POST,
Feasibility::Confirmed,
);
let cfg = ChainSearchConfig::default();
let first = find_chains(&[e.clone()], &surface, cfg);
let first_hashes: Vec<u64> = first.iter().map(|c| c.stable_hash).collect();
for _ in 0..9 {
let again = find_chains(&[e.clone()], &surface, cfg);
let again_hashes: Vec<u64> = again.iter().map(|c| c.stable_hash).collect();
assert_eq!(again_hashes, first_hashes);
}
}
#[test]
fn ssrf_with_local_listener_scores_higher_than_without() {
use crate::surface::{DataStore, DataStoreKind};
let edge = || -> ChainEdge {
edge_with(
"app.py",
10,
"taint-ssrf",
Cap::SSRF,
"/fetch",
HttpMethod::POST,
Feasibility::Confirmed,
)
};
let mut surface_no_listener = SurfaceMap::new();
surface_no_listener.nodes.push(entry("app.py", "/fetch", false));
surface_no_listener
.nodes
.push(sink("app.py", 20, "requests.get", Cap::SSRF));
let baseline = find_chains(
&[edge()],
&surface_no_listener,
ChainSearchConfig {
max_depth: 4,
min_score: 0.0,
},
);
assert_eq!(baseline.len(), 1);
assert_eq!(baseline[0].implied_impact, ImpactCategory::InternalNetworkAccess);
let mut surface_with_listener = surface_no_listener.clone();
surface_with_listener
.nodes
.push(SurfaceNode::DataStore(DataStore {
location: loc("app.py", 5),
kind: DataStoreKind::KeyValue,
label: "redis://127.0.0.1:6379".into(),
}));
let boosted = find_chains(
&[edge()],
&surface_with_listener,
ChainSearchConfig {
max_depth: 4,
min_score: 0.0,
},
);
assert_eq!(boosted.len(), 1);
assert_eq!(boosted[0].implied_impact, ImpactCategory::InternalNetworkAccess);
let ratio = boosted[0].score / baseline[0].score;
assert!(
(ratio - LOCAL_LISTENER_BOOST).abs() < 1e-9,
"expected ×{LOCAL_LISTENER_BOOST} boost, got ratio={ratio}"
);
}
#[test]
fn score_threshold_drops_low_score_chains() {
let mut surface = SurfaceMap::new();
surface.nodes.push(entry("app.py", "/r", false));
surface
.nodes
.push(sink("app.py", 20, "open", Cap::FILE_IO));
let e = edge_with(
"app.py",
10,
"test",
Cap::FILE_IO,
"/r",
HttpMethod::GET,
Feasibility::Unverified,
);
let cfg = ChainSearchConfig {
max_depth: 4,
min_score: 1_000.0,
};
let chains = find_chains(&[e], &surface, cfg);
assert!(chains.is_empty());
}
}