#![forbid(unsafe_code)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::todo,
clippy::unimplemented,
clippy::panic
)
)]
#![allow(
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::missing_errors_doc
)]
pub mod dedup;
pub mod sources;
pub mod wildcard;
pub mod bruteforce;
mod permutations;
#[cfg(test)]
mod hermetic_dns;
#[cfg(test)]
mod hermetic_tests;
use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use gossan_core::{Config, ScanInput, Scanner, Target};
use secfinding::{Evidence, Finding, Severity};
use tokio::sync::{Mutex, Semaphore};
use crate::dedup::normalize_domain;
use crate::sources::{all_sources, SubdomainSource};
use crate::wildcard::detect_wildcards;
const MAX_CONCURRENT_SOURCES: usize = 16;
#[derive(Clone)]
struct Emitter {
live_tx: tokio::sync::mpsc::Sender<Finding>,
target_tx: tokio::sync::mpsc::Sender<Target>,
}
impl Emitter {
async fn emit_target(&self, t: Target) {
if let Err(e) = self.target_tx.send(t).await {
tracing::error!(err = %e, "failed to emit target (channel closed)");
}
}
async fn emit_finding(&self, f: Finding) {
if let Err(e) = self.live_tx.send(f).await {
tracing::error!(err = %e, "failed to emit finding (channel closed)");
}
}
}
impl From<&ScanInput> for Emitter {
fn from(input: &ScanInput) -> Self {
Self {
live_tx: input.live_tx.clone(),
target_tx: input.target_tx.clone(),
}
}
}
pub struct SubdomainScanner;
#[async_trait]
impl Scanner for SubdomainScanner {
fn name(&self) -> &'static str {
"subdomain"
}
fn tags(&self) -> &[&'static str] {
&["active", "dns", "discovery"]
}
fn accepts(&self, target: &Target) -> bool {
matches!(target, Target::Domain(_))
}
async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
self.run_with_sources(input, config, all_sources()).await
}
}
impl SubdomainScanner {
pub async fn run_with_sources(
&self,
input: ScanInput,
config: &Config,
sources: Vec<Box<dyn SubdomainSource>>,
) -> anyhow::Result<()> {
let client = gossan_core::ScanClient::from_config(config, Arc::clone(&input.resolver))?;
let sources = Arc::new(sources);
let emitter = Emitter::from(&input);
let mut all_targets = Vec::new();
{
let mut rx = input.target_rx.lock().await;
while let Some(t) = rx.recv().await {
all_targets.push(t);
}
}
for target in &all_targets {
let Target::Domain(d) = target else { continue };
tracing::info!(domain = %d.domain, sources = sources.len(), "subdomain scan");
let wildcard_ips = detect_wildcards(&d.domain, input.resolver.as_ref(), 5).await;
if !wildcard_ips.is_empty() {
tracing::warn!(domain = %d.domain, ips = ?wildcard_ips, "wildcard DNS detected");
}
let seen = Arc::new(Mutex::new(HashSet::<String>::new()));
let sem = Arc::new(Semaphore::new(MAX_CONCURRENT_SOURCES));
let mut tasks = Vec::new();
for i in 0..sources.len() {
let sources = Arc::clone(&sources);
let domain = d.domain.clone();
let client = client.clone();
let config = config.clone();
let emitter = emitter.clone();
let seen = Arc::clone(&seen);
let sem = Arc::clone(&sem);
let resolver = Arc::clone(&input.resolver);
let wildcard_ips_passive = wildcard_ips.clone();
let limiter = sources[i].rate_limit().build_limiter();
let source_name = sources[i].name();
let discovery = sources[i].discovery_source();
tasks.push(tokio::spawn(async move {
let Ok(_permit) = sem.acquire().await else {
return;
};
match sources[i].query(&domain, &config, &client, &limiter).await {
Ok(targets) => {
for mut t in targets {
if let Target::Domain(ref mut dt) = t {
dt.source = discovery.clone();
}
if let Some(dom) = t.domain() {
if !wildcard_ips_passive.is_empty() {
if let Ok(lookup) = resolver.lookup_ip(dom).await {
if lookup.iter().any(|ip| wildcard_ips_passive.contains(&ip)) {
continue;
}
}
}
if let Some(norm) = normalize_domain(dom) {
if seen.lock().await.insert(norm) {
emitter.emit_target(t).await;
}
}
}
}
}
Err(err) => {
tracing::warn!(source = source_name, domain, err = %err, "subdomain source error");
let severity = Severity::Info;
if let Some(finding) = Finding::builder("subdomain", &domain, severity)
.title(format!("Subdomain source failed: {source_name}"))
.detail(format!(
"Passive source {source_name} failed while enumerating {domain}. \
Fix: inspect connectivity, credentials, and upstream throttling. Error: {err}"
))
.kind(secfinding::FindingKind::Other)
.tag("subdomain")
.tag("source-error")
.evidence(Evidence::raw(err.to_string()))
.build_or_log()
{
emitter.emit_finding(finding).await;
}
}
}
}));
}
let domain_bf = d.domain.clone();
let config_bf = config.clone();
let resolver_bf = Arc::clone(&input.resolver);
let emitter_bf = emitter.clone();
let seen_bf = Arc::clone(&seen);
let wildcard_ips_bf = wildcard_ips.clone();
tasks.push(tokio::spawn(async move {
match bruteforce::scan(
&domain_bf,
&config_bf,
Some(emitter_bf.target_tx.clone()),
resolver_bf,
Some(&wildcard_ips_bf),
)
.await
{
Ok(targets) => {
for mut t in targets {
if let Target::Domain(ref mut dt) = t {
dt.source = gossan_core::DiscoverySource::DnsBruteforce;
}
if let Some(dom) = t.domain() {
if let Some(norm) = normalize_domain(dom) {
if seen_bf.lock().await.insert(norm) {
emitter_bf.emit_target(t).await;
}
}
}
}
}
Err(err) => {
tracing::warn!(source = "bruteforce", domain = domain_bf, err = %err, "bruteforce error");
}
}
}));
for task in tasks {
let _ = task.await;
}
let current_seen: Vec<Target> = {
let locked = seen.lock().await;
locked
.iter()
.map(|dom| {
Target::Domain(gossan_core::DomainTarget {
domain: dom.clone(),
source: gossan_core::DiscoverySource::PassiveDns,
})
})
.collect()
};
match permutations::expand(
¤t_seen,
&d.domain,
config,
&wildcard_ips,
input.resolver.as_ref(),
)
.await
{
Ok(perms) => {
for mut t in perms {
if let Target::Domain(ref mut dt) = t {
dt.source = gossan_core::DiscoverySource::DnsBruteforce;
}
if let Some(dom) = t.domain() {
if let Some(norm) = normalize_domain(dom) {
if seen.lock().await.insert(norm) {
emitter.emit_target(t).await;
}
}
}
}
}
Err(e) => tracing::warn!(err = %e, "permutation expansion error"),
}
}
tracing::info!("subdomain scan complete");
Ok(())
}
}
pub(crate) fn is_subdomain_of(candidate: &str, domain: &str) -> bool {
let candidate = candidate.trim_end_matches('.');
let domain = domain.trim_end_matches('.');
candidate
.strip_suffix(domain)
.is_some_and(|prefix| {
prefix.ends_with('.') && !prefix.contains("..") && prefix != "."
})
}
#[cfg(test)]
mod tests {
use super::*;
use gossan_core::{DiscoverySource, DomainTarget};
use proptest::prelude::*;
fn domain_target(domain: &str) -> Target {
Target::Domain(DomainTarget {
domain: domain.into(),
source: DiscoverySource::Seed,
})
}
#[test]
fn scanner_accepts_only_domain_targets() {
let scanner = SubdomainScanner;
assert!(scanner.accepts(&domain_target("example.com")));
assert!(!scanner.accepts(&Target::Host(gossan_core::HostTarget {
ip: "127.0.0.1".parse().unwrap(),
domain: None,
})));
}
#[test]
fn is_subdomain_of_requires_label_boundary() {
assert!(is_subdomain_of("api.example.com", "example.com"));
assert!(!is_subdomain_of("badexample.com", "example.com"));
assert!(!is_subdomain_of("example.com", "example.com"));
}
#[test]
fn is_subdomain_of_empty_strings() {
assert!(!is_subdomain_of("", ""));
}
#[test]
fn is_subdomain_of_trailing_dot() {
assert!(is_subdomain_of("api.example.com.", "example.com"));
assert!(is_subdomain_of("api.example.com", "example.com."));
}
#[test]
fn is_subdomain_of_double_dot() {
assert!(!is_subdomain_of("a..example.com", "example.com"));
}
#[test]
fn is_subdomain_of_partial_match() {
assert!(!is_subdomain_of("notexample.com", "example.com"));
}
#[test]
fn is_subdomain_of_handles_long_strings() {
let candidate = "a.".repeat(1000) + "example.com";
let domain = "example.com";
let _ = is_subdomain_of(&candidate, domain);
}
proptest! {
#[test]
fn is_subdomain_of_never_panics(candidate in ".*", domain in ".*") {
let _ = is_subdomain_of(&candidate, &domain);
}
#[test]
fn is_subdomain_of_reflexive_false(s in ".{1,256}") {
prop_assert!(!is_subdomain_of(&s, &s));
}
#[test]
fn empty_candidate_is_never_subdomain(domain in ".*") {
prop_assert!(!is_subdomain_of("", &domain));
}
#[test]
fn is_subdomain_of_matches_suffix(
prefix in "[a-z0-9]{1,20}",
domain in "[a-z0-9]{1,20}[.][a-z]{2,6}",
) {
let candidate = format!("{}.{}", prefix, domain);
prop_assert!(is_subdomain_of(&candidate, &domain));
}
}
}