Skip to main content

gossan_cloud/
azure.rs

1//! Azure Blob Storage probe.
2//!
3//! Azure storage account names are 3–24 lowercase alphanumeric chars (no hyphens).
4//! Containers are probed by name — common names and the special `$web` container
5//! (used for static website hosting) are checked.
6//!
7//! URL format: `https://{account}.blob.core.windows.net/{container}/`
8
9use 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/// Azure container definition from TOML.
18#[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/// TOML file containing Azure container definitions.
29#[derive(Debug, Deserialize)]
30struct AzureContainersFile {
31    container: Vec<AzureContainer>,
32}
33
34/// Built-in azure.toml content (embedded at compile time).
35const BUILTIN_AZURE: &str = include_str!("../rules/azure.toml");
36
37/// Global cache for built-in Azure containers.
38static AZURE_CONTAINERS: OnceLock<Vec<AzureContainer>> = OnceLock::new();
39
40/// Initialize and return the built-in Azure containers.
41fn 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                // Fallback to minimal hardcoded list only on parse failure
48                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
58/// Get container names from TOML configuration.
59fn container_names() -> &'static [AzureContainer] {
60    builtin_azure_containers()
61}
62/// Azure Blob Storage container enumeration.
63pub 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        // Azure account names: 3–24 lowercase alphanumeric only
82        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); // one public container is enough to report
132                }
133                403 | 404 if !account_confirmed => {
134                    // 403 = container exists but private; 404 on a valid account
135                    // still confirms the account exists
136                    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        // Check for critical $web container
174        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}