mod http;
mod sources;
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument, warn};
use crate::error::{Result, SeerError};
use sources::Source;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubdomainResult {
pub domain: String,
pub subdomains: Vec<String>,
pub source: String,
pub count: usize,
}
pub struct SubdomainEnumerator;
impl Default for SubdomainEnumerator {
fn default() -> Self {
Self::new()
}
}
impl SubdomainEnumerator {
pub fn new() -> Self {
Self
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn enumerate(&self, domain: &str) -> Result<SubdomainResult> {
let domain = crate::validation::normalize_domain(domain)?;
debug!(domain = %domain, "Enumerating subdomains via CT logs");
enumerate_with_sources(&domain, &sources::default_sources()).await
}
}
async fn enumerate_with_sources(domain: &str, srcs: &[Source]) -> Result<SubdomainResult> {
let mut last_err: Option<SeerError> = None;
for src in srcs {
let url = (src.build_url)(&src.base, domain);
match http::fetch_with_retry(&url).await {
Ok(body) => match (src.parse)(&body) {
Ok(names) => return Ok(build_result(domain, names, src.name)),
Err(e) => {
warn!(source = src.name, error = %e, "CT source returned unparseable data");
last_err = Some(e);
}
},
Err(e) => {
warn!(source = src.name, error = %e, "CT source unavailable, trying next");
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| {
SeerError::HttpError(
"All Certificate Transparency sources are currently unavailable; try again shortly"
.into(),
)
}))
}
fn build_result(domain: &str, raw_names: Vec<String>, source: &str) -> SubdomainResult {
let suffix = format!(".{}", domain);
let mut subdomains = BTreeSet::new();
for name in raw_names {
let name = name.trim().to_lowercase();
if (name.ends_with(&suffix) || name == domain) && !name.starts_with('*') {
subdomains.insert(name);
}
}
subdomains.remove(domain);
let subdomains: Vec<String> = subdomains
.into_iter()
.filter(|s| {
let s = s.strip_prefix("*.").unwrap_or(s);
!s.is_empty()
&& s.len() <= 253
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
&& !s.contains("..")
&& !s.starts_with('.')
&& !s.starts_with('-')
})
.collect();
let count = subdomains.len();
SubdomainResult {
domain: domain.to_string(),
subdomains,
source: source.to_string(),
count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn test_subdomain_result_serialization() {
let result = SubdomainResult {
domain: "example.com".to_string(),
subdomains: vec![
"api.example.com".to_string(),
"mail.example.com".to_string(),
],
source: "crt.sh (Certificate Transparency)".to_string(),
count: 2,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("api.example.com"));
assert!(json.contains("mail.example.com"));
assert!(json.contains("crt.sh"));
}
#[test]
fn test_subdomain_enumerator_default() {
let _ = SubdomainEnumerator::default();
}
#[test]
fn build_result_filters_and_dedups() {
let raw = vec![
"example.com".to_string(), "API.example.com".to_string(), "api.example.com".to_string(), "*.example.com".to_string(), "evil.com".to_string(), "ok.example.com".to_string(),
];
let r = build_result("example.com", raw, "test");
assert_eq!(r.subdomains, vec!["api.example.com", "ok.example.com"]);
assert_eq!(r.count, 2);
assert_eq!(r.source, "test");
}
#[tokio::test]
async fn falls_back_to_second_source_when_primary_rate_limits() {
let primary = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(429).insert_header("retry-after", "0"),
)
.mount(&primary)
.await;
let fallback = MockServer::start().await;
let body = r#"[{"dns_names":["api.example.com","example.com"]}]"#;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&fallback)
.await;
let srcs = vec![
Source {
name: "primary",
base: primary.uri(),
build_url: |b, d| format!("{}/?q={}", b, d),
parse: sources::parse_crtsh,
},
Source {
name: "fallback",
base: fallback.uri(),
build_url: |b, d| format!("{}/?domain={}", b, d),
parse: sources::parse_certspotter,
},
];
let result = enumerate_with_sources("example.com", &srcs).await.unwrap();
assert_eq!(result.source, "fallback");
assert_eq!(result.subdomains, vec!["api.example.com"]);
}
#[tokio::test]
async fn errors_when_all_sources_unavailable() {
let down = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&down)
.await;
let srcs = vec![Source {
name: "only",
base: down.uri(),
build_url: |b, d| format!("{}/?q={}", b, d),
parse: sources::parse_crtsh,
}];
let err = enumerate_with_sources("example.com", &srcs).await;
assert!(err.is_err());
}
}