mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-27 20:29:39 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
360
src/rank.rs
360
src/rank.rs
|
|
@ -90,6 +90,25 @@ pub fn compute_attack_rank(diag: &Diag) -> AttackRank {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 7a. Dynamic verification delta ─────────────────────────────
|
||||
//
|
||||
// `Confirmed` findings are verified exploitable — boost rank so they
|
||||
// surface above equivalent static-only findings.
|
||||
// `NotConfirmed` findings where all available payloads were tried
|
||||
// (corpus exhausted) receive a mild downward nudge.
|
||||
// All other verdicts (Unsupported, Inconclusive, no verdict) are
|
||||
// unaffected: no data is better than speculative data.
|
||||
//
|
||||
// Calibrated values from the eval corpus: N=20, M=5.
|
||||
// N=20 ensures Confirmed findings from any severity tier surface
|
||||
// above static-only peers: High(60)+20=80 > High(60)+taint(10)=70.
|
||||
// M=5 nudges exhausted-corpus NotConfirmed below equal static peers
|
||||
// without burying them: severity-tier ordering preserved.
|
||||
if let Some(delta) = dynamic_verdict_delta(diag) {
|
||||
score += delta;
|
||||
components.push(("dynamic_verdict".into(), format!("{delta:+}")));
|
||||
}
|
||||
|
||||
// ── 7. Completeness penalty (engine provenance notes) ────────────
|
||||
//
|
||||
// When the analysis engine hit a cap, widening, or lowering bail,
|
||||
|
|
@ -190,6 +209,21 @@ pub fn rank_diags(diags: &mut [Diag]) {
|
|||
if !rank.components.is_empty() {
|
||||
d.rank_reason = Some(rank.components.clone());
|
||||
}
|
||||
// Emit rank-delta telemetry for score calibration.
|
||||
// Only fires when the dynamic verdict shifted the score; benign verdicts
|
||||
// (Unsupported, Inconclusive, no verdict) produce delta = None and are
|
||||
// skipped — emitting them would add noise without calibration value.
|
||||
#[cfg(feature = "dynamic")]
|
||||
if let Some(delta) = dynamic_verdict_delta(d) {
|
||||
use crate::dynamic::telemetry::{self, RankDeltaEvent};
|
||||
let status = d
|
||||
.evidence
|
||||
.as_ref()
|
||||
.and_then(|ev| ev.dynamic_verdict.as_ref())
|
||||
.map(|dv| format!("{:?}", dv.status))
|
||||
.unwrap_or_default();
|
||||
telemetry::emit_rank_delta(RankDeltaEvent::new(d.finding_id.clone(), status, delta));
|
||||
}
|
||||
}
|
||||
diags.sort_by(|a, b| {
|
||||
let sa = a.rank_score.unwrap_or(0.0);
|
||||
|
|
@ -200,9 +234,43 @@ pub fn rank_diags(diags: &mut [Diag]) {
|
|||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Scoring helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rank delta from the dynamic verification verdict.
|
||||
///
|
||||
/// Returns `None` when there is no verdict (static-only scan) or the verdict
|
||||
/// does not change the score (Unsupported, Inconclusive).
|
||||
///
|
||||
/// Design note: the spec originally distinguished `NotConfirmed` +
|
||||
/// `payload_corpus_complete == true` from `NotConfirmed` +
|
||||
/// `NoPayloadsForCap`. In practice the
|
||||
/// `NoPayloadsForCap` path always produces `Unsupported`, never `NotConfirmed`,
|
||||
/// so the two cases are already disjoint in the type. The heuristic
|
||||
/// `!dv.attempts.is_empty()` (corpus was actually tried) is equivalent to
|
||||
/// `payload_corpus_complete == true` for all reachable states, so no extra
|
||||
/// field is needed.
|
||||
///
|
||||
/// Values calibrated against the eval corpus: N=20, M=5.
|
||||
fn dynamic_verdict_delta(diag: &Diag) -> Option<f64> {
|
||||
use crate::evidence::VerifyStatus;
|
||||
let dv = diag.evidence.as_ref()?.dynamic_verdict.as_ref()?;
|
||||
match dv.status {
|
||||
VerifyStatus::Confirmed => Some(20.0),
|
||||
// PartiallyConfirmed: the sink was reached at runtime but the
|
||||
// exploit chain did not complete. Runtime corroboration that the
|
||||
// sink is reachable is a positive signal, but weaker than a proven
|
||||
// exploit, so it earns a modest bump rather than the full Confirmed
|
||||
// boost.
|
||||
VerifyStatus::PartiallyConfirmed => Some(8.0),
|
||||
// Apply penalty only when the corpus was actually exhausted (attempts
|
||||
// were made); a NotConfirmed with zero attempts means something went
|
||||
// wrong before payload execution, which is an Inconclusive path, not
|
||||
// a meaningful negative signal. This is equivalent to the spec's
|
||||
// `payload_corpus_complete == true` condition (see design note above).
|
||||
VerifyStatus::NotConfirmed if !dv.attempts.is_empty() => Some(-5.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bonus based on analysis kind inferred from rule ID + evidence.
|
||||
fn analysis_kind_bonus(rule_id: &str, evidence: Option<&Evidence>) -> f64 {
|
||||
|
|
@ -324,9 +392,7 @@ fn state_finding_bonus(rule_id: &str) -> f64 {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -360,6 +426,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1046,4 +1113,289 @@ mod tests {
|
|||
"Bail ({s_bail}) must rank at or below UnderReport ({s_under})"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dynamic verdict delta tests ────────────────────────────────────────
|
||||
|
||||
use crate::evidence::{AttemptSummary, Evidence, VerifyResult, VerifyStatus};
|
||||
|
||||
fn make_diag_with_verdict(verdict: Option<VerifyResult>) -> Diag {
|
||||
let mut d = make_diag(
|
||||
Severity::High,
|
||||
"taint-unsanitised-flow (source 1:1)",
|
||||
"src/main.rs",
|
||||
10,
|
||||
vec![("Source".into(), "stdin at 1:1".into())],
|
||||
false,
|
||||
);
|
||||
d.finding_id = "test_finding_id".into();
|
||||
if let Some(v) = verdict {
|
||||
d.evidence = Some(Evidence {
|
||||
dynamic_verdict: Some(v),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn confirmed_verdict() -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: "test_finding_id".into(),
|
||||
status: VerifyStatus::Confirmed,
|
||||
triggered_payload: Some("sqli-tautology".into()),
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![AttemptSummary {
|
||||
payload_label: "sqli-tautology".into(),
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
triggered: true,
|
||||
sink_hit: true,
|
||||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn not_confirmed_with_attempts() -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: "test_finding_id".into(),
|
||||
status: VerifyStatus::NotConfirmed,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![AttemptSummary {
|
||||
payload_label: "sqli-tautology".into(),
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
triggered: false,
|
||||
sink_hit: false,
|
||||
}],
|
||||
toolchain_match: Some("exact".into()),
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn not_confirmed_no_attempts() -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: "test_finding_id".into(),
|
||||
status: VerifyStatus::NotConfirmed,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_verdict() -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: "test_finding_id".into(),
|
||||
status: VerifyStatus::Unsupported,
|
||||
triggered_payload: None,
|
||||
reason: Some(crate::evidence::UnsupportedReason::NoPayloadsForCap),
|
||||
inconclusive_reason: None,
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn inconclusive_verdict() -> VerifyResult {
|
||||
VerifyResult {
|
||||
finding_id: "test_finding_id".into(),
|
||||
status: VerifyStatus::Inconclusive,
|
||||
triggered_payload: None,
|
||||
reason: None,
|
||||
inconclusive_reason: Some(crate::evidence::InconclusiveReason::BuildFailed),
|
||||
detail: None,
|
||||
attempts: vec![],
|
||||
toolchain_match: None,
|
||||
differential: None,
|
||||
replay_stable: None,
|
||||
wrong: None,
|
||||
hardening_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_confirmed_delta_is_positive() {
|
||||
let d = make_diag_with_verdict(Some(confirmed_verdict()));
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
Some(20.0),
|
||||
"Confirmed must produce +20 delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_not_confirmed_with_attempts_delta_is_negative() {
|
||||
let d = make_diag_with_verdict(Some(not_confirmed_with_attempts()));
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
Some(-5.0),
|
||||
"NotConfirmed with attempts must produce -5 delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_not_confirmed_no_attempts_no_delta() {
|
||||
let d = make_diag_with_verdict(Some(not_confirmed_no_attempts()));
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
None,
|
||||
"NotConfirmed with zero attempts must produce no delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_unsupported_no_delta() {
|
||||
let d = make_diag_with_verdict(Some(unsupported_verdict()));
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
None,
|
||||
"Unsupported must produce no delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_inconclusive_no_delta() {
|
||||
let d = make_diag_with_verdict(Some(inconclusive_verdict()));
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
None,
|
||||
"Inconclusive must produce no delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_no_verdict_no_delta() {
|
||||
let d = make_diag_with_verdict(None);
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&d),
|
||||
None,
|
||||
"No verdict must produce no delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_confirmed_ranks_above_no_verdict() {
|
||||
let confirmed = make_diag_with_verdict(Some(confirmed_verdict()));
|
||||
let no_verdict = make_diag_with_verdict(None);
|
||||
|
||||
let s_confirmed = compute_attack_rank(&confirmed).score;
|
||||
let s_none = compute_attack_rank(&no_verdict).score;
|
||||
assert!(
|
||||
s_confirmed > s_none,
|
||||
"Confirmed ({s_confirmed}) must rank above no-verdict ({s_none})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_no_verdict_ranks_above_not_confirmed_with_attempts() {
|
||||
let no_verdict = make_diag_with_verdict(None);
|
||||
let not_confirmed = make_diag_with_verdict(Some(not_confirmed_with_attempts()));
|
||||
|
||||
let s_none = compute_attack_rank(&no_verdict).score;
|
||||
let s_nc = compute_attack_rank(¬_confirmed).score;
|
||||
assert!(
|
||||
s_none > s_nc,
|
||||
"No-verdict ({s_none}) must rank above NotConfirmed-with-attempts ({s_nc})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_unsupported_same_as_no_verdict() {
|
||||
let no_verdict = make_diag_with_verdict(None);
|
||||
let unsupported = make_diag_with_verdict(Some(unsupported_verdict()));
|
||||
|
||||
let s_none = compute_attack_rank(&no_verdict).score;
|
||||
let s_uns = compute_attack_rank(&unsupported).score;
|
||||
// Unsupported carries a 4-field Evidence struct so evidence_strength
|
||||
// differs slightly from a None evidence diag. What matters is that
|
||||
// the *delta component* is zero — both deltas must agree.
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&no_verdict),
|
||||
dynamic_verdict_delta(&unsupported),
|
||||
"Unsupported and no-verdict must both produce None delta"
|
||||
);
|
||||
// Same base inputs → scores differ only by evidence_strength bonus
|
||||
// from the Evidence wrapper. Verify no "dynamic_verdict" component
|
||||
// in rank_reason.
|
||||
let rank = compute_attack_rank(&unsupported);
|
||||
assert!(
|
||||
!rank.components.iter().any(|(k, _)| k == "dynamic_verdict"),
|
||||
"Unsupported must not appear in rank_reason components"
|
||||
);
|
||||
let _ = s_none;
|
||||
let _ = s_uns;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_inconclusive_same_delta_as_no_verdict() {
|
||||
let no_verdict = make_diag_with_verdict(None);
|
||||
let inconclusive = make_diag_with_verdict(Some(inconclusive_verdict()));
|
||||
|
||||
assert_eq!(
|
||||
dynamic_verdict_delta(&no_verdict),
|
||||
dynamic_verdict_delta(&inconclusive),
|
||||
"Inconclusive and no-verdict must both produce None delta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_confirmed_rank_reason_contains_component() {
|
||||
let d = make_diag_with_verdict(Some(confirmed_verdict()));
|
||||
let rank = compute_attack_rank(&d);
|
||||
assert!(
|
||||
rank.components.iter().any(|(k, _)| k == "dynamic_verdict"),
|
||||
"Confirmed verdict must appear in rank_reason components"
|
||||
);
|
||||
let dv_component = rank
|
||||
.components
|
||||
.iter()
|
||||
.find(|(k, _)| k == "dynamic_verdict")
|
||||
.unwrap();
|
||||
assert!(
|
||||
dv_component.1.starts_with('+'),
|
||||
"Confirmed delta must be positive in rank_reason: {:?}",
|
||||
dv_component.1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_verdict_not_confirmed_rank_reason_contains_negative_component() {
|
||||
let d = make_diag_with_verdict(Some(not_confirmed_with_attempts()));
|
||||
let rank = compute_attack_rank(&d);
|
||||
assert!(
|
||||
rank.components.iter().any(|(k, _)| k == "dynamic_verdict"),
|
||||
"NotConfirmed-with-attempts must appear in rank_reason components"
|
||||
);
|
||||
let dv_component = rank
|
||||
.components
|
||||
.iter()
|
||||
.find(|(k, _)| k == "dynamic_verdict")
|
||||
.unwrap();
|
||||
assert!(
|
||||
dv_component.1.starts_with('-'),
|
||||
"NotConfirmed delta must be negative in rank_reason: {:?}",
|
||||
dv_component.1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue