From cfa4581298e20adc7fb98d90666a915629b025b2 Mon Sep 17 00:00:00 2001 From: shariqueahmad108-ship-it Date: Thu, 3 Sep 2026 12:32:54 +0530 Subject: [PATCH 1/2] feat(scanner): add rule AZ-STOR-010 storage account missing private endpoint Adds a Storage rule that flags storage accounts reachable over the public network that have no approved Private Endpoint connection, so their blob/file/queue/table endpoints stay reachable from the internet instead of staying on a private IP inside a VNet. Detection reads the real azure-mgmt-storage model shape: an account is flagged when private_endpoint_connections has no entry whose private_link_service_connection_state.status is "Approved". An account whose public_network_access is already "Disabled" is treated as NOT_APPLICABLE (network-isolated by another means), so the rule does not raise a false finding. Includes the remediation playbook (creates a blob Private Endpoint and sets public network access to Disabled; args guarded per the fix_az_net_016.sh convention), four unit tests (approved / pending-only / none / public-disabled) that exercise genuine SDK models, and framework mappings: NIST PR.AC-5, ISO 27001 A.13.1.3, SOC 2 CC6.6, and the repo's N/A convention for CIS. Resolves #322. The issue proposed the id AZ-STOR-007, but that id is already in use (TLS below 1.2) and the storage rules run through AZ-STOR-009, so this lands as AZ-STOR-010. Signed-off-by: shariqueahmad108-ship-it --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_stor_010.sh | 38 ++++++++++ scanner/rules/az_stor_010.py | 75 +++++++++++++++++++ tests/test_rules_storage.py | 62 +++++++++++++++ 7 files changed, 195 insertions(+) create mode 100644 playbooks/cli/fix_az_stor_010.sh create mode 100644 scanner/rules/az_stor_010.py 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/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..a6dbc6d --- /dev/null +++ b/scanner/rules/az_stor_010.py @@ -0,0 +1,75 @@ +"""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(account: Any) -> bool: + """True if the account has at least one Private Endpoint connection in the Approved state.""" + for connection in getattr(account, "private_endpoint_connections", None) 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. + """ + 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 + if _has_approved_private_endpoint(account): + 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(getattr(account, "private_endpoint_connections", None) or []), + }, + } + ) + + return findings diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 6351166..0595e6b 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,64 @@ 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" From 94ea7cf9fbcf536107ee2b29ea0d35ee7aefa9cc Mon Sep 17 00:00:00 2001 From: shariqueahmad108-ship-it Date: Wed, 9 Sep 2026 00:05:15 +0530 Subject: [PATCH 2/2] fix(AZ-STOR-010): treat unavailable private-endpoint evidence as indeterminate; document the rule Addresses review feedback on #327: - Indeterminate evidence: private_endpoint_connections of None means the field was not populated / could not be read, not a confirmed absence. The rule now skips such an account (logging a warning) instead of flagging it, so it does not raise a false finding from missing evidence. A genuine empty list, or connections with none in the Approved state, is still a finding. - Adds a regression test for the None (unavailable) case. - Documents AZ-STOR-010 in docs/rules-reference.md. Signed-off-by: shariqueahmad108-ship-it --- docs/rules-reference.md | 1 + scanner/rules/az_stor_010.py | 24 +++++++++++++++++++----- tests/test_rules_storage.py | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) 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/scanner/rules/az_stor_010.py b/scanner/rules/az_stor_010.py index a6dbc6d..47e62fc 100644 --- a/scanner/rules/az_stor_010.py +++ b/scanner/rules/az_stor_010.py @@ -27,9 +27,9 @@ PLAYBOOK = "playbooks/cli/fix_az_stor_010.sh" -def _has_approved_private_endpoint(account: Any) -> bool: - """True if the account has at least one Private Endpoint connection in the Approved state.""" - for connection in getattr(account, "private_endpoint_connections", None) or []: +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 @@ -43,13 +43,27 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: 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 - if _has_approved_private_endpoint(account): + + 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( @@ -67,7 +81,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "frameworks": FRAMEWORKS, "metadata": { "public_network_access": enum_str(getattr(account, "public_network_access", None)) or "unspecified", - "private_endpoint_connections": len(getattr(account, "private_endpoint_connections", None) or []), + "private_endpoint_connections": len(connections), }, } ) diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 0595e6b..95d06db 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -361,3 +361,17 @@ def test_stor_010_only_pending_private_endpoint_is_flagged(mock_azure, subscript 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) == []