use std::collections::HashSet;
use std::net::IpAddr;
use crate::util::{bounded_json, is_routable_ip};
use crate::OriginCandidate;
#[derive(serde::Deserialize)]
struct CrtShEntry {
name_value: String,
}
const MAX_HOSTNAMES: usize = 500;
pub async fn scan(
domain: String,
client: &gossan_core::ScanClient,
) -> anyhow::Result<Vec<OriginCandidate>> {
let mut candidates = Vec::new();
let url = format!(
"https://crt.sh/?q=%.{}&output=json",
urlencoding::encode(&domain)
);
let response = match client.get(&url).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(scanner = "ssl_cert", error = %e, "crt.sh request failed");
return Ok(candidates);
}
};
if !response.status().is_success() {
tracing::warn!(
scanner = "ssl_cert",
status = %response.status(),
"crt.sh returned non-200"
);
return Ok(candidates);
}
let entries: Vec<CrtShEntry> = match bounded_json(response, 10 * 1024 * 1024).await {
Ok(e) => e,
Err(e) => {
tracing::warn!(scanner = "ssl_cert", error = %e, "failed to parse crt.sh response");
return Ok(candidates);
}
};
let mut hostnames = HashSet::new();
for entry in &entries {
for name in entry.name_value.split('\n') {
let clean = name.trim().to_lowercase();
if let Some(base) = clean.strip_prefix("*.") {
if !base.is_empty() {
hostnames.insert(base.to_string());
}
} else if !clean.is_empty() {
hostnames.insert(clean);
}
}
}
if hostnames.len() > MAX_HOSTNAMES {
tracing::warn!(
scanner = "ssl_cert",
total = hostnames.len(),
max = MAX_HOSTNAMES,
"truncating hostname list to avoid excessive DNS queries"
);
}
tracing::info!(
scanner = "ssl_cert",
unique_hostnames = hostnames.len().min(MAX_HOSTNAMES),
"extracted hostnames from CT logs"
);
let resolver = hickory_resolver::TokioAsyncResolver::tokio(
hickory_resolver::config::ResolverConfig::default(),
hickory_resolver::config::ResolverOpts::default(),
);
let mut seen_ips = HashSet::new();
for hostname in hostnames.iter().take(MAX_HOSTNAMES) {
if let Ok(lookup) = resolver.ipv4_lookup(hostname.as_str()).await {
for ip in lookup {
let addr = IpAddr::V4(ip.0);
if is_routable_ip(addr) && seen_ips.insert(addr) {
candidates.push(OriginCandidate::new(
addr,
format!("ssl_cert_ct_log ({hostname})"),
70,
));
}
}
}
}
Ok(candidates)
}