mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
feat(dynamic): add synthetic-fallback handling for partial confirmations and improve validation propagation
This commit is contained in:
parent
1ebeb233c4
commit
5615074177
9 changed files with 261 additions and 8 deletions
31
src/ast.rs
31
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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }}));
|
||||
}})();
|
||||
"#
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue