mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-27 20:29:39 +02:00
[pitboss] phase 25: Track G.2 — Path search, scoring, ChainFinding emission, SARIF property
This commit is contained in:
parent
a3ab1215f1
commit
76d0037073
12 changed files with 1908 additions and 139 deletions
311
tests/chain_emission.rs
Normal file
311
tests/chain_emission.rs
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
//! Phase 25 — exploit-chain emission integration tests.
|
||||
//!
|
||||
//! Covers the design-doc example: a permissive-CORS finding plus an
|
||||
//! unauthenticated entry-point plus a code-exec sink → one Critical
|
||||
//! `BrowserToLocalRce` chain with three members. Also exercises
|
||||
//! determinism (10 reruns produce byte-identical chain lists) and
|
||||
//! SARIF-shape validation of the emitted `runs[0].properties.chains`
|
||||
//! array.
|
||||
|
||||
use nyx_scanner::chain::finding::ChainSeverity;
|
||||
use nyx_scanner::chain::impact::ImpactCategory;
|
||||
use nyx_scanner::chain::{ChainEdge, ChainSearchConfig, find_chains};
|
||||
use nyx_scanner::commands::scan::Diag;
|
||||
use nyx_scanner::entry_points::HttpMethod;
|
||||
use nyx_scanner::evidence::Evidence;
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::output::{build_findings_json, build_sarif_with_chains};
|
||||
use nyx_scanner::patterns::{FindingCategory, Severity};
|
||||
use nyx_scanner::surface::{
|
||||
DangerousLocal, EntryPoint, Framework, SourceLocation, SurfaceMap, SurfaceNode,
|
||||
};
|
||||
|
||||
fn loc(file: &str, line: u32) -> SourceLocation {
|
||||
SourceLocation::new(file, line, 1)
|
||||
}
|
||||
|
||||
/// Build the SurfaceMap for the design-doc scenario:
|
||||
///
|
||||
/// - One Flask entry-point at `app.py:1`, route `/ws`, method `POST`,
|
||||
/// `auth_required: false` (the NoAuth half of CORS+NoAuth+websocket).
|
||||
/// - One DangerousLocal sink at `app.py:30`, function `shell.exec`,
|
||||
/// Cap::CODE_EXEC (the shell tool sink).
|
||||
fn fixture_surface_map() -> SurfaceMap {
|
||||
let mut m = SurfaceMap::new();
|
||||
m.nodes.push(SurfaceNode::EntryPoint(EntryPoint {
|
||||
location: loc("app.py", 1),
|
||||
framework: Framework::Flask,
|
||||
method: HttpMethod::POST,
|
||||
route: "/ws".into(),
|
||||
handler_name: "ws_handler".into(),
|
||||
handler_location: loc("app.py", 2),
|
||||
auth_required: false,
|
||||
}));
|
||||
m.nodes.push(SurfaceNode::DangerousLocal(DangerousLocal {
|
||||
location: loc("app.py", 30),
|
||||
function_name: "shell.exec".into(),
|
||||
cap_bits: Cap::CODE_EXEC.bits(),
|
||||
}));
|
||||
m
|
||||
}
|
||||
|
||||
/// Build the three constituent findings for the scenario:
|
||||
///
|
||||
/// - `d1` — permissive-CORS header injection at `app.py:10`.
|
||||
/// - `d2` — auth-gap diagnostic at `app.py:15` (cfg-auth-gap; carries
|
||||
/// `Cap::UNAUTHORIZED_ID` so the lattice has a third member, but the
|
||||
/// primary chain match is HEADER_INJECTION + CODE_EXEC).
|
||||
/// - `d3` — shell-exec taint finding at `app.py:25`.
|
||||
fn fixture_findings() -> Vec<Diag> {
|
||||
let mk = |line: usize, rule: &str, cap: Cap, sev: Severity| {
|
||||
let ev = Evidence {
|
||||
sink_caps: cap.bits(),
|
||||
..Evidence::default()
|
||||
};
|
||||
let mut d = Diag {
|
||||
path: "app.py".into(),
|
||||
line,
|
||||
col: 1,
|
||||
severity: sev,
|
||||
id: rule.into(),
|
||||
category: FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: Some(ev),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
d.stable_hash = nyx_scanner::commands::scan::compute_stable_hash(&d);
|
||||
d
|
||||
};
|
||||
vec![
|
||||
mk(10, "cfg-cors-allow-all", Cap::HEADER_INJECTION, Severity::Medium),
|
||||
mk(15, "cfg-auth-gap", Cap::UNAUTHORIZED_ID, Severity::Medium),
|
||||
mk(25, "taint-shell-exec", Cap::CODE_EXEC, Severity::High),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_chain_edges_for_route(findings: &[Diag], route: &str) -> Vec<ChainEdge> {
|
||||
// findings_to_edges sets reach from the SurfaceMap; the design-doc
|
||||
// scenario has every finding live in the same file as the entry,
|
||||
// so the file-local reach resolver maps every edge to the entry.
|
||||
let surface = fixture_surface_map();
|
||||
let edges = nyx_scanner::chain::findings_to_edges(findings, &surface);
|
||||
edges
|
||||
.into_iter()
|
||||
.map(|mut e| {
|
||||
// Tighten the reach to the exact route so the DFS pairs
|
||||
// each edge with the right entry deterministically.
|
||||
e.reach = nyx_scanner::chain::edges::Reach::Reachable {
|
||||
location: loc("app.py", 1),
|
||||
method: HttpMethod::POST,
|
||||
route: route.into(),
|
||||
auth_required: false,
|
||||
};
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cors_plus_noauth_plus_websocket_emits_one_critical_chain() {
|
||||
let surface = fixture_surface_map();
|
||||
let findings = fixture_findings();
|
||||
let edges = build_chain_edges_for_route(&findings, "/ws");
|
||||
let chains = find_chains(
|
||||
&edges,
|
||||
&surface,
|
||||
ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(chains.len(), 1, "expected exactly one chain, got {chains:?}");
|
||||
let chain = &chains[0];
|
||||
assert_eq!(chain.implied_impact, ImpactCategory::BrowserToLocalRce);
|
||||
assert_eq!(chain.severity, ChainSeverity::Critical);
|
||||
assert_eq!(chain.members.len(), 3, "expected three constituent members");
|
||||
assert_eq!(chain.sink.function_name, "shell.exec");
|
||||
assert_eq!(chain.sink.cap_bits, Cap::CODE_EXEC.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_set_is_byte_deterministic_across_10_reruns() {
|
||||
let surface = fixture_surface_map();
|
||||
let findings = fixture_findings();
|
||||
let edges = build_chain_edges_for_route(&findings, "/ws");
|
||||
let cfg = ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
};
|
||||
|
||||
let first = find_chains(&edges, &surface, cfg);
|
||||
let first_json = serde_json::to_string(&first).unwrap();
|
||||
for i in 0..9 {
|
||||
let again = find_chains(&edges, &surface, cfg);
|
||||
let again_json = serde_json::to_string(&again).unwrap();
|
||||
assert_eq!(
|
||||
again_json, first_json,
|
||||
"chain emission diverged on rerun {i}"
|
||||
);
|
||||
// stable_hash is a 64-bit fingerprint — verify it does not
|
||||
// drift across reruns even when the JSON happens to match
|
||||
// (defence in depth against accidental hash randomisation).
|
||||
let again_hashes: Vec<u64> = again.iter().map(|c| c.stable_hash).collect();
|
||||
let first_hashes: Vec<u64> = first.iter().map(|c| c.stable_hash).collect();
|
||||
assert_eq!(again_hashes, first_hashes, "stable_hash drift on rerun {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_output_carries_chain_member_of_back_references() {
|
||||
let surface = fixture_surface_map();
|
||||
let findings = fixture_findings();
|
||||
let edges = build_chain_edges_for_route(&findings, "/ws");
|
||||
let chains = find_chains(
|
||||
&edges,
|
||||
&surface,
|
||||
ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
},
|
||||
);
|
||||
|
||||
let value = build_findings_json(&findings, &chains, None);
|
||||
let chains_json = value["chains"].as_array().unwrap();
|
||||
assert_eq!(chains_json.len(), 1);
|
||||
let chain_hash = chains_json[0]["stable_hash"].as_u64().unwrap();
|
||||
|
||||
let findings_json = value["findings"].as_array().unwrap();
|
||||
let with_back_refs: Vec<_> = findings_json
|
||||
.iter()
|
||||
.filter(|f| f.get("chain_member_of").is_some())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
with_back_refs.len(),
|
||||
3,
|
||||
"every constituent finding should carry chain_member_of"
|
||||
);
|
||||
for f in with_back_refs {
|
||||
assert_eq!(f["chain_member_of"].as_u64(), Some(chain_hash));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sarif_output_validates_against_v210_shape() {
|
||||
let surface = fixture_surface_map();
|
||||
let findings = fixture_findings();
|
||||
let edges = build_chain_edges_for_route(&findings, "/ws");
|
||||
let chains = find_chains(
|
||||
&edges,
|
||||
&surface,
|
||||
ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
},
|
||||
);
|
||||
let sarif = build_sarif_with_chains(
|
||||
&findings,
|
||||
&chains,
|
||||
std::path::Path::new("."),
|
||||
);
|
||||
|
||||
// Surface-level v2.1.0 invariants — the SARIF schema requires
|
||||
// these fields and we want a tripwire if any disappear.
|
||||
assert_eq!(sarif["version"], "2.1.0", "missing or wrong version field");
|
||||
assert!(sarif["$schema"].is_string(), "$schema must be a string");
|
||||
assert!(sarif["runs"].is_array(), "runs must be an array");
|
||||
assert_eq!(
|
||||
sarif["runs"].as_array().unwrap().len(),
|
||||
1,
|
||||
"exactly one run"
|
||||
);
|
||||
|
||||
let run = &sarif["runs"][0];
|
||||
assert!(run["tool"]["driver"]["name"].is_string());
|
||||
assert_eq!(run["tool"]["driver"]["name"], "nyx");
|
||||
assert!(run["tool"]["driver"]["rules"].is_array());
|
||||
assert!(run["results"].is_array());
|
||||
|
||||
// Phase 25 extension: chains land on run.properties.chains.
|
||||
let chains_array = run["properties"]["chains"].as_array().unwrap();
|
||||
assert_eq!(chains_array.len(), 1, "exactly one chain emitted");
|
||||
|
||||
// Every chain object carries the documented shape.
|
||||
let chain = &chains_array[0];
|
||||
assert!(chain["stable_hash"].is_number());
|
||||
assert!(chain["members"].is_array());
|
||||
assert_eq!(chain["members"].as_array().unwrap().len(), 3);
|
||||
assert!(chain["sink"].is_object());
|
||||
assert!(chain["implied_impact"].is_string());
|
||||
assert_eq!(chain["severity"], "critical");
|
||||
|
||||
// Per-result `chain_member_of` cross-reference.
|
||||
let results = run["results"].as_array().unwrap();
|
||||
let with_back_refs = results
|
||||
.iter()
|
||||
.filter(|r| r["properties"].get("chain_member_of").is_some())
|
||||
.count();
|
||||
assert_eq!(
|
||||
with_back_refs, 3,
|
||||
"every constituent SARIF result should carry chain_member_of"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_across_input_permutations() {
|
||||
// Same set of findings in two different orders must yield the
|
||||
// same chain set (the composer canonicalises by stable_hash).
|
||||
let surface = fixture_surface_map();
|
||||
let findings = fixture_findings();
|
||||
let cfg = ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
};
|
||||
|
||||
let order_a = build_chain_edges_for_route(&findings, "/ws");
|
||||
let mut findings_rev = findings.clone();
|
||||
findings_rev.reverse();
|
||||
let order_b = build_chain_edges_for_route(&findings_rev, "/ws");
|
||||
|
||||
let chains_a = find_chains(&order_a, &surface, cfg);
|
||||
let chains_b = find_chains(&order_b, &surface, cfg);
|
||||
let hashes_a: Vec<u64> = chains_a.iter().map(|c| c.stable_hash).collect();
|
||||
let hashes_b: Vec<u64> = chains_b.iter().map(|c| c.stable_hash).collect();
|
||||
assert_eq!(hashes_a, hashes_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authed_entry_downgrades_to_rce_without_browser_local() {
|
||||
let mut surface = fixture_surface_map();
|
||||
// Flip auth_required on the entry — should downgrade the chain.
|
||||
if let SurfaceNode::EntryPoint(ref mut e) = surface.nodes[0] {
|
||||
e.auth_required = true;
|
||||
}
|
||||
let findings = fixture_findings();
|
||||
let edges = build_chain_edges_for_route(&findings, "/ws");
|
||||
let chains = find_chains(
|
||||
&edges,
|
||||
&surface,
|
||||
ChainSearchConfig {
|
||||
max_depth: 4,
|
||||
min_score: 0.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(chains.len(), 1);
|
||||
assert_eq!(
|
||||
chains[0].implied_impact,
|
||||
ImpactCategory::Rce,
|
||||
"auth-gated entry must not produce BrowserToLocalRce"
|
||||
);
|
||||
assert_eq!(chains[0].severity, ChainSeverity::Critical);
|
||||
}
|
||||
|
|
@ -615,17 +615,25 @@ fn binary_json_output() {
|
|||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&cmd.stdout);
|
||||
// Find the JSON array in stdout (config notes and "Finished" surround it)
|
||||
let json_start = stdout.find('[').expect("Expected JSON array in stdout");
|
||||
let json_end = stdout.rfind(']').expect("Expected closing bracket in JSON") + 1;
|
||||
// Phase 25: JSON output is `{ "findings": [...], "chains": [...] }`.
|
||||
let json_start = stdout.find('{').expect("Expected JSON object in stdout");
|
||||
let json_end = stdout.rfind('}').expect("Expected closing brace in JSON") + 1;
|
||||
let json_str = &stdout[json_start..json_end];
|
||||
let parsed: Vec<serde_json::Value> =
|
||||
serde_json::from_str(json_str).expect("stdout should contain valid JSON array");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_str).expect("stdout should contain valid JSON object");
|
||||
|
||||
let findings = parsed["findings"]
|
||||
.as_array()
|
||||
.expect("JSON output must have a `findings` array");
|
||||
assert!(
|
||||
!parsed.is_empty(),
|
||||
!findings.is_empty(),
|
||||
"Expected at least 1 finding in JSON output"
|
||||
);
|
||||
// Phase 25: every scan emits a `chains` array (possibly empty).
|
||||
assert!(
|
||||
parsed["chains"].is_array(),
|
||||
"JSON output must have a `chains` array"
|
||||
);
|
||||
}
|
||||
|
||||
// ── EJS / config / debug endpoint fixtures ──────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue