#[cfg(feature = "cli")]
mod baseline;
mod classify;
mod http;
mod sources;
use std::collections::BTreeSet;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use tracing::{debug, instrument, warn};
#[cfg(feature = "cli")]
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,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
}
const DEFAULT_CT_TIMEOUT: Duration = Duration::from_secs(30);
const SOURCE_BUDGET_TIMEOUTS: u32 = 3;
#[derive(Debug, Clone)]
pub struct SubdomainEnumerator {
timeout: Duration,
}
impl Default for SubdomainEnumerator {
fn default() -> Self {
Self::new()
}
}
impl SubdomainEnumerator {
pub fn new() -> Self {
Self {
timeout: DEFAULT_CT_TIMEOUT,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.ct_timeout())
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
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(), self.timeout).await
}
}
async fn enumerate_with_sources(
domain: &str,
srcs: &[Source],
timeout: Duration,
) -> Result<SubdomainResult> {
let mut last_err: Option<SeerError> = None;
for src in srcs {
let budget = http::Budget {
timeout,
deadline: Instant::now() + timeout * SOURCE_BUDGET_TIMEOUTS,
};
let fetched = match &src.paginate {
Some(spec) => fetch_paginated(domain, &src.base, spec, budget).await,
None => {
let url = (src.build_url)(&src.base, domain);
match http::fetch_with_retry(&url, budget).await {
Ok(body) => (src.parse)(&body).map(|names| (names, false)),
Err(e) => Err(e),
}
}
};
match fetched {
Ok((names, truncated)) => {
let mut result = build_result(domain, names, src.name);
result.truncated = truncated;
return Ok(result);
}
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,
budget: http::Budget,
) -> Result<(Vec<String>, bool)> {
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, budget)
.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");
return Ok((names, true));
}
};
if page.names.is_empty() {
return Ok((names, false));
}
names.extend(page.names);
match page.next_cursor {
Some(next) => cursor = Some(next),
None => return Ok((names, false)),
}
}
Ok((names, true))
}
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();
let name = name.strip_prefix("*.").unwrap_or(&name);
if (name.ends_with(&suffix) || name == domain) && !name.contains('*') {
subdomains.insert(name.to_string());
}
}
subdomains.remove(domain);
let subdomains: Vec<String> = subdomains
.into_iter()
.filter(|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,
truncated: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
#[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,
truncated: false,
};
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(), "*.dev.example.com".to_string(), "*.evilexample.com".to_string(), "a.*.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", "dev.example.com", "ok.example.com"]
);
assert_eq!(r.count, 3);
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, TEST_TIMEOUT)
.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, TEST_TIMEOUT).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, TEST_TIMEOUT)
.await
.unwrap();
assert_eq!(
result.subdomains,
vec!["a.example.com", "b.example.com"],
"names from both pages must be accumulated"
);
assert!(!result.truncated, "pagination ran to the end");
}
fn paged_source(base: String, max_pages: usize) -> Source {
Source {
name: "paged",
base,
build_url: |b, d| format!("{}/?domain={}", b, d),
parse: sources::parse_certspotter,
paginate: Some(PaginationSpec {
url_after: |base, domain, after| match after {
Some(c) => format!("{base}/v1/issuances?domain={domain}&after={c}"),
None => format!("{base}/v1/issuances?domain={domain}"),
},
parse_page: sources::parse_certspotter_page,
max_pages,
}),
}
}
#[tokio::test]
async fn paginated_source_cut_short_is_truncated() {
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(400))
.mount(&server)
.await;
let srcs = vec![paged_source(server.uri(), 1)];
let result = enumerate_with_sources("example.com", &srcs, TEST_TIMEOUT)
.await
.unwrap();
assert_eq!(result.subdomains, vec!["a.example.com"]);
assert!(result.truncated);
let srcs = vec![paged_source(server.uri(), 10)];
let result = enumerate_with_sources("example.com", &srcs, TEST_TIMEOUT)
.await
.unwrap();
assert_eq!(result.subdomains, vec!["a.example.com"]);
assert!(result.truncated);
}
#[tokio::test]
async fn a_hanging_source_is_bounded_by_its_budget() {
let hanging = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("[]")
.set_delay(Duration::from_secs(30)),
)
.mount(&hanging)
.await;
let fallback = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_string(r#"[{"dns_names":["b.example.com"]}]"#),
)
.mount(&fallback)
.await;
let srcs = vec![
Source {
name: "hanging",
base: hanging.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 timeout = Duration::from_millis(200);
let started = std::time::Instant::now();
let result = enumerate_with_sources("example.com", &srcs, timeout)
.await
.unwrap();
let elapsed = started.elapsed();
assert_eq!(result.source, "fallback");
assert!(
elapsed < Duration::from_secs(5),
"enumeration took {elapsed:?}"
);
}
#[test]
fn from_config_takes_the_ct_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.ct_secs = 45;
assert_eq!(
SubdomainEnumerator::from_config(&config).timeout,
Duration::from_secs(45)
);
assert_eq!(SubdomainEnumerator::new().timeout, DEFAULT_CT_TIMEOUT);
}
}