feat(dynamic): add synthetic-fallback handling for partial confirmations and improve validation propagation

This commit is contained in:
elipeter 2026-06-02 20:38:59 -05:00
parent 1ebeb233c4
commit 5615074177
9 changed files with 261 additions and 8 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -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 }}));
}})();
"#

View file

@ -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";
}}

View file

@ -501,6 +501,16 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
sink_hit: bool,
oob_nonce_slot: bool,
oob_callback_seen: bool,
/// The harness reached only its SYNTHETIC fallback sink — the real
/// guarded entry could not be driven (e.g. a top-level `$_GET` PHP
/// script with no named entry fn, or a JS fixture whose response
/// import failed), so the fixture's own guards never executed. Such a
/// run must not terminally Confirm (that would claim exploitation of
/// code whose guard was bypassed — the DVWA impossible.php /
/// juiceshop prototype_pollution over-confirm class); it is routed to
/// partial confirmation instead. Set when the harness emitted the
/// `__NYX_SYNTHETIC_FALLBACK__` marker (PHP / JS synthetic branches).
synthetic_fallback: bool,
vuln_probes: Vec<SinkProbe>,
}
let mut vuln_runs: Vec<VulnRun> = Vec::with_capacity(vuln_payloads.len());
@ -603,11 +613,20 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
Some(&run_canary),
);
let sink_hit = outcome.sink_hit;
const SYNTHETIC_FALLBACK_SENTINEL: &[u8] = b"__NYX_SYNTHETIC_FALLBACK__";
let synthetic_fallback = outcome
.stdout
.windows(SYNTHETIC_FALLBACK_SENTINEL.len())
.any(|w| w == SYNTHETIC_FALLBACK_SENTINEL)
|| outcome
.stderr
.windows(SYNTHETIC_FALLBACK_SENTINEL.len())
.any(|w| w == SYNTHETIC_FALLBACK_SENTINEL);
trace_record(
trace_handle.as_ref(),
TraceStage::OracleObserved,
Some(format!(
"attempt={attempt_index} fired={vuln_fired} sink_hit={sink_hit}"
"attempt={attempt_index} fired={vuln_fired} sink_hit={sink_hit} synthetic={synthetic_fallback}"
)),
);
@ -654,6 +673,7 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
sink_hit,
oob_nonce_slot: payload.oob_nonce_slot,
oob_callback_seen,
synthetic_fallback,
vuln_probes,
});
}
@ -678,6 +698,20 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
let mut partial_signal = false;
for vr in &vuln_runs {
// Synthetic-fallback runs reached only the harness's synthetic sink —
// the fixture's real guarded entry never executed — so the attacker
// payload "reaching the sink" proves nothing about the guarded code.
// Reaching the synthetic sink is at most a partial confirmation
// (sink-reachable, exploit unproven). Routing it here (instead of the
// confirm / OOB-self-confirm paths below) yields PartiallyConfirmed
// rather than a false Confirmed, closing the guard-bypass over-confirm
// class (DVWA header_injection/open_redirect on top-level $_GET
// scripts; juiceshop prototype_pollution) without claiming the finding
// is benign.
if vr.synthetic_fallback && vr.sink_hit {
partial_signal = true;
continue;
}
let is_confirm_candidate = vr.vuln_fired && vr.sink_hit;
let is_partial_candidate = vr.sink_hit && !vr.vuln_fired;
if !is_confirm_candidate && !is_partial_candidate {

View file

@ -288,6 +288,11 @@ pub static RULES: &[LabelRule] = &[
case_sensitive: true,
},
// SQL injection: sqlite3 / SQLAlchemy / generic DB connection execute.
// `cur` / `cursor` are the canonical psycopg2 / aiopg / aiosqlite cursor
// aliases; `cur.execute(q)` on a DB cursor is unambiguous and was a recall
// gap (dvpwa blind-SQLi uses `cur.execute`). `match_suffix_cs` is
// word-boundary anchored, so `cur.execute` does not collide with
// `cursor.execute`.
LabelRule {
matchers: &[
"conn.execute",
@ -295,6 +300,10 @@ pub static RULES: &[LabelRule] = &[
"session.execute",
"engine.execute",
"db.execute",
"cur.execute",
"cur.executemany",
"cursor.executescript",
"cur.executescript",
],
label: DataLabel::Sink(Cap::SQL_QUERY),
case_sensitive: false,

View file

@ -193,6 +193,34 @@ pub const PATTERNS: &[Pattern] = &[
category: PatternCategory::Crypto,
confidence: Confidence::Medium,
},
// Bare-call forms after `from hashlib import md5, sha1` (the qualified
// `hashlib.md5(...)` form above is an `attribute` call and never matches
// these `identifier`-function queries, so there is no double-count). Closes
// the dvpwa weak-hash recall gap. Held at Low confidence: a project-local
// function literally named `md5`/`sha1` is a rare incidental FP, so this
// sits below the default high-confidence surface.
Pattern {
id: "py.crypto.md5_bare",
description: "md5() (from hashlib) uses a weak hash algorithm",
query: r#"(call
function: (identifier) @fn (#eq? @fn "md5"))
@vuln"#,
severity: Severity::Low,
tier: PatternTier::A,
category: PatternCategory::Crypto,
confidence: Confidence::Low,
},
Pattern {
id: "py.crypto.sha1_bare",
description: "sha1() (from hashlib) uses a weak hash algorithm",
query: r#"(call
function: (identifier) @fn (#eq? @fn "sha1"))
@vuln"#,
severity: Severity::Low,
tier: PatternTier::A,
category: PatternCategory::Crypto,
confidence: Confidence::Low,
},
// ── Tier A: Template injection ─────────────────────────────────────
Pattern {
id: "py.xss.jinja_from_string",

View file

@ -3653,6 +3653,59 @@ fn apply_container_elem_read_w4(
}
}
/// Validated-reconstruction support (read side): when reading an
/// element of a container whose BASE symbol was validated by a
/// branch guard on this path (e.g. the true branch of
/// `if (is_numeric($octet[0]) && is_numeric($octet[1]) && …)` marks
/// the `octet` symbol validated), propagate that validation to the
/// element-read result so a value later rebuilt from the elements
/// (`$target = $octet[0] . '.' . $octet[1]`) is recognised as
/// validated by the Assign-arm reconstruction propagation.
///
/// This is the symbol-level counterpart to `apply_container_elem_read_w4`,
/// which lifts validation off `(loc, ELEM)` field cells; the branch
/// guard marks the symbol, not the cells, so the cell path alone misses
/// the "validate each element then rebuild" idiom. Consistent with the
/// engine's existing policy of validating the whole base symbol on a
/// single element type-check — it extends the reach of that decision to
/// element reads, it does not introduce a new validation criterion.
fn apply_container_read_receiver_validation(
inst: &SsaInst,
ssa: &SsaBody,
transfer: &SsaTaintTransfer,
state: &mut SsaTaintState,
) {
let SsaOp::Call {
callee, receiver, ..
} = &inst.op
else {
return;
};
if !crate::pointer::is_container_read_callee_pub(callee) {
return;
}
let Some(rcv) = *receiver else {
return;
};
let (rcv_must, rcv_may) = ssa_value_validated_bits(rcv, ssa, transfer.interner, state);
if !rcv_must && !rcv_may {
return;
}
if let Some(name) = ssa
.value_defs
.get(inst.value.0 as usize)
.and_then(|vd| vd.var_name.as_deref())
&& let Some(sym) = transfer.interner.get(name)
{
if rcv_must {
state.validated_must.insert(sym);
}
if rcv_may {
state.validated_may.insert(sym);
}
}
}
/// W4: look up the symbol-keyed `validated_must` / `validated_may`
/// flags for an SSA value via its `var_name`. Returns `(false,
/// false)` when the value has no name, when the name isn't interned,
@ -5692,6 +5745,45 @@ pub(super) fn transfer_inst(
uses_summary: inherited_summary,
},
);
// Validated-reconstruction propagation: when a tainted value is
// rebuilt from operands that are themselves all validated (e.g.
// `$target = $octet[0] . '.' . $octet[1] . '.' . $octet[2]`
// where each `$octet[i]` inherited an `is_numeric` branch-guard
// validation), the result is validated too. We AND `must` /
// OR `may` over the TAINTED operands only — string literals and
// other untainted operands carry no taint into the sink, so
// they neither contribute to nor block validation. This is the
// scalar counterpart to the field-cell `must_all`/`may_any`
// lift below and closes the "validate-each-part then rebuild"
// idiom (DVWA exec/source/impossible.php).
let mut tainted_must_all = true;
let mut tainted_may_any = false;
let mut saw_tainted = false;
for &u in uses {
if state.get(u).is_some() {
saw_tainted = true;
let (am, av) =
ssa_value_validated_bits(u, ssa, transfer.interner, state);
tainted_must_all &= am;
tainted_may_any |= av;
}
}
if saw_tainted
&& (tainted_must_all || tainted_may_any)
&& let Some(name) = ssa
.value_defs
.get(inst.value.0 as usize)
.and_then(|vd| vd.var_name.as_deref())
&& let Some(sym) = transfer.interner.get(name)
{
if tainted_must_all {
state.validated_must.insert(sym);
}
if tainted_may_any {
state.validated_may.insert(sym);
}
}
}
// Synthetic base-update Assign emitted by SSA lowering for
@ -6061,6 +6153,7 @@ pub(super) fn transfer_inst(
// before container-handled early-returns inside the Call arm.
if matches!(&inst.op, SsaOp::Call { .. }) {
apply_container_elem_read_w4(inst, ssa, transfer, state);
apply_container_read_receiver_validation(inst, ssa, transfer, state);
}
// Constraint propagation through instructions

View file

@ -584,7 +584,17 @@ mod e2e_phase_09 {
let Some(outcome) = run(Lang::Php, "vuln.php", "run") else {
return;
};
assert_confirmed(Lang::Php, &outcome);
// The fixture's real entry imports Symfony `RedirectResponse`, which is
// absent from the harness build env, so the eval/invoke fails and the
// harness reaches only its synthetic sink. After the synthetic-fallback
// over-confirm fix that yields PartiallyConfirmed (sink-reachable,
// exploit unproven) rather than a Confirmed claiming exploitation of
// guarded code that never executed. With Symfony present (CI image) the
// real drive still Confirms. Both are valid positive detections.
assert!(
outcome.triggered_by.is_some() || outcome.sink_reached_no_oracle,
"PHP OPEN_REDIRECT vuln must Confirm or PartiallyConfirm; got {outcome:?}",
);
}
#[test]
@ -748,7 +758,29 @@ mod e2e_phase_09 {
let Some(outcome) = run_oob(Lang::Php, "vuln.php", "run") else {
return;
};
assert_oob_recorded(&outcome, "open-redirect-php-oob-nonce");
// The OOB nonce URL is still followed and recorded (infra signal), but
// because the fixture's real entry (Symfony `RedirectResponse`) can't be
// driven in this env, the harness reaches only its synthetic sink. After
// the over-confirm fix a synthetic sink hit no longer self-confirms via
// the OOB nonce (that would confirm code whose guard never ran) — it
// PartiallyConfirms instead. With Symfony present the real drive promotes
// to ConfirmedProvenOob.
let oob_attempt = outcome
.attempts
.iter()
.find(|a| a.payload_label == "open-redirect-php-oob-nonce")
.unwrap_or_else(|| panic!("OOB payload must run; outcome={outcome:?}"));
assert!(
oob_attempt.outcome.oob_callback_seen,
"harness must follow captured Location URL so OOB listener records the nonce; got {oob_attempt:?}",
);
match outcome.differential.as_ref() {
Some(diff) => 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]