use std::collections::HashSet;
use std::net::IpAddr;
use crate::util::{bounded_text, is_routable_ip};
use crate::OriginCandidate;
use futures::future::join_all;
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 = ctlog::crtsh_query_url(&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 body = match bounded_text(response, crate::MAX_ORIGIN_JSON_BYTES).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(scanner = "ssl_cert", error = %e, "failed to read crt.sh response");
return Ok(candidates);
}
};
let hostnames: HashSet<String> = match ctlog::parse_crtsh_hostnames(&body) {
Ok(names) => names.into_iter().collect(),
Err(e) => {
tracing::warn!(scanner = "ssl_cert", error = %e, "failed to parse crt.sh response");
return Ok(candidates);
}
};
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::TokioResolver::builder_with_config(
hickory_resolver::config::ResolverConfig::default(),
hickory_resolver::name_server::TokioConnectionProvider::default(),
)
.with_options(hickory_resolver::config::ResolverOpts::default())
.build();
let mut seen_ips = HashSet::new();
let hostnames: Vec<&String> = hostnames.iter().take(MAX_HOSTNAMES).collect();
let lookups: Vec<_> = hostnames
.iter()
.map(|hostname| async {
let result = resolver.ipv4_lookup(hostname.as_str()).await;
(*hostname, result)
})
.collect();
for (hostname, lookup) in join_all(lookups).await {
match lookup {
Ok(lookup) => {
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,
));
}
}
}
Err(e) if e.is_nx_domain() || e.is_no_records_found() => {}
Err(e) => {
tracing::warn!(
%hostname,
error = %e,
"ssl_cert CT hostname A lookup failed; skipping host"
);
}
}
}
Ok(candidates)
}