Skip to main content

gossan_subdomain/
lib.rs

1#![forbid(unsafe_code)]
2// pedantic moved to workspace [lints.clippy] in root Cargo.toml
3#![cfg_attr(
4    not(test),
5    deny(
6        clippy::unwrap_used,
7        clippy::expect_used,
8        clippy::todo,
9        clippy::unimplemented,
10        clippy::panic
11    )
12)]
13#![allow(
14    clippy::module_name_repetitions,
15    clippy::must_use_candidate,
16    clippy::missing_errors_doc
17)]
18
19//! Subdomain discovery — 80+ concurrent sources + DNS bruteforce + permutation engine.
20//!
21//! Sources (no API key): crt.sh, CertSpotter, Wayback Machine, HackerTarget,
22//!                        RapidDNS, AlienVault OTX, Urlscan.io, CommonCrawl, DNSdumpster,
23//!                        Anubis, BufferOver, Robtex, DNSRepo, and 30+ more.
24//! Sources (API key):   VirusTotal, SecurityTrails, Shodan, Censys, BinaryEdge,
25//!                        FullHunt, GitHub, Chaos, Bevigil, FOFA, Hunter.io, Netlas,
26//!                        ZoomEye, C99, Quake, ThreatBook, IntelX, LeakIX, WhoisXML,
27//!                        and 15+ more.
28//!
29//! Every confirmed target is emitted via `input.emit_target()` immediately
30//! so the port scanner can start while subdomain discovery is still running.
31
32pub mod dedup;
33pub mod sources;
34pub mod wildcard;
35
36mod bruteforce;
37mod permutations;
38
39use std::collections::HashSet;
40use std::sync::Arc;
41
42use async_trait::async_trait;
43use gossan_core::{Config, ScanInput, Scanner, Target};
44use secfinding::{Evidence, Finding, Severity};
45use tokio::sync::Mutex;
46
47use crate::dedup::normalize_domain;
48use crate::sources::{all_sources, SubdomainSource};
49use crate::wildcard::detect_wildcards;
50
51/// Downstream emitter wrapper — cloneable so it can be moved into spawned tasks.
52#[derive(Clone)]
53struct Emitter {
54    live_tx: tokio::sync::mpsc::UnboundedSender<Finding>,
55    target_tx: tokio::sync::mpsc::UnboundedSender<Target>,
56}
57
58impl Emitter {
59    fn emit_target(&self, t: Target) {
60        let _ = self.target_tx.send(t);
61    }
62    fn emit_finding(&self, f: Finding) {
63        let _ = self.live_tx.send(f);
64    }
65}
66
67impl From<&ScanInput> for Emitter {
68    fn from(input: &ScanInput) -> Self {
69        Self {
70            live_tx: input.live_tx.clone(),
71            target_tx: input.target_tx.clone(),
72        }
73    }
74}
75
76/// Multi-source subdomain enumeration and brute-force scanner.
77pub struct SubdomainScanner;
78
79#[async_trait]
80impl Scanner for SubdomainScanner {
81    fn name(&self) -> &'static str {
82        "subdomain"
83    }
84    fn tags(&self) -> &[&'static str] {
85        &["active", "dns", "discovery"]
86    }
87    fn accepts(&self, target: &Target) -> bool {
88        matches!(target, Target::Domain(_))
89    }
90
91    async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
92        let client = gossan_core::ScanClient::from_config(config, Arc::clone(&input.resolver))?;
93        let sources = Arc::new(all_sources());
94        let emitter = Emitter::from(&input);
95
96        // Drain all targets from the channel
97        let mut all_targets = Vec::new();
98        {
99            let mut rx = input.target_rx.lock().await;
100            while let Ok(t) = rx.try_recv() {
101                all_targets.push(t);
102            }
103        }
104
105        for target in &all_targets {
106            let Target::Domain(d) = target else { continue };
107            tracing::info!(domain = %d.domain, sources = sources.len(), "subdomain scan");
108
109            let wildcard_ips = detect_wildcards(&d.domain, &input.resolver, 5).await;
110            if !wildcard_ips.is_empty() {
111                tracing::warn!(domain = %d.domain, ips = ?wildcard_ips, "wildcard DNS detected");
112            }
113
114            let seen = Arc::new(Mutex::new(HashSet::<String>::new()));
115            let mut tasks = Vec::new();
116
117            // Spawn all passive sources
118            for i in 0..sources.len() {
119                let sources = Arc::clone(&sources);
120                let domain = d.domain.clone();
121                let client = client.clone();
122                let config = config.clone();
123                let emitter = emitter.clone();
124                let seen = Arc::clone(&seen);
125                let limiter = sources[i].rate_limit().build_limiter();
126                let source_name = sources[i].name();
127                let discovery = sources[i].discovery_source();
128
129                tasks.push(tokio::spawn(async move {
130                    match sources[i].query(&domain, &config, &client, &limiter).await {
131                        Ok(targets) => {
132                            for mut t in targets {
133                                // Rewrite discovery source to the canonical one for this source
134                                if let Target::Domain(ref mut dt) = t {
135                                    dt.source = discovery.clone();
136                                }
137                                if let Some(dom) = t.domain() {
138                                    if let Some(norm) = normalize_domain(dom) {
139                                        if seen.lock().await.insert(norm) {
140                                            emitter.emit_target(t);
141                                        }
142                                    }
143                                }
144                            }
145                        }
146                        Err(err) => {
147                            tracing::warn!(source = source_name, domain, err = %err, "subdomain source error");
148                            let severity = if config.api_keys.contains_key(source_name) {
149                                Severity::High
150                            } else {
151                                Severity::Medium
152                            };
153                            if let Some(finding) = Finding::builder("subdomain", &domain, severity)
154                                .title(format!("Subdomain source failed: {source_name}"))
155                                .detail(format!(
156                                    "Passive source {source_name} failed while enumerating {domain}. \
157                                     Fix: inspect connectivity, credentials, and upstream throttling. Error: {err}"
158                                ))
159                                .kind(secfinding::FindingKind::Other)
160                                .tag("subdomain")
161                                .tag("source-error")
162                                .evidence(Evidence::Raw(err.to_string().into()))
163                                .build_or_log()
164                            {
165                                emitter.emit_finding(finding);
166                            }
167                        }
168                    }
169                }));
170            }
171
172            // Spawn bruteforce with wildcard filtering
173            let domain_bf = d.domain.clone();
174            let config_bf = config.clone();
175            let resolver_bf = Arc::clone(&input.resolver);
176            let emitter_bf = emitter.clone();
177            let seen_bf = Arc::clone(&seen);
178            let wildcard_ips_bf = wildcard_ips.clone();
179            tasks.push(tokio::spawn(async move {
180                match bruteforce::scan(
181                    &domain_bf,
182                    &config_bf,
183                    Some(emitter_bf.target_tx.clone()),
184                    resolver_bf,
185                    Some(&wildcard_ips_bf),
186                )
187                .await
188                {
189                    Ok(targets) => {
190                        for mut t in targets {
191                            if let Target::Domain(ref mut dt) = t {
192                                dt.source = gossan_core::DiscoverySource::DnsBruteforce;
193                            }
194                            if let Some(dom) = t.domain() {
195                                if let Some(norm) = normalize_domain(dom) {
196                                    if seen_bf.lock().await.insert(norm) {
197                                        emitter_bf.emit_target(t);
198                                    }
199                                }
200                            }
201                        }
202                    }
203                    Err(err) => {
204                        tracing::warn!(source = "bruteforce", domain = domain_bf, err = %err, "bruteforce error");
205                    }
206                }
207            }));
208
209            // Wait for all tasks; failure isolation is automatic because each task is independent.
210            for task in tasks {
211                let _ = task.await;
212            }
213
214            // Collect currently seen domains for permutation input
215            let current_seen: Vec<Target> = {
216                let locked = seen.lock().await;
217                locked
218                    .iter()
219                    .map(|dom| {
220                        Target::Domain(gossan_core::DomainTarget {
221                            domain: dom.clone(),
222                            source: gossan_core::DiscoverySource::PassiveDns,
223                        })
224                    })
225                    .collect()
226            };
227
228            // Permutation expansion with wildcard-aware resolver
229            match permutations::expand(
230                &current_seen,
231                &d.domain,
232                config,
233                &wildcard_ips,
234                &input.resolver,
235            )
236            .await
237            {
238                Ok(perms) => {
239                    for mut t in perms {
240                        if let Target::Domain(ref mut dt) = t {
241                            dt.source = gossan_core::DiscoverySource::DnsBruteforce;
242                        }
243                        if let Some(dom) = t.domain() {
244                            if let Some(norm) = normalize_domain(dom) {
245                                if seen.lock().await.insert(norm) {
246                                    emitter.emit_target(t);
247                                }
248                            }
249                        }
250                    }
251                }
252                Err(e) => tracing::warn!(err = %e, "permutation expansion error"),
253            }
254        }
255
256        tracing::info!("subdomain scan complete");
257        Ok(())
258    }
259}
260
261/// Returns `true` if `candidate` is a direct subdomain of `domain`.
262pub(crate) fn is_subdomain_of(candidate: &str, domain: &str) -> bool {
263    let candidate = candidate.trim_end_matches('.');
264    let domain = domain.trim_end_matches('.');
265    candidate
266        .strip_suffix(domain)
267        .is_some_and(|prefix| prefix.ends_with('.'))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use gossan_core::{DiscoverySource, DomainTarget};
274
275    fn domain_target(domain: &str) -> Target {
276        Target::Domain(DomainTarget {
277            domain: domain.into(),
278            source: DiscoverySource::Seed,
279        })
280    }
281
282    #[test]
283    fn scanner_accepts_only_domain_targets() {
284        let scanner = SubdomainScanner;
285        assert!(scanner.accepts(&domain_target("example.com")));
286        assert!(!scanner.accepts(&Target::Host(gossan_core::HostTarget {
287            ip: "127.0.0.1".parse().unwrap(),
288            domain: None,
289        })));
290    }
291
292    #[test]
293    fn is_subdomain_of_requires_label_boundary() {
294        assert!(is_subdomain_of("api.example.com", "example.com"));
295        assert!(!is_subdomain_of("badexample.com", "example.com"));
296        assert!(!is_subdomain_of("example.com", "example.com"));
297    }
298}