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).await.unwrap_or_default();
55 gossan_core::try_push_finding(crate::finding_builder(
56 target,
57 Severity::Critical,
58 format!("GCS bucket publicly listed: {}", name),
59 format!(
60 "gs://{} is publicly accessible and allows directory listing. \
61 Use `gsutil ls gs://{}` to enumerate objects without credentials.",
62 name, name
63 ),
64 )
65 .evidence(Evidence::HttpResponse {
66 status,
67 headers: vec![("url".into(), url.clone().into())],
68 body_excerpt: if is_xml_listing(&body) {
69 Some(body.chars().take(300).collect::<String>().into())
70 } else {
71 None
72 },
73 })
74 .tag("gcs")
75 .tag("cloud")
76 .tag("exposure")
77 .exploit_hint(format!(
78 "# List objects:\ngsutil ls gs://{}\n\
79 # Download everything:\ngsutil -m cp -r gs://{}/* .",
80 name, name
81 )), &mut findings);
82 try_write(client, name, url, target, &mut findings).await;
83 break; }
85 403 => {
86 gossan_core::try_push_finding(crate::finding_builder(
87 target,
88 Severity::Low,
89 format!("GCS bucket exists (access denied): {}", name),
90 format!(
91 "gs://{} exists but is not publicly accessible (HTTP 403).",
92 name
93 ),
94 )
95 .evidence(Evidence::HttpResponse {
96 status,
97 headers: vec![("url".into(), url.clone().into())],
98 body_excerpt: None,
99 })
100 .tag("gcs")
101 .tag("cloud"), &mut findings);
102 try_write(client, name, url, target, &mut findings).await;
103 break;
104 }
105 _ => {}
106 }
107 }
108
109 Ok(findings)
110 }
111}
112
113async fn try_write(
115 client: &reqwest::Client,
116 bucket: &str,
117 base_url: &str,
118 target: &Target,
119 findings: &mut Vec<Finding>,
120) {
121 const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
122 let put_url = if base_url.contains("storage.googleapis.com/")
124 && !base_url.starts_with("https://storage")
125 {
126 format!("https://{}.storage.googleapis.com/{}", bucket, PROBE_KEY)
127 } else {
128 format!("https://storage.googleapis.com/{}/{}", bucket, PROBE_KEY)
129 };
130
131 let Ok(resp) = client
132 .put(&put_url)
133 .header("content-type", "text/plain")
134 .body("gossan-security-probe — safe to delete")
135 .send()
136 .await
137 else {
138 return;
139 };
140
141 let status = resp.status().as_u16();
142 if matches!(status, 200 | 204) {
143 let _ = client.delete(&put_url).send().await;
144 gossan_core::try_push_finding(crate::finding_builder(
145 target,
146 Severity::Critical,
147 format!("GCS bucket writable without authentication: {}", bucket),
148 format!(
149 "An unauthenticated PUT to gs://{}/{} succeeded (HTTP {}). \
150 The `allUsers: WRITER` IAM binding is set — any attacker can upload files. \
151 Probe object deleted immediately after confirmation.",
152 bucket, PROBE_KEY, status
153 ),
154 )
155 .evidence(Evidence::HttpResponse {
156 status,
157 headers: vec![("url".into(), put_url.into())],
158 body_excerpt: None,
159 })
160 .tag("gcs")
161 .tag("cloud")
162 .tag("file-upload")
163 .tag("exposure"), findings);
164 }
165}