use crate::ai::client::{AiClient, LlmBackend};
use crate::ai::{AiError, AiResult};
use crate::models::{Finding, Severity};
use std::path::Path;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
pub enum VerifyResult {
TruePositive { reason: String },
FalsePositive { reason: String },
Error { message: String },
}
pub struct FindingVerifier {
client: AiClient,
repo_path: std::path::PathBuf,
}
impl FindingVerifier {
pub fn new(repo_path: &Path) -> AiResult<Self> {
if AiClient::ollama_available() {
let client = AiClient::from_env(LlmBackend::Ollama)?;
return Ok(Self {
client,
repo_path: repo_path.to_path_buf(),
});
}
let client = AiClient::from_env(LlmBackend::Anthropic)?;
Ok(Self {
client,
repo_path: repo_path.to_path_buf(),
})
}
pub fn with_backend(repo_path: &Path, backend: LlmBackend) -> AiResult<Self> {
let client = AiClient::from_env(backend)?;
Ok(Self {
client,
repo_path: repo_path.to_path_buf(),
})
}
pub fn verify_finding(&self, finding: &Finding) -> VerifyResult {
let code_context = match self.read_code_context(finding) {
Ok(ctx) => ctx,
Err(e) => return VerifyResult::Error { message: e.to_string() },
};
let prompt = format!(
r#"You are a code analysis expert. Analyze this static analysis finding and determine if it's a TRUE POSITIVE (real issue) or FALSE POSITIVE (not a real issue).
FINDING:
- Detector: {}
- Severity: {:?}
- Title: {}
- Description: {}
CODE CONTEXT:
```
{}
```
Analyze the code and finding carefully. Consider:
1. Is the detection logic correct for this specific code?
2. Could this be a false alarm due to context the detector can't see?
3. Is this actually a problem in practice?
Reply with exactly one line:
TRUE_POSITIVE: <brief reason>
or
FALSE_POSITIVE: <brief reason>"#,
finding.detector, finding.severity, finding.title, finding.description, code_context
);
let messages = vec![crate::ai::Message {
role: crate::ai::Role::User,
content: prompt,
}];
match self.client.generate(messages, None) {
Ok(response) => self.parse_response(&response),
Err(e) => VerifyResult::Error {
message: e.to_string(),
},
}
}
fn read_code_context(&self, finding: &Finding) -> AiResult<String> {
let file_path = finding
.affected_files
.first()
.ok_or_else(|| AiError::ConfigError("No affected file".into()))?;
let full_path = self.repo_path.join(file_path);
let content = std::fs::read_to_string(&full_path)?;
let lines: Vec<&str> = content.lines().collect();
let start = finding.line_start.unwrap_or(1) as usize;
let end = finding.line_end.unwrap_or(start as u32) as usize;
let context_start = start.saturating_sub(6);
let context_end = (end + 5).min(lines.len());
let context: Vec<String> = lines[context_start..context_end]
.iter()
.enumerate()
.map(|(i, line)| format!("{:4} | {}", context_start + i + 1, line))
.collect();
Ok(context.join("\n"))
}
fn parse_response(&self, response: &str) -> VerifyResult {
let response = response.trim();
if response.starts_with("TRUE_POSITIVE:") {
let reason = response.strip_prefix("TRUE_POSITIVE:").unwrap_or("").trim();
VerifyResult::TruePositive {
reason: reason.to_string(),
}
} else if response.starts_with("FALSE_POSITIVE:") {
let reason = response
.strip_prefix("FALSE_POSITIVE:")
.unwrap_or("")
.trim();
VerifyResult::FalsePositive {
reason: reason.to_string(),
}
} else {
let lower = response.to_lowercase();
if lower.contains("false positive") || lower.contains("not a real") {
VerifyResult::FalsePositive {
reason: response.to_string(),
}
} else if lower.contains("true positive") || lower.contains("real issue") {
VerifyResult::TruePositive {
reason: response.to_string(),
}
} else {
VerifyResult::TruePositive {
reason: "Unable to parse response, keeping finding".to_string(),
}
}
}
}
}
pub fn verify_findings(findings: Vec<Finding>, repo_path: &Path) -> Vec<Finding> {
let (high_findings, other_findings): (Vec<_>, Vec<_>) = findings
.into_iter()
.partition(|f| f.severity == Severity::High);
if high_findings.is_empty() {
info!("No HIGH findings to verify");
return other_findings;
}
info!(
"Verifying {} HIGH findings with LLM...",
high_findings.len()
);
let verifier = match FindingVerifier::new(repo_path) {
Ok(v) => v,
Err(e) => {
warn!("Failed to create verifier: {}. Skipping verification.", e);
let mut all = other_findings;
all.extend(high_findings);
return all;
}
};
let mut verified_findings = Vec::new();
let mut fp_count = 0;
let mut tp_count = 0;
let mut err_count = 0;
for finding in high_findings {
let result = verifier.verify_finding(&finding);
match result {
VerifyResult::TruePositive { reason } => {
debug!("TRUE_POSITIVE: {} - {}", finding.title, reason);
tp_count += 1;
verified_findings.push(finding);
}
VerifyResult::FalsePositive { reason } => {
debug!("FALSE_POSITIVE: {} - {}", finding.title, reason);
fp_count += 1;
}
VerifyResult::Error { message } => {
debug!("VERIFY_ERROR: {} - {}", finding.title, message);
err_count += 1;
verified_findings.push(finding);
}
}
}
info!(
"LLM verification: {} true positives, {} false positives filtered, {} errors",
tp_count, fp_count, err_count
);
let mut all = other_findings;
all.extend(verified_findings);
all
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_true_positive() {
let response = "TRUE_POSITIVE: This is a real SQL injection vulnerability";
let result = if response.starts_with("TRUE_POSITIVE:") {
let reason = response.strip_prefix("TRUE_POSITIVE:").unwrap_or("").trim();
VerifyResult::TruePositive {
reason: reason.to_string(),
}
} else {
VerifyResult::FalsePositive {
reason: "".to_string(),
}
};
assert!(matches!(result, VerifyResult::TruePositive { .. }));
}
#[test]
fn test_parse_false_positive() {
let response = "FALSE_POSITIVE: The input is sanitized before use";
let result = if response.starts_with("FALSE_POSITIVE:") {
let reason = response
.strip_prefix("FALSE_POSITIVE:")
.unwrap_or("")
.trim();
VerifyResult::FalsePositive {
reason: reason.to_string(),
}
} else {
VerifyResult::TruePositive {
reason: "".to_string(),
}
};
assert!(matches!(result, VerifyResult::FalsePositive { .. }));
}
}