use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub fn deterministic_finding_id(detector: &str, file: &str, line: u32, _title: &str) -> String {
crate::detectors::base::finding_id(detector, file, line)
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
Default,
clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
#[default]
Info,
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Info => write!(f, "info"),
Severity::Low => write!(f, "low"),
Severity::Medium => write!(f, "medium"),
Severity::High => write!(f, "high"),
Severity::Critical => write!(f, "critical"),
}
}
}
impl std::str::FromStr for Severity {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"critical" => Ok(Severity::Critical),
"high" => Ok(Severity::High),
"medium" => Ok(Severity::Medium),
"low" => Ok(Severity::Low),
"info" => Ok(Severity::Info),
_ => Err(anyhow::anyhow!(
"Unknown severity '{}'. Valid: critical, high, medium, low, info",
s
)),
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Default,
Serialize,
Deserialize,
clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Deep,
#[default]
Advisory,
Blocking,
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Tier::Deep => write!(f, "deep"),
Tier::Advisory => write!(f, "advisory"),
Tier::Blocking => write!(f, "blocking"),
}
}
}
impl std::str::FromStr for Tier {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"deep" => Ok(Tier::Deep),
"advisory" => Ok(Tier::Advisory),
"blocking" => Ok(Tier::Blocking),
_ => Err(anyhow::anyhow!(
"Unknown tier '{}'. Valid: blocking, advisory, deep",
s
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceSpan {
pub file: PathBuf,
pub line_start: u32,
pub line_end: u32,
#[serde(default)]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Evidence {
TaintPath {
source: SourceSpan,
sink: SourceSpan,
sink_kind: String,
#[serde(default)]
flow: Vec<SourceSpan>,
#[serde(default)]
sanitizers_seen: Vec<String>,
},
Secret {
span: SourceSpan,
format: String,
entropy_bits: f32,
#[serde(default)]
checksum_valid: Option<bool>,
},
ConfigFact {
span: SourceSpan,
rule: String,
},
}
fn serialize_empty_map_as_null<S>(
map: &std::collections::BTreeMap<String, String>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if map.is_empty() {
Option::<std::collections::BTreeMap<String, String>>::None.serialize(serializer)
} else {
Some(map).serialize(serializer)
}
}
fn deserialize_null_as_empty_map<'de, D>(
deserializer: D,
) -> Result<std::collections::BTreeMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<std::collections::BTreeMap<String, String>>::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum FindingStatus {
#[default]
New,
Baselined,
Fixed,
Stale,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Attribution {
InChangedNode,
InCallerOfChanged,
#[default]
InUnrelated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
Low,
Medium,
High,
}
impl Confidence {
pub fn from_score(score: f64) -> Self {
if score >= 0.75 {
Confidence::High
} else if score >= 0.5 {
Confidence::Medium
} else {
Confidence::Low
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LineRange {
pub start: u32,
pub end: u32,
}
impl LineRange {
pub fn new(start: u32, end: u32) -> Self {
Self { start, end }
}
pub fn is_valid(self) -> bool {
self.start != 0 && self.end >= self.start
}
pub fn contains_line(self, line: u32) -> bool {
line >= self.start && line <= self.end
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Finding {
#[serde(default)]
pub id: String,
#[serde(default)]
pub detector: String,
#[serde(default)]
pub severity: Severity,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub affected_files: Vec<PathBuf>,
#[serde(default)]
pub line_start: Option<u32>,
#[serde(default)]
pub line_end: Option<u32>,
#[serde(default)]
pub suggested_fix: Option<String>,
#[serde(default)]
pub estimated_effort: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub cwe_id: Option<String>,
#[serde(default)]
pub why_it_matters: Option<String>,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub deterministic: bool,
#[serde(
default,
serialize_with = "serialize_empty_map_as_null",
deserialize_with = "deserialize_null_as_empty_map"
)]
pub threshold_metadata: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub status: FindingStatus,
#[serde(default)]
pub attribution: Attribution,
#[serde(default)]
pub original_severity: Option<Severity>,
#[serde(default)]
pub tier: Tier,
#[serde(default)]
pub evidence: Option<Evidence>,
#[serde(default)]
pub original_tier: Option<Tier>,
#[serde(default)]
pub alternative_branch: Option<crate::dual_branch::AlternativeBranch>,
#[serde(default)]
pub prediction_reasons: Vec<crate::dual_branch::PredictionReason>,
#[serde(default)]
pub resolution_signals: Vec<crate::dual_branch::ResolutionSignal>,
#[serde(default)]
pub suppressed: bool,
#[serde(default)]
pub suppression_reason: Option<String>,
}
const DEFAULT_CONFIDENCE: f64 = 0.70;
impl Finding {
pub fn is_valid(&self) -> bool {
self.validation_errors().is_empty()
}
pub fn validation_errors(&self) -> Vec<&'static str> {
let mut errs = Vec::new();
if self.detector.is_empty() {
errs.push("detector is empty");
}
if self.title.is_empty() {
errs.push("title is empty");
}
let has_id = !self.id.is_empty();
let has_file_line = !self.affected_files.is_empty() && self.line_start.is_some();
if !has_id && !has_file_line {
errs.push("no locator (need non-empty id or affected_files+line_start)");
}
errs
}
pub fn with_default_confidence(mut self, default: f64) -> Self {
if self.confidence.is_none() {
self.confidence = Some(default);
}
self
}
pub fn effective_confidence(&self) -> f64 {
self.confidence.unwrap_or(DEFAULT_CONFIDENCE)
}
pub fn default_confidence_for_category(category: Option<&str>) -> f64 {
match category {
Some("architecture") => 0.85,
Some("security") => 0.75,
Some("design") => 0.65,
Some("dead-code") | Some("dead_code") => 0.70,
Some("ai_watchdog") => 0.60,
_ => DEFAULT_CONFIDENCE,
}
}
#[must_use]
pub fn with_alternative_branch(
mut self,
alternative: crate::dual_branch::AlternativeBranch,
) -> Self {
self.alternative_branch = Some(alternative);
self
}
#[must_use]
pub fn with_prediction_reason(mut self, reason: crate::dual_branch::PredictionReason) -> Self {
self.prediction_reasons.push(reason);
self
}
#[must_use]
pub fn with_resolution_signal(mut self, signal: crate::dual_branch::ResolutionSignal) -> Self {
self.resolution_signals.push(signal);
self
}
pub fn is_dual_branch(&self) -> bool {
self.alternative_branch.is_some()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FindingsSummary {
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
pub info: usize,
pub total: usize,
}
impl FindingsSummary {
pub fn from_findings(findings: &[Finding]) -> Self {
let mut summary = Self::default();
for f in findings {
match f.severity {
Severity::Critical => summary.critical += 1,
Severity::High => summary.high += 1,
Severity::Medium => summary.medium += 1,
Severity::Low => summary.low += 1,
Severity::Info => summary.info += 1,
}
summary.total += 1;
}
summary
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SuppressionEvent {
#[serde(default)]
pub detector: String,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub file: PathBuf,
#[serde(default)]
pub line: Option<u32>,
#[serde(default)]
pub fingerprint: String,
#[serde(default)]
pub harvestable: bool,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
pub enum Grade {
#[default]
F,
#[serde(rename = "D-")]
DMinus,
D,
#[serde(rename = "D+")]
DPlus,
#[serde(rename = "C-")]
CMinus,
C,
#[serde(rename = "C+")]
CPlus,
#[serde(rename = "B-")]
BMinus,
B,
#[serde(rename = "B+")]
BPlus,
#[serde(rename = "A-")]
AMinus,
A,
#[serde(rename = "A+")]
APlus,
}
impl std::fmt::Display for Grade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Grade::APlus => write!(f, "A+"),
Grade::A => write!(f, "A"),
Grade::AMinus => write!(f, "A-"),
Grade::BPlus => write!(f, "B+"),
Grade::B => write!(f, "B"),
Grade::BMinus => write!(f, "B-"),
Grade::CPlus => write!(f, "C+"),
Grade::C => write!(f, "C"),
Grade::CMinus => write!(f, "C-"),
Grade::DPlus => write!(f, "D+"),
Grade::D => write!(f, "D"),
Grade::DMinus => write!(f, "D-"),
Grade::F => write!(f, "F"),
}
}
}
impl std::str::FromStr for Grade {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"A+" => Ok(Grade::APlus),
"A" => Ok(Grade::A),
"A-" => Ok(Grade::AMinus),
"B+" => Ok(Grade::BPlus),
"B" => Ok(Grade::B),
"B-" => Ok(Grade::BMinus),
"C+" => Ok(Grade::CPlus),
"C" => Ok(Grade::C),
"C-" => Ok(Grade::CMinus),
"D+" => Ok(Grade::DPlus),
"D" => Ok(Grade::D),
"D-" => Ok(Grade::DMinus),
"F" => Ok(Grade::F),
_ => Err(anyhow::anyhow!("Unknown grade '{}'", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthReport {
pub overall_score: f64,
pub grade: Grade,
pub structure_score: f64,
pub quality_score: f64,
pub architecture_score: Option<f64>,
pub findings: Vec<Finding>,
pub findings_summary: FindingsSummary,
pub total_files: usize,
pub total_functions: usize,
pub total_classes: usize,
pub total_loc: usize,
#[serde(default)]
pub suppression_events: Vec<SuppressionEvent>,
#[serde(default)]
pub suppressed_unaccounted_blocking_count: usize,
}
impl HealthReport {
pub fn grade_from_score(score: f64) -> String {
match score {
s if s >= 90.0 => "A".to_string(),
s if s >= 80.0 => "B".to_string(),
s if s >= 70.0 => "C".to_string(),
s if s >= 60.0 => "D".to_string(),
_ => "F".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Function {
pub name: String,
pub qualified_name: String,
pub file_path: PathBuf,
pub line_start: u32,
pub line_end: u32,
pub parameters: Vec<String>,
pub return_type: Option<String>,
pub is_async: bool,
pub complexity: Option<u32>,
pub max_nesting: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_comment: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Class {
pub name: String,
pub qualified_name: String,
pub file_path: PathBuf,
pub line_start: u32,
pub line_end: u32,
pub methods: Vec<String>,
#[serde(default)]
pub field_count: usize,
pub bases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_comment: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct File {
pub path: PathBuf,
pub language: String,
pub lines_of_code: usize,
pub functions: usize,
pub classes: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier_ordering_and_serde() {
assert!(Tier::Deep < Tier::Advisory);
assert!(Tier::Advisory < Tier::Blocking);
assert_eq!(
serde_json::to_string(&Tier::Blocking).unwrap(),
"\"blocking\""
);
assert_eq!(
serde_json::from_str::<Tier>("\"advisory\"").unwrap(),
Tier::Advisory
);
assert_eq!(Tier::default(), Tier::Advisory);
assert_eq!("deep".parse::<Tier>().unwrap(), Tier::Deep);
assert!("nope".parse::<Tier>().is_err());
assert_eq!(Tier::Blocking.to_string(), "blocking");
}
#[test]
fn evidence_serde_roundtrip() {
let e = Evidence::Secret {
span: SourceSpan {
file: "a.rs".into(),
line_start: 3,
line_end: 3,
snippet: None,
},
format: "aws_access_key_id".into(),
entropy_bits: 4.2,
checksum_valid: None,
};
let json = serde_json::to_string(&e).unwrap();
assert!(json.starts_with("{\"secret\":"), "unexpected shape: {json}");
let back: Evidence = serde_json::from_str(&json).unwrap();
assert!(matches!(back, Evidence::Secret { .. }));
let t = Evidence::TaintPath {
source: SourceSpan {
file: "h.js".into(),
line_start: 1,
line_end: 1,
snippet: None,
},
sink: SourceSpan {
file: "h.js".into(),
line_start: 9,
line_end: 9,
snippet: None,
},
sink_kind: "exec".into(),
flow: vec![],
sanitizers_seen: vec![],
};
let json = serde_json::to_string(&t).unwrap();
assert!(
json.starts_with("{\"taint_path\":"),
"unexpected shape: {json}"
);
let back: Evidence = serde_json::from_str(&json).unwrap();
assert!(matches!(back, Evidence::TaintPath { .. }));
}
#[test]
fn finding_tier_defaults_for_old_json() {
let old = r#"{"detector":"sql-injection","severity":"high","title":"x"}"#;
let f: Finding = serde_json::from_str(old).unwrap();
assert_eq!(f.tier, Tier::Advisory);
assert!(f.evidence.is_none());
assert!(f.original_tier.is_none());
}
#[test]
fn test_finding_serde_round_trip() {
let finding = Finding {
id: "test-1".into(),
detector: "TestDetector".into(),
severity: Severity::High,
title: "Test finding".into(),
description: "A test".into(),
threshold_metadata: {
let mut m = std::collections::BTreeMap::new();
m.insert("key".into(), "value".into());
m
},
..Default::default()
};
let json = serde_json::to_string(&finding).expect("serialize finding");
let back: Finding = serde_json::from_str(&json).expect("deserialize finding");
assert_eq!(back.id, "test-1");
assert_eq!(
back.threshold_metadata.get("key").expect("key exists"),
"value"
);
}
#[test]
fn test_finding_deserialize_null_threshold_metadata() {
let json = r#"{"id":"t1","detector":"D","severity":"high","title":"T","description":"","affected_files":[],"threshold_metadata":null}"#;
let finding: Finding =
serde_json::from_str(json).expect("deserialize finding with null metadata");
assert!(finding.threshold_metadata.is_empty());
}
#[test]
fn test_finding_deserialize_missing_threshold_metadata() {
let json = r#"{"id":"t1","detector":"D","severity":"high","title":"T","description":"","affected_files":[]}"#;
let finding: Finding =
serde_json::from_str(json).expect("deserialize finding with missing metadata");
assert!(finding.threshold_metadata.is_empty());
}
#[test]
fn test_finding_bincode_round_trip_with_threshold_metadata() {
let finding = Finding {
id: "test-bin".into(),
detector: "TestDetector".into(),
severity: Severity::High,
title: "Test finding".into(),
description: "A test".into(),
confidence: Some(0.85),
threshold_metadata: {
let mut m = std::collections::BTreeMap::new();
m.insert("threshold_source".into(), "adaptive".into());
m.insert("effective_threshold".into(), "15".into());
m
},
..Default::default()
};
let bytes = bitcode::serialize(&finding).expect("serialize finding");
let back: Finding = bitcode::deserialize(&bytes).expect("deserialize finding");
assert_eq!(back.id, "test-bin");
assert_eq!(back.confidence, Some(0.85));
assert_eq!(
back.threshold_metadata
.get("threshold_source")
.expect("key exists"),
"adaptive"
);
assert_eq!(
back.threshold_metadata
.get("effective_threshold")
.expect("key exists"),
"15"
);
}
#[test]
fn test_finding_bincode_round_trip_with_evidence_and_tier() {
let finding = Finding {
detector: "SecretDetector".into(),
severity: Severity::Critical,
tier: Tier::Blocking,
original_tier: Some(Tier::Advisory),
evidence: Some(Evidence::Secret {
span: SourceSpan {
file: "src/aws.rs".into(),
line_start: 12,
line_end: 12,
snippet: Some("const KEY = \"AKIA...\";".into()),
},
format: "aws_access_key_id".into(),
entropy_bits: 4.2,
checksum_valid: None,
}),
..Default::default()
};
let bytes = bitcode::serialize(&finding).expect("serialize finding with evidence");
let back: Finding =
bitcode::deserialize(&bytes).expect("deserialize finding with evidence");
assert_eq!(back.tier, Tier::Blocking);
assert_eq!(back.original_tier, Some(Tier::Advisory));
match back.evidence {
Some(Evidence::Secret { ref format, .. }) => assert_eq!(format, "aws_access_key_id"),
other => panic!("expected Evidence::Secret, got {other:?}"),
}
}
#[test]
fn test_finding_unset_tier_evidence_serialize_defaults() {
let json = serde_json::to_value(Finding {
detector: "x".into(),
..Default::default()
})
.expect("serialize");
assert_eq!(json["tier"], serde_json::json!("advisory"));
assert!(json.get("evidence").is_none() || json["evidence"].is_null());
assert!(json.get("original_tier").is_none() || json["original_tier"].is_null());
}
#[test]
fn test_health_report_grade_from_score() {
assert_eq!(HealthReport::grade_from_score(95.0), "A");
assert_eq!(HealthReport::grade_from_score(85.0), "B");
assert_eq!(HealthReport::grade_from_score(75.0), "C");
assert_eq!(HealthReport::grade_from_score(65.0), "D");
assert_eq!(HealthReport::grade_from_score(50.0), "F");
}
#[test]
fn test_findings_summary_from_findings() {
let findings = vec![
Finding {
severity: Severity::Critical,
..Default::default()
},
Finding {
severity: Severity::High,
..Default::default()
},
Finding {
severity: Severity::High,
..Default::default()
},
Finding {
severity: Severity::Medium,
..Default::default()
},
Finding {
severity: Severity::Low,
..Default::default()
},
];
let summary = FindingsSummary::from_findings(&findings);
assert_eq!(summary.critical, 1);
assert_eq!(summary.high, 2);
assert_eq!(summary.medium, 1);
assert_eq!(summary.low, 1);
assert_eq!(summary.total, 5);
}
#[test]
fn test_with_default_confidence_sets_when_none() {
let finding = Finding {
confidence: None,
..Default::default()
};
let finding = finding.with_default_confidence(0.85);
assert_eq!(finding.confidence, Some(0.85));
}
#[test]
fn test_with_default_confidence_preserves_existing() {
let finding = Finding {
confidence: Some(0.90),
..Default::default()
};
let finding = finding.with_default_confidence(0.50);
assert_eq!(finding.confidence, Some(0.90));
}
#[test]
fn test_effective_confidence_returns_set_value() {
let finding = Finding {
confidence: Some(0.42),
..Default::default()
};
assert!((finding.effective_confidence() - 0.42).abs() < f64::EPSILON);
}
#[test]
fn test_effective_confidence_returns_default_when_none() {
let finding = Finding {
confidence: None,
..Default::default()
};
assert!((finding.effective_confidence() - 0.70).abs() < f64::EPSILON);
}
#[test]
fn test_default_confidence_architecture() {
assert!(
(Finding::default_confidence_for_category(Some("architecture")) - 0.85).abs()
< f64::EPSILON
);
}
#[test]
fn test_default_confidence_security() {
assert!(
(Finding::default_confidence_for_category(Some("security")) - 0.75).abs()
< f64::EPSILON
);
}
#[test]
fn test_default_confidence_design() {
assert!(
(Finding::default_confidence_for_category(Some("design")) - 0.65).abs() < f64::EPSILON
);
}
#[test]
fn test_default_confidence_dead_code_hyphen() {
assert!(
(Finding::default_confidence_for_category(Some("dead-code")) - 0.70).abs()
< f64::EPSILON
);
}
#[test]
fn test_default_confidence_dead_code_underscore() {
assert!(
(Finding::default_confidence_for_category(Some("dead_code")) - 0.70).abs()
< f64::EPSILON
);
}
#[test]
fn test_default_confidence_ai_watchdog() {
assert!(
(Finding::default_confidence_for_category(Some("ai_watchdog")) - 0.60).abs()
< f64::EPSILON
);
}
#[test]
fn test_default_confidence_unknown_category() {
assert!(
(Finding::default_confidence_for_category(Some("testing")) - 0.70).abs() < f64::EPSILON
);
}
#[test]
fn test_default_confidence_none_category() {
assert!((Finding::default_confidence_for_category(None) - 0.70).abs() < f64::EPSILON);
}
#[test]
fn dual_branch_fields_match_existing_finding_serialization_convention() {
let finding = Finding {
id: "x".into(),
detector: "D".into(),
severity: Severity::Low,
..Default::default()
};
let value = serde_json::to_value(&finding).expect("serialize finding");
let obj = value.as_object().expect("finding serializes to object");
assert_eq!(
obj.get("alternative_branch"),
Some(&serde_json::Value::Null),
"alternative_branch should be null when unset; got: {value}"
);
assert_eq!(
obj.get("prediction_reasons"),
Some(&serde_json::Value::Array(vec![])),
"prediction_reasons should be [] when unset; got: {value}"
);
assert_eq!(
obj.get("resolution_signals"),
Some(&serde_json::Value::Array(vec![])),
"resolution_signals should be [] when unset; got: {value}"
);
}
#[test]
fn dual_branch_old_json_parses_unchanged() {
let json = r#"{"id":"old","detector":"D","severity":"high","title":"T","description":"","affected_files":[]}"#;
let finding: Finding = serde_json::from_str(json).expect("deserialize legacy finding");
assert_eq!(finding.id, "old");
assert!(finding.alternative_branch.is_none());
assert!(finding.prediction_reasons.is_empty());
assert!(finding.resolution_signals.is_empty());
assert!(!finding.is_dual_branch());
}
#[test]
fn dual_branch_builder_methods_populate_fields() {
use crate::dual_branch::{
AlternativeBranch, BranchLabel, PredictionReason, PredictionReasonKind, ResolutionKind,
ResolutionSignal,
};
let finding = Finding::default()
.with_alternative_branch(AlternativeBranch {
label: BranchLabel::Benign,
severity: Severity::Info,
title: "Annotated as non-security".into(),
description: "Caller passed usedforsecurity=False.".into(),
suggested_fix: None,
})
.with_prediction_reason(PredictionReason {
kind: PredictionReasonKind::KeywordArgument {
name: "usedforsecurity".into(),
value: "False".into(),
},
weight: 0.9,
note: "Authoritative non-security annotation present.".into(),
})
.with_resolution_signal(ResolutionSignal {
kind: ResolutionKind::KeywordArgument {
name: "usedforsecurity".into(),
value: "False".into(),
},
description: "Python 3.9+ stdlib non-security annotation.".into(),
example: None,
collapses_to: BranchLabel::Benign,
});
assert!(finding.is_dual_branch());
assert_eq!(finding.prediction_reasons.len(), 1);
assert_eq!(finding.resolution_signals.len(), 1);
assert_eq!(
finding
.alternative_branch
.as_ref()
.expect("alternative present")
.label,
BranchLabel::Benign
);
}
#[test]
fn dual_branch_finding_full_roundtrip() {
use crate::dual_branch::{
AlternativeBranch, BranchLabel, PredictionReason, PredictionReasonKind, ResolutionKind,
ResolutionSignal,
};
let finding = Finding {
id: "httpx-auth-309".into(),
detector: "InsecureCryptoDetector".into(),
severity: Severity::Medium,
title: "SHA-1 used in HTTPX Digest auth".into(),
description: "SHA-1 in cryptographic context.".into(),
..Default::default()
}
.with_alternative_branch(AlternativeBranch {
label: BranchLabel::Benign,
severity: Severity::Info,
title: "RFC 7616 Digest auth requires SHA-1 for compatibility".into(),
description: "Protocol-required, not vulnerable usage.".into(),
suggested_fix: Some("# repotoire:protocol-required[RFC7616]".into()),
})
.with_prediction_reason(PredictionReason {
kind: PredictionReasonKind::EnclosingScope {
scope_kind: "class".into(),
name: "DigestAuth".into(),
},
weight: 0.4,
note: "Class name suggests RFC 7616 Digest authentication.".into(),
})
.with_resolution_signal(ResolutionSignal {
kind: ResolutionKind::SourceAnnotation {
syntax: "# repotoire:protocol-required[RFC7616]".into(),
},
description: "Mark this call as protocol-required.".into(),
example: None,
collapses_to: BranchLabel::Benign,
});
let json = serde_json::to_string(&finding).expect("serialize");
let back: Finding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.id, finding.id);
assert!(back.is_dual_branch());
assert_eq!(back.prediction_reasons.len(), 1);
assert_eq!(back.resolution_signals.len(), 1);
assert_eq!(
back.alternative_branch
.as_ref()
.expect("alternative present")
.label,
BranchLabel::Benign
);
}
#[test]
fn finding_default_is_not_valid() {
let f = Finding::default();
assert!(
!f.is_valid(),
"Finding::default() must NOT pass is_valid(); it would let \
garbage like `{{\"bogus\": \"x\"}}` round-trip through the cache."
);
let errs = f.validation_errors();
assert!(errs.iter().any(|e| e.contains("detector")));
assert!(errs.iter().any(|e| e.contains("title")));
assert!(errs.iter().any(|e| e.contains("locator")));
}
#[test]
fn finding_with_detector_title_and_file_line_is_valid() {
let f = Finding {
detector: "Det".into(),
title: "Title".into(),
affected_files: vec![PathBuf::from("src/x.py")],
line_start: Some(1),
..Default::default()
};
assert!(
f.is_valid(),
"expected valid finding (detector+title+file+line); errs: {:?}",
f.validation_errors(),
);
}
#[test]
fn finding_with_detector_title_and_nonempty_id_is_valid() {
let f = Finding {
id: "circular-dep-1".into(),
detector: "Det".into(),
title: "Title".into(),
..Default::default()
};
assert!(f.is_valid(), "errs: {:?}", f.validation_errors());
}
#[test]
fn finding_with_file_but_no_line_is_not_valid() {
let f = Finding {
detector: "Det".into(),
title: "Title".into(),
affected_files: vec![PathBuf::from("src/x.py")],
..Default::default()
};
assert!(!f.is_valid());
assert!(f.validation_errors().iter().any(|e| e.contains("locator")));
}
#[test]
fn finding_with_empty_detector_is_not_valid() {
let f = Finding {
detector: String::new(),
title: "Title".into(),
affected_files: vec![PathBuf::from("src/x.py")],
line_start: Some(1),
..Default::default()
};
assert!(!f.is_valid());
assert!(f.validation_errors().iter().any(|e| e.contains("detector")));
}
#[test]
fn finding_with_empty_title_is_not_valid() {
let f = Finding {
detector: "Det".into(),
title: String::new(),
affected_files: vec![PathBuf::from("src/x.py")],
line_start: Some(1),
..Default::default()
};
assert!(!f.is_valid());
assert!(f.validation_errors().iter().any(|e| e.contains("title")));
}
#[test]
fn finding_deserialized_from_bogus_object_is_not_valid() {
let f: Finding = serde_json::from_str(r#"{"bogus": "x"}"#)
.expect("permissive deserialize accepts anything");
assert!(!f.is_valid());
}
}