1use async_trait::async_trait;
9use gossan_core::Target;
10use secfinding::{Evidence, Finding, Severity};
11use serde::Deserialize;
12use std::sync::OnceLock;
13
14use crate::common::is_xml_listing;
15use crate::provider::CloudProvider;
16
17#[derive(Debug, Clone, Deserialize)]
19struct DoRegion {
20 id: String,
21 #[allow(dead_code)]
22 location: String,
23 #[allow(dead_code)]
24 country: String,
25}
26
27#[derive(Debug, Deserialize)]
29struct DoRegionsFile {
30 region: Vec<DoRegion>,
31}
32
33const BUILTIN_DO_SPACES: &str = include_str!("../rules/do_spaces.toml");
35
36static DO_REGIONS: OnceLock<Vec<DoRegion>> = OnceLock::new();
38
39fn builtin_do_regions() -> &'static Vec<DoRegion> {
41 DO_REGIONS.get_or_init(|| {
42 match toml::from_str::<DoRegionsFile>(BUILTIN_DO_SPACES) {
43 Ok(file) => file.region,
44 Err(e) => {
45 tracing::error!(error = %e, "failed to parse built-in do_spaces.toml");
46 vec![
48 DoRegion {
49 id: "nyc3".to_string(),
50 location: "New York City".to_string(),
51 country: "US".to_string(),
52 },
53 DoRegion {
54 id: "ams3".to_string(),
55 location: "Amsterdam".to_string(),
56 country: "NL".to_string(),
57 },
58 ]
59 }
60 }
61 })
62}
63
64fn region_ids() -> &'static [DoRegion] {
66 builtin_do_regions()
67}
68pub struct DoSpacesProvider;
70
71#[async_trait]
72impl CloudProvider for DoSpacesProvider {
73 fn name(&self) -> &'static str {
74 "spaces"
75 }
76
77 fn endpoint(&self, name: &str) -> String {
78 format!("https://{}.ams3.digitaloceanspaces.com/", name)
79 }
80
81 async fn probe(
82 &self,
83 client: &reqwest::Client,
84 name: &str,
85 target: &Target,
86 ) -> anyhow::Result<Vec<Finding>> {
87 let mut findings = Vec::new();
88
89 for region in region_ids() {
90 let region_id = ®ion.id;
91 let url = if self.endpoint(name).contains("digitaloceanspaces.com") {
92 format!("https://{}.{}.digitaloceanspaces.com/", name, region_id)
93 } else {
94 self.endpoint(name)
96 };
97
98 let resp = match client.get(&url).send().await {
99 Ok(r) => r,
100 Err(_) => continue,
101 };
102 let status = resp.status().as_u16();
103
104 match status {
105 200 => {
106 let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
107 .await
108 .unwrap_or_default();
109 let listed = is_xml_listing(&body);
110 gossan_core::try_push_finding(
111 crate::finding_builder(
112 target,
113 if listed {
114 Severity::Critical
115 } else {
116 Severity::High
117 },
118 format!("Public DO Spaces bucket: {} ({})", name, region.id),
119 if listed {
120 format!(
121 "DO Spaces bucket '{}' ({}) is publicly listable — \
122 all object keys enumerable.",
123 name, region.id
124 )
125 } else {
126 format!(
127 "DO Spaces bucket '{}' ({}) returns 200 — publicly accessible.",
128 name, region.id
129 )
130 },
131 )
132 .evidence(Evidence::HttpResponse {
133 status,
134 headers: vec![("url".into(), url.clone().into())],
135 body_excerpt: Some(body.chars().take(200).collect::<String>().into()),
136 })
137 .tag("cloud")
138 .tag("storage")
139 .tag("do-spaces"),
140 &mut findings,
141 );
142 try_write(client, name, ®ion.id, &url, target, &mut findings).await;
143 break;
144 }
145 403 => {
146 gossan_core::try_push_finding(
147 crate::finding_builder(
148 target,
149 Severity::Low,
150 format!(
151 "DO Spaces bucket exists (private): {} ({})",
152 name, region.id
153 ),
154 format!(
155 "DO Spaces bucket '{}' ({}) exists but is private (HTTP 403). \
156 Verify ownership.",
157 name, region.id
158 ),
159 )
160 .tag("cloud")
161 .tag("storage")
162 .tag("do-spaces"),
163 &mut findings,
164 );
165 try_write(client, name, ®ion.id, &url, target, &mut findings).await;
166 break;
167 }
168 _ => {}
169 }
170 }
171
172 Ok(findings)
173 }
174}
175
176async fn try_write(
178 client: &reqwest::Client,
179 bucket: &str,
180 region: &str,
181 _base_url: &str,
182 target: &Target,
183 findings: &mut Vec<Finding>,
184) {
185 const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
186 let put_url = format!(
187 "https://{}.{}.digitaloceanspaces.com/{}",
188 bucket, region, PROBE_KEY
189 );
190
191 let Ok(resp) = client
192 .put(&put_url)
193 .header("content-type", "text/plain")
194 .body("gossan-security-probe — safe to delete")
195 .send()
196 .await
197 else {
198 return;
199 };
200
201 let status = resp.status().as_u16();
202 if matches!(status, 200 | 204) {
203 let _ = client.delete(&put_url).send().await;
204 gossan_core::try_push_finding(
205 crate::finding_builder(
206 target,
207 Severity::Critical,
208 format!(
209 "DO Spaces bucket writable without authentication: {} ({})",
210 bucket, region
211 ),
212 format!(
213 "An unauthenticated PUT to '{}/{}' succeeded (HTTP {}). \
214 Probe object deleted immediately after confirmation.",
215 put_url.trim_end_matches(PROBE_KEY),
216 PROBE_KEY,
217 status
218 ),
219 )
220 .evidence(Evidence::HttpResponse {
221 status,
222 headers: vec![("url".into(), put_url.clone().into())],
223 body_excerpt: None,
224 })
225 .tag("cloud")
226 .tag("storage")
227 .tag("do-spaces")
228 .tag("file-upload"),
229 findings,
230 );
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn do_regions_load_from_toml() {
240 let regions = region_ids();
241 assert!(!regions.is_empty(), "should have DO regions from TOML");
242
243 assert!(
245 regions.iter().any(|r| r.id == "nyc3"),
246 "should include nyc3 region"
247 );
248 assert!(
249 regions.iter().any(|r| r.id == "ams3"),
250 "should include ams3 region"
251 );
252 }
253
254 #[test]
255 fn do_regions_have_required_fields() {
256 for region in region_ids() {
257 assert!(!region.id.is_empty(), "region id should not be empty");
258 assert!(!region.location.is_empty(), "location should not be empty");
259 assert!(!region.country.is_empty(), "country should not be empty");
260 }
261 }
262
263 #[test]
264 fn do_regions_cover_major_geographies() {
265 let ids: Vec<_> = region_ids().iter().map(|r| r.id.clone()).collect();
266 for expected in ["nyc3", "ams3", "sgp1", "fra1"] {
267 assert!(
268 ids.contains(&expected.to_string()),
269 "missing region: {}",
270 expected
271 );
272 }
273 }
274}