1#![forbid(unsafe_code)]
2#![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
19pub 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;
37pub 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;
60pub 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 if is_ssrf_protected_target(&input.seed) {
91 tracing::warn!("SSRF protection triggered for seed: {}", input.seed);
92 return Ok(());
93 }
94
95 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 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 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 let client = ScanClient::from_config_no_redirect(config, Arc::clone(&input.resolver))?;
133
134 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 if has_ssrf_targets && inbound.is_empty() {
151 tracing::info!("SSRF protection: All targets filtered out, exiting early");
152 return Ok(());
153 }
154
155 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
213fn 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
225fn org_name(input: &str) -> String {
233 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 if let Some(domain) = psl::domain(host.as_bytes()) {
248 let registrable = std::str::from_utf8(domain.as_bytes()).unwrap_or(host);
250 registrable.split('.').next().unwrap_or(host).to_lowercase()
251 } else {
252 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"))); assert!(!is_ssrf_protected_ip(&ip("169.253.0.1"))); 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
369fn is_ssrf_protected_target(target: &str) -> bool {
371 if let Ok(ip) = target.parse::<IpAddr>() {
373 return is_ssrf_protected_ip(&ip);
374 }
375
376 if target == "metadata.google.internal" || target == "169.254.169.254" {
379 return true;
380 }
381
382 false
383}
384
385fn 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
394fn is_ssrf_protected_ip(ip: &IpAddr) -> bool {
396 match ip {
397 IpAddr::V4(ipv4) => {
398 let octets = ipv4.octets();
399 if octets == [169, 254, 169, 254] {
401 return true;
402 }
403 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 if octets[0] == 127 {
415 return true;
416 }
417 if octets[0] == 169 && octets[1] == 254 {
419 return true;
420 }
421 }
422 IpAddr::V6(ipv6) => {
423 if *ipv6 == std::net::Ipv6Addr::LOCALHOST {
425 return true;
426 }
427 let segments = ipv6.segments();
429 if (segments[0] & 0xffc0) == 0xfe80 {
430 return true;
431 }
432 }
433 }
434 false
435}