From 97e7b0df99b48d6dae99f0c772f133416b680449 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 01:59:38 +0000 Subject: [PATCH 1/3] Initial plan From 8455889712301369f78b2f4a0d13bf3b4f68c4ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 02:37:16 +0000 Subject: [PATCH 2/3] Changes before error encountered Agent-Logs-Url: https://github.com/austinsonger/the-grabber/sessions/b02481d8-2f74-4289-b261-b1b86d69071a Co-authored-by: austinsonger <26654315+austinsonger@users.noreply.github.com> --- Cargo.toml | 57 ++++++++++ src/app_config.rs | 42 +++++++- src/evidence.rs | 3 + src/main.rs | 1 + src/providers/azure/acr.rs | 90 ++++++++++++++++ src/providers/azure/aks.rs | 143 ++++++++++++++++++++++++++ src/providers/azure/app_service.rs | 88 ++++++++++++++++ src/providers/azure/defender.rs | 96 +++++++++++++++++ src/providers/azure/factory.rs | 116 +++++++++++++++++++++ src/providers/azure/key_vault.rs | 60 +++++++++++ src/providers/azure/mod.rs | 14 +++ src/providers/azure/monitor_alerts.rs | 85 +++++++++++++++ src/providers/azure/nsg.rs | 103 +++++++++++++++++++ src/providers/azure/policy.rs | 61 +++++++++++ src/providers/gcp/mod.rs | 1 + src/providers/mod.rs | 29 ++++++ 16 files changed, 985 insertions(+), 4 deletions(-) create mode 100644 src/providers/azure/acr.rs create mode 100644 src/providers/azure/aks.rs create mode 100644 src/providers/azure/app_service.rs create mode 100644 src/providers/azure/defender.rs create mode 100644 src/providers/azure/factory.rs create mode 100644 src/providers/azure/key_vault.rs create mode 100644 src/providers/azure/mod.rs create mode 100644 src/providers/azure/monitor_alerts.rs create mode 100644 src/providers/azure/nsg.rs create mode 100644 src/providers/azure/policy.rs create mode 100644 src/providers/gcp/mod.rs create mode 100644 src/providers/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 9b2f076..b5f0342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,5 +68,62 @@ dirs-next = "2" libc = "0.2" calamine = "0.26" +[features] +azure = [ + "dep:azure_core", + "dep:azure_identity", + "dep:azure_mgmt_containerregistry", + "dep:azure_mgmt_web", + "dep:azure_mgmt_security", + "dep:azure_mgmt_keyvault", + "dep:azure_mgmt_policyinsights", + "dep:azure_mgmt_network", + "dep:azure_mgmt_monitor", + "dep:futures", +] + +[dependencies.azure_core] +version = "0.21" +features = ["enable_reqwest"] +optional = true + +[dependencies.azure_identity] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_containerregistry] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_web] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_security] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_keyvault] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_policyinsights] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_network] +version = "0.21" +optional = true + +[dependencies.azure_mgmt_monitor] +version = "0.21" +features = ["package-preview-2023-09"] +default-features = false +optional = true + +[dependencies.futures] +version = "0.3" +optional = true + [dev-dependencies] tempfile = "3" diff --git a/src/app_config.rs b/src/app_config.rs index 212f57f..f12e41d 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -72,16 +72,40 @@ pub struct CollectorConfig { pub enable_extra: Option>, } -/// A named AWS account that the tool can collect evidence from. +/// Cloud provider tag for an account entry. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CloudProvider { + Aws, + Azure, + Gcp, +} + +impl Default for CloudProvider { + fn default() -> Self { + CloudProvider::Aws + } +} + +fn default_provider() -> CloudProvider { + CloudProvider::Aws +} + +/// A named cloud account that the tool can collect evidence from. /// -/// Each account maps to an AWS CLI profile (typically an SSO role) -/// and carries its own region, output directory, and collector -/// override settings. +/// Each account maps to credentials and carries its own region, output +/// directory, and collector override settings. #[derive(Debug, Clone, Deserialize)] pub struct Account { /// Human-readable display name (e.g. "Corporate Production"). pub name: String, + /// Cloud provider for this account entry. Defaults to `aws`. + #[serde(default = "default_provider")] + pub provider: CloudProvider, + + // ── AWS ────────────────────────────────────────────────────────────────── + /// AWS account ID, shown in the TUI for identification. pub account_id: Option, @@ -94,6 +118,16 @@ pub struct Account { /// Override the default region for this account. pub region: Option, + // ── Azure ───────────────────────────────────────────────────────────────── + + /// Azure Active Directory tenant ID (UUID). + pub tenant_id: Option, + + /// Azure subscription ID (UUID) to collect from. + pub subscription_id: Option, + + // ── Shared ──────────────────────────────────────────────────────────────── + /// Override the default output directory for this account. pub output_dir: Option, diff --git a/src/evidence.rs b/src/evidence.rs index 6609601..de87634 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -138,4 +138,7 @@ pub enum EvidenceSource { BackupApi, RdsApi, CloudTrailS3, + AzureActivityLog, + AzureSecurityCenter, + GcpAuditLog, } diff --git a/src/main.rs b/src/main.rs index 033e6a3..ca675f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod providers; mod access_analyzer; mod account_config; mod acm; diff --git a/src/providers/azure/acr.rs b/src/providers/azure/acr.rs new file mode 100644 index 0000000..7ad5cb2 --- /dev/null +++ b/src/providers/azure/acr.rs @@ -0,0 +1,90 @@ +//! Azure Container Registry (ACR) collector. +//! +//! Maps to AWS ECR. Uses `azure_mgmt_containerregistry` to list all registries +//! in the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use futures::StreamExt; + +use crate::evidence::CsvCollector; + +pub struct AcrCollector { + client: AcrClient, + subscription_id: String, +} + +impl AcrCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> anyhow::Result { + Ok(Self { + client: AcrClient::builder(credential).build() + .context("Failed to build ACR client")?, + subscription_id, + }) + } +} + +#[async_trait] +impl CsvCollector for AcrCollector { + fn name(&self) -> &str { "Azure Container Registry" } + fn filename_prefix(&self) -> &str { "Azure_Container_Registries" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "Registry Name", + "Resource Group", + "Location", + "SKU", + "Admin User Enabled", + "Login Server", + "Provisioning State", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + let mut rows = Vec::new(); + + let mut stream = self.client + .registries_client() + .list(&self.subscription_id) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("ACR: list page failed")?; + for reg in page.value { + let props = reg.properties.as_ref(); + let rg = reg.resource.id.as_deref() + .and_then(|id| id.split("/resourceGroups/").nth(1)) + .and_then(|s| s.split('/').next()) + .unwrap_or("") + .to_string(); + + rows.push(vec![ + reg.resource.name.clone().unwrap_or_default(), + rg, + reg.resource.location.clone(), + format!("{:?}", reg.sku.name), + props.and_then(|p| p.admin_user_enabled) + .map(|b| b.to_string()) + .unwrap_or_default(), + props.and_then(|p| p.login_server.clone()).unwrap_or_default(), + props.and_then(|p| p.provisioning_state.as_ref()) + .map(|s| format!("{:?}", s)) + .unwrap_or_default(), + ]); + } + } + + Ok(rows) + } +} diff --git a/src/providers/azure/aks.rs b/src/providers/azure/aks.rs new file mode 100644 index 0000000..137f849 --- /dev/null +++ b/src/providers/azure/aks.rs @@ -0,0 +1,143 @@ +//! Azure Kubernetes Service (AKS) collector. +//! +//! Maps to AWS EKS. Uses the Azure Resource Management REST API directly +//! (via `azure_core`) to list all managed clusters across the subscription. +//! This avoids the `azure_mgmt_containerservice` crate which is on an older +//! `azure_core` version and is not compatible with the rest of the v0.21 SDK. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_core::{new_http_client, Request}; +use serde_json::Value; + +use crate::evidence::CsvCollector; + +const AKS_API_VERSION: &str = "2023-01-01"; + +pub struct AksCollector { + credential: Arc, + subscription_id: String, +} + +impl AksCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { credential, subscription_id } + } +} + +#[async_trait] +impl CsvCollector for AksCollector { + fn name(&self) -> &str { "Azure Kubernetes Service" } + fn filename_prefix(&self) -> &str { "Azure_AKS_Clusters" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "Cluster Name", + "Resource Group", + "Location", + "Kubernetes Version", + "Provisioning State", + "RBAC Enabled", + "Node Count", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + let token = self.credential + .get_token(&["https://management.azure.com/.default"]) + .await + .context("AKS: failed to obtain access token")?; + + let auth_value = format!("Bearer {}", token.token.secret()); + let http_client = new_http_client(); + let mut rows = Vec::new(); + + let mut next_url = Some(format!( + "https://management.azure.com/subscriptions/{}/providers/\ + Microsoft.ContainerService/managedClusters?api-version={}", + self.subscription_id, AKS_API_VERSION + )); + + while let Some(url_str) = next_url.take() { + let url = url_str.parse().context("AKS: invalid URL")?; + let mut req = Request::new(url, azure_core::Method::Get); + req.insert_header("Authorization", auth_value.clone()); + req.insert_header("Content-Type", "application/json"); + + let resp = http_client + .execute_request(&req) + .await + .context("AKS: HTTP request failed")?; + + let page: Value = resp + .into_body() + .json() + .await + .context("AKS: failed to parse response")?; + + if let Some(clusters) = page.get("value").and_then(|v| v.as_array()) { + for cluster in clusters { + let name = cluster["name"].as_str().unwrap_or_default().to_string(); + let location = + cluster["location"].as_str().unwrap_or_default().to_string(); + let rg = cluster["id"] + .as_str() + .unwrap_or_default() + .split("/resourceGroups/") + .nth(1) + .and_then(|s| s.split('/').next()) + .unwrap_or_default() + .to_string(); + + let props = &cluster["properties"]; + let k8s_version = props["kubernetesVersion"] + .as_str() + .unwrap_or_default() + .to_string(); + let prov_state = props["provisioningState"] + .as_str() + .unwrap_or_default() + .to_string(); + let rbac_enabled = props["enableRBAC"] + .as_bool() + .map(|b| b.to_string()) + .unwrap_or_default(); + let node_count: i64 = props["agentPoolProfiles"] + .as_array() + .map(|pools| { + pools + .iter() + .filter_map(|p| p["count"].as_i64()) + .sum() + }) + .unwrap_or(0); + + rows.push(vec![ + name, + rg, + location, + k8s_version, + prov_state, + rbac_enabled, + node_count.to_string(), + ]); + } + } + + // Follow pagination. + next_url = page["nextLink"].as_str().map(|s| s.to_string()); + } + + Ok(rows) + } +} diff --git a/src/providers/azure/app_service.rs b/src/providers/azure/app_service.rs new file mode 100644 index 0000000..4e515f0 --- /dev/null +++ b/src/providers/azure/app_service.rs @@ -0,0 +1,88 @@ +//! Azure App Service collector. +//! +//! Maps to AWS ECS / Lambda. Uses `azure_mgmt_web` to list all Web Apps and +//! Function Apps in the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_web::Client as WebClient; +use futures::StreamExt; + +use crate::evidence::CsvCollector; + +pub struct AppServiceCollector { + client: WebClient, + subscription_id: String, +} + +impl AppServiceCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: WebClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl CsvCollector for AppServiceCollector { + fn name(&self) -> &str { "Azure App Service" } + fn filename_prefix(&self) -> &str { "Azure_App_Service" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "App Name", + "Resource Group", + "Location", + "Kind", + "State", + "HTTPS Only", + "Default Host Name", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + let mut rows = Vec::new(); + + let mut stream = self.client + .web_apps_client() + .list(&self.subscription_id) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("App Service: list page failed")?; + for app in page.value { + let props = app.properties.as_ref(); + let rg = app.resource.id.as_deref() + .and_then(|id| id.split("/resourceGroups/").nth(1)) + .and_then(|s| s.split('/').next()) + .unwrap_or("") + .to_string(); + + rows.push(vec![ + app.resource.name.clone().unwrap_or_default(), + rg, + app.resource.location.clone(), + app.resource.kind.clone().unwrap_or_default(), + props.and_then(|p| p.state.clone()).unwrap_or_default(), + props.and_then(|p| p.https_only) + .map(|b| b.to_string()) + .unwrap_or_default(), + props.and_then(|p| p.default_host_name.clone()).unwrap_or_default(), + ]); + } + } + + Ok(rows) + } +} diff --git a/src/providers/azure/defender.rs b/src/providers/azure/defender.rs new file mode 100644 index 0000000..acbcc8d --- /dev/null +++ b/src/providers/azure/defender.rs @@ -0,0 +1,96 @@ +//! Microsoft Defender for Cloud collector. +//! +//! Maps to AWS SecurityHub + GuardDuty. Uses `azure_mgmt_security` to list +//! all security assessments (findings) across the subscription scope. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_security::Client as SecurityClient; +use futures::StreamExt; + +use crate::evidence::CsvCollector; + +pub struct DefenderCollector { + client: SecurityClient, + subscription_id: String, +} + +impl DefenderCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: SecurityClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl CsvCollector for DefenderCollector { + fn name(&self) -> &str { "Microsoft Defender for Cloud" } + fn filename_prefix(&self) -> &str { "Azure_Defender_Assessments" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "Assessment Name", + "Resource ID", + "Resource Type", + "Status", + "Severity", + "Category", + "Description", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + let mut rows = Vec::new(); + let scope = format!("/subscriptions/{}", self.subscription_id); + + let mut stream = self.client + .assessments_client() + .list(&scope) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("Defender: assessments list page failed")?; + for assessment in page.value { + let props = assessment.properties.as_ref(); + let base = props.map(|p| &p.security_assessment_properties_base); + let status_code = props + .map(|p| format!("{:?}", p.status.assessment_status.code)) + .unwrap_or_default(); + let metadata = base.and_then(|b| b.metadata.as_ref()); + + rows.push(vec![ + assessment.resource.name.clone().unwrap_or_default(), + assessment.resource.id.clone().unwrap_or_default(), + assessment.resource.type_.clone().unwrap_or_default(), + status_code, + metadata.and_then(|m| m.severity.as_ref()) + .map(|s| format!("{:?}", s)) + .unwrap_or_default(), + metadata.and_then(|m| m.categories.as_ref()) + .map(|c| { + c.iter() + .map(|x| format!("{:?}", x)) + .collect::>() + .join(", ") + }) + .unwrap_or_default(), + metadata.and_then(|m| m.description.clone()).unwrap_or_default(), + ]); + } + } + + Ok(rows) + } +} diff --git a/src/providers/azure/factory.rs b/src/providers/azure/factory.rs new file mode 100644 index 0000000..f75b62c --- /dev/null +++ b/src/providers/azure/factory.rs @@ -0,0 +1,116 @@ +//! Azure provider factory. +//! +//! `AzureProviderFactory` is the entry point for instantiating Azure collectors. +//! It holds a shared credential and subscription ID, and produces lists of +//! `CsvCollector` and `JsonCollector` instances for the selected Azure services. + +use std::sync::Arc; + +use crate::evidence::{CsvCollector, JsonCollector}; +use crate::providers::azure::{ + acr::AcrCollector, + aks::AksCollector, + app_service::AppServiceCollector, + defender::DefenderCollector, + key_vault::KeyVaultCollector, + monitor_alerts::MonitorAlertsCollector, + nsg::NsgCollector, + policy::PolicyCollector, +}; + +/// Factory for Azure evidence collectors. +pub struct AzureProviderFactory { + /// Azure credential shared by all collectors. + credential: Arc, + /// Azure subscription ID to collect from. + subscription_id: String, + /// Selector keys for which collectors are enabled. An empty list means + /// all collectors are enabled. + selected: Vec, +} + +impl AzureProviderFactory { + /// Create a new factory. + /// + /// # Arguments + /// * `credential` – Any type that implements + /// `azure_core::auth::TokenCredential` (e.g. `DefaultAzureCredential`). + /// * `subscription_id` – Azure subscription UUID. + /// * `selected` – Optional list of collector selector keys. Pass + /// an empty `Vec` to enable every collector. + pub fn new( + credential: Arc, + subscription_id: String, + selected: Vec, + ) -> Self { + Self { credential, subscription_id, selected } + } + + fn is_selected(&self, key: &str) -> bool { + self.selected.is_empty() || self.selected.iter().any(|s| s == key) + } + + /// Return the list of CSV-based Azure collectors that match the selection. + pub fn csv_collectors(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + + if self.is_selected("azure-aks") { + out.push(Box::new(AksCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-acr") { + out.push(Box::new(AcrCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-app-service") { + out.push(Box::new(AppServiceCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-defender") { + out.push(Box::new(DefenderCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-nsg") { + out.push(Box::new(NsgCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-monitor-alerts") { + out.push(Box::new(MonitorAlertsCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + + out + } + + /// Return the list of JSON-based Azure collectors that match the selection. + pub fn json_collectors(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + + if self.is_selected("azure-key-vault") { + out.push(Box::new(KeyVaultCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + if self.is_selected("azure-policy") { + out.push(Box::new(PolicyCollector::new( + Arc::clone(&self.credential), + self.subscription_id.clone(), + ))); + } + + out + } +} diff --git a/src/providers/azure/key_vault.rs b/src/providers/azure/key_vault.rs new file mode 100644 index 0000000..025b753 --- /dev/null +++ b/src/providers/azure/key_vault.rs @@ -0,0 +1,60 @@ +//! Azure Key Vault collector. +//! +//! Maps to AWS KMS + SecretsManager. Uses `azure_mgmt_keyvault` to enumerate +//! all vaults in the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_keyvault::Client as KeyVaultClient; +use futures::StreamExt; +use serde_json::{json, Value}; + +use crate::evidence::JsonCollector; + +pub struct KeyVaultCollector { + client: KeyVaultClient, + subscription_id: String, +} + +impl KeyVaultCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: KeyVaultClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl JsonCollector for KeyVaultCollector { + fn name(&self) -> &str { "Azure Key Vault" } + fn filename_prefix(&self) -> &str { "Azure_Key_Vaults" } + + async fn collect_records( + &self, + _account_id: &str, + _region: &str, + ) -> Result> { + let mut records = Vec::new(); + + let mut stream = self.client + .vaults_client() + .list_by_subscription(&self.subscription_id) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("Key Vault: list page failed")?; + for vault in page.value { + let vault_val = serde_json::to_value(&vault).unwrap_or(Value::Null); + records.push(json!({ "vault": vault_val })); + } + } + + Ok(records) + } +} diff --git a/src/providers/azure/mod.rs b/src/providers/azure/mod.rs new file mode 100644 index 0000000..a3fd0df --- /dev/null +++ b/src/providers/azure/mod.rs @@ -0,0 +1,14 @@ +//! Azure provider collectors. +//! +//! Each sub-module implements one or more `CsvCollector` or `JsonCollector` +//! traits (from `crate::evidence`) for an Azure service. + +pub mod acr; +pub mod aks; +pub mod app_service; +pub mod defender; +pub mod factory; +pub mod key_vault; +pub mod monitor_alerts; +pub mod nsg; +pub mod policy; diff --git a/src/providers/azure/monitor_alerts.rs b/src/providers/azure/monitor_alerts.rs new file mode 100644 index 0000000..71898cb --- /dev/null +++ b/src/providers/azure/monitor_alerts.rs @@ -0,0 +1,85 @@ +//! Azure Monitor Alert Rules collector. +//! +//! Maps to AWS CloudWatch Alarms. Uses `azure_mgmt_monitor` to list all +//! metric alert rules in the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_monitor::Client as MonitorClient; + +use crate::evidence::CsvCollector; + +pub struct MonitorAlertsCollector { + client: MonitorClient, + subscription_id: String, +} + +impl MonitorAlertsCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: MonitorClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl CsvCollector for MonitorAlertsCollector { + fn name(&self) -> &str { "Azure Monitor Alert Rules" } + fn filename_prefix(&self) -> &str { "Azure_Monitor_Alert_Rules" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "Alert Name", + "Resource Group", + "Location", + "Severity", + "Enabled", + "Description", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + // The metric alerts list endpoint does not support server-side pagination, + // so we fetch a single response page. + let result = self.client + .metric_alerts_client() + .list_by_subscription(&self.subscription_id) + .send() + .await + .context("Monitor Alerts: send failed")? + .into_body() + .await + .context("Monitor Alerts: parse response failed")?; + + let mut rows = Vec::new(); + for alert in result.value { + let rg = alert.resource.id.as_deref() + .and_then(|id| id.split("/resourceGroups/").nth(1)) + .and_then(|s| s.split('/').next()) + .unwrap_or("") + .to_string(); + + rows.push(vec![ + alert.resource.name.clone().unwrap_or_default(), + rg, + alert.resource.location.clone(), + alert.properties.severity.to_string(), + alert.properties.enabled.to_string(), + alert.properties.description.clone().unwrap_or_default(), + ]); + } + + Ok(rows) + } +} diff --git a/src/providers/azure/nsg.rs b/src/providers/azure/nsg.rs new file mode 100644 index 0000000..7453d8d --- /dev/null +++ b/src/providers/azure/nsg.rs @@ -0,0 +1,103 @@ +//! Azure Network Security Groups (NSG) collector. +//! +//! Maps to AWS Security Groups + NACLs. Uses `azure_mgmt_network` to list all +//! NSGs and their security rules across the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_network::Client as NetworkClient; +use futures::StreamExt; + +use crate::evidence::CsvCollector; + +pub struct NsgCollector { + client: NetworkClient, + subscription_id: String, +} + +impl NsgCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: NetworkClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl CsvCollector for NsgCollector { + fn name(&self) -> &str { "Azure Network Security Groups" } + fn filename_prefix(&self) -> &str { "Azure_Network_Security_Groups" } + + fn headers(&self) -> &'static [&'static str] { + &[ + "NSG Name", + "Resource Group", + "Location", + "Rule Name", + "Direction", + "Protocol", + "Source", + "Destination", + "Destination Port", + "Access", + "Priority", + ] + } + + async fn collect_rows( + &self, + _account_id: &str, + _region: &str, + _dates: Option<(i64, i64)>, + ) -> Result>> { + let mut rows = Vec::new(); + + let mut stream = self.client + .network_security_groups_client() + .list_all(&self.subscription_id) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("NSG: list_all page failed")?; + for nsg in page.value { + let nsg_name = nsg.resource.name.clone().unwrap_or_default(); + let location = nsg.resource.location.clone().unwrap_or_default(); + let rg = nsg.resource.id.as_deref() + .and_then(|id| id.split("/resourceGroups/").nth(1)) + .and_then(|s| s.split('/').next()) + .unwrap_or("") + .to_string(); + + let rules = nsg.properties + .as_ref() + .map(|p| p.security_rules.as_slice()) + .unwrap_or_default(); + + for rule in rules { + let rp = rule.properties.as_ref(); + rows.push(vec![ + nsg_name.clone(), + rg.clone(), + location.clone(), + rule.name.clone().unwrap_or_default(), + rp.map(|p| format!("{:?}", p.direction)).unwrap_or_default(), + rp.map(|p| format!("{:?}", p.protocol)).unwrap_or_default(), + rp.and_then(|p| p.source_address_prefix.clone()).unwrap_or_default(), + rp.and_then(|p| p.destination_address_prefix.clone()).unwrap_or_default(), + rp.and_then(|p| p.destination_port_range.clone()).unwrap_or_default(), + rp.map(|p| format!("{:?}", p.access)).unwrap_or_default(), + rp.map(|p| p.priority.to_string()).unwrap_or_default(), + ]); + } + } + } + + Ok(rows) + } +} diff --git a/src/providers/azure/policy.rs b/src/providers/azure/policy.rs new file mode 100644 index 0000000..8942a1d --- /dev/null +++ b/src/providers/azure/policy.rs @@ -0,0 +1,61 @@ +//! Azure Policy Compliance collector. +//! +//! Maps to AWS Config Rules + SCPs. Uses `azure_mgmt_policyinsights` to query +//! the latest policy compliance state for all resources in the subscription. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use azure_mgmt_policyinsights::Client as PolicyInsightsClient; +use futures::StreamExt; +use serde_json::Value; + +use crate::evidence::JsonCollector; + +pub struct PolicyCollector { + client: PolicyInsightsClient, + subscription_id: String, +} + +impl PolicyCollector { + pub fn new( + credential: Arc, + subscription_id: String, + ) -> Self { + Self { + client: PolicyInsightsClient::builder(credential).build(), + subscription_id, + } + } +} + +#[async_trait] +impl JsonCollector for PolicyCollector { + fn name(&self) -> &str { "Azure Policy Compliance" } + fn filename_prefix(&self) -> &str { "Azure_Policy_Compliance" } + + async fn collect_records( + &self, + _account_id: &str, + _region: &str, + ) -> Result> { + let mut records = Vec::new(); + + // Query latest policy states at subscription scope. + let mut stream = self.client + .policy_states_client() + .list_query_results_for_subscription("latest", &self.subscription_id) + .into_stream(); + + while let Some(page) = stream.next().await { + let page = page.context("Azure Policy: compliance query failed")?; + for state in page.value { + let val = serde_json::to_value(&state).unwrap_or(Value::Null); + records.push(val); + } + } + + Ok(records) + } +} diff --git a/src/providers/gcp/mod.rs b/src/providers/gcp/mod.rs new file mode 100644 index 0000000..d8febe7 --- /dev/null +++ b/src/providers/gcp/mod.rs @@ -0,0 +1 @@ +//! GCP provider stub. diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..257782d --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,29 @@ +//! Cloud provider abstractions. +//! +//! This module defines the `CloudProvider` enum and re-exports the concrete +//! provider sub-modules that are enabled via feature flags. + +use serde::{Deserialize, Serialize}; + +/// Identifies the target cloud platform for a configuration account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CloudProvider { + Aws, + #[cfg(feature = "azure")] + Azure, + #[cfg(feature = "gcp")] + Gcp, +} + +impl Default for CloudProvider { + fn default() -> Self { + CloudProvider::Aws + } +} + +#[cfg(feature = "azure")] +pub mod azure; + +#[cfg(feature = "gcp")] +pub mod gcp; From ce57b28fdf321836d03d49b9cb8d5d3401c6abaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:44:41 +0000 Subject: [PATCH 3/3] Fix Azure review comments --- src/app_config.rs | 47 +++++++++++++++++++++++++++++++++++--- src/providers/azure/acr.rs | 38 +++++++++++++++++++----------- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/app_config.rs b/src/app_config.rs index f12e41d..080557d 100644 --- a/src/app_config.rs +++ b/src/app_config.rs @@ -91,6 +91,10 @@ fn default_provider() -> CloudProvider { CloudProvider::Aws } +fn default_profile() -> String { + String::new() +} + /// A named cloud account that the tool can collect evidence from. /// /// Each account maps to credentials and carries its own region, output @@ -105,7 +109,6 @@ pub struct Account { pub provider: CloudProvider, // ── AWS ────────────────────────────────────────────────────────────────── - /// AWS account ID, shown in the TUI for identification. pub account_id: Option, @@ -113,13 +116,14 @@ pub struct Account { pub description: Option, /// AWS CLI profile name or SSO role name (must match ~/.aws/config). + /// Non-AWS account entries can omit this field. + #[serde(default = "default_profile")] pub profile: String, /// Override the default region for this account. pub region: Option, // ── Azure ───────────────────────────────────────────────────────────────── - /// Azure Active Directory tenant ID (UUID). pub tenant_id: Option, @@ -127,7 +131,6 @@ pub struct Account { pub subscription_id: Option, // ── Shared ──────────────────────────────────────────────────────────────── - /// Override the default output directory for this account. pub output_dir: Option, @@ -158,3 +161,41 @@ fn global_config_path() -> Option { let base = dirs_next::home_dir()?; Some(base.join(".config").join("evidence").join("config.toml")) } + +#[cfg(test)] +mod tests { + use super::{AppConfig, CloudProvider}; + + #[test] + fn deserializes_azure_account_without_aws_profile() { + let config: AppConfig = toml::from_str( + r#" + [[account]] + name = "Azure Prod" + provider = "azure" + tenant_id = "tenant-id" + subscription_id = "subscription-id" + "#, + ) + .expect("config should deserialize"); + + let account = &config.account[0]; + assert_eq!(account.provider, CloudProvider::Azure); + assert!(account.profile.is_empty()); + } + + #[test] + fn defaults_provider_to_aws() { + let config: AppConfig = toml::from_str( + r#" + [[account]] + name = "AWS Prod" + profile = "prod" + "#, + ) + .expect("config should deserialize"); + + assert_eq!(config.account[0].provider, CloudProvider::Aws); + assert_eq!(config.account[0].profile, "prod"); + } +} diff --git a/src/providers/azure/acr.rs b/src/providers/azure/acr.rs index 7ad5cb2..4aca234 100644 --- a/src/providers/azure/acr.rs +++ b/src/providers/azure/acr.rs @@ -7,12 +7,13 @@ use std::sync::Arc; use anyhow::{Context, Result}; use async_trait::async_trait; +use azure_mgmt_containerregistry::Client as AcrClient; use futures::StreamExt; use crate::evidence::CsvCollector; pub struct AcrCollector { - client: AcrClient, + client: AcrClient, subscription_id: String, } @@ -20,19 +21,22 @@ impl AcrCollector { pub fn new( credential: Arc, subscription_id: String, - ) -> anyhow::Result { - Ok(Self { - client: AcrClient::builder(credential).build() - .context("Failed to build ACR client")?, + ) -> Self { + Self { + client: AcrClient::builder(credential).build(), subscription_id, - }) + } } } #[async_trait] impl CsvCollector for AcrCollector { - fn name(&self) -> &str { "Azure Container Registry" } - fn filename_prefix(&self) -> &str { "Azure_Container_Registries" } + fn name(&self) -> &str { + "Azure Container Registry" + } + fn filename_prefix(&self) -> &str { + "Azure_Container_Registries" + } fn headers(&self) -> &'static [&'static str] { &[ @@ -54,7 +58,8 @@ impl CsvCollector for AcrCollector { ) -> Result>> { let mut rows = Vec::new(); - let mut stream = self.client + let mut stream = self + .client .registries_client() .list(&self.subscription_id) .into_stream(); @@ -63,7 +68,10 @@ impl CsvCollector for AcrCollector { let page = page.context("ACR: list page failed")?; for reg in page.value { let props = reg.properties.as_ref(); - let rg = reg.resource.id.as_deref() + let rg = reg + .resource + .id + .as_deref() .and_then(|id| id.split("/resourceGroups/").nth(1)) .and_then(|s| s.split('/').next()) .unwrap_or("") @@ -74,11 +82,15 @@ impl CsvCollector for AcrCollector { rg, reg.resource.location.clone(), format!("{:?}", reg.sku.name), - props.and_then(|p| p.admin_user_enabled) + props + .and_then(|p| p.admin_user_enabled) .map(|b| b.to_string()) .unwrap_or_default(), - props.and_then(|p| p.login_server.clone()).unwrap_or_default(), - props.and_then(|p| p.provisioning_state.as_ref()) + props + .and_then(|p| p.login_server.clone()) + .unwrap_or_default(), + props + .and_then(|p| p.provisioning_state.as_ref()) .map(|s| format!("{:?}", s)) .unwrap_or_default(), ]);