Skip to main content

gossan_horizontal/
lib.rs

1#![forbid(unsafe_code)]
2// pedantic moved to workspace [lints.clippy] in root Cargo.toml
3//
4// `expect_used` is intentionally ALLOWED here because the conservative
5// regex literals in `conservative.rs` are infallible (they're compile-
6// time string constants known to parse). The `expect("compile-time
7// regex literal must compile")` documents that invariant. Other
8// correctness lints (unwrap_used, todo, unimplemented, panic) stay
9// forbidden in non-test code.
10#![cfg_attr(
11    not(test),
12    deny(
13        clippy::unwrap_used,
14        clippy::todo,
15        clippy::unimplemented,
16        clippy::panic
17    )
18)]
19#![allow(
20    clippy::module_name_repetitions,
21    clippy::must_use_candidate,
22    clippy::missing_errors_doc
23)]
24
25//! Horizontal discovery — ASN/BGP prefix mapping and sibling domain correlation.
26//!
27//! Expands the attack surface beyond a single domain by mapping the
28//! organization's network footprint via public BGP and WHOIS data.
29
30use async_trait::async_trait;
31use futures::StreamExt;
32use gossan_core::{
33    Config, DiscoverySource, DomainTarget, NetworkTarget, ScanInput, Scanner, Target,
34};
35use secfinding::{Finding, Severity};
36use std::sync::Arc;
37
38pub mod asn;
39pub mod conservative;
40pub mod ownership;
41/// ASN/BGP prefix mapper and sibling domain correlator for attack surface expansion.
42pub struct HorizontalScanner;
43
44#[async_trait]
45impl Scanner for HorizontalScanner {
46    fn name(&self) -> &'static str {
47        "horizontal"
48    }
49    fn tags(&self) -> &[&'static str] {
50        &["passive", "network", "intel", "horizontal"]
51    }
52    fn accepts(&self, target: &Target) -> bool {
53        matches!(
54            target,
55            Target::Domain(_) | Target::Host(_) | Target::Network(_)
56        )
57    }
58
59    async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
60        let client = gossan_core::ScanClient::from_config(config, Arc::clone(&input.resolver))?;
61
62        // Drain the inbound stream up-front. The original code held a
63        // `targets: Vec<Target>` field on ScanInput; the streaming
64        // refactor replaced it with `target_rx: Mutex<UnboundedReceiver>`
65        // and horizontal was missed in that pass. The horizontal stage
66        // does ASN/PTR/ownership pivots that need to see the full input
67        // batch (it can't act incrementally on each new target the way
68        // a portscan can), so collecting here matches the stage's
69        // semantics — not a performance regression.
70        let inbound: Vec<Target> = {
71            let mut rx = input.target_rx.lock().await;
72            let mut buf = Vec::new();
73            while let Ok(t) = rx.try_recv() {
74                buf.push(t);
75            }
76            buf
77        };
78
79        for target in &inbound {
80            // 1. IP → ASN → BGP Prefixes
81            if let Some(ip) = target.ip() {
82                if let Ok(prefixes) = asn::get_prefixes_for_ip(&client, &ip.to_string()).await {
83                    for prefix in prefixes {
84                        let network = Target::Network(NetworkTarget {
85                            cidr: prefix.clone(),
86                            source: DiscoverySource::AsnLookup,
87                        });
88
89                        // Emit to the target stream for recursive
90                        // scanning. (The historical
91                        // `if let Some(ref tx) = input.target_tx` +
92                        // explicit `tx.send` + `emit_target` was
93                        // double-emit; `target_tx` is no longer
94                        // optional, so `emit_target` alone is correct
95                        // and emits exactly once.)
96                        input.emit_target(network);
97                    }
98                }
99            }
100
101            // 2. Network → PTR Sweep (Legendary Internal Discovery)
102            if let Target::Network(net) = target {
103                if let Ok(prefix) = net.cidr.parse::<ipnet::IpNet>() {
104                    // Sample the first 16 IPs in the block for PTR records
105                    let hosts: Vec<_> = prefix.hosts().take(16).collect();
106                    let ptr_results: Vec<Option<String>> = futures::stream::iter(hosts)
107                        .map(|ip| {
108                            let resolver = Arc::clone(&input.resolver);
109                            async move {
110                                resolver.reverse_lookup(ip).await.ok().and_then(|r| {
111                                    r.iter().next().map(|name| {
112                                        name.to_string().trim_end_matches('.').to_string()
113                                    })
114                                })
115                            }
116                        })
117                        .buffer_unordered(config.concurrency)
118                        .collect()
119                        .await;
120
121                    for name in ptr_results.into_iter().flatten() {
122                        let new_domain = Target::Domain(DomainTarget {
123                            domain: name.clone(),
124                            source: DiscoverySource::Crawl, // Discovered via PTR sweep
125                        });
126                        input.emit_target(new_domain);
127                    }
128                }
129            }
130
131            // 3. Domain → Organization → Root Domains
132            if let Target::Domain(d) = target {
133                if let Ok(sibling_domains) =
134                    ownership::get_sibling_domains(&client, &d.domain).await
135                {
136                    for domain in sibling_domains {
137                        let new_domain = Target::Domain(DomainTarget {
138                            domain: domain.clone(),
139                            source: DiscoverySource::Crawl, // Pivoted from ownership
140                        });
141
142                        input.emit_target(new_domain);
143
144                        // Create a finding for the discovery
145                        if let Some(finding) = Finding::builder("horizontal", &d.domain, Severity::Info)
146                            .title("Horizontal discovery: sibling domain found via ownership correlation".to_string())
147                            .detail(format!("Domain {} shares ownership attributes with {}. This reveals a wider attack surface.", domain, d.domain))
148                            .tag("horizontal")
149                            .tag("ownership-pivot")
150                            .kind(secfinding::FindingKind::InfoDisclosure)
151                            .build_or_log()
152                        {
153                            input.emit(finding);
154                        }
155                    }
156                }
157            }
158        }
159
160        Ok(())
161    }
162}