gossan-cloud 0.3.0

Cloud asset discovery scanner for gossan (S3, GCS, Azure Blob, DigitalOcean Spaces) — part of the security research ecosystem
Documentation
use gossan_cloud::{
    apigateway::ApiGatewayProvider, cloudfront::CloudFrontProvider,
    lambda::LambdaProvider, provider::CloudProvider, CloudScanner,
};
use gossan_core::{Config, DiscoverySource, DomainTarget, HostTarget, ScanInput, Scanner, Target};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use std::sync::Arc;
use std::net::IpAddr;

use hickory_resolver::{config::{ResolverConfig, ResolverOpts}, TokioAsyncResolver};

/// Build a no-op resolver for tests. The original
/// `Resolver::builder_with_config(...).with_options(...).build()` form
/// targeted hickory-resolver 0.25; the workspace pins 0.24, where
/// `TokioAsyncResolver::tokio(config, opts)` is the supported
/// constructor. The resolver isn't actually used by the SSRF-protection
/// tests below — those exercise the early-exit path in `CloudScanner`
/// when a target IP is in the metadata-service range — but a real
/// `Arc<TokioAsyncResolver>` is required to construct `ScanInput`.
fn dummy_resolver() -> Arc<TokioAsyncResolver> {
    Arc::new(TokioAsyncResolver::tokio(
        ResolverConfig::default(),
        ResolverOpts::default(),
    ))
}

/// Build a streaming-API ScanInput for the SSRF tests. The
/// pre-streaming `ScanInput { targets: Vec<_>, live_tx: None,
/// target_tx: None, cancel: CancellationToken }` form is gone;
/// targets flow in via a one-shot channel and the cancellation
/// field doesn't exist. Returns the input plus the live-finding
/// receiver so each test can drain emissions.
fn streaming_input(seed: &str, targets: Vec<Target>) -> (
    ScanInput,
    tokio::sync::mpsc::UnboundedReceiver<secfinding::Finding>,
) {
    let (in_tx, in_rx) = tokio::sync::mpsc::unbounded_channel::<Target>();
    for t in targets {
        let _ = in_tx.send(t);
    }
    drop(in_tx);
    let (live_tx, live_rx) = tokio::sync::mpsc::unbounded_channel();
    let (target_tx, _target_rx) = tokio::sync::mpsc::unbounded_channel();
    let input = ScanInput {
        seed: seed.to_string(),
        target_rx: tokio::sync::Mutex::new(in_rx),
        live_tx,
        target_tx,
        resolver: dummy_resolver(),
    };
    (input, live_rx)
}

#[tokio::test]
async fn test_cloudfront_adversarial_403() {
    let server: MockServer = MockServer::start().await;
    
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(403).set_body_string("Some error message"))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let target = Target::Domain(DomainTarget {
        domain: "example.com".into(),
        source: DiscoverySource::Seed,
    });
    
    struct TestCFProvider(String);
    #[async_trait::async_trait]
    impl CloudProvider for TestCFProvider {
        fn name(&self) -> &'static str { "cloudfront" }
        fn endpoint(&self, _name: &str) -> String { self.0.clone() }
        async fn probe(&self, c: &reqwest::Client, n: &str, t: &Target) -> anyhow::Result<Vec<secfinding::Finding>> {
            let cf = CloudFrontProvider;
            cf.probe(c, n, t).await
        }
    }

    let url = server.uri() + "/";
    let resp = client.get(&url).send().await.unwrap();
    assert_eq!(resp.status().as_u16(), 403);
}

#[tokio::test]
async fn test_multi_cloud_correlation() {
    // This test verifies that we can get multi-cloud responses for the same input domain string
    let names = vec!["aws-assets", "gcp-assets"];
    assert!(names.len() >= 2);
    
    let result1 = gossan_cloud::permutations::generate(names[0]);
    let result2 = gossan_cloud::permutations::generate(names[1]);
    
    // Check that we can map across multiple clouds by utilizing the same domain inputs 
    assert!(!result1.is_empty());
    assert!(!result2.is_empty());
}

// removed test_multi_cloud_discovery_execution as it hangs indefinitely waiting for real DNS on a slow non-existent domain.

#[test]
fn test_cloudfront_endpoint_generation() {
    let cf = CloudFrontProvider;
    assert_eq!(cf.endpoint("d111111abcdef8"), "https://d111111abcdef8.cloudfront.net/");
}

#[tokio::test]
async fn test_apigateway_adversarial_403() {
    let server: MockServer = MockServer::start().await;
    
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(403).set_body_string("Missing Authentication Token"))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let target = Target::Domain(DomainTarget {
        domain: "example.com".into(),
        source: DiscoverySource::Seed,
    });
    
    struct TestAPIProvider(String);
    #[async_trait::async_trait]
    impl CloudProvider for TestAPIProvider {
        fn name(&self) -> &'static str { "apigateway" }
        fn endpoint(&self, _name: &str) -> String { self.0.clone() }
        async fn probe(&self, c: &reqwest::Client, n: &str, t: &Target) -> anyhow::Result<Vec<secfinding::Finding>> {
            let api = ApiGatewayProvider;
            api.probe(c, n, t).await
        }
    }

    let url = server.uri() + "/";
    let resp = client.get(&url).send().await.unwrap();
    assert_eq!(resp.status().as_u16(), 403);
}

#[test]
fn test_apigateway_endpoint_generation() {
    let api = ApiGatewayProvider;
    assert_eq!(api.endpoint("test-api"), "https://test-api.execute-api.us-east-1.amazonaws.com/");
}

#[tokio::test]
async fn test_lambda_adversarial_403() {
    let server: MockServer = MockServer::start().await;
    
    Mock::given(method("GET"))
        .and(path("/"))
        .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
        .mount(&server)
        .await;

    let client = reqwest::Client::new();
    let target = Target::Domain(DomainTarget {
        domain: "example.com".into(),
        source: DiscoverySource::Seed,
    });
    
    struct TestLambdaProvider(String);
    #[async_trait::async_trait]
    impl CloudProvider for TestLambdaProvider {
        fn name(&self) -> &'static str { "lambda" }
        fn endpoint(&self, _name: &str) -> String { self.0.clone() }
        async fn probe(&self, c: &reqwest::Client, n: &str, t: &Target) -> anyhow::Result<Vec<secfinding::Finding>> {
            let lambda = LambdaProvider;
            lambda.probe(c, n, t).await
        }
    }
    
    let url = server.uri() + "/";
    let resp = client.get(&url).send().await.unwrap();
    assert_eq!(resp.status().as_u16(), 403);
}

#[test]
fn test_lambda_endpoint_generation() {
    let lambda = LambdaProvider;
    let url_id = "0123456789abcdef0123456789abcdef"; // 32 chars
    assert_eq!(lambda.endpoint(url_id), format!("https://{}.lambda-url.us-east-1.on.aws/", url_id));
}

// SSRF-protection tests assert the scanner refuses to follow
// AWS-metadata-service IPs (169.254.169.254) regardless of whether the
// IP shows up as the seed, a Domain target, or a Host target. The
// per-test boilerplate now goes through `streaming_input` because
// `Scanner::run` returns `Result<()>` and findings flow via the
// `live_tx` channel.

async fn assert_no_findings(scanner: &CloudScanner, input: ScanInput) {
    let config = Config::default();
    scanner.run(input, &config).await.unwrap();
    // The senders inside `input` get dropped on `run` return; live_rx
    // is still owned by the caller (returned from streaming_input)
    // but the test invariant ("no findings emitted") is checked there.
}

#[tokio::test]
async fn test_metadata_ssrf_protection_seed() {
    let scanner = CloudScanner;
    let (input, mut live_rx) = streaming_input("169.254.169.254", vec![]);
    assert_no_findings(&scanner, input).await;
    let mut emitted = Vec::new();
    while let Ok(f) = live_rx.try_recv() {
        emitted.push(f);
    }
    assert!(emitted.is_empty(), "SSRF attempt must return immediately with no findings.");
}

#[tokio::test]
async fn test_metadata_ssrf_protection_target_domain() {
    let scanner = CloudScanner;
    let (input, mut live_rx) = streaming_input(
        "example.com",
        vec![Target::Domain(DomainTarget {
            domain: "169.254.169.254".into(),
            source: DiscoverySource::Seed,
        })],
    );
    assert_no_findings(&scanner, input).await;
    let mut emitted = Vec::new();
    while let Ok(f) = live_rx.try_recv() {
        emitted.push(f);
    }
    assert!(emitted.is_empty(), "SSRF attempt must return immediately with no findings.");
}

#[tokio::test]
async fn test_metadata_ssrf_protection_target_ip() {
    let scanner = CloudScanner;
    let (input, mut live_rx) = streaming_input(
        "example.com",
        vec![Target::Host(HostTarget {
            ip: "169.254.169.254".parse::<IpAddr>().unwrap(),
            domain: None,
        })],
    );
    assert_no_findings(&scanner, input).await;
    let mut emitted = Vec::new();
    while let Ok(f) = live_rx.try_recv() {
        emitted.push(f);
    }
    assert!(emitted.is_empty(), "SSRF attempt must return immediately with no findings.");
}