gossan-cloud 0.3.3

Cloud asset discovery scanner for gossan (S3, GCS, Azure Blob, DigitalOcean Spaces), part of the security research ecosystem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
#![forbid(unsafe_code)]
// pedantic moved to workspace [lints.clippy] in root Cargo.toml
#![cfg_attr(
    not(test),
    deny(
        clippy::unwrap_used,
        clippy::expect_used,
        clippy::todo,
        clippy::unimplemented,
        clippy::panic
    )
)]
#![allow(
    clippy::module_name_repetitions,
    clippy::must_use_candidate,
    clippy::missing_errors_doc
)]

//! Cloud asset discovery scanner.
//!
//! Derives candidate bucket/account names from the target domain via the
//! Mozilla Public Suffix List, generates permutations, then probes every
//! registered [`CloudProvider`] in parallel.
//!
//! # Adding a new cloud provider
//! 1. Create `src/{provider}.rs` and implement [`CloudProvider`].
//! 2. Add it to the `providers()` constructor in this file (the only change needed).

pub mod azure;
pub mod common;
pub mod do_spaces;
pub mod gcs;
pub mod inside_out;
pub mod permutations;
pub mod provider;
pub mod s3;
// AWS-service-specific probes implementing `provider::CloudProvider`.
// These were committed as orphan files (no `mod` declaration) when
// the workspace was last reorganised; re-exporting so the integration
// test in `tests/test_cloud_adversarial_network.rs` can drive each
// provider's adversarial-network behaviour directly. Each module is
// safe to import independently.
pub mod apigateway;
pub mod cloudfront;
pub mod lambda;

#[cfg(test)]
mod integration_tests;

use std::net::IpAddr;
use std::sync::Arc;

use async_trait::async_trait;
use futures::StreamExt;
use gossan_core::{Config, ScanClient, ScanInput, Scanner, Target};
use secfinding::{Finding, FindingBuilder, Severity};

use common::make_target;
use provider::CloudProvider;

/// Maximum response body size to read when probing cloud storage endpoints.
///
/// Directory listings and object manifests are typically well under 1 MB;
/// 4 MB gives headroom for densely-packed listings while bounding memory
/// usage against decompression-bomb or infinite-chunked-transfer attacks.
pub(crate) const MAX_CLOUD_RESPONSE_BYTES: usize = 4 * 1024 * 1024;

/// Maximum characters included in the `body_excerpt` field of cloud findings.
/// 300 chars is long enough to capture structural error messages (e.g. AWS XML
/// error codes) while keeping findings compact in JSON/JSONL output.
pub(crate) const MAX_BODY_EXCERPT_CHARS: usize = 300;
/// Cloud storage asset scanner (discovers open buckets and containers).
pub struct CloudScanner;

pub(crate) fn finding_builder(
    target: &Target,
    severity: Severity,
    title: impl Into<String>,
    detail: impl Into<String>,
) -> FindingBuilder {
    Finding::builder("cloud", target.domain().unwrap_or("?"), severity)
        .title(title)
        .detail(detail)
        .kind(secfinding::FindingKind::Exposure)
}

#[async_trait]
impl Scanner for CloudScanner {
    fn name(&self) -> &'static str {
        "cloud"
    }
    fn tags(&self) -> &[&'static str] {
        &["active", "cloud", "exposure"]
    }

    fn accepts(&self, target: &Target) -> bool {
        matches!(target, Target::Domain(_) | Target::Web(_))
    }

    async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
        // SSRF Protection: Early exit on metadata service and private IPs
        if is_ssrf_protected_target(&input.seed) {
            tracing::warn!("SSRF protection triggered for seed: {}", input.seed);
            return Ok(());
        }

        // Drain inbound targets up-front. Cloud bucket-permutation
        // and inside-out discovery both need the full domain set to
        // deduplicate org names + seed the AWS API enumeration; this
        // is not the kind of stage that benefits from incremental
        // processing.
        let (inbound, has_ssrf_targets): (Vec<Target>, bool) = {
            let mut rx = input.target_rx.lock().await;
            let mut buf = Vec::new();
            let mut ssrf_detected = false;
            // recv() until the pipeline closes the inbox — try_recv races the
            // sender and drops asynchronously delivered targets.
            while let Some(t) = rx.recv().await {
                // SSRF Protection: Filter out metadata service and private IPs
                if !is_ssrf_protected_target_obj(&t) {
                    buf.push(t);
                } else {
                    tracing::warn!("SSRF protection triggered for target: {:?}", t);
                    ssrf_detected = true;
                }
            }
            (buf, ssrf_detected)
        };

        #[cfg(feature = "cloud")]
        {
            // Inside-Out Discovery: use credentials to find unmapped
            // assets (S3, EC2, Route53, RDS). Emits directly via
            // input.emit_target (no separate buffer parameter).
            // Skip if we detected SSRF-protected targets to avoid hanging.
            if !has_ssrf_targets {
                if let Err(e) = crate::inside_out::discover_aws(&input).await {
                    tracing::error!("AWS inside-out discovery failed: {}", e);
                }
            } else {
                tracing::warn!("Skipping AWS inside-out discovery due to SSRF protection");
            }
        }

        // Cloud scanner never follows redirects (we need exact 3xx/403 status codes)
        let client = ScanClient::from_config_no_redirect(config, Arc::clone(&input.resolver))?;

        // Derive unique org names from all targets using the PSL
        let mut org_names: Vec<String> = inbound
            .iter()
            .filter(|t| self.accepts(t))
            .filter_map(|t| t.domain())
            .map(org_name)
            .filter(|n| !n.is_empty())
            .collect();
        org_names.dedup();

        let seed_org = org_name(&input.seed);
        if !seed_org.is_empty() && !org_names.contains(&seed_org) {
            org_names.push(seed_org);
        }

        // Early exit if we detected SSRF targets and have no inbound targets
        if has_ssrf_targets && inbound.is_empty() {
            tracing::info!("SSRF protection: All targets filtered out, exiting early");
            return Ok(());
        }

        // Early exit if no valid organizations to scan
        if org_names.is_empty() {
            tracing::info!("No valid organizations to scan, exiting early");
            return Ok(());
        }

        let providers: Arc<Vec<Box<dyn CloudProvider>>> = Arc::new(providers());
        let seed_target = make_target(&input.seed);

        for org in &org_names {
            let candidates = permutations::generate(org);
            tracing::info!(
                org = %org,
                buckets = candidates.len(),
                "cloud scan, probing {} providers",
                providers.len()
            );

            let findings: Vec<Finding> = futures::stream::iter(candidates)
                .map(|name| {
                    let client = client.clone();
                    let target = seed_target.clone();
                    let providers = providers.clone();
                    async move {
                        let futs: Vec<_> = providers
                            .iter()
                            .map(|p| p.probe(&client, &name, &target))
                            .collect();
                        let results = futures::stream::iter(futs)
                            .buffer_unordered(2)
                            .collect::<Vec<_>>()
                            .await;
                        let mut f = Vec::new();
                        for (provider, result) in providers.iter().zip(results) {
                            match result {
                                Ok(v) => f.extend(v),
                                Err(e) => tracing::warn!(
                                    provider = provider.name(),
                                    bucket   = %name,
                                    err      = %e,
                                    "cloud probe error"
                                ),
                            }
                        }
                        f
                    }
                })
                .buffer_unordered(config.concurrency)
                .flat_map(futures::stream::iter)
                .collect()
                .await;

            for f in findings {
                input.emit(f).await;
            }
        }

        Ok(())
    }
}

/// Return all registered cloud storage providers.
///
/// To add a new provider: implement [`CloudProvider`] and append it here.
fn providers() -> Vec<Box<dyn CloudProvider>> {
    vec![
        Box::new(s3::S3Provider::new()),
        Box::new(gcs::GcsProvider::new()),
        Box::new(azure::AzureProvider::new()),
        Box::new(do_spaces::DoSpacesProvider::new()),
    ]
}

/// Extract the organisation name from a domain using the Mozilla Public Suffix List.
///
/// Examples:
/// - `"example.com"`        → `"example"`
/// - `"shop.example.co.uk"` → `"example"`
/// - `"api.example.com.br"` → `"example"`
/// - `"localhost"`           → `"localhost"`
///
/// Delegates to the workspace-canonical [`gossan_core::domain::org_label`]
/// rather than re-deriving the PSL org label inline. The canonical
/// `normalize_host` it builds on also strips userinfo, handles bracketed /
/// bare IPv6, and folds IDNA, strictly more correct than the previous
/// scheme/port-only stripping here.
pub fn org_name(input: &str) -> String {
    gossan_core::domain::org_label(input)
}

#[cfg(test)]
mod ssrf_tests {
    use super::{is_ssrf_protected_ip, is_ssrf_protected_target};
    use std::net::IpAddr;

    fn ip(s: &str) -> IpAddr {
        s.parse().unwrap()
    }

    #[test]
    fn aws_metadata_blocked() {
        assert!(is_ssrf_protected_ip(&ip("169.254.169.254")));
        assert!(is_ssrf_protected_target("169.254.169.254"));
        assert!(is_ssrf_protected_target("metadata.google.internal"));
    }

    #[test]
    fn rfc1918_blocked() {
        assert!(is_ssrf_protected_ip(&ip("10.0.0.1")));
        assert!(is_ssrf_protected_ip(&ip("10.255.255.255")));
        assert!(is_ssrf_protected_ip(&ip("172.16.0.1")));
        assert!(is_ssrf_protected_ip(&ip("172.31.255.255")));
        assert!(is_ssrf_protected_ip(&ip("192.168.0.1")));
    }

    #[test]
    fn loopback_blocked() {
        assert!(is_ssrf_protected_ip(&ip("127.0.0.1")));
        assert!(is_ssrf_protected_ip(&ip("127.255.255.254")));
    }

    #[test]
    fn link_local_blocked() {
        assert!(is_ssrf_protected_ip(&ip("169.254.0.1")));
        assert!(is_ssrf_protected_ip(&ip("169.254.255.254")));
    }

    #[test]
    fn ipv6_loopback_and_link_local_blocked() {
        assert!(is_ssrf_protected_ip(&ip("::1")));
        assert!(is_ssrf_protected_ip(&ip("fe80::1")));
        assert!(is_ssrf_protected_ip(&ip("fe80::dead:beef")));
    }

    #[test]
    fn public_ips_allowed() {
        assert!(!is_ssrf_protected_ip(&ip("1.1.1.1")));
        assert!(!is_ssrf_protected_ip(&ip("8.8.8.8")));
        assert!(!is_ssrf_protected_ip(&ip("172.32.0.1"))); // outside 172.16-31
        assert!(!is_ssrf_protected_ip(&ip("169.253.0.1"))); // adjacent /16
        assert!(!is_ssrf_protected_ip(&ip("2606:4700:4700::1111")));
    }

    #[test]
    fn bogon_classifier_covers_cgn_and_documentation() {
        // Carrier-Grade NAT and documentation ranges are covered by the bogon crate
        // but were missing from the old hand-rolled checker.
        assert!(is_ssrf_protected_ip(&ip("100.64.0.1")));
        assert!(is_ssrf_protected_ip(&ip("192.0.2.1")));
        assert!(is_ssrf_protected_ip(&ip("198.51.100.1")));
        assert!(is_ssrf_protected_ip(&ip("203.0.113.1")));
    }

    #[test]
    fn bogon_classifier_covers_ipv6_ula_and_documentation() {
        assert!(is_ssrf_protected_ip(&ip("fc00::1")));
        assert!(is_ssrf_protected_ip(&ip("2001:db8::1")));
    }
}

#[cfg(test)]
mod tests {
    use super::{org_name, providers};

    #[test]
    fn simple() {
        assert_eq!(org_name("example.com"), "example");
    }
    #[test]
    fn subdomain() {
        assert_eq!(org_name("sub.example.com"), "example");
    }
    #[test]
    fn co_uk() {
        assert_eq!(org_name("shop.example.co.uk"), "example");
    }
    #[test]
    fn com_br() {
        assert_eq!(org_name("api.example.com.br"), "example");
    }
    #[test]
    fn gov_au() {
        assert_eq!(org_name("www.agency.gov.au"), "agency");
    }
    #[test]
    fn https_scheme() {
        assert_eq!(org_name("https://example.com"), "example");
    }
    #[test]
    fn with_port() {
        assert_eq!(org_name("example.com:8080"), "example");
    }
    #[test]
    fn localhost() {
        assert_eq!(org_name("localhost"), "localhost");
    }
    #[test]
    fn deep_sub() {
        assert_eq!(org_name("a.b.c.example.io"), "example");
    }
    #[test]
    fn ip_address() {
        assert_eq!(org_name("192.0.2.10"), "192.0.2.10");
    }
    #[test]
    fn hyphenated() {
        assert_eq!(org_name("cdn.example-site.com"), "example-site");
    }
    #[test]
    fn trailing_slash() {
        assert_eq!(org_name("https://example.com/"), "example");
    }
    #[test]
    fn providers_registered() {
        assert_eq!(providers().len(), 4);
    }
}

/// Check if a string target (seed) should be blocked due to SSRF protection.
fn is_ssrf_protected_target(target: &str) -> bool {
    // Try to parse as IP address
    if let Ok(ip) = target.parse::<IpAddr>() {
        return is_ssrf_protected_ip(&ip);
    }

    // Check if it's a hostname that resolves to a protected IP
    // For simplicity, check known metadata service hostname patterns
    if target == "metadata.google.internal" || target == "169.254.169.254" {
        return true;
    }

    false
}

/// Check if a Target object should be blocked due to SSRF protection.
fn is_ssrf_protected_target_obj(target: &Target) -> bool {
    match target {
        Target::Host(host_target) => is_ssrf_protected_ip(&host_target.ip),
        Target::Domain(domain_target) => is_ssrf_protected_target(&domain_target.domain),
        _ => false,
    }
}

/// Check if an IP address should be blocked due to SSRF protection.
fn is_ssrf_protected_ip(ip: &IpAddr) -> bool {
    bogon::ip_addr_is_bogon(*ip)
}