use async_trait::async_trait;
use gossan_core::Target;
use secfinding::{Evidence, Finding, Severity};
use crate::common::is_xml_listing;
use crate::provider::CloudProvider;
pub struct GcsProvider;
#[async_trait]
impl CloudProvider for GcsProvider {
fn name(&self) -> &'static str {
"gcs"
}
fn endpoint(&self, name: &str) -> String {
format!("https://{}.storage.googleapis.com/", name)
}
async fn probe(
&self,
client: &reqwest::Client,
name: &str,
target: &Target,
) -> anyhow::Result<Vec<Finding>> {
let vhost = self.endpoint(name);
let path = format!("https://storage.googleapis.com/{}/", name);
let mut urls = vec![vhost.clone()];
if vhost.contains("googleapis.com") {
urls.push(path);
}
let mut findings = Vec::new();
for url in &urls {
let resp = match client.get(url).send().await {
Ok(r) => r,
Err(_) => continue,
};
let status = resp.status().as_u16();
match status {
200 => {
let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
.await
.unwrap_or_default();
gossan_core::try_push_finding(
crate::finding_builder(
target,
Severity::Critical,
format!("GCS bucket publicly listed: {}", name),
format!(
"gs://{} is publicly accessible and allows directory listing. \
Use `gsutil ls gs://{}` to enumerate objects without credentials.",
name, name
),
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("url".into(), url.clone().into())],
body_excerpt: if is_xml_listing(&body) {
Some(body.chars().take(300).collect::<String>().into())
} else {
None
},
})
.tag("gcs")
.tag("cloud")
.tag("exposure")
.exploit_hint(format!(
"# List objects:\ngsutil ls gs://{}\n\
# Download everything:\ngsutil -m cp -r gs://{}/* .",
name, name
)),
&mut findings,
);
try_write(client, name, url, target, &mut findings).await;
break; }
403 => {
gossan_core::try_push_finding(
crate::finding_builder(
target,
Severity::Low,
format!("GCS bucket exists (access denied): {}", name),
format!(
"gs://{} exists but is not publicly accessible (HTTP 403).",
name
),
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("url".into(), url.clone().into())],
body_excerpt: None,
})
.tag("gcs")
.tag("cloud"),
&mut findings,
);
try_write(client, name, url, target, &mut findings).await;
break;
}
_ => {}
}
}
Ok(findings)
}
}
async fn try_write(
client: &reqwest::Client,
bucket: &str,
base_url: &str,
target: &Target,
findings: &mut Vec<Finding>,
) {
const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
let put_url = if base_url.contains("storage.googleapis.com/")
&& !base_url.starts_with("https://storage")
{
format!("https://{}.storage.googleapis.com/{}", bucket, PROBE_KEY)
} else {
format!("https://storage.googleapis.com/{}/{}", bucket, PROBE_KEY)
};
let Ok(resp) = client
.put(&put_url)
.header("content-type", "text/plain")
.body("gossan-security-probe — safe to delete")
.send()
.await
else {
return;
};
let status = resp.status().as_u16();
if matches!(status, 200 | 204) {
let _ = client.delete(&put_url).send().await;
gossan_core::try_push_finding(
crate::finding_builder(
target,
Severity::Critical,
format!("GCS bucket writable without authentication: {}", bucket),
format!(
"An unauthenticated PUT to gs://{}/{} succeeded (HTTP {}). \
The `allUsers: WRITER` IAM binding is set — any attacker can upload files. \
Probe object deleted immediately after confirmation.",
bucket, PROBE_KEY, status
),
)
.evidence(Evidence::HttpResponse {
status,
headers: vec![("url".into(), put_url.into())],
body_excerpt: None,
})
.tag("gcs")
.tag("cloud")
.tag("file-upload")
.tag("exposure"),
findings,
);
}
}