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;
31pub mod common;
32pub mod do_spaces;
33pub mod gcs;
34pub mod inside_out;
35pub mod permutations;
36pub mod provider;
37pub mod s3;
38pub 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;
61pub 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 if is_ssrf_protected_target(&input.seed) {
92 tracing::warn!("SSRF protection triggered for seed: {}", input.seed);
93 return Ok(());
94 }
95
96 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 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 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 let client = ScanClient::from_config_no_redirect(config, Arc::clone(&input.resolver))?;
134
135 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 if has_ssrf_targets && inbound.is_empty() {
152 tracing::info!("SSRF protection: All targets filtered out, exiting early");
153 return Ok(());
154 }
155
156 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
214fn 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
226fn org_name(input: &str) -> String {
234 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 if let Some(domain) = psl::domain(host.as_bytes()) {
249 let registrable = std::str::from_utf8(domain.as_bytes()).unwrap_or(host);
251 registrable.split('.').next().unwrap_or(host).to_lowercase()
252 } else {
253 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"))); assert!(!is_ssrf_protected_ip(&ip("169.253.0.1"))); 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
370fn is_ssrf_protected_target(target: &str) -> bool {
372 if let Ok(ip) = target.parse::<IpAddr>() {
374 return is_ssrf_protected_ip(&ip);
375 }
376
377 if target == "metadata.google.internal" || target == "169.254.169.254" {
380 return true;
381 }
382
383 false
384}
385
386fn 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
395fn is_ssrf_protected_ip(ip: &IpAddr) -> bool {
397 match ip {
398 IpAddr::V4(ipv4) => {
399 let octets = ipv4.octets();
400 if octets == [169, 254, 169, 254] {
402 return true;
403 }
404 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 if octets[0] == 127 {
416 return true;
417 }
418 if octets[0] == 169 && octets[1] == 254 {
420 return true;
421 }
422 }
423 IpAddr::V6(ipv6) => {
424 if *ipv6 == std::net::Ipv6Addr::LOCALHOST {
426 return true;
427 }
428 let segments = ipv6.segments();
430 if (segments[0] & 0xffc0) == 0xfe80 {
431 return true;
432 }
433 }
434 }
435 false
436}