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;
#[derive(Debug, Clone, Deserialize)]
struct DoRegion {
id: String,
#[allow(dead_code)]
location: String,
#[allow(dead_code)]
country: String,
}
#[derive(Debug, Deserialize)]
struct DoRegionsFile {
region: Vec<DoRegion>,
}
const BUILTIN_DO_SPACES: &str = include_str!("../rules/do_spaces.toml");
static DO_REGIONS: OnceLock<Vec<DoRegion>> = OnceLock::new();
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) => {
tracing::error!(error = %e, "failed to parse built-in do_spaces.toml");
vec![
DoRegion {
id: "nyc3".to_string(),
location: "New York City".to_string(),
country: "US".to_string(),
},
DoRegion {
id: "ams3".to_string(),
location: "Amsterdam".to_string(),
country: "NL".to_string(),
},
]
}
}
})
}
fn region_ids() -> &'static [DoRegion] {
builtin_do_regions()
}
pub struct DoSpacesProvider;
#[async_trait]
impl CloudProvider for DoSpacesProvider {
fn name(&self) -> &'static str {
"spaces"
}
fn endpoint(&self, name: &str) -> String {
format!("https://{}.ams3.digitaloceanspaces.com/", name)
}
async fn probe(
&self,
client: &reqwest::Client,
name: &str,
target: &Target,
) -> anyhow::Result<Vec<Finding>> {
let mut findings = Vec::new();
for region in region_ids() {
let region_id = ®ion.id;
let url = if self.endpoint(name).contains("digitaloceanspaces.com") {
format!("https://{}.{}.digitaloceanspaces.com/", name, region_id)
} else {
self.endpoint(name)
};
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = resp.status().as_u16();
match status {
200 => {
let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
.await
.unwrap_or_default();
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.id),
if listed {
format!(
"DO Spaces bucket '{}' ({}) is publicly listable — \
all object keys enumerable.",
name, region.id
)
} else {
format!(
"DO Spaces bucket '{}' ({}) returns 200 — publicly accessible.",
name, region.id
)
},
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("url".into(), url.clone().into())],
body_excerpt: Some(body.chars().take(200).collect::<String>().into()),
})
.tag("cloud")
.tag("storage")
.tag("do-spaces"),
&mut findings,
);
try_write(client, name, ®ion.id, &url, target, &mut findings).await;
break;
}
403 => {
gossan_core::try_push_finding(
crate::finding_builder(
target,
Severity::Low,
format!(
"DO Spaces bucket exists (private): {} ({})",
name, region.id
),
format!(
"DO Spaces bucket '{}' ({}) exists but is private (HTTP 403). \
Verify ownership.",
name, region.id
),
)
.tag("cloud")
.tag("storage")
.tag("do-spaces"),
&mut findings,
);
try_write(client, name, ®ion.id, &url, target, &mut findings).await;
break;
}
_ => {}
}
}
Ok(findings)
}
}
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 = 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) {
let _ = client.delete(&put_url).send().await;
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");
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
);
}
}
}