use crate::commands::scan::Diag;
use crate::engine_notes::{LossDirection, worst_direction};
use crate::evidence::{Confidence, Evidence};
use crate::patterns::Severity;
use std::hash::{DefaultHasher, Hash, Hasher};
#[derive(Debug, Clone)]
pub struct AttackRank {
pub score: f64,
pub components: Vec<(String, String)>,
}
pub fn compute_attack_rank(diag: &Diag) -> AttackRank {
let mut score = 0.0_f64;
let mut components: Vec<(String, String)> = Vec::new();
let sev_score = match diag.severity {
Severity::High => 60.0,
Severity::Medium => 30.0,
Severity::Low => 10.0,
};
score += sev_score;
components.push(("severity".into(), format!("{sev_score}")));
let kind_bonus = analysis_kind_bonus(&diag.id, diag.evidence.as_ref());
score += kind_bonus;
if kind_bonus != 0.0 {
components.push(("analysis_kind".into(), format!("{kind_bonus}")));
}
let evidence_bonus = evidence_strength(diag);
score += evidence_bonus;
if evidence_bonus != 0.0 {
components.push(("evidence".into(), format!("{evidence_bonus}")));
}
let state_bonus = state_finding_bonus(&diag.id);
score += state_bonus;
if state_bonus != 0.0 {
components.push(("state_rule".into(), format!("{state_bonus}")));
}
let path_validated = diag.evidence.as_ref().map_or(diag.path_validated, |ev| {
ev.notes.iter().any(|n| n == "path_validated")
});
if path_validated {
score -= 5.0;
components.push(("path_validated_penalty".into(), "-5".into()));
}
if let Some(conf) = diag.confidence {
let conf_adj = match conf {
Confidence::High => 3.0,
Confidence::Medium => 0.0,
Confidence::Low => -5.0,
};
score += conf_adj;
if conf_adj != 0.0 {
components.push(("confidence".into(), format!("{conf_adj}")));
}
}
if let Some(penalty) = completeness_penalty(diag) {
score += penalty.value;
components.push((
"completeness".into(),
format!("{:+} ({})", penalty.value as i32, penalty.direction.tag()),
));
}
AttackRank { score, components }
}
struct CompletenessPenalty {
value: f64,
direction: LossDirection,
}
fn completeness_penalty(diag: &Diag) -> Option<CompletenessPenalty> {
let ev = diag.evidence.as_ref()?;
if ev.engine_notes.is_empty() {
return None;
}
let direction = worst_direction(&ev.engine_notes)?;
let value = match direction {
LossDirection::Bail | LossDirection::OverReport => -8.0,
LossDirection::UnderReport => -3.0,
LossDirection::Informational => return None,
};
Some(CompletenessPenalty { value, direction })
}
pub fn sort_key(diag: &Diag) -> impl Ord {
let sev_ord: u8 = match diag.severity {
Severity::High => 0,
Severity::Medium => 1,
Severity::Low => 2,
};
let msg_hash = {
let mut h = DefaultHasher::new();
diag.message.hash(&mut h);
h.finish()
};
(
sev_ord,
diag.id.clone(),
diag.path.clone(),
diag.line,
diag.col,
msg_hash,
)
}
pub fn rank_diags(diags: &mut [Diag]) {
let ranks: Vec<AttackRank> = diags.iter().map(compute_attack_rank).collect();
for (d, rank) in diags.iter_mut().zip(ranks.iter()) {
d.rank_score = Some(rank.score);
if !rank.components.is_empty() {
d.rank_reason = Some(rank.components.clone());
}
}
diags.sort_by(|a, b| {
let sa = a.rank_score.unwrap_or(0.0);
let sb = b.rank_score.unwrap_or(0.0);
sb.partial_cmp(&sa)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| sort_key(a).cmp(&sort_key(b)))
});
}
fn analysis_kind_bonus(rule_id: &str, evidence: Option<&Evidence>) -> f64 {
if rule_id.starts_with("taint-data-exfiltration") {
7.0
} else if rule_id.starts_with("taint-") {
10.0
} else if rule_id.starts_with("state-") {
8.0
} else if rule_id.starts_with("cfg-") {
if evidence.is_some_and(|e| !e.is_empty()) {
5.0
} else {
3.0
}
} else {
0.0
}
}
fn evidence_strength(diag: &Diag) -> f64 {
let mut bonus = 0.0;
if let Some(ev) = &diag.evidence {
let item_count = ev.source.is_some() as usize
+ ev.sink.is_some() as usize
+ (ev.guards.len() + ev.sanitizers.len()).min(2);
bonus += item_count.min(4) as f64;
for note in &ev.notes {
if let Some(kind) = note.strip_prefix("source_kind:") {
bonus += source_kind_priority(kind);
break;
}
}
} else {
bonus += (diag.labels.len() as f64).min(4.0);
for (label, value) in &diag.labels {
if label == "Source" {
bonus += source_kind_priority(value);
}
}
}
bonus
}
fn source_kind_priority(source_value: &str) -> f64 {
match source_value {
"UserInput" => return 6.0,
"EnvironmentConfig" => return 5.0,
"FileSystem" => return 3.0,
"Database" => return 2.0,
"CaughtException" => return 2.0,
"Unknown" => return 4.0,
_ => {}
}
let lower = source_value.to_ascii_lowercase();
if lower.contains("stdin")
|| lower.contains("argv")
|| lower.contains("request")
|| lower.contains("form")
|| lower.contains("query")
|| lower.contains("param")
|| lower.contains("header")
|| lower.contains("body")
|| lower.contains("read_line")
{
6.0
} else if lower.contains("env") || lower.contains("var(") || lower.contains("getenv") {
5.0
} else if lower.contains("read") || lower.contains("file") || lower.contains("open") {
3.0
} else if lower.contains("query") || lower.contains("fetch") || lower.contains("select") {
2.0
} else {
4.0
}
}
fn state_finding_bonus(rule_id: &str) -> f64 {
match rule_id {
"state-use-after-close" => 6.0,
"state-unauthed-access" => 6.0,
"state-double-close" => 3.0,
"state-resource-leak" => 2.0, "state-resource-leak-possible" => 1.0, _ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_diag(
severity: Severity,
id: &str,
path: &str,
line: usize,
labels: Vec<(String, String)>,
path_validated: bool,
) -> Diag {
Diag {
path: path.into(),
line,
col: 1,
severity,
id: id.into(),
category: crate::patterns::FindingCategory::Security,
path_validated,
guard_kind: None,
message: None,
labels,
confidence: None,
evidence: None,
rank_score: None,
rank_reason: None,
suppressed: false,
suppression: None,
rollup: None,
finding_id: String::new(),
alternative_finding_ids: Vec::new(),
}
}
#[test]
fn high_taint_user_input_ranks_above_medium_file_io() {
let high_taint = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![
("Source".into(), "read_line() at 1:1".into()),
("Sink".into(), "exec()".into()),
],
false,
);
let med_file = make_diag(
Severity::Medium,
"taint-unsanitised-flow (source 5:1)",
"src/lib.rs",
20,
vec![
("Source".into(), "File::open() at 5:1".into()),
("Sink".into(), "write()".into()),
],
false,
);
let score_high = compute_attack_rank(&high_taint).score;
let score_med = compute_attack_rank(&med_file).score;
assert!(
score_high > score_med,
"high taint user-input ({score_high}) should rank above medium file-io ({score_med})"
);
}
#[test]
fn must_leak_ranks_above_may_leak() {
let must = make_diag(
Severity::Medium,
"state-resource-leak",
"src/db.rs",
30,
vec![],
false,
);
let may = make_diag(
Severity::Low,
"state-resource-leak-possible",
"src/db.rs",
35,
vec![],
false,
);
let score_must = compute_attack_rank(&must).score;
let score_may = compute_attack_rank(&may).score;
assert!(
score_must > score_may,
"must-leak ({score_must}) should rank above may-leak ({score_may})"
);
}
#[test]
fn cfg_without_evidence_ranks_below_taint_confirmed() {
let taint = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![
("Source".into(), "env::var(\"CMD\") at 1:1".into()),
("Sink".into(), "exec()".into()),
],
false,
);
let cfg_only = make_diag(
Severity::High,
"cfg-unguarded-sink",
"src/main.rs",
10,
vec![],
false,
);
let score_taint = compute_attack_rank(&taint).score;
let score_cfg = compute_attack_rank(&cfg_only).score;
assert!(
score_taint > score_cfg,
"taint-confirmed ({score_taint}) should rank above cfg-only ({score_cfg})"
);
}
#[test]
fn determinism_input_order_independent() {
let d1 = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"a.rs",
1,
vec![("Source".into(), "stdin at 1:1".into())],
false,
);
let d2 = make_diag(
Severity::Medium,
"cfg-unguarded-sink",
"b.rs",
2,
vec![],
false,
);
let d3 = make_diag(Severity::Low, "rs.code_exec.eval", "c.rs", 3, vec![], false);
let mut order_a = vec![d1.clone(), d2.clone(), d3.clone()];
let mut order_b = vec![d3, d1, d2];
rank_diags(&mut order_a);
rank_diags(&mut order_b);
let ids_a: Vec<_> = order_a.iter().map(|d| (&d.id, d.line)).collect();
let ids_b: Vec<_> = order_b.iter().map(|d| (&d.id, d.line)).collect();
assert_eq!(
ids_a, ids_b,
"ranking must be deterministic regardless of input order"
);
}
#[test]
fn path_validated_penalty_applied() {
let unvalidated = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![("Source".into(), "env::var(\"X\") at 1:1".into())],
false,
);
let validated = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![("Source".into(), "env::var(\"X\") at 1:1".into())],
true,
);
let score_unval = compute_attack_rank(&unvalidated).score;
let score_val = compute_attack_rank(&validated).score;
assert!(
score_unval > score_val,
"unvalidated ({score_unval}) should rank above validated ({score_val})"
);
}
#[test]
fn state_use_after_close_ranks_above_may_leak() {
let uac = make_diag(
Severity::High,
"state-use-after-close",
"x.rs",
1,
vec![],
false,
);
let may = make_diag(
Severity::Low,
"state-resource-leak-possible",
"x.rs",
2,
vec![],
false,
);
let score_uac = compute_attack_rank(&uac).score;
let score_may = compute_attack_rank(&may).score;
assert!(score_uac > score_may);
}
#[test]
fn unauthed_access_ranks_above_resource_leak() {
let unauth = make_diag(
Severity::High,
"state-unauthed-access",
"x.rs",
1,
vec![],
false,
);
let leak = make_diag(
Severity::Medium,
"state-resource-leak",
"x.rs",
2,
vec![],
false,
);
let score_ua = compute_attack_rank(&unauth).score;
let score_lk = compute_attack_rank(&leak).score;
assert!(score_ua > score_lk);
}
#[test]
fn ast_only_ranks_below_all_others_at_same_severity() {
let ast = make_diag(
Severity::High,
"rs.code_exec.eval",
"x.rs",
1,
vec![],
false,
);
let cfg = make_diag(
Severity::High,
"cfg-unguarded-sink",
"x.rs",
2,
vec![],
false,
);
let taint = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"x.rs",
3,
vec![("Source".into(), "env::var(\"X\") at 1:1".into())],
false,
);
let state = make_diag(
Severity::High,
"state-use-after-close",
"x.rs",
4,
vec![],
false,
);
let s_ast = compute_attack_rank(&ast).score;
let s_cfg = compute_attack_rank(&cfg).score;
let s_taint = compute_attack_rank(&taint).score;
let s_state = compute_attack_rank(&state).score;
assert!(s_ast < s_cfg, "AST ({s_ast}) < CFG ({s_cfg})");
assert!(s_ast < s_taint, "AST ({s_ast}) < taint ({s_taint})");
assert!(s_ast < s_state, "AST ({s_ast}) < state ({s_state})");
}
#[test]
fn structured_evidence_source_kind_matches_legacy() {
let mut structured = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![],
false,
);
structured.evidence = Some(crate::evidence::Evidence {
source: Some(crate::evidence::SpanEvidence {
path: "src/main.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("read_line()".into()),
}),
sink: Some(crate::evidence::SpanEvidence {
path: "src/main.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: Some("exec()".into()),
}),
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec!["source_kind:UserInput".into()],
..Default::default()
});
let legacy = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![
("Source".into(), "read_line() at 1:1".into()),
("Sink".into(), "exec()".into()),
],
false,
);
let score_structured = compute_attack_rank(&structured).score;
let score_legacy = compute_attack_rank(&legacy).score;
assert_eq!(
score_structured, score_legacy,
"structured ({score_structured}) should equal legacy ({score_legacy})"
);
}
#[test]
fn evidence_item_count_capped_at_4() {
let mut d = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![],
false,
);
let span = || crate::evidence::SpanEvidence {
path: "x.rs".into(),
line: 1,
col: 1,
kind: "guard".into(),
snippet: None,
};
d.evidence = Some(crate::evidence::Evidence {
source: Some(span()),
sink: Some(span()),
guards: vec![span(), span(), span()], sanitizers: vec![span()], state: None,
notes: vec![],
..Default::default()
});
let score = evidence_strength(&d);
assert!(
(score - 4.0).abs() < f64::EPSILON,
"evidence item count should be capped at 4, got {score}"
);
}
#[test]
fn path_validated_from_evidence_notes() {
let mut d = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"src/main.rs",
10,
vec![],
false, );
d.evidence = Some(crate::evidence::Evidence {
source: None,
sink: None,
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec!["path_validated".into()],
..Default::default()
});
let rank = compute_attack_rank(&d);
assert!(
rank.components
.iter()
.any(|(k, _)| k == "path_validated_penalty"),
"path_validated note in evidence should trigger penalty"
);
}
#[test]
fn confidence_high_boosts_score() {
let d_none = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"x.rs",
1,
vec![("Source".into(), "stdin at 1:1".into())],
false,
);
let mut d_high = d_none.clone();
d_high.confidence = Some(crate::evidence::Confidence::High);
let score_none = compute_attack_rank(&d_none).score;
let score_high = compute_attack_rank(&d_high).score;
assert!(
score_high > score_none,
"High confidence ({score_high}) should score above None ({score_none})"
);
}
#[test]
fn confidence_low_demotes_score() {
let d_none = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"x.rs",
1,
vec![("Source".into(), "stdin at 1:1".into())],
false,
);
let mut d_low = d_none.clone();
d_low.confidence = Some(crate::evidence::Confidence::Low);
let score_none = compute_attack_rank(&d_none).score;
let score_low = compute_attack_rank(&d_low).score;
assert!(
score_low < score_none,
"Low confidence ({score_low}) should score below None ({score_none})"
);
}
#[test]
fn confidence_does_not_override_severity_tier() {
let mut high_sev_low_conf = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"x.rs",
1,
vec![("Source".into(), "stdin at 1:1".into())],
false,
);
high_sev_low_conf.confidence = Some(crate::evidence::Confidence::Low);
let mut med_sev_high_conf = make_diag(
Severity::Medium,
"taint-unsanitised-flow (source 2:1)",
"x.rs",
2,
vec![("Source".into(), "stdin at 2:1".into())],
false,
);
med_sev_high_conf.confidence = Some(crate::evidence::Confidence::High);
let score_high_sev = compute_attack_rank(&high_sev_low_conf).score;
let score_med_sev = compute_attack_rank(&med_sev_high_conf).score;
assert!(
score_high_sev > score_med_sev,
"High-sev/Low-conf ({score_high_sev}) should still beat Med-sev/High-conf ({score_med_sev})"
);
}
#[test]
fn rank_reason_populated() {
let d1 = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"a.rs",
1,
vec![],
false,
);
let d2 = make_diag(
Severity::Medium,
"cfg-unguarded-sink",
"b.rs",
2,
vec![],
false,
);
let mut diags = vec![d1, d2];
rank_diags(&mut diags);
for d in &diags {
assert!(
d.rank_reason.is_some(),
"rank_reason should be populated after rank_diags()"
);
assert!(
!d.rank_reason.as_ref().unwrap().is_empty(),
"rank_reason should not be empty"
);
}
}
use crate::engine_notes::EngineNote;
fn clean_diag_with_evidence() -> Diag {
let mut d = make_diag(
Severity::High,
"taint-unsanitised-flow (source 1:1)",
"x.rs",
1,
vec![("Source".into(), "stdin at 1:1".into())],
false,
);
d.evidence = Some(crate::evidence::Evidence {
notes: vec!["source_kind:UserInput".into()],
..Default::default()
});
d
}
fn attach_notes(d: &mut Diag, notes: Vec<EngineNote>) {
let mut ev = d.evidence.clone().unwrap_or_default();
ev.engine_notes = smallvec::SmallVec::from_vec(notes);
d.evidence = Some(ev);
}
#[test]
fn completeness_penalty_absent_when_no_engine_notes() {
let d = clean_diag_with_evidence();
let rank = compute_attack_rank(&d);
assert!(
!rank.components.iter().any(|(k, _)| k == "completeness"),
"completeness should be absent without engine_notes"
);
}
#[test]
fn completeness_penalty_absent_for_informational_only() {
let mut d = clean_diag_with_evidence();
attach_notes(&mut d, vec![EngineNote::InlineCacheReused]);
let rank = compute_attack_rank(&d);
assert!(
!rank.components.iter().any(|(k, _)| k == "completeness"),
"informational-only notes must not trigger a penalty"
);
}
#[test]
fn completeness_penalty_under_report_applies_minus_3() {
let d_clean = clean_diag_with_evidence();
let mut d_under = d_clean.clone();
attach_notes(
&mut d_under,
vec![EngineNote::WorklistCapped { iterations: 100 }],
);
let s_clean = compute_attack_rank(&d_clean).score;
let s_under = compute_attack_rank(&d_under).score;
assert!(
(s_clean - s_under - 3.0).abs() < f64::EPSILON,
"UnderReport must apply -3.0 penalty (clean={s_clean} under={s_under})"
);
}
#[test]
fn completeness_penalty_over_report_applies_minus_8() {
let d_clean = clean_diag_with_evidence();
let mut d_over = d_clean.clone();
attach_notes(&mut d_over, vec![EngineNote::PredicateStateWidened]);
let s_clean = compute_attack_rank(&d_clean).score;
let s_over = compute_attack_rank(&d_over).score;
assert!(
(s_clean - s_over - 8.0).abs() < f64::EPSILON,
"OverReport must apply -8.0 penalty (clean={s_clean} over={s_over})"
);
}
#[test]
fn completeness_penalty_bail_applies_minus_8() {
let d_clean = clean_diag_with_evidence();
let mut d_bail = d_clean.clone();
attach_notes(
&mut d_bail,
vec![EngineNote::ParseTimeout { timeout_ms: 100 }],
);
let s_clean = compute_attack_rank(&d_clean).score;
let s_bail = compute_attack_rank(&d_bail).score;
assert!(
(s_clean - s_bail - 8.0).abs() < f64::EPSILON,
"Bail must apply -8.0 penalty (clean={s_clean} bail={s_bail})"
);
}
#[test]
fn completeness_penalty_is_not_additive_across_notes() {
let mut d_many = clean_diag_with_evidence();
let many = (0..10)
.map(|i| EngineNote::OriginsTruncated { dropped: i })
.collect();
attach_notes(&mut d_many, many);
let mut d_one = clean_diag_with_evidence();
attach_notes(
&mut d_one,
vec![EngineNote::OriginsTruncated { dropped: 1 }],
);
let s_many = compute_attack_rank(&d_many).score;
let s_one = compute_attack_rank(&d_one).score;
assert!(
(s_many - s_one).abs() < f64::EPSILON,
"penalty must be direction-based, not additive (many={s_many} one={s_one})"
);
}
#[test]
fn completeness_penalty_picks_worst_when_mixed() {
let mut d_mixed = clean_diag_with_evidence();
attach_notes(
&mut d_mixed,
vec![
EngineNote::WorklistCapped { iterations: 10 },
EngineNote::PredicateStateWidened,
],
);
let d_clean = clean_diag_with_evidence();
let s_clean = compute_attack_rank(&d_clean).score;
let s_mixed = compute_attack_rank(&d_mixed).score;
assert!(
(s_clean - s_mixed - 8.0).abs() < f64::EPSILON,
"mixed UnderReport+OverReport must apply OverReport magnitude"
);
let rank = compute_attack_rank(&d_mixed);
let completeness = rank
.components
.iter()
.find(|(k, _)| k == "completeness")
.expect("completeness component must be present");
assert!(
completeness.1.contains("over-report"),
"mixed notes must tag worst direction (got {:?})",
completeness.1
);
}
#[test]
fn completeness_penalty_preserves_severity_tier() {
let mut high_capped = clean_diag_with_evidence();
attach_notes(
&mut high_capped,
vec![EngineNote::ParseTimeout { timeout_ms: 100 }],
);
let mut medium_clean = clean_diag_with_evidence();
medium_clean.severity = Severity::Medium;
medium_clean.id = "taint-unsanitised-flow (source 2:1)".into();
let s_high_capped = compute_attack_rank(&high_capped).score;
let s_medium_clean = compute_attack_rank(&medium_clean).score;
assert!(
s_high_capped > s_medium_clean,
"High+Bail ({s_high_capped}) must outrank Medium+clean ({s_medium_clean})"
);
}
#[test]
fn completeness_penalty_orders_bail_at_or_below_under_report() {
let mut d_under = clean_diag_with_evidence();
attach_notes(
&mut d_under,
vec![EngineNote::WorklistCapped { iterations: 10 }],
);
let mut d_bail = clean_diag_with_evidence();
attach_notes(
&mut d_bail,
vec![EngineNote::ParseTimeout { timeout_ms: 100 }],
);
let s_under = compute_attack_rank(&d_under).score;
let s_bail = compute_attack_rank(&d_bail).score;
assert!(
s_bail <= s_under,
"Bail ({s_bail}) must rank at or below UnderReport ({s_under})"
);
}
}