diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 9adc052..f980c9b 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -33,6 +33,11 @@ "control_name": "Required Blob Container Immutability Missing", "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." }, + "AZ-STOR-010": { + "control_id": "N/A-STOR-010", + "control_name": "Storage Account Missing Private Endpoint", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, "AZ-DB-005": {"control_id": "N/A-DB-005", "control_name": "SQL Server Microsoft Entra-Only Authentication Not Enforced", "description": "Service-specific OpenShield control."}, "AZ-DB-006": {"control_id": "N/A-DB-006", "control_name": "SQL Vulnerability Assessment Not Configured", "description": "Service-specific OpenShield control."}, "AZ-DB-007": {"control_id": "N/A-DB-007", "control_name": "SQL Auditing Retention Below Minimum", "description": "Service-specific OpenShield control."}, diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index e328198..0dc11f1 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -33,6 +33,11 @@ "control_name": "Required Blob Container Immutability Missing", "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." }, + "AZ-STOR-010": { + "control_id": "A.13.1.3", + "control_name": "Segregation in networks", + "description": "A storage account reachable over the public network has no approved Private Endpoint, leaving its blob/file/queue/table endpoints on the public internet. A.13.1.3 requires segregation of networks; a Private Endpoint isolates storage traffic within the private VNet." + }, "AZ-DB-005": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "SQL authentication is restricted to approved Entra identities."}, "AZ-DB-006": {"control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", "description": "Required SQL vulnerability assessment is configured."}, "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index ae145cf..13b3a31 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -33,6 +33,11 @@ "control_name": "Required Blob Container Immutability Missing", "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." }, + "AZ-STOR-010": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A storage account reachable over the public network has no approved Private Endpoint, so its data-plane endpoints stay internet-reachable. PR.AC-5 requires that network integrity is protected; a Private Endpoint keeps storage traffic on private IPs inside the VNet." + }, "AZ-DB-005": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "SQL authentication is restricted to approved Entra identities."}, "AZ-DB-006": {"control_id": "DE.CM-8", "control_name": "Vulnerability scans are performed", "description": "Required SQL vulnerability assessment is configured."}, "AZ-DB-007": {"control_id": "PR.PT-1", "control_name": "Audit/log records are determined, documented, implemented, and reviewed", "description": "SQL audit logs are retained for at least 90 days in accordance with the defined audit policy."}, diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 22ae99a..a9040c5 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -33,6 +33,11 @@ "control_name": "Required Blob Container Immutability Missing", "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." }, + "AZ-STOR-010": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A storage account reachable over the public network has no approved Private Endpoint, so it is reachable from outside the network boundary. CC6.6 requires that access from outside the network boundary is restricted; a Private Endpoint enforces that boundary at the network layer." + }, "AZ-DB-005": {"control_id": "CC6.3", "control_name": "Logical access security", "description": "SQL authentication is restricted to approved Entra identities."}, "AZ-DB-006": {"control_id": "CC7.1", "control_name": "Detection of security events", "description": "Required SQL vulnerability assessment is configured."}, "AZ-DB-007": {"control_id": "CC7.2", "control_name": "System monitoring", "description": "SQL audit logs are retained according to policy."}, diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 1cfb1cb..e8a97aa 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -79,6 +79,7 @@ OpenShield currently ships 90 Azure scan rules. This table is generated from the | AZ-STOR-007 | Storage Account Allows TLS Below 1.2 | HIGH | Storage | N/A-STOR-007 | N/A-STOR-007 | N/A-STOR-007 | | AZ-STOR-008 | Required Storage Customer-Managed Key Protection Missing | HIGH | Storage | N/A-STOR-008 | N/A-STOR-008 | N/A-STOR-008 | | AZ-STOR-009 | Required Blob Container Immutability Missing | HIGH | Storage | N/A-STOR-009 | N/A-STOR-009 | N/A-STOR-009 | +| AZ-STOR-010 | Storage Account Missing Private Endpoint | HIGH | Storage | N/A-STOR-010 | PR.AC-5 | A.13.1.3 | | AZ-DB-005 | SQL Server Microsoft Entra-Only Authentication Not Enforced | HIGH | Database | N/A-DB-005 | PR.AC-6 | A.9.4.2 | | AZ-DB-006 | SQL Vulnerability Assessment Not Configured | HIGH | Database | N/A-DB-006 | DE.CM-8 | A.12.6.1 | | AZ-DB-007 | SQL Auditing Retention Below Minimum | MEDIUM | Database | N/A-DB-007 | PR.PT-1 | A.12.4.1 | diff --git a/playbooks/cli/fix_az_stor_010.sh b/playbooks/cli/fix_az_stor_010.sh new file mode 100644 index 0000000..ac3ca3f --- /dev/null +++ b/playbooks/cli/fix_az_stor_010.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# fix_az_stor_010.sh +# Create a Private Endpoint for a storage account and lock it down to the private +# network, so its blob/file/queue/table endpoints are no longer reachable over the +# public internet. +# Usage: ./fix_az_stor_010.sh + +set -euo pipefail + +RESOURCE_GROUP="${1:-}" +STORAGE_ACCOUNT="${2:-}" +VNET_NAME="${3:-}" +SUBNET_NAME="${4:-}" + +if [[ -z "$RESOURCE_GROUP" || -z "$STORAGE_ACCOUNT" || -z "$VNET_NAME" || -z "$SUBNET_NAME" ]]; then + echo "Usage: $0 " + exit 1 +fi + +STORAGE_ID="$(az storage account show --name "$STORAGE_ACCOUNT" --resource-group "$RESOURCE_GROUP" --query id -o tsv)" + +echo "Creating a Private Endpoint (blob) for $STORAGE_ACCOUNT..." +az network private-endpoint create \ + --name "${STORAGE_ACCOUNT}-pe" \ + --resource-group "$RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --subnet "$SUBNET_NAME" \ + --private-connection-resource-id "$STORAGE_ID" \ + --group-id blob \ + --connection-name "${STORAGE_ACCOUNT}-pe-conn" + +echo "Restricting public network access on $STORAGE_ACCOUNT..." +az storage account update \ + --name "$STORAGE_ACCOUNT" \ + --resource-group "$RESOURCE_GROUP" \ + --public-network-access Disabled + +echo "Done. $STORAGE_ACCOUNT now reaches its data plane over a Private Endpoint only." diff --git a/scanner/rules/az_stor_010.py b/scanner/rules/az_stor_010.py new file mode 100644 index 0000000..47e62fc --- /dev/null +++ b/scanner/rules/az_stor_010.py @@ -0,0 +1,89 @@ +"""AZ-STOR-010: Storage account reachable publicly with no approved private endpoint.""" + +import logging +from typing import Any, Dict, List + +from scanner.azure_client import enum_str + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-010" +RULE_NAME = "Storage Account Missing Private Endpoint" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "N/A-STOR-010", "NIST": "PR.AC-5", "ISO27001": "A.13.1.3", "SOC2": "CC6.6"} +DESCRIPTION = ( + "A storage account reachable over the public network has no approved Private " + "Endpoint connection, so its blob/file/queue/table endpoints stay reachable from " + "the internet. Traffic does not remain inside a private VNet, widening the attack " + "surface for unauthorized access and data exfiltration." +) +REMEDIATION = ( + "Create a Private Endpoint for the storage account and approve the connection " + "(`az network private-endpoint create ...`), then set the account's public network " + "access to Disabled (or Selected networks) so traffic flows only over the private " + "IP inside the VNet." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_010.sh" + + +def _has_approved_private_endpoint(connections: Any) -> bool: + """True if any Private Endpoint connection in the list is in the Approved state.""" + for connection in connections or []: + state = getattr(connection, "private_link_service_connection_state", None) + if enum_str(getattr(state, "status", None)).lower() == "approved": + return True + return False + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag publicly reachable storage accounts with no approved Private Endpoint. + + A storage account whose ``public_network_access`` is already ``Disabled`` is + network-isolated regardless of private endpoints and is treated as + NOT_APPLICABLE, so the rule does not raise a false finding against an account + that is closed to the public network by another means. + + ``private_endpoint_connections`` of ``None`` means the evidence is + unavailable (the field was not populated / could not be read), not a + confirmed absence, so the account is skipped as indeterminate rather than + flagged. Only a genuine empty list (or connections with none Approved) is a + finding. + """ + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + if enum_str(getattr(account, "public_network_access", None)).lower() == "disabled": + continue + + connections = getattr(account, "private_endpoint_connections", None) + if connections is None: + logger.warning( + "AZ-STOR-010: private endpoint connections unavailable for %s — skipping (indeterminate)", + getattr(account, "name", ""), + ) + continue + if _has_approved_private_endpoint(connections): + continue + + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(account, "id", ""), + "resource_name": getattr(account, "name", ""), + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "public_network_access": enum_str(getattr(account, "public_network_access", None)) or "unspecified", + "private_endpoint_connections": len(connections), + }, + } + ) + + return findings diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 6351166..95d06db 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -13,6 +13,7 @@ import scanner.rules.az_stor_007 as az_stor_007 import scanner.rules.az_stor_008 as az_stor_008 import scanner.rules.az_stor_009 as az_stor_009 +import scanner.rules.az_stor_010 as az_stor_010 from tests.helpers.mock_azure import make_resource _REQUIRED_FIELDS = { @@ -299,3 +300,78 @@ def test_stor_009_policy_or_api_failure_is_not_flagged(mock_azure, subscription_ assert az_stor_009.scan(mock_azure, subscription_id) == [] mock_azure.set_blob_containers(_RG, "sa-immutable-ok", None) assert az_stor_009.scan(mock_azure, subscription_id) == [] + + +# ── AZ-STOR-010: storage account missing an approved Private Endpoint ──────── + + +def _private_endpoint(status="Approved"): + return make_resource(private_link_service_connection_state=make_resource(status=status)) + + +def test_stor_010_approved_private_endpoint_returns_no_findings(mock_azure, subscription_id): + """An account with an Approved Private Endpoint connection is compliant.""" + account = make_resource( + id=_storage_id("sa-with-pe"), + name="sa-with-pe", + private_endpoint_connections=[_private_endpoint("Approved")], + ) + mock_azure.set_storage_accounts([account]) + assert az_stor_010.scan(mock_azure, subscription_id) == [] + + +def test_stor_010_public_access_disabled_is_not_applicable(mock_azure, subscription_id): + """An account with public_network_access Disabled is already isolated — not flagged.""" + account = make_resource( + id=_storage_id("sa-private-only"), + name="sa-private-only", + public_network_access="Disabled", + private_endpoint_connections=[], + ) + mock_azure.set_storage_accounts([account]) + assert az_stor_010.scan(mock_azure, subscription_id) == [] + + +def test_stor_010_no_private_endpoint_returns_one_finding(mock_azure, subscription_id): + """A publicly reachable account with no Private Endpoint must produce one HIGH finding.""" + account = make_resource( + id=_storage_id("sa-public"), + name="sa-public", + public_network_access="Enabled", + private_endpoint_connections=[], + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_010.scan(mock_azure, subscription_id) + assert len(findings) == 1 + finding = findings[0] + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["rule_id"] == "AZ-STOR-010" + assert finding["severity"] == "HIGH" + assert finding["resource_name"] == "sa-public" + + +def test_stor_010_only_pending_private_endpoint_is_flagged(mock_azure, subscription_id): + """A Private Endpoint connection that is not Approved does not count as coverage.""" + account = make_resource( + id=_storage_id("sa-pending-pe"), + name="sa-pending-pe", + private_endpoint_connections=[_private_endpoint("Pending")], + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_010.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-010" + + +def test_stor_010_unavailable_evidence_is_indeterminate_not_flagged(mock_azure, subscription_id): + """When private_endpoint_connections is None (evidence unavailable, e.g. not populated + or a permissions failure), the account is indeterminate and must not be flagged as a + confirmed absence.""" + account = make_resource( + id=_storage_id("sa-unknown-pe"), + name="sa-unknown-pe", + public_network_access="Enabled", + private_endpoint_connections=None, + ) + mock_azure.set_storage_accounts([account]) + assert az_stor_010.scan(mock_azure, subscription_id) == []