Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions compliance/frameworks/cis_azure_benchmark.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/iso27001.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/nist_csf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/soc2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down
1 change: 1 addition & 0 deletions docs/rules-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
38 changes: 38 additions & 0 deletions playbooks/cli/fix_az_stor_010.sh
Original file line number Diff line number Diff line change
@@ -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 <resource-group> <storage-account-name> <vnet-name> <subnet-name>

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 <resource-group> <storage-account-name> <vnet-name> <subnet-name>"
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."
89 changes: 89 additions & 0 deletions scanner/rules/az_stor_010.py
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions tests/test_rules_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) == []
Loading