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}