Skip to main content

gossan_cloud/
do_spaces.rs

1//! DigitalOcean Spaces probe.
2//!
3//! URL format: `https://{bucket}.{region}.digitaloceanspaces.com/`
4//!
5//! DO Spaces is S3-compatible, so the same listing and write probes apply.
6//! Regions are tried in order; scan stops at first confirmed result.
7
8use 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/// DO Spaces region definition from TOML.
18#[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/// TOML file containing DO Spaces region definitions.
28#[derive(Debug, Deserialize)]
29struct DoRegionsFile {
30    region: Vec<DoRegion>,
31}
32
33/// Built-in do_spaces.toml content (embedded at compile time).
34const BUILTIN_DO_SPACES: &str = include_str!("../rules/do_spaces.toml");
35
36/// Global cache for built-in DO regions.
37static DO_REGIONS: OnceLock<Vec<DoRegion>> = OnceLock::new();
38
39/// Initialize and return the built-in DO Spaces regions.
40fn 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                // Fallback to minimal hardcoded list only on parse failure
47                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
64/// Get region IDs from TOML configuration.
65fn region_ids() -> &'static [DoRegion] {
66    builtin_do_regions()
67}
68/// DigitalOcean Spaces bucket enumeration provider.
69pub 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 = &region.id;
91            let url = if self.endpoint(name).contains("digitaloceanspaces.com") {
92                format!("https://{}.{}.digitaloceanspaces.com/", name, region_id)
93            } else {
94                // If overridden in tests, just use the test endpoint directly
95                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, &region.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, &region.id, &url, target, &mut findings).await;
166                    break;
167                }
168                _ => {}
169            }
170        }
171
172        Ok(findings)
173    }
174}
175
176/// Attempt an unauthenticated S3-compatible PUT. On success: Critical finding + cleanup.
177async 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        // Check for expected regions
244        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}