diff --git a/src/ast.rs b/src/ast.rs index 4d7d5f3e..f461df9b 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -235,10 +235,17 @@ fn build_taint_diag( .map(sanitize_desc) }) .unwrap_or_else(|| "(unknown)".into()); + // Sink-callee attribution: when the sink node is an *argument* of a call + // (e.g. PHP `header("location: " . $_GET['x'])` — the `$_GET[...]` subscript + // carries `callee = "$_GET"` but `outer_callee = "header"`), the enclosing + // call is the real sink and should be displayed, not the source token. + // `outer_callee` is only populated for nested/argument positions, so for a + // plain call node it is None and we fall back to the node's own callee. let call_site_callee = cfg_graph[finding.sink] .call - .callee + .outer_callee .as_deref() + .or(cfg_graph[finding.sink].call.callee.as_deref()) .map(sanitize_desc) .unwrap_or_else(|| "(unknown)".into()); let kind_label = source_kind_label(finding.source_kind); @@ -1979,6 +1986,27 @@ impl<'a> ParsedFile<'a> { cfg_analysis::Confidence::Medium => crate::evidence::Confidence::Medium, cfg_analysis::Confidence::Low => crate::evidence::Confidence::Low, }); + // Carry the sink node's resolved Sink caps onto the structural + // finding's evidence so downstream cap-classification (and the + // eval `cap_of`) buckets `cfg-unguarded-sink` under its real cap + // (sqli/cmdi/ssrf/…) instead of the catch-all `other`. Without + // this every taint-less structural sink finding fell through to + // `other`, hiding real recall (e.g. dvpwa `cur.execute` SQLi) + // and inflating the `other` bucket. Non-sink structural findings + // (resource-leak, auth-gap) carry no Sink label, so this is 0. + let cf_sink_caps: u32 = cf + .evidence + .first() + .map(|&n| { + cfg_ctx.cfg[n].taint.labels.iter().fold(0u32, |acc, l| { + if let crate::labels::DataLabel::Sink(c) = l { + acc | c.bits() + } else { + acc + } + }) + }) + .unwrap_or(0); out.push(Diag { path: self.source.path.to_string_lossy().into_owned(), line: point.row + 1, @@ -2000,6 +2028,7 @@ impl<'a> ParsedFile<'a> { kind: "sink".into(), snippet: None, }), + sink_caps: cf_sink_caps, guards: vec![], sanitizers: vec![], state: None, diff --git a/src/auth_analysis/mod.rs b/src/auth_analysis/mod.rs index 2298650d..6b9937ad 100644 --- a/src/auth_analysis/mod.rs +++ b/src/auth_analysis/mod.rs @@ -1015,7 +1015,18 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: & guard_kind: None, message: Some(finding.message.clone()), labels: vec![], - confidence: Some(Confidence::Medium), + // Auth-analysis findings are *structural* (parameter-name + control-flow + // shape heuristics) and carry no taint witness — `source = None`, + // `sink_caps = 0`, no flow steps — so the per-payload dynamic oracle + // cannot confirm or refute them (missing-authz needs a 2-user + // differential the corpus does not run). Emitting them at Medium put a + // large zero-witness, dynamically-Unsupported tranche on the default / + // verified surface (the bulk of the nodegoat/railsgoat/juiceshop `auth` + // FP flood). Demote to Low so they sit below the default min-confidence + // and verify gates while remaining available for access-control audits. + // assert_has tests pin rule-id presence, not confidence, so they stay + // green. + confidence: Some(Confidence::Low), evidence: Some(Evidence { source: None, sink: Some(SpanEvidence { diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index f29ceb3f..36862388 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -2323,9 +2323,9 @@ function nyxWireFrameProbe(rawBytes) {{ }; let invoke_via_fixture = if uses_node_writer { - "const captured = nyxHeaderViaFixture(payload);\nif (Array.isArray(captured) && captured.length > 0) {\n for (const [hname, hvalue] of captured) {\n nyxHeaderProbe(hname, hvalue);\n }\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ headers: captured.map(([n, v]) => [n, v]) }));\n} else {\n // Synthetic fallback — fixture import / call failed.\n const name = 'Set-Cookie';\n const value = payload;\n nyxHeaderProbe(name, value);\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ name: name, value: value }));\n}\n" + "const captured = nyxHeaderViaFixture(payload);\nif (Array.isArray(captured) && captured.length > 0) {\n for (const [hname, hvalue] of captured) {\n nyxHeaderProbe(hname, hvalue);\n }\n console.log('__NYX_SINK_HIT__');\n console.log(JSON.stringify({ headers: captured.map(([n, v]) => [n, v]) }));\n} else {\n // Synthetic fallback — fixture import / call failed. The real header\n // surface (and its guards) never ran, so the verdict must not Confirm;\n // the synthetic marker routes the runner to PartiallyConfirmed.\n const name = 'Set-Cookie';\n const value = payload;\n nyxHeaderProbe(name, value);\n console.log('__NYX_SINK_HIT__');\n console.log('__NYX_SYNTHETIC_FALLBACK__');\n console.log(JSON.stringify({ name: name, value: value }));\n}\n" } else { - "const name = 'Set-Cookie';\nconst value = payload;\nnyxHeaderProbe(name, value);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log(JSON.stringify({ name: name, value: value }));\n" + "const name = 'Set-Cookie';\nconst value = payload;\nnyxHeaderProbe(name, value);\nconsole.log('__NYX_SINK_HIT__');\nconsole.log('__NYX_SYNTHETIC_FALLBACK__');\nconsole.log(JSON.stringify({ name: name, value: value }));\n" }; // Phase 08 tier-(b): when the fixture imports `net.createServer`, run @@ -2384,11 +2384,14 @@ function nyxHeaderProbe(name, value) {{ console.log(JSON.stringify({{ wire_frame_len: rawBytes.length }})); return; }} - // Synthetic fallback — wire-frame branch did not produce bytes. + // Synthetic fallback — wire-frame branch did not produce bytes. The real + // socket-write path never ran, so this records the raw payload at a + // synthetic sink; the marker routes the runner to PartiallyConfirmed. const name = 'Set-Cookie'; const value = payload; nyxHeaderProbe(name, value); console.log('__NYX_SINK_HIT__'); + console.log('__NYX_SYNTHETIC_FALLBACK__'); console.log(JSON.stringify({{ name: name, value: value }})); }})(); "# diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 865a2b44..68708781 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1523,6 +1523,12 @@ function _nyx_header_probe(string $name, string $value): void {{ $value = $payload; _nyx_header_probe($name, $value); echo "__NYX_SINK_HIT__\n"; + // The real entry could not be driven (no named entry fn captured a + // header); this records the raw payload at a synthetic sink WITHOUT + // running the fixture's own guards, so the verdict must not terminally + // Confirm. The runner downgrades synthetic-marked sink hits to + // PartiallyConfirmed. + echo "__NYX_SYNTHETIC_FALLBACK__\n"; echo json_encode(['name' => $name, 'value' => $value]) . "\n"; }} @@ -1949,6 +1955,14 @@ function _nyx_follow_location(string $location): void {{ _nyx_redirect_probe($location, $requestHost); _nyx_follow_location($location); echo "__NYX_SINK_HIT__\n"; + // Synthetic sink: the real redirect surface (with its host allowlist / + // path guard) never ran, so this raw-payload assignment proves nothing + // about the guarded code. Emit the synthetic marker so the runner + // downgrades the verdict to PartiallyConfirmed instead of terminally + // Confirming guard-bypassed code (the DVWA open_redirect over-confirm + // class). The OOB callback may still be recorded (infra signal), but the + // runner ignores it for synthetic-marked runs. + echo "__NYX_SYNTHETIC_FALLBACK__\n"; echo json_encode(['location' => $location, 'request_host' => $requestHost]) . "\n"; }} diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 2e3b87a5..957dad07 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -501,6 +501,16 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result, } let mut vuln_runs: Vec = Vec::with_capacity(vuln_payloads.len()); @@ -603,11 +613,20 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result Result Result assert_eq!(diff.verdict, DifferentialVerdict::ConfirmedProvenOob), + None => assert!( + outcome.sink_reached_no_oracle, + "synthetic-fallback OOB run must PartiallyConfirm (not self-confirm); got {outcome:?}", + ), + } } #[test]