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