1use async_trait::async_trait;
13use gossan_core::Target;
14use secfinding::{Evidence, Finding, Severity};
15
16use crate::common::is_xml_listing;
17use crate::provider::CloudProvider;
18pub struct S3Provider;
20
21#[async_trait]
22impl CloudProvider for S3Provider {
23 fn name(&self) -> &'static str {
24 "s3"
25 }
26
27 fn endpoint(&self, name: &str) -> String {
28 let encoded_name = urlencoding::encode(name);
29 format!("https://{}.s3.amazonaws.com/", encoded_name)
30 }
31
32 async fn probe(
33 &self,
34 client: &reqwest::Client,
35 name: &str,
36 target: &Target,
37 ) -> anyhow::Result<Vec<Finding>> {
38 let vhost = self.endpoint(name);
39 let encoded_name = urlencoding::encode(name);
40 let path = format!("https://s3.amazonaws.com/{}/", encoded_name);
41 let mut findings = Vec::new();
42
43 let (status, body, effective_url) = {
45 let mut status = 0u16;
46 let mut body = String::new();
47 let mut eff = vhost.clone();
48
49 if let Ok(resp) = client.get(&vhost).send().await {
50 status = resp.status().as_u16();
51 body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024).await.unwrap_or_default();
52 }
53 if (status == 0 || status == 301) && vhost.contains("amazonaws.com") {
55 if let Ok(resp) = client.get(&path).send().await {
56 status = resp.status().as_u16();
57 body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024).await.unwrap_or_default();
58 eff = path.clone();
59 }
60 }
61 (status, body, eff)
62 };
63
64 match status {
65 200 => {
66 gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
67 format!("S3 bucket publicly listed: {}", name),
68 format!(
69 "s3://{} is publicly accessible and allows directory listing. \
70 All object keys are enumerable; use \
71 `aws s3 ls s3://{} --no-sign-request` to download without credentials.",
72 name, name
73 ))
74 .evidence(Evidence::HttpResponse {
75 status,
76 headers: vec![("url".into(), effective_url.clone().into())],
77 body_excerpt: if is_xml_listing(&body) {
78 Some(body.chars().take(400).collect::<String>().into())
79 } else {
80 None
81 },
82 })
83 .tag("s3").tag("cloud").tag("exposure")
84 .exploit_hint(format!(
85 "# List all objects:\naws s3 ls s3://{} --no-sign-request\n\
86 # Download everything:\naws s3 sync s3://{} . --no-sign-request",
87 name, name
88 )), &mut findings);
89 try_write(client, name, &effective_url, target, &mut findings).await;
91 }
92 403 => {
93 gossan_core::try_push_finding(crate::finding_builder(
94 target,
95 Severity::Low,
96 format!("S3 bucket exists (access denied): {}", name),
97 format!(
98 "s3://{} exists but public listing is blocked (HTTP 403). \
99 Probe for write access and per-object ACL misconfigurations.",
100 name
101 ),
102 )
103 .evidence(Evidence::HttpResponse {
104 status,
105 headers: vec![("url".into(), effective_url.clone().into())],
106 body_excerpt: None,
107 })
108 .tag("s3")
109 .tag("cloud"), &mut findings);
110 try_write(client, name, &effective_url, target, &mut findings).await;
112 }
113 _ => {} }
115
116 Ok(findings)
117 }
118}
119
120async fn try_write(
122 client: &reqwest::Client,
123 bucket: &str,
124 base_url: &str,
125 target: &Target,
126 findings: &mut Vec<Finding>,
127) {
128 const PROBE_KEY: &str = "gossan-write-probe-delete-me.txt";
129 let encoded_bucket = urlencoding::encode(bucket);
130 let put_url = if base_url.contains(".s3.amazonaws.com") {
131 format!("https://{}.s3.amazonaws.com/{}", encoded_bucket, PROBE_KEY)
132 } else {
133 format!("https://s3.amazonaws.com/{}/{}", encoded_bucket, PROBE_KEY)
134 };
135
136 let Ok(resp) = client
137 .put(&put_url)
138 .header("content-type", "text/plain")
139 .body("gossan-security-probe — safe to delete")
140 .send()
141 .await
142 else {
143 return;
144 };
145
146 let status = resp.status().as_u16();
147 if matches!(status, 200 | 204) {
148 let _ = client.delete(&put_url).send().await; gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
150 format!("S3 bucket writable without authentication: {}", bucket),
151 format!(
152 "An unauthenticated PUT to s3://{}/{} succeeded (HTTP {}). \
153 Any attacker can upload arbitrary files including web shells. \
154 The probe object was deleted immediately after confirmation.",
155 bucket, PROBE_KEY, status
156 ))
157 .evidence(Evidence::HttpResponse {
158 status,
159 headers: vec![("url".into(), put_url.clone().into())],
160 body_excerpt: None,
161 })
162 .tag("s3").tag("cloud").tag("file-upload").tag("exposure")
163 .exploit_hint(format!(
164 "# Upload a malicious file:\naws s3 cp malware.html s3://{}/malware.html --no-sign-request\n\
165 # Via curl:\ncurl -s -X PUT '{}' --upload-file payload.bin",
166 bucket,
167 put_url.replace(PROBE_KEY, "payload.bin")
168 )), findings);
169 }
170}