use crate::config::ProjectConfig;
use crate::detectors::FileContentCache;
use crate::models::{Finding, Severity};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(super) fn apply_detector_overrides(
findings: &mut Vec<Finding>,
project_config: &ProjectConfig,
) {
if project_config.detectors.is_empty() {
return;
}
let detector_configs = &project_config.detectors;
findings.retain(|f| {
let detector_name = crate::config::normalize_detector_name(&f.detector);
if let Some(config) = detector_configs.get(&detector_name) {
if let Some(false) = config.enabled {
return false;
}
}
true
});
for finding in findings.iter_mut() {
let detector_name = crate::config::normalize_detector_name(&finding.detector);
if let Some(config) = detector_configs.get(&detector_name) {
if let Some(sev) = config.severity {
finding.severity = sev;
}
}
}
}
fn apply_labels_to_findings(
findings: &mut Vec<Finding>,
labels: &HashMap<String, bool>,
show_all: bool,
) {
if labels.is_empty() {
return;
}
let mut fp_findings: Vec<Finding> = Vec::new();
let mut applied = 0u32;
findings.retain_mut(|f| {
match labels.get(&f.id) {
Some(false) => {
applied += 1;
if show_all {
f.confidence = Some(0.05);
f.threshold_metadata
.insert("user_label".to_string(), "false_positive".to_string());
fp_findings.push(f.clone());
}
false }
Some(true) => {
applied += 1;
f.confidence = Some(0.95);
f.deterministic = true;
f.threshold_metadata
.insert("user_label".to_string(), "true_positive".to_string());
true }
None => true, }
});
if show_all {
findings.extend(fp_findings);
}
if applied > 0 {
tracing::info!(
"Applied {} user feedback labels ({} in training data)",
applied,
labels.len()
);
}
}
fn apply_user_labels(findings: &mut Vec<Finding>) {
let labels = crate::classifier::FeedbackCollector::default().load_label_map();
apply_labels_to_findings(findings, &labels, false);
}
pub struct PostprocessParams<'a> {
pub project_config: &'a ProjectConfig,
pub all_files: &'a [PathBuf],
pub graph: &'a dyn crate::graph::GraphQuery,
pub repo_path: &'a Path,
pub bypass_set: &'a HashSet<String>,
pub verify: bool,
pub file_cache: Option<&'a Arc<FileContentCache>>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PostprocessSummary {
pub security_downgraded: usize,
}
pub fn postprocess_findings(
findings: &mut Vec<Finding>,
params: &PostprocessParams<'_>,
) -> PostprocessSummary {
let mut summary = PostprocessSummary::default();
for finding in findings.iter_mut() {
let file = finding
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let line = finding.line_start.unwrap_or(0);
finding.id = crate::detectors::base::finding_id(&finding.detector, &file, line);
}
assign_default_confidence(findings);
crate::detectors::confidence_enrichment::enrich_all(findings);
crate::detectors::graph_enrichment::enrich_graph_evidence(findings, params.graph);
apply_user_labels(findings);
apply_detector_overrides(findings, params.project_config);
if !params
.project_config
.exclude
.effective_patterns()
.is_empty()
{
let before = findings.len();
findings.retain(|f| {
!f.affected_files
.iter()
.any(|p| params.project_config.should_exclude(p))
});
let removed = before - findings.len();
if removed > 0 {
tracing::debug!("Filtered {} findings from excluded paths", removed);
}
}
if !params.project_config.per_file_ignores.is_empty() {
let before = findings.len();
findings.retain(|f| {
!f.affected_files
.iter()
.any(|p| params.project_config.is_per_file_ignored(p, &f.detector))
});
let removed = before - findings.len();
if removed > 0 {
tracing::debug!("[per_file_ignores] suppressed {} findings", removed);
}
}
filter_file_level_suppressed(findings);
filter_inline_suppressed(findings);
filter_detector_test_fixtures(findings);
dedupe_dead_code_overlap(findings);
deduplicate_findings(findings);
crate::scoring::escalate_compound_smells(findings);
summary.security_downgraded = classify_param_gated_security_branches(
findings,
params.file_cache,
params.repo_path,
¶ms.project_config.dual_branch,
);
downgrade_non_production_security(findings);
filter_false_positives(findings, params.graph, params.bypass_set);
for finding in findings.iter_mut() {
if let Some(ref mut c) = finding.confidence {
*c = c.clamp(0.0, 1.0);
}
}
if params.verify {
let has_claude = std::env::var("ANTHROPIC_API_KEY").is_ok();
let has_ollama = std::process::Command::new("ollama")
.arg("list")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !has_claude && !has_ollama {
eprintln!(
"\n⚠️ --verify requires an AI backend but none is available.\n\
Set ANTHROPIC_API_KEY for Claude, or install Ollama (https://ollama.ai).\n\
Skipping LLM verification."
);
} else {
tracing::debug!("LLM verification: backend available, implementation pending");
}
}
let _ = params.all_files;
let _ = params.repo_path;
summary
}
fn assign_default_confidence(findings: &mut [Finding]) {
let mut assigned = 0usize;
for finding in findings.iter_mut() {
if finding.confidence.is_none() {
let default = Finding::default_confidence_for_category(finding.category.as_deref());
finding.confidence = Some(default);
assigned += 1;
}
}
if assigned > 0 {
tracing::debug!(
"Assigned default confidence to {} findings without explicit confidence",
assigned
);
}
}
fn dedupe_dead_code_overlap(findings: &mut Vec<Finding>) {
use std::collections::HashSet;
let mut unreachable_keys: HashSet<(String, u32, String)> = HashSet::new();
for f in findings
.iter()
.filter(|f| f.detector == "UnreachableCodeDetector")
{
let file = f
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let line = f.line_start.unwrap_or(0);
let symbol = extract_symbol_from_title(&f.title);
unreachable_keys.insert((file, line, symbol));
}
findings.retain(|f| {
if f.detector != "DeadCodeDetector" {
return true;
}
let file = f
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let line = f.line_start.unwrap_or(0);
let symbol = extract_symbol_from_title(&f.title);
!unreachable_keys.contains(&(file, line, symbol))
});
}
fn extract_symbol_from_title(title: &str) -> String {
title
.split(':')
.nth(1)
.map(|s| s.trim().to_lowercase())
.unwrap_or_else(|| title.trim().to_lowercase())
}
fn downgrade_non_production_security(findings: &mut [Finding]) {
use crate::detectors::content_classifier::is_non_production_path;
const SECURITY_DETECTORS: &[&str] = &[
"CommandInjectionDetector",
"SQLInjectionDetector",
"XssDetector",
"SsrfDetector",
"PathTraversalDetector",
"LogInjectionDetector",
"EvalDetector",
"InsecureRandomDetector",
"HardcodedCredentialsDetector",
"CleartextCredentialsDetector",
];
for finding in findings.iter_mut() {
let is_non_prod = finding
.affected_files
.iter()
.any(|p| is_non_production_path(&p.to_string_lossy()));
if is_non_prod
&& SECURITY_DETECTORS.contains(&finding.detector.as_str())
&& (finding.severity == Severity::Critical || finding.severity == Severity::High)
{
finding.severity = Severity::Medium;
finding.description = format!("[Non-production path] {}", finding.description);
}
}
}
const PARAM_GATED_DEMOTION_NOTE: &str =
"\n\n_Note: this finding lives inside a parameter-gated conditional branch. \
The library is implementing a caller-controlled security parameter; the \
actionable security decision is at the call sites that pass the dangerous \
value to this function._";
pub(crate) const PARAM_GATED_METADATA_KEY: &str = "library_opt_out_implementation";
fn classify_param_gated_security_branches(
findings: &mut [Finding],
file_cache: Option<&Arc<FileContentCache>>,
repo_path: &Path,
dual_branch: &crate::config::DualBranchConfig,
) -> usize {
use crate::detectors::security::{
dual_branch_annotation, node_at_line, python_param_gated_branch_param_name,
};
use crate::parsers::lightweight::Language;
let local_cache = Arc::new(FileContentCache::new());
let cache: &Arc<FileContentCache> = file_cache.unwrap_or(&local_cache);
let insecure_tls_flag_on = dual_branch.is_enabled_for("insecure-tls");
let resolve = |p: &Path| -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
repo_path.join(p)
}
};
let mut by_file: HashMap<PathBuf, Vec<usize>> = HashMap::new();
for (idx, f) in findings.iter().enumerate() {
if !is_security_detector(&f.detector) {
continue;
}
let is_insecure_tls = f.detector == "InsecureTlsDetector";
let outcome_b_eligible = insecure_tls_flag_on && is_insecure_tls;
if !outcome_b_eligible {
if !matches!(
f.severity,
Severity::Critical | Severity::High | Severity::Medium
) {
continue;
}
}
let Some(path) = f.affected_files.first() else {
continue;
};
if path.extension().and_then(|e| e.to_str()) != Some("py") {
continue;
}
if f.line_start.is_none() {
continue;
}
by_file.entry(resolve(path)).or_default().push(idx);
}
let mut demoted = 0usize;
let mut promoted = 0usize;
for (path, finding_indices) in by_file {
let Some((source, tree)) = cache.get_or_parse(&path, Language::Python) else {
continue;
};
let root = tree.root_node();
let bytes = source.as_bytes();
let source_lines: Vec<&str> = source.lines().collect();
for idx in finding_indices {
let line = match findings[idx].line_start {
Some(l) => l,
None => continue,
};
let Some(node) = node_at_line(root, line) else {
continue;
};
let param_name = python_param_gated_branch_param_name(node, bytes);
let is_param_gated = param_name.is_some();
let is_insecure_tls = findings[idx].detector == "InsecureTlsDetector";
let outcome_b = insecure_tls_flag_on && is_insecure_tls;
let annotation = if outcome_b {
let line_idx = (line as usize).saturating_sub(1);
let candidates = [
source_lines.get(line_idx).copied(),
line_idx
.checked_sub(1)
.and_then(|i| source_lines.get(i).copied()),
];
candidates
.into_iter()
.flatten()
.find_map(dual_branch_annotation::parse_python_comment)
.filter(|a| a.kind.eq_ignore_ascii_case("tls-disabled"))
} else {
None
};
if !outcome_b {
if !is_param_gated {
continue;
}
let f = &mut findings[idx];
f.severity = Severity::Low;
let new_conf = (f.confidence.unwrap_or(0.7) * 0.5).min(0.4);
f.confidence = Some(new_conf);
f.description.push_str(PARAM_GATED_DEMOTION_NOTE);
f.threshold_metadata
.insert(PARAM_GATED_METADATA_KEY.to_string(), "true".to_string());
demoted += 1;
continue;
}
let f = &mut findings[idx];
let original_severity = f.severity;
let original_title = f.title.clone();
let original_description = f.description.clone();
let original_suggested_fix = f.suggested_fix.clone();
let (predicted_label, alt_label) = if annotation.is_some() || is_param_gated {
(
crate::dual_branch::BranchLabel::Benign,
crate::dual_branch::BranchLabel::RealBug,
)
} else {
(
crate::dual_branch::BranchLabel::RealBug,
crate::dual_branch::BranchLabel::Benign,
)
};
let (predicted_severity, alt_severity) = match predicted_label {
crate::dual_branch::BranchLabel::Benign => (Severity::Info, original_severity),
crate::dual_branch::BranchLabel::RealBug => (original_severity, Severity::Info),
};
let benign_title = "Caller-controlled TLS opt-out (likely benign)".to_string();
let benign_description = format!(
"**Dual-branch interpretation**\n\n\
The TLS-verification opt-out at this site is inside a \
parameter-gated conditional branch (the library is implementing \
a caller-controlled `verify=...` / `insecure=...` flag). The \
actionable security decision is at the call sites that pass \
the dangerous value to this function, not at this implementation.\n\n\
The {alt_label_str} interpretation is carried in \
`alternative_branch` so consumers wanting the conservative \
view can still see the underlying issue.",
alt_label_str = match alt_label {
crate::dual_branch::BranchLabel::RealBug => "stricter RealBug",
crate::dual_branch::BranchLabel::Benign => "Benign",
},
);
let benign_suggested_fix = Some(
"If this is intentional opt-out plumbing, no fix is needed at \
this site — review the call sites instead. To collapse this \
finding definitively, annotate the line with \
`# repotoire: tls-disabled[<reason>]`."
.to_string(),
);
f.severity = predicted_severity;
match predicted_label {
crate::dual_branch::BranchLabel::Benign => {
f.title = benign_title.clone();
f.description = benign_description.clone();
f.suggested_fix = benign_suggested_fix.clone();
}
crate::dual_branch::BranchLabel::RealBug => {
let _ = (
&original_title,
&original_description,
&original_suggested_fix,
);
}
}
let (alt_title, alt_description, alt_fix) = match alt_label {
crate::dual_branch::BranchLabel::Benign => {
(benign_title, benign_description, benign_suggested_fix)
}
crate::dual_branch::BranchLabel::RealBug => (
original_title.clone(),
original_description.clone(),
original_suggested_fix.clone(),
),
};
f.alternative_branch = Some(crate::dual_branch::AlternativeBranch {
label: alt_label,
severity: alt_severity,
title: alt_title,
description: alt_description,
suggested_fix: alt_fix,
});
if let Some(param) = param_name.as_ref() {
f.prediction_reasons
.push(crate::dual_branch::PredictionReason {
kind: crate::dual_branch::PredictionReasonKind::EnclosingScope {
scope_kind: "param_gated_conditional".to_string(),
name: param.clone(),
},
weight: 0.9,
note: format!(
"The TLS opt-out is inside a conditional branch gated by the \
enclosing function's `{param}` parameter; the actionable \
site is the caller passing the dangerous value."
),
});
}
if let Some(ann) = annotation {
let args_str = if ann.args.is_empty() {
String::new()
} else {
format!("[{}]", ann.args.join(","))
};
f.resolution_signals
.push(crate::dual_branch::ResolutionSignal {
kind: crate::dual_branch::ResolutionKind::SourceAnnotation {
syntax: format!("# repotoire: tls-disabled{args_str}"),
},
description: "Source-level annotation forces the Benign \
interpretation; remove the annotation to \
surface the underlying TLS opt-out finding."
.to_string(),
example: Some(
"session.verify = False # repotoire: tls-disabled[trusted-dev-cert]"
.to_string(),
),
collapses_to: crate::dual_branch::BranchLabel::Benign,
});
}
promoted += 1;
}
}
if demoted > 0 {
tracing::info!(
"Demoted {} security findings inside parameter-gated conditional branches",
demoted
);
}
if promoted > 0 {
tracing::info!(
"Promoted {} InsecureTls findings to dual-branch shape",
promoted
);
}
demoted
}
pub(crate) fn rank_findings(findings: &mut Vec<Finding>, _graph: &dyn crate::graph::GraphQuery) {
let extractor = crate::classifier::FeatureExtractor::new();
let classifier = crate::classifier::HeuristicClassifier;
let mut scored: Vec<(f32, usize)> = findings
.iter()
.enumerate()
.map(|(i, f)| {
let features = extractor.extract(f);
(classifier.score(&features), i)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let reordered: Vec<Finding> = scored
.into_iter()
.map(|(_, i)| findings[i].clone())
.collect();
*findings = reordered;
}
fn filter_false_positives(
findings: &mut Vec<Finding>,
_graph: &dyn crate::graph::GraphQuery,
bypass_set: &HashSet<String>,
) {
use crate::classifier::{
model::HeuristicClassifier, CategoryThresholds, DetectorCategory, FeatureExtractor,
};
let thresholds = CategoryThresholds::default();
let extractor = FeatureExtractor::new();
let classifier = HeuristicClassifier;
let before_count = findings.len();
let mut filtered_by_category: std::collections::HashMap<DetectorCategory, usize> =
std::collections::HashMap::new();
findings.retain(|f| {
if f.deterministic || bypass_set.contains(&f.detector) {
return true;
}
let features = extractor.extract(f);
let tp_prob = classifier.score(&features);
let category = DetectorCategory::from_detector(&f.detector);
let config = thresholds.get_category(category);
if tp_prob >= config.filter_threshold {
true
} else {
*filtered_by_category.entry(category).or_insert(0) += 1;
false
}
});
let total_filtered = before_count - findings.len();
if total_filtered > 0 {
tracing::info!(
"FP classifier filtered {} findings (Security: {}, Quality: {}, ML: {}, Perf: {}, Other: {})",
total_filtered,
filtered_by_category.get(&DetectorCategory::Security).unwrap_or(&0),
filtered_by_category.get(&DetectorCategory::CodeQuality).unwrap_or(&0),
filtered_by_category.get(&DetectorCategory::MachineLearning).unwrap_or(&0),
filtered_by_category.get(&DetectorCategory::Performance).unwrap_or(&0),
filtered_by_category.get(&DetectorCategory::Other).unwrap_or(&0),
);
}
}
fn filter_file_level_suppressed(findings: &mut Vec<Finding>) {
use std::collections::HashMap;
let mut content_cache: HashMap<PathBuf, Option<String>> = HashMap::new();
let before = findings.len();
findings.retain(|f| {
for path in &f.affected_files {
let content = content_cache
.entry(path.clone())
.or_insert_with(|| std::fs::read_to_string(path).ok());
if let Some(c) = content.as_deref() {
if crate::detectors::is_file_suppressed_for(c, Some(&f.detector)) {
return false;
}
}
}
true
});
let removed = before - findings.len();
if removed > 0 {
tracing::debug!("File-level suppression filtered {} findings", removed);
}
}
fn filter_inline_suppressed(findings: &mut Vec<Finding>) {
use std::collections::HashMap;
let mut file_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
let before = findings.len();
findings.retain(|f| {
let line_start = match f.line_start {
Some(l) if l > 0 => l as usize,
_ => return true, };
for path in &f.affected_files {
let lines = file_cache.entry(path.clone()).or_insert_with(|| {
std::fs::read_to_string(path)
.map(|c| c.lines().map(String::from).collect())
.unwrap_or_default()
});
if lines.is_empty() {
continue;
}
let line_idx = line_start.saturating_sub(1); let scan_start = line_idx.saturating_sub(3);
let scan_end = (line_idx + 3).min(lines.len().saturating_sub(1));
for i in scan_start..=scan_end {
let line = lines.get(i).map(|s| s.as_str()).unwrap_or("");
let prev = if i > 0 {
lines.get(i - 1).map(|s| s.as_str())
} else {
None
};
if crate::detectors::is_line_suppressed_for(line, prev, &f.detector) {
return false; }
}
}
true
});
let removed = before - findings.len();
if removed > 0 {
tracing::debug!("Inline suppression filtered {} findings", removed);
}
}
fn deduplicate_findings(findings: &mut Vec<Finding>) {
use std::collections::HashSet;
let before = findings.len();
let mut seen = HashSet::new();
findings.retain(|f| {
let file = f
.affected_files
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let key = (f.detector.clone(), f.title.clone(), file, f.line_start);
seen.insert(key)
});
let removed = before - findings.len();
if removed > 0 {
tracing::debug!("Deduplicated {} findings", removed);
}
}
fn filter_detector_test_fixtures(findings: &mut Vec<Finding>) {
let before = findings.len();
findings.retain(|f| !is_detector_test_fixture(&f.detector, &f.affected_files));
let removed = before - findings.len();
if removed > 0 {
tracing::debug!(
"Auto-suppressed {} findings from detector test fixtures",
removed
);
}
}
fn is_detector_test_fixture(detector_name: &str, affected_files: &[PathBuf]) -> bool {
let slug = detector_name_to_path_slug(detector_name);
for path in affected_files {
let path_str = path.to_string_lossy();
let in_detector_dir =
path_str.contains("/detectors/") || path_str.contains("\\detectors\\");
let in_tests_dir = path_str.contains("/tests/") || path_str.contains("\\tests\\");
if !in_detector_dir && !in_tests_dir {
continue;
}
if path_str.contains(&format!("/{}/", &slug))
|| path_str.contains(&format!("\\{}\\", &slug))
|| path_str.contains(&format!("/{}.rs", &slug))
|| path_str.contains(&format!("\\{}.rs", &slug))
{
return true;
}
if is_security_detector(detector_name)
&& (path_str.contains("/taint/")
|| path_str.contains("\\taint\\")
|| path_str.contains("/taint.rs")
|| path_str.contains("\\taint.rs"))
{
return true;
}
}
false
}
fn detector_name_to_path_slug(name: &str) -> String {
let name = name.strip_suffix("Detector").unwrap_or(name);
let mut slug = String::with_capacity(name.len() + 4);
let chars: Vec<char> = name.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_uppercase() {
if i > 0 {
let prev_upper = chars[i - 1].is_uppercase();
let next_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase());
if !prev_upper || next_lower {
slug.push('_');
}
}
slug.push(
ch.to_lowercase()
.next()
.expect("to_lowercase always yields at least one char"),
);
} else {
slug.push(ch);
}
}
slug
}
pub(crate) fn filter_by_min_confidence(
findings: &mut Vec<Finding>,
min_confidence: Option<f64>,
show_all: bool,
) {
if show_all {
return;
}
let Some(threshold) = min_confidence else {
return;
};
let threshold = threshold.clamp(0.0, 1.0);
let before = findings.len();
findings.retain(|f| f.effective_confidence() >= threshold);
let removed = before - findings.len();
if removed > 0 {
tracing::debug!(
"Confidence filter (threshold={:.2}): removed {} findings below threshold",
threshold,
removed,
);
}
}
fn is_security_detector(name: &str) -> bool {
const SECURITY_DETECTORS: &[&str] = &[
"SQLInjectionDetector",
"CommandInjectionDetector",
"XssDetector",
"SsrfDetector",
"PathTraversalDetector",
"LogInjectionDetector",
"EvalDetector",
"InsecureRandomDetector",
"HardcodedCredentialsDetector",
"CleartextCredentialsDetector",
"NosqlInjectionDetector",
"XxeDetector",
"PrototypePollutionDetector",
"InsecureCryptoDetector",
"InsecureTlsDetector",
"JwtWeakDetector",
"CorsMisconfigDetector",
"SecretDetector",
"InsecureCookieDetector",
"InsecureDeserializeDetector",
];
SECURITY_DETECTORS.contains(&name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slug_sql_injection() {
assert_eq!(
detector_name_to_path_slug("SQLInjectionDetector"),
"sql_injection"
);
}
#[test]
fn test_slug_xss() {
assert_eq!(detector_name_to_path_slug("XssDetector"), "xss");
}
#[test]
fn test_slug_command_injection() {
assert_eq!(
detector_name_to_path_slug("CommandInjectionDetector"),
"command_injection"
);
}
#[test]
fn test_slug_god_class() {
assert_eq!(detector_name_to_path_slug("GodClassDetector"), "god_class");
}
#[test]
fn test_slug_ssrf() {
assert_eq!(detector_name_to_path_slug("SsrfDetector"), "ssrf");
}
#[test]
fn test_slug_n_plus_one() {
assert_eq!(detector_name_to_path_slug("NPlusOneDetector"), "n_plus_one");
}
#[test]
fn test_slug_ai_boilerplate() {
assert_eq!(
detector_name_to_path_slug("AIBoilerplateDetector"),
"ai_boilerplate"
);
}
#[test]
fn test_fixture_match_sql_injection_tests() {
let files = vec![PathBuf::from("src/detectors/sql_injection/tests.rs")];
assert!(is_detector_test_fixture("SQLInjectionDetector", &files));
}
#[test]
fn test_fixture_match_sql_injection_mod() {
let files = vec![PathBuf::from("src/detectors/sql_injection/mod.rs")];
assert!(is_detector_test_fixture("SQLInjectionDetector", &files));
}
#[test]
fn test_fixture_match_taint_for_security() {
let files = vec![PathBuf::from("src/detectors/taint/mod.rs")];
assert!(is_detector_test_fixture("SQLInjectionDetector", &files));
}
#[test]
fn test_fixture_no_match_different_detector() {
let files = vec![PathBuf::from("src/detectors/sql_injection/tests.rs")];
assert!(!is_detector_test_fixture("XssDetector", &files));
}
#[test]
fn test_fixture_no_match_regular_source() {
let files = vec![PathBuf::from("src/main.rs")];
assert!(!is_detector_test_fixture("SQLInjectionDetector", &files));
}
#[test]
fn test_fixture_no_match_user_code() {
let files = vec![PathBuf::from("tests/integration_test.rs")];
assert!(!is_detector_test_fixture("SQLInjectionDetector", &files));
}
#[test]
fn test_fixture_match_god_class_file() {
let files = vec![PathBuf::from("src/detectors/god_class.rs")];
assert!(is_detector_test_fixture("GodClassDetector", &files));
}
#[test]
fn test_fixture_taint_not_matched_for_non_security() {
let files = vec![PathBuf::from("src/detectors/taint/mod.rs")];
assert!(!is_detector_test_fixture("GodClassDetector", &files));
}
#[test]
fn test_is_security_detector() {
assert!(is_security_detector("SQLInjectionDetector"));
assert!(is_security_detector("XssDetector"));
assert!(is_security_detector("CommandInjectionDetector"));
assert!(!is_security_detector("GodClassDetector"));
assert!(!is_security_detector("DeadCodeDetector"));
}
#[test]
fn test_assign_default_confidence_sets_architecture() {
let mut findings = vec![Finding {
category: Some("architecture".into()),
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.85));
}
#[test]
fn test_assign_default_confidence_sets_security() {
let mut findings = vec![Finding {
category: Some("security".into()),
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.75));
}
#[test]
fn test_assign_default_confidence_sets_design() {
let mut findings = vec![Finding {
category: Some("design".into()),
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.65));
}
#[test]
fn test_assign_default_confidence_sets_dead_code() {
let mut findings = vec![
Finding {
category: Some("dead-code".into()),
confidence: None,
..Default::default()
},
Finding {
category: Some("dead_code".into()),
confidence: None,
..Default::default()
},
];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.70));
assert_eq!(findings[1].confidence, Some(0.70));
}
#[test]
fn test_assign_default_confidence_sets_ai_watchdog() {
let mut findings = vec![Finding {
category: Some("ai_watchdog".into()),
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.60));
}
#[test]
fn test_assign_default_confidence_sets_unknown_category() {
let mut findings = vec![Finding {
category: Some("testing".into()),
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.70));
}
#[test]
fn test_assign_default_confidence_sets_none_category() {
let mut findings = vec![Finding {
category: None,
confidence: None,
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.70));
}
#[test]
fn test_assign_default_confidence_does_not_overwrite_existing() {
let mut findings = vec![Finding {
category: Some("architecture".into()),
confidence: Some(0.42),
..Default::default()
}];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.42));
}
#[test]
fn test_assign_default_confidence_mixed_findings() {
let mut findings = vec![
Finding {
category: Some("security".into()),
confidence: Some(0.99),
..Default::default()
},
Finding {
category: Some("architecture".into()),
confidence: None,
..Default::default()
},
Finding {
category: None,
confidence: None,
..Default::default()
},
];
assign_default_confidence(&mut findings);
assert_eq!(findings[0].confidence, Some(0.99)); assert_eq!(findings[1].confidence, Some(0.85)); assert_eq!(findings[2].confidence, Some(0.70)); }
#[test]
fn test_min_confidence_filters_below_threshold() {
let mut findings = vec![
Finding {
confidence: Some(0.9),
..Default::default()
},
Finding {
confidence: Some(0.5),
..Default::default()
},
Finding {
confidence: Some(0.7),
..Default::default()
},
];
filter_by_min_confidence(&mut findings, Some(0.6), false);
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].confidence, Some(0.9));
assert_eq!(findings[1].confidence, Some(0.7));
}
#[test]
fn test_min_confidence_none_does_not_filter() {
let mut findings = vec![
Finding {
confidence: Some(0.1),
..Default::default()
},
Finding {
confidence: Some(0.9),
..Default::default()
},
];
filter_by_min_confidence(&mut findings, None, false);
assert_eq!(findings.len(), 2);
}
#[test]
fn test_min_confidence_show_all_bypasses_filter() {
let mut findings = vec![
Finding {
confidence: Some(0.1),
..Default::default()
},
Finding {
confidence: Some(0.2),
..Default::default()
},
];
filter_by_min_confidence(&mut findings, Some(0.99), true);
assert_eq!(findings.len(), 2); }
#[test]
fn test_min_confidence_exact_threshold_kept() {
let mut findings = vec![Finding {
confidence: Some(0.7),
..Default::default()
}];
filter_by_min_confidence(&mut findings, Some(0.7), false);
assert_eq!(findings.len(), 1); }
#[test]
fn test_min_confidence_clamps_above_one() {
let mut findings = vec![Finding {
confidence: Some(0.99),
..Default::default()
}];
filter_by_min_confidence(&mut findings, Some(1.5), false);
assert_eq!(findings.len(), 0);
}
#[test]
fn test_min_confidence_clamps_below_zero() {
let mut findings = vec![Finding {
confidence: Some(0.01),
..Default::default()
}];
filter_by_min_confidence(&mut findings, Some(-0.5), false);
assert_eq!(findings.len(), 1);
}
#[test]
fn test_min_confidence_uses_effective_confidence_for_none() {
let mut findings = vec![Finding {
confidence: None,
..Default::default()
}];
filter_by_min_confidence(&mut findings, Some(0.5), false);
assert_eq!(findings.len(), 1);
filter_by_min_confidence(&mut findings, Some(0.8), false);
assert_eq!(findings.len(), 0);
}
}
#[cfg(test)]
mod label_tests {
use super::*;
use crate::models::{Finding, Severity};
use std::collections::HashMap;
fn make_finding(id: &str, detector: &str) -> Finding {
Finding {
id: id.into(),
detector: detector.into(),
severity: Severity::Medium,
title: format!("Finding {}", id),
..Default::default()
}
}
#[test]
fn test_fp_label_removes_finding() {
let mut findings = vec![make_finding("aaa", "Det1"), make_finding("bbb", "Det2")];
let labels = HashMap::from([("aaa".to_string(), false)]);
apply_labels_to_findings(&mut findings, &labels, false);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].id, "bbb");
}
#[test]
fn test_fp_label_show_all_reinserts_with_low_confidence() {
let mut findings = vec![make_finding("aaa", "Det1"), make_finding("bbb", "Det2")];
let labels = HashMap::from([("aaa".to_string(), false)]);
apply_labels_to_findings(&mut findings, &labels, true);
assert_eq!(findings.len(), 2, "show_all should keep FP finding");
let fp = findings
.iter()
.find(|f| f.id == "aaa")
.expect("FP finding should exist");
assert_eq!(fp.confidence, Some(0.05));
assert_eq!(
fp.threshold_metadata.get("user_label").map(|s| s.as_str()),
Some("false_positive")
);
}
#[test]
fn test_tp_label_pins_finding() {
let mut findings = vec![make_finding("aaa", "Det1")];
let labels = HashMap::from([("aaa".to_string(), true)]);
apply_labels_to_findings(&mut findings, &labels, false);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].confidence, Some(0.95));
assert!(findings[0].deterministic);
assert_eq!(
findings[0]
.threshold_metadata
.get("user_label")
.map(|s| s.as_str()),
Some("true_positive")
);
}
#[test]
fn test_unlabeled_findings_unchanged() {
let mut findings = vec![make_finding("aaa", "Det1")];
let labels = HashMap::from([("zzz".to_string(), false)]);
apply_labels_to_findings(&mut findings, &labels, false);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].id, "aaa");
assert!(findings[0].confidence.is_none()); }
#[test]
fn test_empty_labels_is_noop() {
let mut findings = vec![make_finding("aaa", "Det1")];
let labels = HashMap::new();
apply_labels_to_findings(&mut findings, &labels, false);
assert_eq!(findings.len(), 1);
}
}
#[cfg(test)]
mod demote_param_gated_tests {
use super::*;
use std::path::PathBuf;
fn finding_at(detector: &str, severity: Severity, path: &Path, line: u32) -> Finding {
Finding {
detector: detector.to_string(),
severity,
title: format!("{} test finding", detector),
description: "Original description.".to_string(),
affected_files: vec![path.to_path_buf()],
line_start: Some(line),
line_end: Some(line),
confidence: Some(0.95),
category: Some("security".to_string()),
..Default::default()
}
}
fn write_py(src: &str) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.py");
std::fs::write(&path, src).unwrap();
(tmp, path)
}
#[test]
fn test_demote_httpx_style_verify_false_branch() {
let src = "def create_ssl_context(verify, trust_env):\n\
\x20 if verify is True:\n\
\x20 ctx = ssl.create_default_context()\n\
\x20 elif verify is False:\n\
\x20 ctx = ssl.SSLContext()\n\
\x20 ctx.check_hostname = False\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
7,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 1);
assert_eq!(findings[0].severity, Severity::Low);
assert!(findings[0].confidence.unwrap() <= 0.4);
assert!(findings[0].description.contains("parameter-gated"));
assert_eq!(
findings[0].threshold_metadata.get(PARAM_GATED_METADATA_KEY),
Some(&"true".to_string())
);
}
#[test]
fn test_dont_demote_module_level_cert_none() {
let src = "import ssl\nctx = ssl.SSLContext()\nctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
3,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 0);
assert_eq!(findings[0].severity, Severity::Critical);
}
#[test]
fn test_dont_demote_global_gated_branch() {
let src = "def configure(other_arg):\n\
\x20 if not is_production():\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
3,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 0);
assert_eq!(findings[0].severity, Severity::Critical);
}
#[test]
fn test_demote_applies_to_crypto_findings() {
let src = "def hash_password(pwd, use_legacy):\n\
\x20 if use_legacy:\n\
\x20 return hashlib.sha1(pwd).hexdigest()\n\
\x20 return hashlib.sha256(pwd).hexdigest()\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureCryptoDetector",
Severity::High,
&path,
3,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 1);
assert_eq!(findings[0].severity, Severity::Low);
}
#[test]
fn test_demote_applies_to_subprocess_findings() {
let src = "def run(cmd, shell_mode):\n\
\x20 if shell_mode:\n\
\x20 subprocess.run(cmd, shell=True)\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"CommandInjectionDetector",
Severity::Critical,
&path,
3,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 1);
assert_eq!(findings[0].severity, Severity::Low);
}
#[test]
fn test_else_branch_of_param_gated_if_demoted() {
let src = "def f(verify):\n\
\x20 if verify:\n\
\x20 a = 1\n\
\x20 else:\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
5,
)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 1);
assert_eq!(findings[0].severity, Severity::Low);
}
#[test]
fn test_non_security_detector_left_alone() {
let src = "def f(verify):\n\
\x20 if verify is False:\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("GodClassDetector", Severity::High, &path, 3)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 0);
assert_eq!(findings[0].severity, Severity::High);
}
#[test]
fn test_low_severity_is_skipped() {
let src = "def f(verify):\n\
\x20 if verify is False:\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("InsecureTlsDetector", Severity::Low, &path, 3)];
let n = classify_param_gated_security_branches(
&mut findings,
None,
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n, 0);
assert!(!findings[0].description.contains("parameter-gated"));
}
#[test]
fn test_warm_cache_is_reused() {
let src = "def f(verify):\n\
\x20 if verify is False:\n\
\x20 ctx.verify_mode = ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let cache = Arc::new(FileContentCache::new());
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
3,
)];
let n1 = classify_param_gated_security_branches(
&mut findings,
Some(&cache),
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(n1, 1);
assert_eq!(cache.tree_count(), 1);
let mut findings2 = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
3,
)];
let _ = classify_param_gated_security_branches(
&mut findings2,
Some(&cache),
Path::new("/"),
&crate::config::DualBranchConfig::default(),
);
assert_eq!(cache.tree_count(), 1);
}
fn dual_branch_insecure_tls_on() -> crate::config::DualBranchConfig {
let mut cfg = crate::config::DualBranchConfig {
enabled: true,
detectors: HashMap::new(),
};
cfg.detectors.insert("insecure-tls".to_string(), true);
cfg
}
#[test]
fn b1_param_gated_no_annotation_promotes_to_benign_primary() {
let src = "def configure(verify):\n\
\x20 if not verify:\n\
\x20 ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("InsecureTlsDetector", Severity::High, &path, 3)];
let cfg = dual_branch_insecure_tls_on();
let demoted =
classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
assert_eq!(demoted, 0, "Outcome B does not count toward demoted total");
let f = &findings[0];
assert_eq!(f.severity, Severity::Info, "predicted Benign → Info");
let alt = f
.alternative_branch
.as_ref()
.expect("alternative_branch set");
assert_eq!(alt.label, crate::dual_branch::BranchLabel::RealBug);
assert_eq!(
alt.severity,
Severity::High,
"alternative carries orig severity"
);
let scopes: Vec<_> = f
.prediction_reasons
.iter()
.filter_map(|r| match &r.kind {
crate::dual_branch::PredictionReasonKind::EnclosingScope { scope_kind, name } => {
Some((scope_kind.as_str(), name.as_str()))
}
_ => None,
})
.collect();
assert_eq!(scopes, vec![("param_gated_conditional", "verify")]);
assert!(f.resolution_signals.is_empty());
}
#[test]
fn b2_not_param_gated_no_annotation_keeps_realbug_primary() {
let src = "import ssl\n\
ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("InsecureTlsDetector", Severity::High, &path, 2)];
let cfg = dual_branch_insecure_tls_on();
let demoted =
classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
assert_eq!(demoted, 0);
let f = &findings[0];
assert_eq!(
f.severity,
Severity::High,
"predicted RealBug keeps orig severity"
);
let alt = f
.alternative_branch
.as_ref()
.expect("alternative_branch set");
assert_eq!(alt.label, crate::dual_branch::BranchLabel::Benign);
assert_eq!(alt.severity, Severity::Info);
assert!(
f.prediction_reasons.is_empty(),
"B3 case is the pure tiebreaker-conservative emission"
);
assert!(f.resolution_signals.is_empty());
}
#[test]
fn b3_param_gated_with_annotation_attaches_resolution_signal() {
let src = "def configure(verify):\n\
\x20 if not verify:\n\
\x20 ssl.CERT_NONE # repotoire: tls-disabled[self-signed-dev]\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("InsecureTlsDetector", Severity::High, &path, 3)];
let cfg = dual_branch_insecure_tls_on();
let _ = classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
let f = &findings[0];
assert_eq!(
f.severity,
Severity::Info,
"annotation forces Benign primary"
);
let alt = f
.alternative_branch
.as_ref()
.expect("alternative_branch set");
assert_eq!(alt.label, crate::dual_branch::BranchLabel::RealBug);
let syntaxes: Vec<&str> = f
.resolution_signals
.iter()
.filter_map(|r| match &r.kind {
crate::dual_branch::ResolutionKind::SourceAnnotation { syntax } => {
Some(syntax.as_str())
}
_ => None,
})
.collect();
assert_eq!(syntaxes.len(), 1);
assert!(
syntaxes[0].contains("tls-disabled[self-signed-dev]"),
"syntax preserves args: got `{}`",
syntaxes[0]
);
assert_eq!(
f.resolution_signals[0].collapses_to,
crate::dual_branch::BranchLabel::Benign
);
let has_enclosing = f.prediction_reasons.iter().any(|r| {
matches!(
&r.kind,
crate::dual_branch::PredictionReasonKind::EnclosingScope { .. }
)
});
assert!(
has_enclosing,
"annotation does not erase the AST-derived evidence"
);
}
#[test]
fn b4_not_param_gated_with_preceding_line_annotation_collapses_to_benign() {
let src = "import ssl\n\
# repotoire: tls-disabled[integration-test-mitm]\n\
ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at(
"InsecureTlsDetector",
Severity::Critical,
&path,
3,
)];
let cfg = dual_branch_insecure_tls_on();
let _ = classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
let f = &findings[0];
assert_eq!(
f.severity,
Severity::Info,
"annotation collapses even without param-gating"
);
let alt = f
.alternative_branch
.as_ref()
.expect("alternative_branch set");
assert_eq!(alt.label, crate::dual_branch::BranchLabel::RealBug);
assert_eq!(
alt.severity,
Severity::Critical,
"alternative preserves original Critical severity"
);
assert_eq!(f.resolution_signals.len(), 1);
let has_enclosing = f.prediction_reasons.iter().any(|r| {
matches!(
&r.kind,
crate::dual_branch::PredictionReasonKind::EnclosingScope { .. }
)
});
assert!(!has_enclosing);
}
#[test]
fn flag_off_preserves_pre_phase_2c_demotion_for_insecure_tls() {
let src = "def configure(verify):\n\
\x20 if not verify:\n\
\x20 ssl.CERT_NONE\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("InsecureTlsDetector", Severity::High, &path, 3)];
let cfg = crate::config::DualBranchConfig::default();
let demoted =
classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
assert_eq!(demoted, 1);
let f = &findings[0];
assert_eq!(f.severity, Severity::Low);
assert!(
f.alternative_branch.is_none(),
"flag off → no dual-branch shape"
);
assert!(f.prediction_reasons.is_empty());
assert!(f.resolution_signals.is_empty());
assert_eq!(
f.threshold_metadata.get(PARAM_GATED_METADATA_KEY),
Some(&"true".to_string()),
"flag off → original threshold metadata still set"
);
}
#[test]
fn flag_on_does_not_affect_non_insecure_tls_security_findings() {
let src = "def query(safe):\n\
\x20 if not safe:\n\
\x20 cursor.execute(user_input)\n";
let (_tmp, path) = write_py(src);
let mut findings = vec![finding_at("SQLInjectionDetector", Severity::High, &path, 3)];
let cfg = dual_branch_insecure_tls_on();
let demoted =
classify_param_gated_security_branches(&mut findings, None, Path::new("/"), &cfg);
assert_eq!(demoted, 1, "non-TLS findings still go through Outcome A");
let f = &findings[0];
assert_eq!(f.severity, Severity::Low);
assert!(f.alternative_branch.is_none());
}
}