1use async_trait::async_trait;
11use gossan_core::Target;
12use secfinding::{Evidence, Finding, Severity};
13
14use crate::common::is_xml_listing;
15use crate::provider::CloudProvider;
16pub 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; }
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
121async 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 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}