Skip to main content

gossan_origin/
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//! Origin IP Discovery Engine.
20//!
21//! Breaks through CDNs/WAFs using heuristic scanners (DNS, SSL, HTTP headers,
22//! favicon hashing, DNS history, etc.) to uncover the direct origin IP for
23//! WAF-bypass networking.
24//!
25//! Each scanner is feature-gated so consumers can pick exactly what they need.
26//! All scanners run in parallel and results are aggregated by confidence score.
27//!
28//! # Scanners
29//!
30//! | Scanner | Feature | API Key? | Confidence |
31//! |---------|---------|----------|------------|
32//! | DNS misconfig (MX, SPF, DMARC, bypass subs) | `dns_misconfig` | No | 60-85 |
33//! | SSL certificate transparency (crt.sh) | `ssl_cert` | No | 70 |
34//! | HTTP header leaks | `http_header` | No | 50-90 |
35//! | Favicon hash (Shodan + Censys) | `favicon` | Optional | 80 |
36//! | DNS history (SecurityTrails/ViewDNS) | `dns_history` | Optional | 85-90 |
37//! | Historical DNS (Censys, DNSDB, CIRCL, PassiveTotal) | — | Optional | 70-85 |
38
39pub mod scanners;
40pub mod sources;
41pub mod types;
42pub mod util;
43pub mod validator;
44
45use gossan_core::{Config, ScanClient};
46pub use types::{OriginCandidate, ValidationState};
47
48/// Discover the origin IP of a given domain behind a CDN/WAF.
49///
50/// Invokes all activated heuristic scanners and external sources in parallel,
51/// aggregates the results, runs active validation, and returns candidates
52/// sorted by validation state and confidence (highest first).
53pub async fn discover_origin(
54    domain: &str,
55    config: &Config,
56) -> anyhow::Result<Vec<OriginCandidate>> {
57    // ── Single shared transport ──────────────────────────────────────
58    let resolver = std::sync::Arc::new(gossan_core::net::build_resolver(config)?);
59    let client = std::sync::Arc::new(ScanClient::from_config(config, resolver)?);
60
61    let mut tasks: Vec<tokio::task::JoinHandle<anyhow::Result<Vec<OriginCandidate>>>> = Vec::new();
62
63    let d = domain.to_string();
64    let cfg = config.clone();
65
66    #[cfg(feature = "dns_misconfig")]
67    {
68        let domain_clone = d.clone();
69        tasks.push(tokio::spawn(async move {
70            scanners::dns_misconfig::scan(domain_clone).await
71        }));
72    }
73
74    #[cfg(feature = "ssl_cert")]
75    {
76        let domain_clone = d.clone();
77        let c = std::sync::Arc::clone(&client);
78        tasks.push(tokio::spawn(async move {
79            scanners::ssl_cert::scan(domain_clone, &c).await
80        }));
81    }
82
83    #[cfg(feature = "http_header")]
84    {
85        let domain_clone = d.clone();
86        let config_clone = cfg.clone();
87        let c = std::sync::Arc::clone(&client);
88        tasks.push(tokio::spawn(async move {
89            scanners::http_header::scan(domain_clone, &config_clone, &c).await
90        }));
91    }
92
93    #[cfg(feature = "favicon")]
94    {
95        let domain_clone = d.clone();
96        let config_clone = cfg.clone();
97        let c = std::sync::Arc::clone(&client);
98        tasks.push(tokio::spawn(async move {
99            scanners::favicon::scan(domain_clone, &config_clone, &c).await
100        }));
101    }
102
103    #[cfg(feature = "dns_history")]
104    {
105        let domain_clone = d.clone();
106        let config_clone = cfg.clone();
107        let c = std::sync::Arc::clone(&client);
108        tasks.push(tokio::spawn(async move {
109            scanners::dns_history::scan(domain_clone, &config_clone, &c).await
110        }));
111    }
112
113    // External passive sources (always enabled, gracefully skip when unconfigured).
114    {
115        let domain_clone = d.clone();
116        let config_clone = cfg.clone();
117        let c = std::sync::Arc::clone(&client);
118        tasks.push(tokio::spawn(async move {
119            sources::censys::scan(&domain_clone, &config_clone, &c).await
120        }));
121    }
122    {
123        let domain_clone = d.clone();
124        let config_clone = cfg.clone();
125        let c = std::sync::Arc::clone(&client);
126        tasks.push(tokio::spawn(async move {
127            sources::dnsdb::scan(&domain_clone, &config_clone, &c).await
128        }));
129    }
130    {
131        let domain_clone = d.clone();
132        let config_clone = cfg.clone();
133        let c = std::sync::Arc::clone(&client);
134        tasks.push(tokio::spawn(async move {
135            sources::circl::scan(&domain_clone, &config_clone, &c).await
136        }));
137    }
138    {
139        let domain_clone = d.clone();
140        let config_clone = cfg.clone();
141        let c = std::sync::Arc::clone(&client);
142        tasks.push(tokio::spawn(async move {
143            sources::passivetotal::scan(&domain_clone, &config_clone, &c).await
144        }));
145    }
146
147    let mut candidates = Vec::new();
148
149    for task in tasks {
150        match task.await {
151            Ok(Ok(results)) => candidates.extend(results),
152            Ok(Err(e)) => {
153                tracing::warn!(error = %e, "origin scanner returned error");
154            }
155            Err(e) => {
156                tracing::warn!(error = %e, "origin scanner task panicked");
157            }
158        }
159    }
160
161    // Active validation
162    candidates = validator::validate(candidates, domain, config, &client).await;
163
164    Ok(candidates)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// With no scanner features active, the function should return an empty vec.
172    #[cfg(not(any(
173        feature = "dns_misconfig",
174        feature = "ssl_cert",
175        feature = "http_header",
176        feature = "favicon",
177        feature = "dns_history"
178    )))]
179    #[tokio::test]
180    async fn discover_origin_returns_empty_without_scanners() {
181        let candidates = discover_origin("example.com", &Config::default())
182            .await
183            .unwrap();
184        assert!(candidates.is_empty());
185    }
186
187    /// With scanner features active, the function runs without panicking.
188    #[cfg(any(
189        feature = "dns_misconfig",
190        feature = "ssl_cert",
191        feature = "http_header",
192        feature = "favicon",
193        feature = "dns_history"
194    ))]
195    #[tokio::test]
196    async fn discover_origin_runs_without_panic() {
197        let result = discover_origin("example.com", &Config::default()).await;
198        assert!(result.is_ok() || result.is_err());
199    }
200}