use async_trait::async_trait;
use gossan_core::Target;
use secfinding::{Evidence, Finding, Severity};
use serde::Deserialize;
use std::sync::OnceLock;
use crate::provider::CloudProvider;
#[derive(Debug, Clone, Deserialize)]
struct AzureContainer {
name: String,
description: String,
#[serde(rename = "severity_if_exposed")]
severity: String,
}
impl AzureContainer {
fn severity(&self) -> Severity {
match self.severity.to_ascii_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"medium" => Severity::Medium,
"low" => Severity::Low,
_ => {
tracing::warn!(
container = %self.name,
raw = %self.severity,
"unknown severity in azure.toml; defaulting to High"
);
Severity::High
}
}
}
}
#[derive(Debug, Deserialize)]
struct AzureContainersFile {
container: Vec<AzureContainer>,
}
const BUILTIN_AZURE: &str = include_str!("../rules/azure.toml");
static AZURE_CONTAINERS: OnceLock<Vec<AzureContainer>> = OnceLock::new();
fn builtin_azure_containers() -> &'static Vec<AzureContainer> {
AZURE_CONTAINERS.get_or_init(|| {
match toml::from_str::<AzureContainersFile>(BUILTIN_AZURE) {
Ok(file) => file.container,
Err(e) => panic!("gossan-cloud: built-in azure.toml is malformed: {e}")
}
})
}
fn container_names() -> &'static [AzureContainer] {
builtin_azure_containers()
}
pub struct AzureProvider {
pub(crate) endpoint_override: Option<String>,
}
impl AzureProvider {
#[must_use]
pub fn new() -> Self {
Self { endpoint_override: None }
}
#[must_use]
pub fn with_endpoint(url: impl Into<String>) -> Self {
Self { endpoint_override: Some(url.into()) }
}
}
impl Default for AzureProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CloudProvider for AzureProvider {
fn name(&self) -> &'static str {
"azure"
}
fn endpoint(&self, name: &str) -> String {
if let Some(ref url) = self.endpoint_override {
return url.clone();
}
format!("https://{}.blob.core.windows.net/", name)
}
async fn probe(
&self,
client: &reqwest::Client,
name: &str,
target: &Target,
) -> anyhow::Result<Vec<Finding>> {
let account: String = name
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_lowercase();
if account.len() < 3 || account.len() > 24 {
return Ok(vec![]);
}
let base_endpoint = self.endpoint(&account);
let mut findings = Vec::new();
let mut account_confirmed = false;
for container in container_names() {
let container_name = &container.name;
let url = format!("{}{}/", base_endpoint, container_name);
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
account = %account,
container = %container_name,
url = %url,
error = %e,
"Azure blob probe send failed"
);
continue;
}
};
let status = resp.status().as_u16();
match status {
200 => {
let body = match gossan_core::net::bounded_text(
resp,
crate::MAX_CLOUD_RESPONSE_BYTES,
)
.await
{
Ok(b) => b,
Err(e) => {
tracing::warn!(
account = %account,
container = %container_name,
url = %url,
error = %e,
"Azure blob body read failed"
);
continue;
}
};
let is_web = container_name == "$web";
gossan_core::try_push_finding(crate::finding_builder(target, container.severity(),
format!("Azure Blob container public: {}/{}", account, container_name),
if is_web {
format!(
"https://{}.blob.core.windows.net/$web is the static website \
hosting container ({}) and is publicly readable, all files accessible.",
account, container.description
)
} else {
format!(
"https://{}.blob.core.windows.net/{} ({}) is publicly accessible \
and returns a directory listing.",
account, container_name, container.description
)
})
.evidence(Evidence::HttpResponse {
status,
headers: vec![("url".into(), url.clone().into())],
body_excerpt: Some(body.chars().take(crate::MAX_BODY_EXCERPT_CHARS).collect::<String>().into()),
})
.tag("azure").tag("cloud").tag("exposure"), &mut findings);
return Ok(findings); }
403 | 404 if !account_confirmed => {
if status == 403 {
account_confirmed = true;
}
}
_ => {}
}
}
Ok(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn azure_containers_load_from_toml() {
let containers = container_names();
assert!(
!containers.is_empty(),
"should have Azure containers from TOML"
);
assert!(
containers.iter().any(|c| c.name == "$web"),
"should include $web container"
);
}
#[test]
fn azure_containers_have_required_fields() {
for container in container_names() {
assert!(
!container.name.is_empty(),
"container name should not be empty"
);
assert!(
!container.severity.is_empty(),
"severity should not be empty"
);
}
}
#[test]
fn azure_containers_include_common_names() {
let names: Vec<_> = container_names().iter().map(|c| c.name.clone()).collect();
for expected in ["$web", "public", "assets", "backup"] {
assert!(
names.contains(&expected.to_string()),
"missing container: {}",
expected
);
}
}
}