1use async_trait::async_trait;
10use gossan_core::Target;
11use secfinding::{Evidence, Finding, Severity};
12use serde::Deserialize;
13use std::sync::OnceLock;
14
15use crate::provider::CloudProvider;
16
17#[derive(Debug, Clone, Deserialize)]
19struct AzureContainer {
20 name: String,
21 #[allow(dead_code)]
22 description: String,
23 #[allow(dead_code)]
24 #[serde(rename = "severity_if_exposed")]
25 severity: String,
26}
27
28#[derive(Debug, Deserialize)]
30struct AzureContainersFile {
31 container: Vec<AzureContainer>,
32}
33
34const BUILTIN_AZURE: &str = include_str!("../rules/azure.toml");
36
37static AZURE_CONTAINERS: OnceLock<Vec<AzureContainer>> = OnceLock::new();
39
40fn builtin_azure_containers() -> &'static Vec<AzureContainer> {
42 AZURE_CONTAINERS.get_or_init(|| {
43 match toml::from_str::<AzureContainersFile>(BUILTIN_AZURE) {
44 Ok(file) => file.container,
45 Err(e) => {
46 tracing::error!(error = %e, "failed to parse built-in azure.toml");
47 vec![AzureContainer {
49 name: "$web".to_string(),
50 description: "static website hosting".to_string(),
51 severity: "critical".to_string(),
52 }]
53 }
54 }
55 })
56}
57
58fn container_names() -> &'static [AzureContainer] {
60 builtin_azure_containers()
61}
62pub struct AzureProvider;
64
65#[async_trait]
66impl CloudProvider for AzureProvider {
67 fn name(&self) -> &'static str {
68 "azure"
69 }
70
71 fn endpoint(&self, name: &str) -> String {
72 format!("https://{}.blob.core.windows.net/", name)
73 }
74
75 async fn probe(
76 &self,
77 client: &reqwest::Client,
78 name: &str,
79 target: &Target,
80 ) -> anyhow::Result<Vec<Finding>> {
81 let account: String = name
83 .chars()
84 .filter(|c| c.is_ascii_alphanumeric())
85 .collect::<String>()
86 .to_lowercase();
87 if account.len() < 3 || account.len() > 24 {
88 return Ok(vec![]);
89 }
90
91 let base_endpoint = self.endpoint(&account);
92 let mut findings = Vec::new();
93 let mut account_confirmed = false;
94
95 for container in container_names() {
96 let container_name = &container.name;
97 let url = format!("{}{}/", base_endpoint, container_name);
98 let resp = match client.get(&url).send().await {
99 Ok(r) => r,
100 Err(_) => continue,
101 };
102 let status = resp.status().as_u16();
103
104 match status {
105 200 => {
106 let body = gossan_core::net::bounded_text(resp, 4 * 1024 * 1024)
107 .await
108 .unwrap_or_default();
109 let is_web = container_name == "$web";
110 gossan_core::try_push_finding(crate::finding_builder(target, Severity::Critical,
111 format!("Azure Blob container public: {}/{}", account, container_name),
112 if is_web {
113 format!(
114 "https://{}.blob.core.windows.net/$web is the static website \
115 hosting container and is publicly readable — all files accessible.",
116 account
117 )
118 } else {
119 format!(
120 "https://{}.blob.core.windows.net/{} is publicly accessible \
121 and returns a directory listing.",
122 account, container_name
123 )
124 })
125 .evidence(Evidence::HttpResponse {
126 status,
127 headers: vec![("url".into(), url.clone().into())],
128 body_excerpt: Some(body.chars().take(300).collect::<String>().into()),
129 })
130 .tag("azure").tag("cloud").tag("exposure"), &mut findings);
131 return Ok(findings); }
133 403 | 404 if !account_confirmed => {
134 if status == 403 {
137 account_confirmed = true;
138 gossan_core::try_push_finding(crate::finding_builder(target, Severity::Low,
139 format!("Azure storage account exists: {}", account),
140 format!(
141 "https://{}.blob.core.windows.net exists — account name confirmed \
142 via HTTP 403 on container probe.",
143 account
144 ))
145 .evidence(Evidence::HttpResponse {
146 status,
147 headers: vec![("url".into(), url.clone().into())],
148 body_excerpt: None,
149 })
150 .tag("azure").tag("cloud"), &mut findings);
151 }
152 }
153 _ => {}
154 }
155 }
156
157 Ok(findings)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn azure_containers_load_from_toml() {
167 let containers = container_names();
168 assert!(
169 !containers.is_empty(),
170 "should have Azure containers from TOML"
171 );
172
173 assert!(
175 containers.iter().any(|c| c.name == "$web"),
176 "should include $web container"
177 );
178 }
179
180 #[test]
181 fn azure_containers_have_required_fields() {
182 for container in container_names() {
183 assert!(
184 !container.name.is_empty(),
185 "container name should not be empty"
186 );
187 assert!(
188 !container.severity.is_empty(),
189 "severity should not be empty"
190 );
191 }
192 }
193
194 #[test]
195 fn azure_containers_include_common_names() {
196 let names: Vec<_> = container_names().iter().map(|c| c.name.clone()).collect();
197 for expected in ["$web", "public", "assets", "backup"] {
198 assert!(
199 names.contains(&expected.to_string()),
200 "missing container: {}",
201 expected
202 );
203 }
204 }
205}