mod baseline;
mod classify;
mod http;
mod sources;
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument, warn};
pub use baseline::{SubdomainBaseline, SubdomainBaselineDiff, SubdomainBaselines};
pub use classify::{
classify_subdomains, ClassifiedSubdomain, SubdomainClassification, SubdomainStatus,
};
use crate::error::{Result, SeerError};
use sources::{PaginationSpec, 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 fetched = match &src.paginate {
Some(spec) => fetch_paginated(domain, &src.base, spec).await,
None => {
let url = (src.build_url)(&src.base, domain);
match http::fetch_with_retry(&url).await {
Ok(body) => (src.parse)(&body),
Err(e) => Err(e),
}
}
};
match fetched {
Ok(names) => return Ok(build_result(domain, names, src.name)),
Err(e) => {
warn!(source = src.name, error = %e, "CT source unavailable or unparseable, 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(),
)
}))
}
async fn fetch_paginated(domain: &str, base: &str, spec: &PaginationSpec) -> Result<Vec<String>> {
let mut names = Vec::new();
let mut cursor: Option<String> = None;
for page_num in 0..spec.max_pages {
let url = (spec.url_after)(base, domain, cursor.as_deref());
let page = match http::fetch_with_retry(&url)
.await
.and_then(|body| (spec.parse_page)(&body))
{
Ok(page) => page,
Err(e) if page_num == 0 => return Err(e),
Err(e) => {
warn!(error = %e, page = page_num, "pagination stopped early; returning partial results");
break;
}
};
if page.names.is_empty() {
break;
}
names.extend(page.names);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => break,
}
}
Ok(names)
}
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 == '-' || 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");
}
#[test]
fn build_result_keeps_underscore_service_labels() {
let raw = vec![
"_acme-challenge.example.com".to_string(),
"_dmarc.example.com".to_string(),
"www.example.com".to_string(),
];
let r = build_result("example.com", raw, "test");
assert!(r
.subdomains
.contains(&"_acme-challenge.example.com".to_string()));
assert!(r.subdomains.contains(&"_dmarc.example.com".to_string()));
assert!(r.subdomains.contains(&"www.example.com".to_string()));
assert_eq!(r.count, 3);
}
#[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,
paginate: None,
},
Source {
name: "fallback",
base: fallback.uri(),
build_url: |b, d| format!("{}/?domain={}", b, d),
parse: sources::parse_certspotter,
paginate: None,
},
];
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,
paginate: None,
}];
let err = enumerate_with_sources("example.com", &srcs).await;
assert!(err.is_err());
}
#[tokio::test]
async fn paginated_source_follows_cursor_across_pages() {
use wiremock::matchers::{query_param, query_param_is_missing};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(query_param_is_missing("after"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"[{"id":"100","dns_names":["a.example.com"]}]"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(query_param("after", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"[{"id":"200","dns_names":["b.example.com"]}]"#),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(query_param("after", "200"))
.respond_with(ResponseTemplate::new(200).set_body_string("[]"))
.mount(&server)
.await;
let srcs = vec![Source {
name: "paged",
base: server.uri(),
build_url: |b, d| format!("{}/?domain={}", b, d),
parse: sources::parse_certspotter,
paginate: Some(PaginationSpec {
url_after: |base, domain, after| {
let mut url = format!("{}/v1/issuances?domain={}", base, domain);
if let Some(c) = after {
url.push_str("&after=");
url.push_str(c);
}
url
},
parse_page: sources::parse_certspotter_page,
max_pages: 10,
}),
}];
let result = enumerate_with_sources("example.com", &srcs).await.unwrap();
assert_eq!(
result.subdomains,
vec!["a.example.com", "b.example.com"],
"names from both pages must be accumulated"
);
}
}