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