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,
location: String,
country: String,
}
impl DoRegion {
fn label(&self) -> String {
format!("{} ({}, {})", self.id, self.location, self.country)
}
}
#[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) => panic!("gossan-cloud: built-in do_spaces.toml is malformed: {e}")
}
})
}
fn region_ids() -> &'static [DoRegion] {
builtin_do_regions()
}
pub struct DoSpacesProvider {
pub(crate) endpoint_override: Option<String>,
}
impl DoSpacesProvider {
#[must_use]
pub fn new() -> Self {
Self { endpoint_override: None }
}
#[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 !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 = ®ion.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, ®ion.id, &url, target, &mut findings).await;
break;
}
403 => {
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 = if !_base_url.contains("digitaloceanspaces.com") {
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");
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
);
}
}
}