use std::collections::HashSet;
pub fn normalize_host(target: &str) -> String {
let mut s = target;
if let Some(rest) = s.strip_prefix("http://") {
s = rest;
} else if let Some(rest) = s.strip_prefix("https://") {
s = rest;
}
if let Some(at) = s.find('@') {
s = &s[at + 1..];
}
if let Some(idx) = s.find('/') {
s = &s[..idx];
}
if let Some(idx) = s.rfind(':') {
if s.starts_with('[') && s.contains("]:") {
s = &s[..idx];
} else if s[idx + 1..].chars().all(|c| c.is_ascii_digit()) {
s = &s[..idx];
}
}
let mut out = s.to_lowercase();
out = out.trim_end_matches('.').to_string();
if out.starts_with("xn--") {
let (decoded, _maybe_err) = idna::domain_to_unicode(&out);
out = decoded.to_lowercase();
}
out
}
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();
for f in findings {
let norm = normalize_host(f.target());
let key = if norm.starts_with("*.") {
strip_wildcard(&norm)
} else if let Some(wild) = wildcards
.iter()
.find(|w| is_wildcard_covered(w, f.target()))
{
strip_wildcard(wild)
} 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_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"));
}
}