#![allow(clippy::collapsible_if)]
use crate::commands::scan::Diag;
use crate::patterns::Severity;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Confidence {
Low,
Medium,
High,
}
impl fmt::Display for Confidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => write!(f, "Low"),
Self::Medium => write!(f, "Medium"),
Self::High => write!(f, "High"),
}
}
}
impl FromStr for Confidence {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"low" => Ok(Self::Low),
"medium" | "med" => Ok(Self::Medium),
"high" => Ok(Self::High),
_ => Err(format!(
"unknown confidence level: {s:?} (expected low, medium, high)"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FlowStepKind {
Source,
Assignment,
Call,
Phi,
Sink,
}
impl fmt::Display for FlowStepKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Source => write!(f, "source"),
Self::Assignment => write!(f, "assignment"),
Self::Call => write!(f, "call"),
Self::Phi => write!(f, "phi"),
Self::Sink => write!(f, "sink"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowStep {
pub step: u32,
pub kind: FlowStepKind,
pub file: String,
pub line: u32,
pub col: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variable: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callee: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_cross_file: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Confirmed,
Infeasible,
Inconclusive,
NotAttempted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolicVerdict {
pub verdict: Verdict,
#[serde(default)]
pub constraints_checked: u32,
#[serde(default)]
pub paths_explored: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub witness: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interproc_call_chains: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cutoff_notes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Evidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SpanEvidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sink: Option<SpanEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guards: Vec<SpanEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sanitizers: Vec<SpanEvidence>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<StateEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_kind: Option<crate::labels::SourceKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hop_count: Option<u16>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub uses_summary: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cap_specificity: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flow_steps: Vec<FlowStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub confidence_limiters: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbolic: Option<SymbolicVerdict>,
#[serde(default, skip_serializing_if = "is_zero_u16")]
pub sink_caps: u16,
#[serde(default, skip_serializing_if = "smallvec::SmallVec::is_empty")]
pub engine_notes: smallvec::SmallVec<[crate::engine_notes::EngineNote; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_exfil_field: Option<String>,
}
fn is_zero_u16(v: &u16) -> bool {
*v == 0
}
impl Evidence {
pub fn is_empty(&self) -> bool {
self.source.is_none()
&& self.sink.is_none()
&& self.guards.is_empty()
&& self.sanitizers.is_empty()
&& self.state.is_none()
&& self.notes.is_empty()
&& self.source_kind.is_none()
&& self.hop_count.is_none()
&& !self.uses_summary
&& self.cap_specificity.is_none()
&& self.flow_steps.is_empty()
&& self.explanation.is_none()
&& self.confidence_limiters.is_empty()
&& self.symbolic.is_none()
&& self.sink_caps == 0
&& self.engine_notes.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpanEvidence {
pub path: String,
pub line: u32,
pub col: u32,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateEvidence {
pub machine: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
pub from_state: String,
pub to_state: String,
}
pub fn compute_confidence(diag: &Diag) -> Confidence {
if let Some(ev) = &diag.evidence
&& ev.notes.iter().any(|n| n.starts_with("degraded:"))
{
return Confidence::Low;
}
let id = &diag.id;
let base = if id.starts_with("taint-data-exfiltration") {
compute_data_exfil_confidence(diag)
} else if id.starts_with("taint-") {
compute_taint_confidence(diag)
} else if id.starts_with("state-") {
match id.as_str() {
"state-use-after-close" => Confidence::High,
"state-double-close" => Confidence::High,
"state-unauthed-access" => Confidence::High,
"state-resource-leak" => Confidence::Medium,
"state-resource-leak-possible" => Confidence::Low,
_ => Confidence::Medium,
}
} else if id.starts_with("cfg-") {
diag.confidence.unwrap_or(Confidence::Medium)
} else if diag.severity == Severity::High {
Confidence::Medium
} else {
Confidence::Low
};
apply_engine_notes_cap(diag, base)
}
fn apply_engine_notes_cap(diag: &Diag, base: Confidence) -> Confidence {
let Some(ev) = &diag.evidence else {
return base;
};
let Some(worst) = crate::engine_notes::worst_direction(&ev.engine_notes) else {
return base;
};
match worst {
crate::engine_notes::LossDirection::OverReport
| crate::engine_notes::LossDirection::Bail => base.min(Confidence::Medium),
crate::engine_notes::LossDirection::UnderReport => base,
crate::engine_notes::LossDirection::Informational => base,
}
}
fn compute_taint_confidence(diag: &Diag) -> Confidence {
let ev = match &diag.evidence {
Some(e) => e,
None => return Confidence::High, };
let mut score: i32 = 0;
score += match ev.source_kind {
Some(kind) => structured_source_kind_score(kind),
None => source_kind_score(&ev.notes),
};
let has_source = ev.source.is_some();
let has_sink = ev.sink.is_some();
let has_snippet = ev.source.as_ref().is_some_and(|s| s.snippet.is_some())
|| ev.sink.as_ref().is_some_and(|s| s.snippet.is_some());
score += if has_source && has_sink && has_snippet {
3
} else if has_source && has_sink {
2
} else {
1
};
score += match ev.hop_count {
Some(count) => match count {
0..=3 => 0,
4..=8 => -1,
_ => -2,
},
None => hop_count_score(&ev.notes),
};
if diag.path_validated {
score -= 3;
}
score += match ev.cap_specificity {
Some(count) => {
if count == 1 {
1
} else {
0
}
}
None => cap_specificity_score(&ev.notes),
};
if ev.uses_summary || ev.notes.iter().any(|n| n == "uses_summary") {
score -= 1;
}
if let Some(ref sv) = ev.symbolic {
match sv.verdict {
Verdict::Infeasible => score -= 5,
Verdict::Confirmed => {
if sv
.witness
.as_ref()
.is_some_and(|w| w.contains("flows to") || w.contains("reaches"))
{
score += 3;
} else {
score += 2;
}
}
Verdict::Inconclusive | Verdict::NotAttempted => {}
}
use crate::taint::backwards::{NOTE_BUDGET, NOTE_CONFIRMED, NOTE_INFEASIBLE};
if sv.cutoff_notes.iter().any(|n| n == NOTE_CONFIRMED) {
score += 1;
}
if sv.cutoff_notes.iter().any(|n| n == NOTE_INFEASIBLE) {
score -= 3;
}
let _ = NOTE_BUDGET;
}
match score {
5.. => Confidence::High,
2..=4 => Confidence::Medium,
_ => Confidence::Low,
}
}
fn compute_data_exfil_confidence(diag: &Diag) -> Confidence {
let ev = match &diag.evidence {
Some(e) => e,
None => return Confidence::Low,
};
let is_sensitive = ev
.source_kind
.map(|k| k.sensitivity() >= crate::labels::Sensitivity::Sensitive)
.unwrap_or(false);
if !is_sensitive {
return Confidence::Low;
}
let mut base = match ev.symbolic.as_ref().map(|s| s.verdict) {
Some(Verdict::Confirmed) => Confidence::Medium,
Some(Verdict::Infeasible) => Confidence::Low,
Some(Verdict::Inconclusive) | Some(Verdict::NotAttempted) | None => Confidence::Low,
};
if diag.path_validated && base > Confidence::Low {
base = Confidence::Low;
}
apply_engine_notes_cap(diag, base)
}
fn structured_source_kind_score(kind: crate::labels::SourceKind) -> i32 {
use crate::labels::SourceKind;
match kind {
SourceKind::UserInput | SourceKind::Cookie | SourceKind::Header => 3,
SourceKind::EnvironmentConfig => 2,
SourceKind::Unknown | SourceKind::FileSystem => 1,
SourceKind::Database | SourceKind::CaughtException => 0,
}
}
fn source_kind_score(notes: &[String]) -> i32 {
for note in notes {
if let Some(kind) = note.strip_prefix("source_kind:") {
return match kind {
"UserInput" => 3,
"EnvironmentConfig" => 2,
"Unknown" | "FileSystem" => 1,
_ => 0, };
}
}
1 }
fn hop_count_score(notes: &[String]) -> i32 {
for note in notes {
if let Some(count_str) = note.strip_prefix("hop_count:") {
if let Ok(count) = count_str.parse::<u16>() {
return match count {
0..=3 => 0,
4..=8 => -1,
_ => -2,
};
}
}
}
0 }
fn cap_specificity_score(notes: &[String]) -> i32 {
for note in notes {
if let Some(count_str) = note.strip_prefix("cap_specificity:") {
if let Ok(count) = count_str.parse::<u8>() {
return if count == 1 { 1 } else { 0 };
}
}
}
0
}
pub fn generate_explanation(diag: &Diag) -> Option<String> {
let ev = diag.evidence.as_ref()?;
let source = ev.source.as_ref()?;
let sink = ev.sink.as_ref()?;
let source_callee = source.snippet.as_deref().unwrap_or("(unknown source)");
let sink_callee = sink.snippet.as_deref().unwrap_or("(unknown sink)");
let source_kind_label = if let Some(kind) = ev.source_kind {
use crate::labels::SourceKind;
match kind {
SourceKind::UserInput => "user input",
SourceKind::Cookie => "cookie",
SourceKind::Header => "request header",
SourceKind::EnvironmentConfig => "environment/config",
SourceKind::Database => "database",
SourceKind::FileSystem => "file system",
SourceKind::CaughtException => "caught exception",
SourceKind::Unknown => "unclassified",
}
} else {
let kind_str = ev
.notes
.iter()
.find_map(|n| n.strip_prefix("source_kind:"))
.unwrap_or("unknown");
match kind_str {
"UserInput" => "user input",
"EnvironmentConfig" => "environment/config",
"Database" => "database",
"FileSystem" => "file system",
"CaughtException" => "caught exception",
_ => "unclassified",
}
};
let category = diag
.id
.strip_prefix("taint-unsanitised-flow")
.map(|_| extract_category_from_id(&diag.id))
.unwrap_or_else(|| "injection".to_string());
let step_count = ev.flow_steps.len();
let mut explanation = if step_count > 2 {
format!(
"Unsanitised {source_kind_label} data flows from {source_callee} (line {}) through {} steps to {sink_callee} (line {}), creating a potential {category} vulnerability.",
source.line,
step_count - 2, sink.line,
)
} else {
format!(
"Unsanitised {source_kind_label} data flows from {source_callee} (line {}) to {sink_callee} (line {}), creating a potential {category} vulnerability.",
source.line, sink.line,
)
};
if diag.path_validated {
if let Some(ref guard) = diag.guard_kind {
explanation.push_str(&format!(
" A {guard} guard was detected but may not be sufficient."
));
}
}
if ev.uses_summary || ev.notes.iter().any(|n| n == "uses_summary") {
explanation.push_str(" The flow crosses function boundaries via summary resolution.");
}
Some(explanation)
}
fn extract_category_from_id(id: &str) -> String {
if id.contains("sql") || id.contains("SQL") {
"SQL injection".to_string()
} else if id.contains("xss") || id.contains("XSS") {
"XSS".to_string()
} else {
"injection".to_string()
}
}
pub fn compute_confidence_limiters(diag: &Diag) -> Vec<String> {
let mut limiters = Vec::new();
let ev = match &diag.evidence {
Some(e) => e,
None => return limiters,
};
let hop = ev.hop_count.or_else(|| {
ev.notes
.iter()
.find_map(|n| n.strip_prefix("hop_count:")?.parse::<u16>().ok())
});
if let Some(count) = hop {
if count >= 4 {
limiters.push(format!(
"Taint path spans {count} blocks, increasing chance of intermediate sanitization"
));
}
}
if ev.uses_summary || ev.notes.iter().any(|n| n == "uses_summary") {
limiters.push("Flow resolved via cross-function summary (may be imprecise)".into());
}
if diag.path_validated {
limiters.push("Validation guard detected on path (may provide protection)".into());
}
let cap_spec = ev.cap_specificity.or_else(|| {
ev.notes
.iter()
.find_map(|n| n.strip_prefix("cap_specificity:")?.parse::<u8>().ok())
});
if cap_spec == Some(0) {
limiters.push("Source and sink capability types do not match specifically".into());
}
let is_unknown = ev.source_kind == Some(crate::labels::SourceKind::Unknown)
|| ev.notes.iter().any(|n| n == "source_kind:Unknown");
if is_unknown {
limiters.push("Source type is unclassified (lower exploitation confidence)".into());
}
if let Some(ref sv) = ev.symbolic {
if sv.verdict == Verdict::Infeasible {
limiters.push("Symbolic analysis proved this path is infeasible".into());
}
}
if let Some(ref sv) = ev.symbolic {
use crate::taint::backwards::{NOTE_BUDGET, NOTE_CONFIRMED, NOTE_INFEASIBLE};
if sv.cutoff_notes.iter().any(|n| n == NOTE_INFEASIBLE) {
limiters.push("Backwards demand-driven analysis proved this flow infeasible".into());
} else if sv.cutoff_notes.iter().any(|n| n == NOTE_BUDGET) {
limiters.push(
"Backwards demand-driven analysis exceeded its budget (verdict not reached)".into(),
);
}
let _ = NOTE_CONFIRMED;
}
limiters
}
#[cfg(test)]
mod tests {
use super::*;
use crate::labels::SourceKind;
fn make_diag(id: &str, severity: Severity) -> Diag {
Diag {
path: "test.rs".into(),
line: 1,
col: 1,
severity,
id: id.into(),
category: crate::patterns::FindingCategory::Security,
path_validated: false,
guard_kind: None,
message: None,
labels: vec![],
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 compute_confidence_taint_strong_path() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("env::var(\"X\")".into()),
}),
sink: Some(SpanEvidence {
path: "test.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(),
"hop_count:1".into(),
"cap_specificity:1".into(),
],
source_kind: Some(crate::labels::SourceKind::UserInput),
hop_count: Some(1),
cap_specificity: Some(1),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn compute_confidence_taint_medium_path() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: None,
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: None,
}),
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec!["source_kind:EnvironmentConfig".into(), "hop_count:5".into()],
source_kind: Some(crate::labels::SourceKind::EnvironmentConfig),
hop_count: Some(5),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn compute_confidence_taint_weak_path() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: None,
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 20,
col: 5,
kind: "sink".into(),
snippet: None,
}),
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec![
"source_kind:Database".into(),
"hop_count:12".into(),
"uses_summary".into(),
],
source_kind: Some(crate::labels::SourceKind::Database),
hop_count: Some(12),
uses_summary: true,
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Low);
}
#[test]
fn compute_confidence_taint_validated_with_source() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.path_validated = true;
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("req.query".into()),
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: Some("exec()".into()),
}),
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec!["path_validated".into(), "source_kind:UserInput".into()],
source_kind: Some(crate::labels::SourceKind::UserInput),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn compute_confidence_taint_no_evidence() {
let d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn compute_confidence_degraded_caps_to_low() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: None,
sink: None,
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec!["degraded:budget_exceeded".into()],
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Low);
}
#[test]
fn compute_confidence_state_rules() {
assert_eq!(
compute_confidence(&make_diag("state-use-after-close", Severity::High)),
Confidence::High,
);
assert_eq!(
compute_confidence(&make_diag("state-double-close", Severity::Medium)),
Confidence::High,
);
assert_eq!(
compute_confidence(&make_diag("state-unauthed-access", Severity::High)),
Confidence::High,
);
assert_eq!(
compute_confidence(&make_diag("state-resource-leak", Severity::Medium)),
Confidence::Medium,
);
assert_eq!(
compute_confidence(&make_diag("state-resource-leak-possible", Severity::Low)),
Confidence::Low,
);
}
#[test]
fn compute_confidence_cfg_preserves_existing() {
let mut d = make_diag("cfg-unguarded-sink", Severity::High);
d.confidence = Some(Confidence::Low);
assert_eq!(compute_confidence(&d), Confidence::Low);
}
#[test]
fn compute_confidence_ast_low() {
let d = make_diag("rs.code_exec.eval", Severity::Medium);
assert_eq!(compute_confidence(&d), Confidence::Low);
}
#[test]
fn compute_confidence_ast_high_severity_medium() {
let d = make_diag("rs.code_exec.eval", Severity::High);
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
fn taint_high_confidence_diag() -> Diag {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("req.query.id".into()),
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 5,
col: 1,
kind: "sink".into(),
snippet: Some("exec(id)".into()),
}),
source_kind: Some(SourceKind::UserInput),
cap_specificity: Some(1),
hop_count: Some(1),
..Default::default()
});
d
}
fn with_notes(mut d: Diag, notes: Vec<crate::engine_notes::EngineNote>) -> Diag {
let mut ev = d.evidence.clone().unwrap_or_default();
ev.engine_notes = smallvec::SmallVec::from_vec(notes);
d.evidence = Some(ev);
d
}
#[test]
fn confidence_uncapped_without_engine_notes() {
assert_eq!(
compute_confidence(&taint_high_confidence_diag()),
Confidence::High,
"baseline must be High so cap tests have something to cap"
);
}
#[test]
fn confidence_not_capped_by_under_report() {
let d = with_notes(
taint_high_confidence_diag(),
vec![crate::engine_notes::EngineNote::WorklistCapped { iterations: 100 }],
);
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn confidence_capped_at_medium_by_over_report() {
let d = with_notes(
taint_high_confidence_diag(),
vec![crate::engine_notes::EngineNote::PredicateStateWidened],
);
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn confidence_capped_at_medium_by_bail() {
let d = with_notes(
taint_high_confidence_diag(),
vec![crate::engine_notes::EngineNote::ParseTimeout { timeout_ms: 1000 }],
);
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn confidence_cap_does_not_upgrade_low() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::Low);
d.evidence = Some(Evidence {
source: None,
sink: None,
source_kind: Some(SourceKind::Database),
hop_count: Some(10),
..Default::default()
});
d = with_notes(
d,
vec![crate::engine_notes::EngineNote::ParseTimeout { timeout_ms: 100 }],
);
assert_eq!(
compute_confidence(&d),
Confidence::Low,
"Bail cap must never raise Low → Medium"
);
}
#[test]
fn confidence_not_capped_by_informational() {
let d = with_notes(
taint_high_confidence_diag(),
vec![crate::engine_notes::EngineNote::InlineCacheReused],
);
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn confidence_cap_applies_to_state_findings_too() {
let d = with_notes(
make_diag("state-use-after-close", Severity::High),
vec![crate::engine_notes::EngineNote::PredicateStateWidened],
);
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn confidence_cap_chooses_worst_when_mixed() {
let d = with_notes(
taint_high_confidence_diag(),
vec![
crate::engine_notes::EngineNote::WorklistCapped { iterations: 10 },
crate::engine_notes::EngineNote::PredicateStateWidened,
],
);
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn evidence_is_empty() {
let ev = Evidence::default();
assert!(ev.is_empty());
let ev2 = Evidence {
source: Some(SpanEvidence {
path: "x.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: None,
}),
..Default::default()
};
assert!(!ev2.is_empty());
}
#[test]
fn confidence_ord() {
assert!(Confidence::Low < Confidence::Medium);
assert!(Confidence::Medium < Confidence::High);
assert!(Confidence::Low < Confidence::High);
}
#[test]
fn confidence_display_and_parse() {
assert_eq!(Confidence::Low.to_string(), "Low");
assert_eq!(Confidence::Medium.to_string(), "Medium");
assert_eq!(Confidence::High.to_string(), "High");
assert_eq!("low".parse::<Confidence>().unwrap(), Confidence::Low);
assert_eq!("MEDIUM".parse::<Confidence>().unwrap(), Confidence::Medium);
assert_eq!("High".parse::<Confidence>().unwrap(), Confidence::High);
assert!("invalid".parse::<Confidence>().is_err());
}
#[test]
fn compute_confidence_does_not_override_preset() {
let mut d = make_diag("rs.quality.expect", Severity::Low);
d.confidence = Some(Confidence::High);
assert_eq!(compute_confidence(&d), Confidence::Low);
assert_eq!(d.confidence, Some(Confidence::High));
}
#[test]
fn json_omits_none_fields() {
let ev = Evidence::default();
let json = serde_json::to_string(&ev).unwrap();
assert_eq!(json, "{}");
}
#[test]
fn symbolic_verdict_serde_round_trip() {
for verdict in [
Verdict::Confirmed,
Verdict::Infeasible,
Verdict::Inconclusive,
Verdict::NotAttempted,
] {
let sv = SymbolicVerdict {
verdict,
constraints_checked: 42,
paths_explored: 7,
witness: Some("x=null forces false branch".into()),
interproc_call_chains: Vec::new(),
cutoff_notes: Vec::new(),
};
let json = serde_json::to_string(&sv).unwrap();
let rt: SymbolicVerdict = serde_json::from_str(&json).unwrap();
assert_eq!(rt.verdict, verdict);
assert_eq!(rt.constraints_checked, 42);
assert_eq!(rt.paths_explored, 7);
assert_eq!(rt.witness.as_deref(), Some("x=null forces false branch"));
}
let json = serde_json::to_string(&Verdict::NotAttempted).unwrap();
assert_eq!(json, "\"not_attempted\"");
}
#[test]
fn evidence_with_symbolic_not_empty() {
let ev = Evidence {
symbolic: Some(SymbolicVerdict {
verdict: Verdict::Confirmed,
constraints_checked: 1,
paths_explored: 1,
witness: None,
interproc_call_chains: Vec::new(),
cutoff_notes: Vec::new(),
}),
..Default::default()
};
assert!(!ev.is_empty());
}
#[test]
fn symbolic_witness_omitted_when_none() {
let sv = SymbolicVerdict {
verdict: Verdict::Inconclusive,
constraints_checked: 0,
paths_explored: 0,
witness: None,
interproc_call_chains: Vec::new(),
cutoff_notes: Vec::new(),
};
let json = serde_json::to_string(&sv).unwrap();
assert!(!json.contains("witness"));
}
#[test]
fn compute_confidence_structured_fields_only() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("req.query".into()),
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: Some("exec()".into()),
}),
source_kind: Some(crate::labels::SourceKind::UserInput),
hop_count: Some(1),
cap_specificity: Some(1),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn compute_confidence_notes_only_backward_compat() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: None,
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: None,
}),
notes: vec!["source_kind:EnvironmentConfig".into(), "hop_count:5".into()],
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Medium);
}
#[test]
fn compute_confidence_symbolic_infeasible_demotes() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: Some("req.query".into()),
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: Some("exec()".into()),
}),
source_kind: Some(crate::labels::SourceKind::UserInput),
symbolic: Some(SymbolicVerdict {
verdict: Verdict::Infeasible,
constraints_checked: 3,
paths_explored: 1,
witness: None,
interproc_call_chains: Vec::new(),
cutoff_notes: Vec::new(),
}),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::Low);
}
#[test]
fn compute_confidence_symbolic_confirmed_boosts() {
let mut d = make_diag("taint-unsanitised-flow (source 1:1)", Severity::High);
d.evidence = Some(Evidence {
source: Some(SpanEvidence {
path: "test.rs".into(),
line: 1,
col: 1,
kind: "source".into(),
snippet: None,
}),
sink: Some(SpanEvidence {
path: "test.rs".into(),
line: 10,
col: 5,
kind: "sink".into(),
snippet: None,
}),
source_kind: Some(crate::labels::SourceKind::EnvironmentConfig),
symbolic: Some(SymbolicVerdict {
verdict: Verdict::Confirmed,
constraints_checked: 2,
paths_explored: 1,
witness: None,
interproc_call_chains: Vec::new(),
cutoff_notes: Vec::new(),
}),
..Default::default()
});
assert_eq!(compute_confidence(&d), Confidence::High);
}
#[test]
fn evidence_with_structured_fields_not_empty() {
let ev = Evidence {
source_kind: Some(crate::labels::SourceKind::UserInput),
..Default::default()
};
assert!(!ev.is_empty());
let ev2 = Evidence {
uses_summary: true,
..Default::default()
};
assert!(!ev2.is_empty());
}
#[test]
fn source_kind_serde_round_trip() {
use crate::labels::SourceKind;
for kind in [
SourceKind::UserInput,
SourceKind::EnvironmentConfig,
SourceKind::FileSystem,
SourceKind::Database,
SourceKind::CaughtException,
SourceKind::Unknown,
] {
let json = serde_json::to_string(&kind).unwrap();
let rt: SourceKind = serde_json::from_str(&json).unwrap();
assert_eq!(rt, kind);
}
let json = serde_json::to_string(&crate::labels::SourceKind::UserInput).unwrap();
assert_eq!(json, "\"user_input\"");
}
}