diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index c575a6f..28a5e34 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -52,6 +52,16 @@ "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", "description": "Azure Key Vault soft delete should be enabled on all Key Vaults. The soft delete feature allows recovery of deleted vaults and vault objects (keys, secrets, certificates) for a configurable retention period (7–90 days), protecting against accidental or malicious deletion." + }, + "AZ-STOR-003": { + "control_id": "3.7", + "control_name": "Ensure that storage accounts have lifecycle management policies configured", + "description": "Storage accounts without lifecycle management policies retain data indefinitely. This increases storage costs, expands the attack surface through accumulation of stale data, and may violate data retention compliance requirements. Lifecycle policies automate the transition and deletion of blobs based on age and access patterns." + }, + "AZ-KV-002": { + "control_id": "8.3", + "control_name": "Ensure that public network access to Key Vault is disabled", + "description": "Azure Key Vault should not allow public network access unless absolutely necessary. Enabling public access increases the attack surface and exposes sensitive secrets, keys, and certificates to potential unauthorized access. Private endpoints should be used to restrict access to trusted networks." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 4283790..df8fc6f 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -52,6 +52,16 @@ "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Information processing facilities shall be implemented with sufficient redundancy to meet availability requirements. Disabling soft delete on Key Vault removes the ability to recover deleted secrets, keys, and certificates, creating a single point of failure for critical cryptographic material and violating availability and recovery requirements." - } + }, + "AZ-STOR-003": { + "control_id": "A.8.3.1", + "control_name": "Management of removable media", + "description": "Information stored on Azure storage accounts should be subject to formal lifecycle management controls governing retention and disposal. Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism, violating information handling and disposal requirements under this control." + }, + "AZ-KV-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 869bc5a..fe1ca80 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -52,6 +52,16 @@ "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", "description": "Key material in Azure Key Vault must be recoverable after accidental or malicious deletion. Soft delete provides a recoverable state for secrets, keys, and certificates, supporting backup and recovery requirements for critical cryptographic material." + }, + "AZ-STOR-003": { + "control_id": "PR.DS-3", + "control_name": "Assets are formally managed throughout removal, transfers, and disposition", + "description": "Data stored in Azure storage accounts should be subject to formal lifecycle management policies that govern retention, transition, and deletion. Without these policies, stale data accumulates indefinitely and is never formally dispositioned, violating data management and minimisation requirements." + }, + "AZ-KV-002": { + "control_id": "AC-17", + "control_name": "Remote Access", + "description": "Remote access to systems should be controlled, monitored, and restricted. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be limited to trusted networks using private endpoints or network restrictions." } } } diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index 2d60b2b..0ef1d79 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -214,3 +214,41 @@ Then open a PR. Use the PR template — it will ask you for the rule ID, severit - **Hardcoded subscription ID**: use the `subscription_id` parameter passed to `scan()`, never hardcode. - **Exceptions crashing the scan**: the engine catches unhandled exceptions per rule, but write defensively — use `getattr(obj, "field", default)` for optional SDK attributes. - **Empty `frameworks` dict**: always populate all three keys (CIS, NIST, ISO27001) even if you map to `"N/A"`. + + + +## Real-world impact of each rule + +**AZ-STOR-001 — Public blob access enabled** +This is how 38 million records leaked in the 2021 Power Apps breach — blob containers set to public, no authentication needed, just know the URL and download everything. Attackers don't even need to "hack" anything. Automated tools scan Azure for public blobs constantly. If yours is exposed it will be found, usually within hours. + +**AZ-STOR-002 — Storage account allows unencrypted HTTP** +Any data moving over plain HTTP can be read by anyone on the same network path. This sounds theoretical until you realise most corporate VPNs, shared offices and cloud interconnects are exactly that kind of shared environment. One internal tool uploading customer data over HTTP to Azure storage is all it takes. The fix is one toggle — HTTPS only — but it gets missed constantly. + +**AZ-NET-001 — NSG allows SSH from internet** + +SSH brute force attacks are constant — attackers run automated scripts trying millions of username and password combinations against any open port 22 they find. In 2023 a university research cluster was compromised through an exposed SSH port, with attackers using it to mine cryptocurrency for three months before detection. Restricting SSH to known IP ranges or using Azure Bastion eliminates this risk entirely. + + +**AZ-NET-002 — NSG allows RDP from internet** + +RDP on port 3389 open to 0.0.0.0/0 is one of the most scanned ports on the internet — automated bots find it within minutes of a VM being provisioned. The 2021 Colonial Pipeline attack started with an exposed RDP port and a compromised password. Once an attacker gets in via RDP they have full GUI access to the machine and can move laterally across the entire network. + + +**AZ-IDN-001 — Overprivileged service principal** +Contributor at subscription scope means the service principal can touch everything — every VM, every database, every storage account across the whole subscription. The moment that client secret leaks — through a git commit, a build log, a misconfigured app — the attacker has the keys to the kingdom. This exact pattern showed up in the SolarWinds breach. Least privilege is not optional. + +**AZ-IDN-002 — MFA not enforced on privileged accounts** +Credential stuffing is not sophisticated. Attackers just take leaked password lists from other breaches and try them on Azure AD. Without MFA a matching password is all they need. Microsoft says MFA stops 99.9% of these attacks. A Global Admin account without MFA is genuinely one of the highest risk findings you can have — one leaked password from any other service and your entire tenant is gone. + +**AZ-DB-001 — SQL Server TDE disabled** +The database itself might be behind a firewall, but what about the backups? Backup files get moved around — to blob storage, to tapes, to DR sites. Without TDE the data is sitting in plain text in all of those places. A healthcare company learned this the hard way in 2019 when stolen backup files exposed 2.3 million patient records. The attacker never touched the live database. + +**AZ-DB-002 — SQL Server firewall allows all IPs** +Opening the SQL Server firewall to all IPs is the same as putting your database on the public internet. Shodan and similar tools index these constantly. In 2020 a startup had their production database dumped within days of launching because the firewall rule was still set to 0.0.0.0 from a development config that nobody cleaned up. Lock it to your app service IPs only — nothing else needs direct database access. + +**AZ-CMP-001 — Unencrypted managed disk** +An attacker who gets into your subscription — even temporarily — can snapshot a disk in seconds. They create the snapshot, export it, mount it on their own VM and read everything on it at their leisure. The original VM keeps running, no one notices. A SaaS company found out about this 6 weeks after it happened when their data showed up for sale. The disks were unencrypted so the snapshot was immediately readable. + +**AZ-KV-001 — Key Vault soft delete disabled** +Key Vault is where everything important lives — database passwords, API keys, TLS certificates, encryption keys. Without soft delete an attacker or a disgruntled employee can delete every single secret permanently in about 30 seconds. No recovery, no rollback. A real incident in 2021 saw an employee delete an entire production Key Vault on their last day. The company was down for 6 days rebuilding access from scratch. Soft delete costs nothing to enable. diff --git a/playbooks/cli/fix_az_kv_002.sh b/playbooks/cli/fix_az_kv_002.sh new file mode 100755 index 0000000..4c69ef3 --- /dev/null +++ b/playbooks/cli/fix_az_kv_002.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -euo pipefail + +VAULT_NAME="${1:-}" +RESOURCE_GROUP="${2:-}" + +if [[ -z "$VAULT_NAME" || -z "$RESOURCE_GROUP" ]]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Disabling public network access for Key Vault: $VAULT_NAME (RG: $RESOURCE_GROUP)" + +az keyvault update \ + --name "$VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --public-network-access Disabled + +echo "Public network access disabled successfully." +echo "Next step: Configure a private endpoint for full protection." \ No newline at end of file diff --git a/scanner/rules/az_kv_002.py b/scanner/rules/az_kv_002.py new file mode 100644 index 0000000..159d789 --- /dev/null +++ b/scanner/rules/az_kv_002.py @@ -0,0 +1,71 @@ +"""AZ-KV-002: Key Vault allows public network access without private endpoint.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-002" +RULE_NAME = "Key Vault Allows Public Network Access Without Private Endpoint" +SEVERITY = "HIGH" +CATEGORY = "Key Vault" +FRAMEWORKS = { + "CIS": "8.3", + "NIST": "AC-17", + "ISO27001": "A.13.1.1" +} + +DESCRIPTION = ( + "The Azure Key Vault is accessible over the public internet without a private endpoint configured. " + "This increases the risk of unauthorized access to sensitive secrets, keys, and certificates." +) + +REMEDIATION = ( + "Disable public network access for the Key Vault and configure a private endpoint " + "to restrict access to trusted virtual networks." +) + +PLAYBOOK = "playbooks/cli/fix_az_kv_002.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect Key Vaults with public network access enabled and no private endpoint configured.""" + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + props = getattr(vault, "properties", None) + if not props: + continue + + # Handle SDK inconsistencies (snake_case vs camelCase) + public_access = getattr(props, "public_network_access", None) + if public_access is None: + public_access = getattr(props, "publicNetworkAccess", None) + + private_endpoints = getattr(props, "private_endpoint_connections", None) + if private_endpoints is None: + private_endpoints = getattr(props, "privateEndpointConnections", None) + + # Normalize values safely + is_public = str(public_access).lower() in ("enabled", "true", "1") + has_private_endpoint = bool(private_endpoints) and len(private_endpoints) > 0 + + if is_public and not has_private_endpoint: + parsed = azure_client.parse_resource_id(vault.id) + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vault.id, + "resource_name": vault.name, + "resource_type": "Microsoft.KeyVault/vaults", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(vault, "location", ""), + }, + }) + + return findings \ No newline at end of file