mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +02:00
654 lines
22 KiB
Rust
654 lines
22 KiB
Rust
//! 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());
|
||
}
|
||
}
|