gossan-cloud 0.3.3

Cloud asset discovery scanner for gossan (S3, GCS, Azure Blob, DigitalOcean Spaces), part of the security research ecosystem
//! DigitalOcean Spaces probe.
//!
//! URL format: `https://{bucket}.{region}.digitaloceanspaces.com/`
//!
//! DO Spaces is S3-compatible, so the same listing and write probes apply.
//! Regions are tried in order; scan stops at first confirmed result.

use async_trait::async_trait;
use gossan_core::Target;
use secfinding::{Evidence, Finding, Severity};
use serde::Deserialize;
use std::sync::OnceLock;

use crate::common::is_xml_listing;
use crate::provider::CloudProvider;

/// DO Spaces region definition from TOML.
#[derive(Debug, Clone, Deserialize)]
struct DoRegion {
    id: String,
    location: String,
    country: String,
}

impl DoRegion {
    /// Human-readable label for use in finding details, e.g. "nyc3 (New York City, US)".
    fn label(&self) -> String {
        format!("{} ({}, {})", self.id, self.location, self.country)
    }
}

/// TOML file containing DO Spaces region definitions.
#[derive(Debug, Deserialize)]
struct DoRegionsFile {
    region: Vec<DoRegion>,
}

/// Built-in do_spaces.toml content (embedded at compile time).
const BUILTIN_DO_SPACES: &str = include_str!("../rules/do_spaces.toml");

/// Global cache for built-in DO regions.
static DO_REGIONS: OnceLock<Vec<DoRegion>> = OnceLock::new();

/// Initialize and return the built-in DO Spaces regions.
fn builtin_do_regions() -> &'static Vec<DoRegion> {
    DO_REGIONS.get_or_init(|| {
        match toml::from_str::<DoRegionsFile>(BUILTIN_DO_SPACES) {
            Ok(file) => file.region,
            Err(e) => panic!("gossan-cloud: built-in do_spaces.toml is malformed: {e}")
        }
    })
}

/// Get region IDs from TOML configuration.
fn region_ids() -> &'static [DoRegion] {
    builtin_do_regions()
}
/// DigitalOcean Spaces bucket enumeration provider.
pub struct DoSpacesProvider {
    /// Optional endpoint override for testing.
    pub(crate) endpoint_override: Option<String>,
}

impl DoSpacesProvider {
    /// Create a new DO Spaces provider with the default DigitalOcean endpoint.
    #[must_use]
    pub fn new() -> Self {
        Self { endpoint_override: None }
    }

    /// Create a DO Spaces provider with a custom endpoint (for tests).
    #[must_use]
    pub fn with_endpoint(url: impl Into<String>) -> Self {
        Self { endpoint_override: Some(url.into()) }
    }
}

impl Default for DoSpacesProvider {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl CloudProvider for DoSpacesProvider {
    fn name(&self) -> &'static str {
        "spaces"
    }

    fn endpoint(&self, name: &str) -> String {
        if let Some(ref url) = self.endpoint_override {
            return url.clone();
        }
        format!("https://{}.ams3.digitaloceanspaces.com/", name)
    }

    async fn probe(
        &self,
        client: &reqwest::Client,
        name: &str,
        target: &Target,
    ) -> anyhow::Result<Vec<Finding>> {
        let base = self.endpoint(name);
        let mut findings = Vec::new();

        // If the endpoint has been overridden (e.g. in tests) we only need to
        // probe once (iterating regions is meaningless).
        if !base.contains("digitaloceanspaces.com") {
            let resp = match client.get(&base).send().await {
                Ok(r) => r,
                Err(e) => {
                    tracing::warn!(
                        bucket = %name,
                        url = %base,
                        error = %e,
                        "DO Spaces custom-endpoint probe send failed"
                    );
                    return Ok(findings);
                }
            };
            let status = resp.status().as_u16();

            match status {
                200 => {
                    let body = match gossan_core::net::bounded_text(
                        resp,
                        crate::MAX_CLOUD_RESPONSE_BYTES,
                    )
                    .await
                    {
                        Ok(b) => b,
                        Err(e) => {
                            tracing::warn!(
                                bucket = %name,
                                url = %base,
                                error = %e,
                                "DO Spaces custom-endpoint body read failed"
                            );
                            return Ok(vec![]);
                        }
                    };
                    let listed = is_xml_listing(&body);
                    gossan_core::try_push_finding(
                        crate::finding_builder(
                            target,
                            if listed {
                                Severity::Critical
                            } else {
                                Severity::High
                            },
                            format!("Public DO Spaces bucket: {} (custom)", name),
                            if listed {
                                format!(
                                    "DO Spaces bucket '{}' (custom endpoint) is publicly listable.",
                                    name
                                )
                            } else {
                                format!(
                                    "DO Spaces bucket '{}' (custom endpoint) returns 200, publicly accessible.",
                                    name
                                )
                            },
                        )
                        .evidence(Evidence::HttpResponse {
                            status,
                            headers: vec![("url".into(), base.clone().into())],
                            body_excerpt: Some(body.chars().take(crate::MAX_BODY_EXCERPT_CHARS).collect::<String>().into()),
                        })
                        .tag("cloud")
                        .tag("storage")
                        .tag("do-spaces"),
                        &mut findings,
                    );
                    try_write(client, name, "custom", &base, target, &mut findings).await;
                }
                403 => {
                    try_write(client, name, "custom", &base, target, &mut findings).await;
                }
                _ => {}
            }
            return Ok(findings);
        }

        for region in region_ids() {
            let region_id = &region.id;
            let url = format!("https://{}.{}.digitaloceanspaces.com/", name, region_id);

            let resp = match client.get(&url).send().await {
                Ok(r) => r,
                Err(e) => {
                    tracing::warn!(
                        bucket = %name,
                        region = %region_id,
                        url = %url,
                        error = %e,
                        "DO Spaces probe send failed"
                    );
                    continue;
                }
            };
            let status = resp.status().as_u16();

            match status {
                200 => {
                    let body = match gossan_core::net::bounded_text(
                        resp,
                        crate::MAX_CLOUD_RESPONSE_BYTES,
                    )
                    .await
                    {
                        Ok(b) => b,
                        Err(e) => {
                            tracing::warn!(
                                bucket = %name,
                                region = %region_id,
                                url = %url,
                                error = %e,
                                "DO Spaces body read failed"
                            );
                            continue;
                        }
                    };
                    let listed = is_xml_listing(&body);
                    gossan_core::try_push_finding(
                        crate::finding_builder(
                            target,
                            if listed {
                                Severity::Critical
                            } else {
                                Severity::High
                            },
                            format!("Public DO Spaces bucket: {} ({})", name, region.label()),
                            if listed {
                                format!(
                                    "DO Spaces bucket '{}' in {} is publicly listable. \
                                     all object keys enumerable.",
                                    name, region.label()
                                )
                            } else {
                                format!(
                                    "DO Spaces bucket '{}' in {} returns 200, publicly accessible.",
                                    name, region.label()
                                )
                            },
                        )
                        .evidence(Evidence::HttpResponse {
                            status,
                            headers: vec![("url".into(), url.clone().into())],
                            body_excerpt: Some(body.chars().take(crate::MAX_BODY_EXCERPT_CHARS).collect::<String>().into()),
                        })
                        .tag("cloud")
                        .tag("storage")
                        .tag("do-spaces"),
                        &mut findings,
                    );
                    try_write(client, name, &region.id, &url, target, &mut findings).await;
                    break;
                }
                403 => {
                    try_write(client, name, &region.id, &url, target, &mut findings).await;
                    break;
                }
                _ => {}
            }
        }

        Ok(findings)
    }
}

/// Attempt an unauthenticated S3-compatible PUT. On success: Critical finding + cleanup.
async fn try_write(
    client: &reqwest::Client,
    bucket: &str,
    region: &str,
    _base_url: &str,
    target: &Target,
    findings: &mut Vec<Finding>,
) {
    const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
    let put_url = if !_base_url.contains("digitaloceanspaces.com") {
        // Custom endpoint (e.g. test mock) (append probe key directly).
        format!("{}/{}", _base_url.trim_end_matches('/'), PROBE_KEY)
    } else {
        format!(
            "https://{}.{}.digitaloceanspaces.com/{}",
            bucket, region, PROBE_KEY
        )
    };

    let Ok(resp) = client
        .put(&put_url)
        .header("content-type", "text/plain")
        .body("gossan-security-probe, safe to delete")
        .send()
        .await
    else {
        return;
    };

    let status = resp.status().as_u16();
    if matches!(status, 200 | 204) {
        if let Err(e) = client.delete(&put_url).send().await {
            tracing::error!(bucket = %bucket, region = %region, err = %e, "probe cleanup failed");
        }
        gossan_core::try_push_finding(
            crate::finding_builder(
                target,
                Severity::Critical,
                format!(
                    "DO Spaces bucket writable without authentication: {} ({})",
                    bucket, region
                ),
                format!(
                    "An unauthenticated PUT to '{}/{}' succeeded (HTTP {}). \
                     Probe object deleted immediately after confirmation.",
                    put_url.trim_end_matches(PROBE_KEY),
                    PROBE_KEY,
                    status
                ),
            )
            .evidence(Evidence::HttpResponse {
                status,
                headers: vec![("url".into(), put_url.clone().into())],
                body_excerpt: None,
            })
            .tag("cloud")
            .tag("storage")
            .tag("do-spaces")
            .tag("file-upload"),
            findings,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn do_regions_load_from_toml() {
        let regions = region_ids();
        assert!(!regions.is_empty(), "should have DO regions from TOML");

        // Check for expected regions
        assert!(
            regions.iter().any(|r| r.id == "nyc3"),
            "should include nyc3 region"
        );
        assert!(
            regions.iter().any(|r| r.id == "ams3"),
            "should include ams3 region"
        );
    }

    #[test]
    fn do_regions_have_required_fields() {
        for region in region_ids() {
            assert!(!region.id.is_empty(), "region id should not be empty");
            assert!(!region.location.is_empty(), "location should not be empty");
            assert!(!region.country.is_empty(), "country should not be empty");
        }
    }

    #[test]
    fn do_regions_cover_major_geographies() {
        let ids: Vec<_> = region_ids().iter().map(|r| r.id.clone()).collect();
        for expected in ["nyc3", "ams3", "sgp1", "fra1"] {
            assert!(
                ids.contains(&expected.to_string()),
                "missing region: {}",
                expected
            );
        }
    }
}