Skip to main content

gossan_cloud/
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//! Cloud asset discovery scanner.
20//!
21//! Derives candidate bucket/account names from the target domain via the
22//! Mozilla Public Suffix List, generates permutations, then probes every
23//! registered [`CloudProvider`] in parallel.
24//!
25//! # Adding a new cloud provider
26//! 1. Create `src/{provider}.rs` and implement [`CloudProvider`].
27//! 2. Add it to the `providers()` constructor in this file — the only change needed.
28
29pub mod azure;
30pub mod common;
31pub mod do_spaces;
32pub mod gcs;
33pub mod inside_out;
34pub mod permutations;
35pub mod provider;
36pub mod s3;
37// AWS-service-specific probes implementing `provider::CloudProvider`.
38// These were committed as orphan files (no `mod` declaration) when
39// the workspace was last reorganised; re-exporting so the integration
40// test in `tests/test_cloud_adversarial_network.rs` can drive each
41// provider's adversarial-network behaviour directly. Each module is
42// safe to import independently.
43pub mod apigateway;
44pub mod cloudfront;
45pub mod lambda;
46
47#[cfg(test)]
48mod integration_tests;
49
50use std::net::IpAddr;
51use std::sync::Arc;
52
53use async_trait::async_trait;
54use futures::StreamExt;
55use gossan_core::{Config, ScanClient, ScanInput, Scanner, Target};
56use secfinding::{Finding, FindingBuilder, Severity};
57
58use common::make_target;
59use provider::CloudProvider;
60/// Cloud storage asset scanner — discovers open buckets and containers.
61pub struct CloudScanner;
62
63pub(crate) fn finding_builder(
64    target: &Target,
65    severity: Severity,
66    title: impl Into<String>,
67    detail: impl Into<String>,
68) -> FindingBuilder {
69    Finding::builder("cloud", target.domain().unwrap_or("?"), severity)
70        .title(title)
71        .detail(detail)
72        .kind(secfinding::FindingKind::Exposure)
73}
74
75#[async_trait]
76impl Scanner for CloudScanner {
77    fn name(&self) -> &'static str {
78        "cloud"
79    }
80    fn tags(&self) -> &[&'static str] {
81        &["active", "cloud", "exposure"]
82    }
83
84    fn accepts(&self, target: &Target) -> bool {
85        matches!(target, Target::Domain(_) | Target::Web(_))
86    }
87
88    async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
89        // SSRF Protection: Early exit on metadata service and private IPs
90        if is_ssrf_protected_target(&input.seed) {
91            tracing::warn!("SSRF protection triggered for seed: {}", input.seed);
92            return Ok(());
93        }
94
95        // Drain inbound targets up-front. Cloud bucket-permutation
96        // and inside-out discovery both need the full domain set to
97        // deduplicate org names + seed the AWS API enumeration; this
98        // is not the kind of stage that benefits from incremental
99        // processing.
100        let (inbound, has_ssrf_targets): (Vec<Target>, bool) = {
101            let mut rx = input.target_rx.lock().await;
102            let mut buf = Vec::new();
103            let mut ssrf_detected = false;
104            while let Ok(t) = rx.try_recv() {
105                // SSRF Protection: Filter out metadata service and private IPs
106                if !is_ssrf_protected_target_obj(&t) {
107                    buf.push(t);
108                } else {
109                    tracing::warn!("SSRF protection triggered for target: {:?}", t);
110                    ssrf_detected = true;
111                }
112            }
113            (buf, ssrf_detected)
114        };
115
116        #[cfg(feature = "cloud")]
117        {
118            // Inside-Out Discovery: use credentials to find unmapped
119            // assets (S3, EC2, Route53, RDS). Emits directly via
120            // input.emit_target — no separate buffer parameter.
121            // Skip if we detected SSRF-protected targets to avoid hanging.
122            if !has_ssrf_targets {
123                if let Err(e) = crate::inside_out::discover_aws(&input).await {
124                    tracing::error!("AWS inside-out discovery failed: {}", e);
125                }
126            } else {
127                tracing::warn!("Skipping AWS inside-out discovery due to SSRF protection");
128            }
129        }
130
131        // Cloud scanner never follows redirects (we need exact 3xx/403 status codes)
132        let client = ScanClient::from_config_no_redirect(config, Arc::clone(&input.resolver))?;
133
134        // Derive unique org names from all targets using the PSL
135        let mut org_names: Vec<String> = inbound
136            .iter()
137            .filter(|t| self.accepts(t))
138            .filter_map(|t| t.domain())
139            .map(org_name)
140            .filter(|n| !n.is_empty())
141            .collect();
142        org_names.dedup();
143
144        let seed_org = org_name(&input.seed);
145        if !seed_org.is_empty() && !org_names.contains(&seed_org) {
146            org_names.push(seed_org);
147        }
148
149        // Early exit if we detected SSRF targets and have no inbound targets
150        if has_ssrf_targets && inbound.is_empty() {
151            tracing::info!("SSRF protection: All targets filtered out, exiting early");
152            return Ok(());
153        }
154
155        // Early exit if no valid organizations to scan
156        if org_names.is_empty() {
157            tracing::info!("No valid organizations to scan, exiting early");
158            return Ok(());
159        }
160
161        let providers: Arc<Vec<Box<dyn CloudProvider>>> = Arc::new(providers());
162        let seed_target = make_target(&input.seed);
163
164        for org in &org_names {
165            let candidates = permutations::generate(org);
166            tracing::info!(
167                org = %org,
168                buckets = candidates.len(),
169                "cloud scan — probing {} providers",
170                providers.len()
171            );
172
173            let findings: Vec<Finding> = futures::stream::iter(candidates)
174                .map(|name| {
175                    let client = client.clone();
176                    let target = seed_target.clone();
177                    let providers = providers.clone();
178                    async move {
179                        let futs: Vec<_> = providers
180                            .iter()
181                            .map(|p| p.probe(&client, &name, &target))
182                            .collect();
183                        let results = futures::future::join_all(futs).await;
184                        let mut f = Vec::new();
185                        for (provider, result) in providers.iter().zip(results) {
186                            match result {
187                                Ok(v) => f.extend(v),
188                                Err(e) => tracing::warn!(
189                                    provider = provider.name(),
190                                    bucket   = %name,
191                                    err      = %e,
192                                    "cloud probe error"
193                                ),
194                            }
195                        }
196                        f
197                    }
198                })
199                .buffer_unordered(config.concurrency)
200                .flat_map(futures::stream::iter)
201                .collect()
202                .await;
203
204            for f in findings {
205                input.emit(f);
206            }
207        }
208
209        Ok(())
210    }
211}
212
213/// Return all registered cloud storage providers.
214///
215/// To add a new provider: implement [`CloudProvider`] and append it here.
216fn providers() -> Vec<Box<dyn CloudProvider>> {
217    vec![
218        Box::new(s3::S3Provider),
219        Box::new(gcs::GcsProvider),
220        Box::new(azure::AzureProvider),
221        Box::new(do_spaces::DoSpacesProvider),
222    ]
223}
224
225/// Extract the organisation name from a domain using the Mozilla Public Suffix List.
226///
227/// Examples:
228/// - `"example.com"`        → `"example"`
229/// - `"shop.example.co.uk"` → `"example"`
230/// - `"api.example.com.br"` → `"example"`
231/// - `"localhost"`           → `"localhost"`
232fn org_name(input: &str) -> String {
233    // Strip scheme and port
234    let host = input
235        .trim_start_matches("http://")
236        .trim_start_matches("https://")
237        .trim_end_matches('/')
238        .split(':')
239        .next()
240        .unwrap_or(input);
241
242    if host.parse::<std::net::IpAddr>().is_ok() {
243        return host.to_lowercase();
244    }
245
246    // Use PSL to find the registrable domain
247    if let Some(domain) = psl::domain(host.as_bytes()) {
248        // domain.as_bytes() = "example.co.uk" — first label is always the org name
249        let registrable = std::str::from_utf8(domain.as_bytes()).unwrap_or(host);
250        registrable.split('.').next().unwrap_or(host).to_lowercase()
251    } else {
252        // IP address, localhost, or unrecognised TLD — use first label as-is
253        host.split('.').next().unwrap_or(host).to_lowercase()
254    }
255}
256
257#[cfg(test)]
258mod ssrf_tests {
259    use super::{is_ssrf_protected_ip, is_ssrf_protected_target};
260    use std::net::IpAddr;
261
262    fn ip(s: &str) -> IpAddr {
263        s.parse().unwrap()
264    }
265
266    #[test]
267    fn aws_metadata_blocked() {
268        assert!(is_ssrf_protected_ip(&ip("169.254.169.254")));
269        assert!(is_ssrf_protected_target("169.254.169.254"));
270        assert!(is_ssrf_protected_target("metadata.google.internal"));
271    }
272
273    #[test]
274    fn rfc1918_blocked() {
275        assert!(is_ssrf_protected_ip(&ip("10.0.0.1")));
276        assert!(is_ssrf_protected_ip(&ip("10.255.255.255")));
277        assert!(is_ssrf_protected_ip(&ip("172.16.0.1")));
278        assert!(is_ssrf_protected_ip(&ip("172.31.255.255")));
279        assert!(is_ssrf_protected_ip(&ip("192.168.0.1")));
280    }
281
282    #[test]
283    fn loopback_blocked() {
284        assert!(is_ssrf_protected_ip(&ip("127.0.0.1")));
285        assert!(is_ssrf_protected_ip(&ip("127.255.255.254")));
286    }
287
288    #[test]
289    fn link_local_blocked() {
290        assert!(is_ssrf_protected_ip(&ip("169.254.0.1")));
291        assert!(is_ssrf_protected_ip(&ip("169.254.255.254")));
292    }
293
294    #[test]
295    fn ipv6_loopback_and_link_local_blocked() {
296        assert!(is_ssrf_protected_ip(&ip("::1")));
297        assert!(is_ssrf_protected_ip(&ip("fe80::1")));
298        assert!(is_ssrf_protected_ip(&ip("fe80::dead:beef")));
299    }
300
301    #[test]
302    fn public_ips_allowed() {
303        assert!(!is_ssrf_protected_ip(&ip("1.1.1.1")));
304        assert!(!is_ssrf_protected_ip(&ip("8.8.8.8")));
305        assert!(!is_ssrf_protected_ip(&ip("172.32.0.1"))); // outside 172.16-31
306        assert!(!is_ssrf_protected_ip(&ip("169.253.0.1"))); // adjacent /16
307        assert!(!is_ssrf_protected_ip(&ip("2606:4700:4700::1111")));
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::{org_name, providers};
314
315    #[test]
316    fn simple() {
317        assert_eq!(org_name("example.com"), "example");
318    }
319    #[test]
320    fn subdomain() {
321        assert_eq!(org_name("sub.example.com"), "example");
322    }
323    #[test]
324    fn co_uk() {
325        assert_eq!(org_name("shop.example.co.uk"), "example");
326    }
327    #[test]
328    fn com_br() {
329        assert_eq!(org_name("api.example.com.br"), "example");
330    }
331    #[test]
332    fn gov_au() {
333        assert_eq!(org_name("www.agency.gov.au"), "agency");
334    }
335    #[test]
336    fn https_scheme() {
337        assert_eq!(org_name("https://example.com"), "example");
338    }
339    #[test]
340    fn with_port() {
341        assert_eq!(org_name("example.com:8080"), "example");
342    }
343    #[test]
344    fn localhost() {
345        assert_eq!(org_name("localhost"), "localhost");
346    }
347    #[test]
348    fn deep_sub() {
349        assert_eq!(org_name("a.b.c.example.io"), "example");
350    }
351    #[test]
352    fn ip_address() {
353        assert_eq!(org_name("192.0.2.10"), "192.0.2.10");
354    }
355    #[test]
356    fn hyphenated() {
357        assert_eq!(org_name("cdn.example-site.com"), "example-site");
358    }
359    #[test]
360    fn trailing_slash() {
361        assert_eq!(org_name("https://example.com/"), "example");
362    }
363    #[test]
364    fn providers_registered() {
365        assert_eq!(providers().len(), 4);
366    }
367}
368
369/// Check if a string target (seed) should be blocked due to SSRF protection.
370fn is_ssrf_protected_target(target: &str) -> bool {
371    // Try to parse as IP address
372    if let Ok(ip) = target.parse::<IpAddr>() {
373        return is_ssrf_protected_ip(&ip);
374    }
375
376    // Check if it's a hostname that resolves to a protected IP
377    // For simplicity, check known metadata service hostname patterns
378    if target == "metadata.google.internal" || target == "169.254.169.254" {
379        return true;
380    }
381
382    false
383}
384
385/// Check if a Target object should be blocked due to SSRF protection.
386fn is_ssrf_protected_target_obj(target: &Target) -> bool {
387    match target {
388        Target::Host(host_target) => is_ssrf_protected_ip(&host_target.ip),
389        Target::Domain(domain_target) => is_ssrf_protected_target(&domain_target.domain),
390        _ => false,
391    }
392}
393
394/// Check if an IP address should be blocked due to SSRF protection.
395fn is_ssrf_protected_ip(ip: &IpAddr) -> bool {
396    match ip {
397        IpAddr::V4(ipv4) => {
398            let octets = ipv4.octets();
399            // AWS metadata service
400            if octets == [169, 254, 169, 254] {
401                return true;
402            }
403            // RFC1918 private ranges
404            if octets[0] == 10 {
405                return true;
406            }
407            if octets[0] == 172 && (16..=31).contains(&octets[1]) {
408                return true;
409            }
410            if octets[0] == 192 && octets[1] == 168 {
411                return true;
412            }
413            // Loopback
414            if octets[0] == 127 {
415                return true;
416            }
417            // Link-local (169.254.0.0/16)
418            if octets[0] == 169 && octets[1] == 254 {
419                return true;
420            }
421        }
422        IpAddr::V6(ipv6) => {
423            // IPv6 loopback
424            if *ipv6 == std::net::Ipv6Addr::LOCALHOST {
425                return true;
426            }
427            // IPv6 link-local (fe80::/10)
428            let segments = ipv6.segments();
429            if (segments[0] & 0xffc0) == 0xfe80 {
430                return true;
431            }
432        }
433    }
434    false
435}