use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::scanner::{Finding, ScanResults, Severity};
use super::{Baseline, BaselineFinding, FindingFingerprint};
pub struct DiffEngine;
impl DiffEngine {
pub fn diff(baseline: &Baseline, current: &ScanResults) -> DiffResult {
let baseline_set: HashSet<_> = baseline
.findings
.iter()
.map(|f| f.evidence_hash.as_str())
.collect();
let current_fingerprints: Vec<_> = current
.findings
.iter()
.map(|f| (FindingFingerprint::from_finding(f), f))
.collect();
let mut new_findings = Vec::new();
let mut unchanged_count = 0;
for (fingerprint, finding) in ¤t_fingerprints {
if baseline_set.contains(fingerprint.as_str()) {
unchanged_count += 1;
} else {
new_findings.push((*finding).clone());
}
}
let current_set: HashSet<_> = current_fingerprints
.iter()
.map(|(fp, _)| fp.as_str())
.collect();
let fixed_findings: Vec<_> = baseline
.findings
.iter()
.filter(|f| !current_set.contains(f.evidence_hash.as_str()))
.cloned()
.collect();
let (new_critical, new_high) = new_findings.iter().fold((0, 0), |(crit, high), f| match f
.severity
{
Severity::Critical => (crit + 1, high),
Severity::High => (crit, high + 1),
_ => (crit, high),
});
DiffResult {
new_findings,
fixed_findings,
unchanged_count,
summary: DiffSummary {
total_baseline: baseline.findings.len(),
total_current: current.findings.len(),
new_count: current.findings.len() - unchanged_count,
fixed_count: baseline.findings.len()
- baseline
.findings
.iter()
.filter(|f| current_set.contains(f.evidence_hash.as_str()))
.count(),
unchanged_count,
new_critical,
new_high,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffResult {
pub new_findings: Vec<Finding>,
pub fixed_findings: Vec<BaselineFinding>,
pub unchanged_count: usize,
pub summary: DiffSummary,
}
impl DiffResult {
pub fn has_new_critical_or_high(&self) -> bool {
self.summary.new_critical > 0 || self.summary.new_high > 0
}
#[allow(dead_code)] pub fn has_new_findings(&self) -> bool {
!self.new_findings.is_empty()
}
pub fn new_count(&self) -> usize {
self.new_findings.len()
}
pub fn fixed_count(&self) -> usize {
self.fixed_findings.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffSummary {
pub total_baseline: usize,
pub total_current: usize,
pub new_count: usize,
pub fixed_count: usize,
pub unchanged_count: usize,
pub new_critical: usize,
pub new_high: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{FindingLocation, ScanSummary};
fn create_finding(rule_id: &str, tool: &str) -> Finding {
Finding::new(rule_id, Severity::High, "Test Finding", "Description")
.with_location(FindingLocation::tool(tool))
}
fn create_results(findings: Vec<Finding>) -> ScanResults {
let mut results = ScanResults {
server: "test-server".to_string(),
profile: "standard".to_string(),
total_checks: 10,
findings: Vec::new(),
summary: ScanSummary::default(),
duration_ms: 1000,
};
for finding in findings {
results.add_finding(finding);
}
results
}
#[test]
fn diff_detects_new_findings() {
let finding_a = create_finding("MCP-INJ-001", "tool_a");
let finding_b = create_finding("MCP-INJ-002", "tool_b");
let baseline_results = create_results(vec![finding_a.clone()]);
let baseline = Baseline::from_results(&baseline_results);
let current = create_results(vec![finding_a.clone(), finding_b.clone()]);
let diff = DiffEngine::diff(&baseline, ¤t);
assert_eq!(diff.new_findings.len(), 1);
assert_eq!(diff.unchanged_count, 1);
assert_eq!(diff.fixed_findings.len(), 0);
}
#[test]
fn diff_detects_fixed_findings() {
let finding_a = create_finding("MCP-INJ-001", "tool_a");
let finding_b = create_finding("MCP-INJ-002", "tool_b");
let baseline_results = create_results(vec![finding_a.clone(), finding_b.clone()]);
let baseline = Baseline::from_results(&baseline_results);
let current = create_results(vec![finding_a.clone()]);
let diff = DiffEngine::diff(&baseline, ¤t);
assert_eq!(diff.new_findings.len(), 0);
assert_eq!(diff.unchanged_count, 1);
assert_eq!(diff.fixed_findings.len(), 1);
}
#[test]
fn diff_unchanged_findings() {
let finding_a = create_finding("MCP-INJ-001", "tool_a");
let baseline_results = create_results(vec![finding_a.clone()]);
let baseline = Baseline::from_results(&baseline_results);
let current = create_results(vec![finding_a.clone()]);
let diff = DiffEngine::diff(&baseline, ¤t);
assert_eq!(diff.new_findings.len(), 0);
assert_eq!(diff.unchanged_count, 1);
assert_eq!(diff.fixed_findings.len(), 0);
assert!(!diff.has_new_findings());
}
#[test]
fn diff_counts_critical_high() {
let finding_crit = Finding::new("MCP-INJ-001", Severity::Critical, "Critical", "Desc")
.with_location(FindingLocation::tool("tool_crit"));
let finding_high = Finding::new("MCP-INJ-002", Severity::High, "High", "Desc")
.with_location(FindingLocation::tool("tool_high"));
let finding_med = Finding::new("MCP-INJ-003", Severity::Medium, "Medium", "Desc")
.with_location(FindingLocation::tool("tool_med"));
let baseline_results = create_results(vec![]);
let baseline = Baseline::from_results(&baseline_results);
let current = create_results(vec![
finding_crit.clone(),
finding_high.clone(),
finding_med.clone(),
]);
let diff = DiffEngine::diff(&baseline, ¤t);
assert_eq!(diff.summary.new_critical, 1);
assert_eq!(diff.summary.new_high, 1);
assert!(diff.has_new_critical_or_high());
}
}