use std::collections::HashSet;
pub fn normalize_host(target: &str) -> String {
crate::domain::normalize_host(target)
}
pub fn strip_wildcard(host: &str) -> String {
host.trim_start_matches("*.").to_string()
}
pub fn is_wildcard_covered(wildcard: &str, host: &str) -> bool {
let norm_wild = normalize_host(wildcard);
let norm_host = normalize_host(host);
if let Some(suffix) = norm_wild.strip_prefix("*.") {
norm_host.ends_with(&format!(".{suffix}"))
} else {
false
}
}
pub fn dedup_findings(findings: &[secfinding::Finding]) -> Vec<secfinding::Finding> {
let mut seen = HashSet::new();
let mut out = Vec::new();
let wildcards: Vec<String> = findings
.iter()
.filter_map(|f| {
let norm = normalize_host(f.target());
if norm.starts_with("*.") {
Some(norm)
} else {
None
}
})
.collect();
let mut ordered: Vec<&secfinding::Finding> = Vec::with_capacity(findings.len());
ordered.extend(findings.iter().filter(|f| normalize_host(f.target()).starts_with("*.")));
ordered.extend(findings.iter().filter(|f| !normalize_host(f.target()).starts_with("*.")));
for f in ordered {
let norm = normalize_host(f.target());
let key = if norm.starts_with("*.") {
norm.clone()
} else if let Some(wild) = wildcards
.iter()
.find(|w| is_wildcard_covered(w, f.target()))
{
wild.clone()
} else {
norm.clone()
};
if seen.insert(key.clone()) {
let needs_tag = wildcards.iter().any(|w| is_wildcard_covered(w, f.target()))
&& !f.tags().iter().any(|t| t.as_ref() == "wildcard-origin");
if needs_tag {
if let Ok(rebuilt) =
secfinding::Finding::builder(f.scanner(), f.target(), f.severity())
.title(f.title())
.detail(f.detail())
.add_tags(f.tags().iter().map(|t| t.to_string()))
.tag("wildcard-origin")
.build()
{
out.push(rebuilt);
continue;
}
}
out.push(f.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_schemes() {
assert_eq!(normalize_host("http://example.com"), "example.com");
assert_eq!(normalize_host("https://example.com"), "example.com");
}
#[test]
fn normalize_strips_ports() {
assert_eq!(normalize_host("example.com:443"), "example.com");
assert_eq!(normalize_host("1.2.3.4:8080"), "1.2.3.4");
}
#[test]
fn normalize_strips_trailing_dot() {
assert_eq!(normalize_host("example.com."), "example.com");
}
#[test]
fn normalize_case_folds() {
assert_eq!(normalize_host("EXAMPLE.COM"), "example.com");
}
#[test]
fn normalize_ipv6_brackets() {
assert_eq!(normalize_host("[::1]:443"), "[::1]");
}
#[test]
fn wildcard_coverage() {
assert!(is_wildcard_covered("*.example.com", "sub.example.com"));
assert!(!is_wildcard_covered("*.example.com", "example.com"));
assert!(!is_wildcard_covered("example.com", "sub.example.com"));
}
#[test]
fn dedup_associative_commutative() {
let f1 = secfinding::Finding::new(
"s",
"https://Example.COM:443/",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let f2 = secfinding::Finding::new(
"s",
"http://example.com.",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let deduped = dedup_findings(&[f1.clone(), f2.clone()]);
assert_eq!(deduped.len(), 1);
let deduped2 = dedup_findings(&[f2, f1]);
assert_eq!(deduped2.len(), 1);
}
#[test]
fn dedup_does_not_collapse_apex_into_wildcard() {
let wildcard = secfinding::Finding::new(
"dns",
"*.example.com",
secfinding::Severity::Info,
"Wildcard DNS record",
"",
)
.unwrap();
let apex = secfinding::Finding::new(
"web",
"example.com",
secfinding::Severity::Critical,
"RCE on apex host",
"",
)
.unwrap();
for input in [
vec![wildcard.clone(), apex.clone()],
vec![apex.clone(), wildcard.clone()],
] {
let deduped = dedup_findings(&input);
assert_eq!(
deduped.len(),
2,
"apex finding was wrongly deduplicated against the wildcard"
);
assert!(
deduped.iter().any(|f| f.title() == "RCE on apex host"),
"the distinct apex Critical finding was silently dropped"
);
}
}
#[test]
fn dedup_still_collapses_wildcard_and_covered_subdomains() {
let wildcard =
secfinding::Finding::new("dns", "*.example.com", secfinding::Severity::Info, "t", "")
.unwrap();
let sub_a =
secfinding::Finding::new("web", "a.example.com", secfinding::Severity::Info, "t", "")
.unwrap();
let sub_b = secfinding::Finding::new(
"web",
"deep.b.example.com",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let deduped = dedup_findings(&[wildcard, sub_a, sub_b]);
assert_eq!(
deduped.len(),
1,
"wildcard + covered subdomains must collapse to a single survivor"
);
}
#[test]
fn dedup_tags_wildcard_origin() {
let wildcard =
secfinding::Finding::new("s", "*.example.com", secfinding::Severity::Info, "t", "")
.unwrap();
let concrete =
secfinding::Finding::new("s", "sub.example.com", secfinding::Severity::Info, "t", "")
.unwrap();
let deduped = dedup_findings(&[wildcard, concrete]);
assert_eq!(deduped.len(), 1);
assert!(deduped[0]
.tags()
.iter()
.any(|t| t.as_ref() == "wildcard-origin"));
}
#[test]
fn dedup_does_not_double_tag_wildcard_origin() {
let pre_tagged =
secfinding::Finding::builder("s", "*.example.com", secfinding::Severity::Info)
.title("t")
.detail("")
.tag("wildcard-origin")
.build()
.unwrap();
let concrete =
secfinding::Finding::new("s", "sub.example.com", secfinding::Severity::Info, "t", "")
.unwrap();
let deduped = dedup_findings(&[pre_tagged, concrete]);
assert_eq!(deduped.len(), 1);
let wildcard_tag_count = deduped[0]
.tags()
.iter()
.filter(|t| t.as_ref() == "wildcard-origin")
.count();
assert_eq!(
wildcard_tag_count, 1,
"wildcard-origin tag must appear at most once"
);
}
#[test]
fn dedup_empty_input_is_empty_output() {
let deduped = dedup_findings(&[]);
assert!(deduped.is_empty());
}
#[test]
fn dedup_collapses_url_form_variants_of_same_host() {
let a = secfinding::Finding::new(
"s",
"https://example.com/",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let b = secfinding::Finding::new(
"s",
"http://example.com",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let c = secfinding::Finding::new(
"s",
"https://EXAMPLE.com.:443/",
secfinding::Severity::Info,
"t",
"",
)
.unwrap();
let deduped = dedup_findings(&[a, b, c]);
assert_eq!(
deduped.len(),
1,
"three URL forms of the same host must collapse to one survivor"
);
}
#[test]
fn dedup_is_order_independent_for_wildcard_vs_concrete() {
let concrete = secfinding::Finding::builder("s", "sub.example.com", secfinding::Severity::Info)
.title("t")
.detail("")
.build()
.expect("concrete");
let wild = secfinding::Finding::builder("s", "*.example.com", secfinding::Severity::Info)
.title("t")
.detail("")
.build()
.expect("wild");
let a = dedup_findings(&[concrete.clone(), wild.clone()]);
let b = dedup_findings(&[wild.clone(), concrete.clone()]);
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_eq!(a[0].target(), "*.example.com");
assert_eq!(b[0].target(), "*.example.com");
}
}