Skip to main content

gossan_cloud/
gcs.rs

1//! Google Cloud Storage bucket probe.
2//!
3//! Two URL forms are tried:
4//!   - `https://storage.googleapis.com/{name}/`  (path-style)
5//!   - `https://{name}.storage.googleapis.com/`  (vhost-style)
6//!
7//! Also probes for unauthenticated write access via an unsigned PUT,
8//! matching the depth of the S3 probe.
9
10use async_trait::async_trait;
11use gossan_core::Target;
12use secfinding::{Evidence, Finding, Severity};
13
14use crate::common::is_xml_listing;
15use crate::provider::CloudProvider;
16/// Google Cloud Storage bucket discovery.
17pub struct GcsProvider;
18
19#[async_trait]
20impl CloudProvider for GcsProvider {
21    fn name(&self) -> &'static str {
22        "gcs"
23    }
24
25    fn endpoint(&self, name: &str) -> String {
26        format!("https://{}.storage.googleapis.com/", name)
27    }
28
29    async fn probe(
30        &self,
31        client: &reqwest::Client,
32        name: &str,
33        target: &Target,
34    ) -> anyhow::Result<Vec<Finding>> {
35        let vhost = self.endpoint(name);
36        let path = format!("https://storage.googleapis.com/{}/", name);
37
38        let mut urls = vec![vhost.clone()];
39        if vhost.contains("googleapis.com") {
40            urls.push(path);
41        }
42
43        let mut findings = Vec::new();
44
45        for url in &urls {
46            let resp = match client.get(url).send().await {
47                Ok(r) => r,
48                Err(_) => continue,
49            };
50            let status = resp.status().as_u16();
51
52            match status {
53                200 => {
54                    let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
55                        .await
56                        .unwrap_or_default();
57                    gossan_core::try_push_finding(
58                        crate::finding_builder(
59                            target,
60                            Severity::Critical,
61                            format!("GCS bucket publicly listed: {}", name),
62                            format!(
63                                "gs://{} is publicly accessible and allows directory listing. \
64                                 Use `gsutil ls gs://{}` to enumerate objects without credentials.",
65                                name, name
66                            ),
67                        )
68                        .evidence(Evidence::HttpResponse {
69                            status,
70                            headers: vec![("url".into(), url.clone().into())],
71                            body_excerpt: if is_xml_listing(&body) {
72                                Some(body.chars().take(300).collect::<String>().into())
73                            } else {
74                                None
75                            },
76                        })
77                        .tag("gcs")
78                        .tag("cloud")
79                        .tag("exposure")
80                        .exploit_hint(format!(
81                            "# List objects:\ngsutil ls gs://{}\n\
82                             # Download everything:\ngsutil -m cp -r gs://{}/* .",
83                            name, name
84                        )),
85                        &mut findings,
86                    );
87                    try_write(client, name, url, target, &mut findings).await;
88                    break; // found — no need to try second URL form
89                }
90                403 => {
91                    gossan_core::try_push_finding(
92                        crate::finding_builder(
93                            target,
94                            Severity::Low,
95                            format!("GCS bucket exists (access denied): {}", name),
96                            format!(
97                                "gs://{} exists but is not publicly accessible (HTTP 403).",
98                                name
99                            ),
100                        )
101                        .evidence(Evidence::HttpResponse {
102                            status,
103                            headers: vec![("url".into(), url.clone().into())],
104                            body_excerpt: None,
105                        })
106                        .tag("gcs")
107                        .tag("cloud"),
108                        &mut findings,
109                    );
110                    try_write(client, name, url, target, &mut findings).await;
111                    break;
112                }
113                _ => {}
114            }
115        }
116
117        Ok(findings)
118    }
119}
120
121/// Attempt an unauthenticated PUT to GCS. On success: Critical finding + cleanup.
122async fn try_write(
123    client: &reqwest::Client,
124    bucket: &str,
125    base_url: &str,
126    target: &Target,
127    findings: &mut Vec<Finding>,
128) {
129    const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
130    // GCS simple upload via XML API
131    let put_url = if base_url.contains("storage.googleapis.com/")
132        && !base_url.starts_with("https://storage")
133    {
134        format!("https://{}.storage.googleapis.com/{}", bucket, PROBE_KEY)
135    } else {
136        format!("https://storage.googleapis.com/{}/{}", bucket, PROBE_KEY)
137    };
138
139    let Ok(resp) = client
140        .put(&put_url)
141        .header("content-type", "text/plain")
142        .body("gossan-security-probe — safe to delete")
143        .send()
144        .await
145    else {
146        return;
147    };
148
149    let status = resp.status().as_u16();
150    if matches!(status, 200 | 204) {
151        let _ = client.delete(&put_url).send().await;
152        gossan_core::try_push_finding(
153            crate::finding_builder(
154                target,
155                Severity::Critical,
156                format!("GCS bucket writable without authentication: {}", bucket),
157                format!(
158                    "An unauthenticated PUT to gs://{}/{} succeeded (HTTP {}). \
159                     The `allUsers: WRITER` IAM binding is set — any attacker can upload files. \
160                     Probe object deleted immediately after confirmation.",
161                    bucket, PROBE_KEY, status
162                ),
163            )
164            .evidence(Evidence::HttpResponse {
165                status,
166                headers: vec![("url".into(), put_url.into())],
167                body_excerpt: None,
168            })
169            .tag("gcs")
170            .tag("cloud")
171            .tag("file-upload")
172            .tag("exposure"),
173            findings,
174        );
175    }
176}