#![forbid(unsafe_code)]
#![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,
)]
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;
pub mod apigateway;
pub mod cloudfront;
pub mod lambda;
#[cfg(test)]
mod integration_tests;
use std::sync::Arc;
use std::net::IpAddr;
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;
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<()> {
if is_ssrf_protected_target(&input.seed) {
tracing::warn!("SSRF protection triggered for seed: {}", input.seed);
return Ok(());
}
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;
while let Ok(t) = rx.try_recv() {
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")]
{
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");
}
}
let client = ScanClient::from_config_no_redirect(config, Arc::clone(&input.resolver))?;
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);
}
if has_ssrf_targets && inbound.is_empty() {
tracing::info!("SSRF protection: All targets filtered out, exiting early");
return Ok(());
}
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::future::join_all(futs).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);
}
}
Ok(())
}
}
fn providers() -> Vec<Box<dyn CloudProvider>> {
vec![
Box::new(s3::S3Provider),
Box::new(gcs::GcsProvider),
Box::new(azure::AzureProvider),
Box::new(do_spaces::DoSpacesProvider),
]
}
fn org_name(input: &str) -> String {
let host = input
.trim_start_matches("http://")
.trim_start_matches("https://")
.trim_end_matches('/')
.split(':')
.next()
.unwrap_or(input);
if host.parse::<std::net::IpAddr>().is_ok() {
return host.to_lowercase();
}
if let Some(domain) = psl::domain(host.as_bytes()) {
let registrable = std::str::from_utf8(domain.as_bytes()).unwrap_or(host);
registrable.split('.').next().unwrap_or(host).to_lowercase()
} else {
host.split('.').next().unwrap_or(host).to_lowercase()
}
}
#[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"))); assert!(!is_ssrf_protected_ip(&ip("169.253.0.1"))); assert!(!is_ssrf_protected_ip(&ip("2606:4700:4700::1111")));
}
}
#[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);
}
}
fn is_ssrf_protected_target(target: &str) -> bool {
if let Ok(ip) = target.parse::<IpAddr>() {
return is_ssrf_protected_ip(&ip);
}
if target == "metadata.google.internal" || target == "169.254.169.254" {
return true;
}
false
}
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,
}
}
fn is_ssrf_protected_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(ipv4) => {
let octets = ipv4.octets();
if octets == [169, 254, 169, 254] {
return true;
}
if octets[0] == 10 {
return true;
}
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return true;
}
if octets[0] == 192 && octets[1] == 168 {
return true;
}
if octets[0] == 127 {
return true;
}
if octets[0] == 169 && octets[1] == 254 {
return true;
}
}
IpAddr::V6(ipv6) => {
if *ipv6 == std::net::Ipv6Addr::LOCALHOST {
return true;
}
let segments = ipv6.segments();
if (segments[0] & 0xffc0) == 0xfe80 {
return true;
}
}
}
false
}