use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{Finding, FindingKind, Severity};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FindingFilter {
#[serde(default)]
pub min_severity: Option<Severity>,
#[serde(default)]
pub max_severity: Option<Severity>,
#[serde(default)]
pub include_scanners: Vec<Arc<str>>,
#[serde(default)]
pub exclude_scanners: Vec<Arc<str>>,
#[serde(default)]
pub include_tags: Vec<Arc<str>>,
#[serde(default)]
pub exclude_tags: Vec<Arc<str>>,
#[serde(default)]
pub tag_mode: TagMode,
#[serde(default)]
pub include_kinds: Vec<FindingKind>,
#[serde(default)]
pub exclude_kinds: Vec<FindingKind>,
#[serde(default)]
pub min_confidence: Option<f64>,
#[serde(default)]
pub start_date: Option<DateTime<Utc>>,
#[serde(default)]
pub end_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TagMode {
#[default]
Any,
All,
}
impl FindingFilter {
pub fn from_toml(toml: &str) -> Result<Self, String> {
toml::from_str(toml).map_err(|e| format!("Failed to parse TOML filter config: {e}"))
}
}
impl std::fmt::Display for FindingFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"min_severity={:?}, max_severity={:?}, include_scanners={}, exclude_scanners={}, include_tags={}, exclude_tags={}, tag_mode={:?}, include_kinds={}, exclude_kinds={}, min_confidence={:?}, start_date={:?}, end_date={:?}",
self.min_severity,
self.max_severity,
self.include_scanners.join(","),
self.exclude_scanners.join(","),
self.include_tags.join(","),
self.exclude_tags.join(","),
self.tag_mode,
self.include_kinds.iter().map(|k| format!("{k:?}")).collect::<Vec<_>>().join(","),
self.exclude_kinds.iter().map(|k| format!("{k:?}")).collect::<Vec<_>>().join(","),
self.min_confidence,
self.start_date,
self.end_date,
)
}
}
#[must_use]
pub fn filter<'a>(findings: &'a [Finding], config: &FindingFilter) -> Vec<&'a Finding> {
findings
.iter()
.filter(|finding| {
if let Some(min) = config.min_severity {
if finding.severity() < min {
return false;
}
}
if let Some(max) = config.max_severity {
if finding.severity() > max {
return false;
}
}
if !config.include_scanners.is_empty()
&& !config
.include_scanners
.iter()
.any(|s| s.as_ref() == finding.scanner())
{
return false;
}
if config
.exclude_scanners
.iter()
.any(|s| s.as_ref() == finding.scanner())
{
return false;
}
if !config.include_tags.is_empty() {
let matches = match config.tag_mode {
TagMode::Any => finding
.tags()
.iter()
.any(|ft| config.include_tags.iter().any(|it| it == ft)),
TagMode::All => config
.include_tags
.iter()
.all(|it| finding.tags().iter().any(|ft| ft == it)),
};
if !matches {
return false;
}
}
if config
.exclude_tags
.iter()
.any(|et| finding.tags().iter().any(|ft| ft == et))
{
return false;
}
if !config.include_kinds.is_empty() && !config.include_kinds.contains(&finding.kind()) {
return false;
}
if config.exclude_kinds.contains(&finding.kind()) {
return false;
}
if let Some(min_conf) = config.min_confidence {
if let Some(conf) = finding.confidence() {
if conf < min_conf {
return false;
}
} else {
return false;
}
}
if let Some(start) = config.start_date {
if finding.timestamp() < start {
return false;
}
}
if let Some(end) = config.end_date {
if finding.timestamp() > end {
return false;
}
}
true
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Finding;
#[test]
fn filter_applies_severity_scanner_and_tags() {
let findings = vec![
Finding::builder("nmap", "https://example.com", Severity::Critical)
.title("RCE")
.tag("critical")
.build()
.unwrap(),
Finding::builder("burp", "https://example.com", Severity::High)
.title("SQLi")
.tag("sqli")
.build()
.unwrap(),
Finding::builder("trivy", "https://example.org", Severity::Low)
.title("Info")
.tag("auth")
.build()
.unwrap(),
];
let config = FindingFilter {
min_severity: Some(Severity::High),
exclude_scanners: vec!["nmap".into()],
include_tags: vec!["sqli".into()],
..Default::default()
};
let filtered = filter(&findings, &config);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].scanner(), "burp");
}
#[test]
fn filter_with_no_includes_keeps_matching_scanners() {
let findings = vec![
Finding::builder("a", "target", Severity::High)
.title("t")
.tag("x")
.build()
.unwrap(),
Finding::builder("b", "target", Severity::Medium)
.title("t")
.tag("x")
.build()
.unwrap(),
];
let config = FindingFilter {
min_severity: Some(Severity::Medium),
exclude_scanners: vec!["b".into()],
include_tags: Vec::new(),
..Default::default()
};
let filtered = filter(&findings, &config);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].scanner(), "a");
}
#[test]
fn filter_all_excluded() {
let findings = vec![Finding::builder("a", "target", Severity::High)
.title("t")
.build()
.unwrap()];
let config = FindingFilter {
min_severity: None,
exclude_scanners: vec!["a".into()],
include_tags: Vec::new(),
..Default::default()
};
let filtered = filter(&findings, &config);
assert!(filtered.is_empty());
}
#[test]
fn filter_by_min_severity_only() {
let findings = vec![
Finding::builder("a", "target", Severity::Info)
.title("t")
.build()
.unwrap(),
Finding::builder("b", "target", Severity::Low)
.title("t")
.build()
.unwrap(),
Finding::builder("c", "target", Severity::Critical)
.title("t")
.build()
.unwrap(),
];
let config = FindingFilter {
min_severity: Some(Severity::Low),
exclude_scanners: Vec::new(),
include_tags: Vec::new(),
..Default::default()
};
let filtered = filter(&findings, &config);
assert_eq!(filtered.len(), 2);
}
#[test]
fn filter_no_tags_match() {
let findings = vec![Finding::builder("a", "target", Severity::High)
.title("t")
.tag("t1")
.build()
.unwrap()];
let config = FindingFilter {
min_severity: None,
exclude_scanners: Vec::new(),
include_tags: vec!["t2".into()],
..Default::default()
};
let filtered = filter(&findings, &config);
assert!(filtered.is_empty());
}
#[test]
fn parse_toml_filter_config() {
let toml_str = r#"
min_severity = "high"
exclude_scanners = ["test"]
include_tags = ["t1", "t2"]
"#;
let config = FindingFilter::from_toml(toml_str).unwrap();
assert_eq!(config.min_severity, Some(Severity::High));
assert_eq!(config.exclude_scanners.len(), 1);
assert_eq!(config.include_tags.len(), 2);
}
#[test]
fn parse_empty_toml_filter_config() {
let config = FindingFilter::from_toml("").unwrap();
assert_eq!(config.min_severity, None);
assert!(config.exclude_scanners.is_empty());
assert!(config.include_tags.is_empty());
}
#[test]
fn filter_multiple_conditions() {
let findings = vec![
Finding::builder("nmap", "target", Severity::High)
.title("t")
.tag("web")
.build()
.unwrap(),
Finding::builder("burp", "target", Severity::Low)
.title("t")
.tag("web")
.build()
.unwrap(),
Finding::builder("burp", "target", Severity::Critical)
.title("t")
.tag("api")
.build()
.unwrap(),
];
let config = FindingFilter {
min_severity: Some(Severity::High),
exclude_scanners: vec!["nmap".into()],
include_tags: vec!["api".into(), "web".into()],
..Default::default()
};
let filtered = filter(&findings, &config);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].scanner(), "burp");
assert_eq!(filtered[0].severity(), Severity::Critical);
}
}