1#![forbid(unsafe_code)]
2#![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
19pub 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#[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
76pub 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 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 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 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 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 for task in tasks {
211 let _ = task.await;
212 }
213
214 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 match permutations::expand(
230 ¤t_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
261pub(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}