From 3e258c43dc1ddf433775b419e58ecb177a32dc32 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Wed, 15 Jul 2026 15:05:45 +0100 Subject: [PATCH 1/5] feat(scanner): add enterprise resilience security packs --- .github/workflows/dependency-review.yml | 8 +- .../frameworks/cis_azure_benchmark.json | 17 ++- docs/enterprise-resilience-rules.md | 69 +++++++++++ playbooks/cli/fix_az_bak_001.sh | 2 + playbooks/cli/fix_az_bak_002.sh | 2 + playbooks/cli/fix_az_bak_004.sh | 2 + playbooks/cli/fix_az_bak_006.sh | 2 + playbooks/cli/fix_az_func_001.sh | 2 + playbooks/cli/fix_az_func_002.sh | 2 + playbooks/cli/fix_az_func_003.sh | 2 + playbooks/cli/fix_az_func_004.sh | 2 + playbooks/cli/fix_az_func_005.sh | 2 + playbooks/cli/fix_az_pe_001.sh | 2 + playbooks/cli/fix_az_pe_002.sh | 2 + playbooks/cli/fix_az_pe_003.sh | 2 + playbooks/cli/fix_az_pe_004.sh | 2 + playbooks/cli/fix_az_pe_005.sh | 2 + playbooks/cli/fix_az_pe_006.sh | 2 + playbooks/cli/review_enterprise_resilience.sh | 21 ++++ requirements.txt | 1 + scanner/azure_client.py | 115 +++++++++++++++++ .../rules/_enterprise_resilience_common.py | 78 ++++++++++++ scanner/rules/az_bak_001.py | 5 + scanner/rules/az_bak_002.py | 5 + scanner/rules/az_bak_004.py | 5 + scanner/rules/az_bak_006.py | 5 + scanner/rules/az_func_001.py | 5 + scanner/rules/az_func_002.py | 5 + scanner/rules/az_func_003.py | 5 + scanner/rules/az_func_004.py | 5 + scanner/rules/az_func_005.py | 5 + scanner/rules/az_pe_001.py | 3 + scanner/rules/az_pe_002.py | 3 + scanner/rules/az_pe_003.py | 3 + scanner/rules/az_pe_004.py | 3 + scanner/rules/az_pe_005.py | 3 + scanner/rules/az_pe_006.py | 3 + tests/test_rules_enterprise_resilience.py | 117 ++++++++++++++++++ 38 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 docs/enterprise-resilience-rules.md create mode 100644 playbooks/cli/fix_az_bak_001.sh create mode 100644 playbooks/cli/fix_az_bak_002.sh create mode 100644 playbooks/cli/fix_az_bak_004.sh create mode 100644 playbooks/cli/fix_az_bak_006.sh create mode 100644 playbooks/cli/fix_az_func_001.sh create mode 100644 playbooks/cli/fix_az_func_002.sh create mode 100644 playbooks/cli/fix_az_func_003.sh create mode 100644 playbooks/cli/fix_az_func_004.sh create mode 100644 playbooks/cli/fix_az_func_005.sh create mode 100644 playbooks/cli/fix_az_pe_001.sh create mode 100644 playbooks/cli/fix_az_pe_002.sh create mode 100644 playbooks/cli/fix_az_pe_003.sh create mode 100644 playbooks/cli/fix_az_pe_004.sh create mode 100644 playbooks/cli/fix_az_pe_005.sh create mode 100644 playbooks/cli/fix_az_pe_006.sh create mode 100644 playbooks/cli/review_enterprise_resilience.sh create mode 100644 scanner/rules/_enterprise_resilience_common.py create mode 100644 scanner/rules/az_bak_001.py create mode 100644 scanner/rules/az_bak_002.py create mode 100644 scanner/rules/az_bak_004.py create mode 100644 scanner/rules/az_bak_006.py create mode 100644 scanner/rules/az_func_001.py create mode 100644 scanner/rules/az_func_002.py create mode 100644 scanner/rules/az_func_003.py create mode 100644 scanner/rules/az_func_004.py create mode 100644 scanner/rules/az_func_005.py create mode 100644 scanner/rules/az_pe_001.py create mode 100644 scanner/rules/az_pe_002.py create mode 100644 scanner/rules/az_pe_003.py create mode 100644 scanner/rules/az_pe_004.py create mode 100644 scanner/rules/az_pe_005.py create mode 100644 scanner/rules/az_pe_006.py create mode 100644 tests/test_rules_enterprise_resilience.py diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b67e860..a737a3c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,8 +16,10 @@ jobs: - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4 with: fail-on-severity: high - # The wheel omits license metadata, but Microsoft's upstream Azure - # SDK repository licenses this package under MIT: + # These wheels omit license metadata, but Microsoft's upstream Azure + # SDK repository licenses both packages under MIT: # https://github.com/Azure/azure-sdk-for-python/blob/main/LICENSE - allow-dependencies-licenses: pkg:pypi/azure-mgmt-containerservice@41.3.0 + allow-dependencies-licenses: >- + pkg:pypi/azure-mgmt-containerservice@41.3.0, + pkg:pypi/azure-mgmt-recoveryservices@4.1.0 comment-summary-in-pr: always diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 7620192..b15147d 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -257,6 +257,21 @@ "control_id": "N/A-AKS-006", "control_name": "AKS node OS upgrade baseline (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends a managed node OS upgrade channel for timely security patches. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." - } + }, + "AZ-FUNC-001": {"control_id":"TBD-FUNC-001","control_name":"Function HTTPS baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-FUNC-002": {"control_id":"TBD-FUNC-002","control_name":"Function TLS baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-FUNC-003": {"control_id":"TBD-FUNC-003","control_name":"Function publishing baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-FUNC-004": {"control_id":"TBD-FUNC-004","control_name":"Function debugging baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-FUNC-005": {"control_id":"TBD-FUNC-005","control_name":"Function identity baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-001": {"control_id":"TBD-PE-001","control_name":"Storage Private Link baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-002": {"control_id":"TBD-PE-002","control_name":"SQL Private Link baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-003": {"control_id":"TBD-PE-003","control_name":"PostgreSQL private networking placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-004": {"control_id":"TBD-PE-004","control_name":"App Service Private Link baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-005": {"control_id":"TBD-PE-005","control_name":"Recovery Services Private Link placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-PE-006": {"control_id":"TBD-PE-006","control_name":"Private endpoint approval placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-BAK-001": {"control_id":"TBD-BAK-001","control_name":"Backup soft delete placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-BAK-002": {"control_id":"TBD-BAK-002","control_name":"Backup immutability placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-BAK-004": {"control_id":"TBD-BAK-004","control_name":"Backup MUA placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, + "AZ-BAK-006": {"control_id":"TBD-BAK-006","control_name":"Backup monitoring placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."} } } diff --git a/docs/enterprise-resilience-rules.md b/docs/enterprise-resilience-rules.md new file mode 100644 index 0000000..d1dc2c4 --- /dev/null +++ b/docs/enterprise-resilience-rules.md @@ -0,0 +1,69 @@ +# Azure Enterprise Resilience Rules + +OpenShield evaluates Azure Functions, Private Link, and Recovery Services vault +configuration through Azure Resource Manager. It does not read application +settings, connection strings, deployment credentials, private IP addresses, +backup items, recovery points, encryption keys, or customer data. + +## Coverage + +| Pack | Rules | Controls | +|---|---|---| +| Azure Functions | `AZ-FUNC-001`–`005` | HTTPS, TLS 1.2, FTP publishing, remote debugging, managed identity | +| Private Endpoint | `AZ-PE-001`–`006` | Storage, SQL, PostgreSQL, App Service, Recovery Services, connection approval | +| Azure Backup | `AZ-BAK-001`, `002`, `004`, `006` | Soft delete, immutability, MUA, Azure Monitor alerts | + +Private Link checks evaluate public-network state and approved target groups +together. A private endpoint does not itself disable a service's public +endpoint. Storage currently requires the `blob` target group as the universal +minimum; additional service groups such as `file`, `queue`, `table`, `dfs`, and +`web` depend on which data services the account uses and are not inferred. + +The Backup baseline requires soft delete to be `Enabled` or `AlwaysON` with at +least 35 days of retention. Immutability locking is deliberately not automated: +the locked state is irreversible. Storage redundancy and production-only lock +policies remain outside this first phase because they require organization +specific exceptions and production classification. + +## Required permissions + +Azure's built-in Reader role normally includes the required management-plane +read actions: + +```text +Microsoft.Web/sites/read +Microsoft.Web/sites/config/read +Microsoft.Network/privateEndpoints/read +Microsoft.Storage/storageAccounts/read +Microsoft.Sql/servers/read +Microsoft.DBforPostgreSQL/flexibleServers/read +Microsoft.RecoveryServices/vaults/read +``` + +Each inventory has an explicit indeterminate state. If a required API fails or +a security property is absent, affected rules log and skip the resource instead +of creating a finding or claiming compliance. + +## Remediation safety + +Network isolation can interrupt applications, deployment systems, backup +agents, and administrators when private DNS or routing is incomplete. Validate +the resource, hosting tier, private endpoint approval, DNS, and client path +before disabling public access. Backup immutability must be reviewed separately +and is never locked by an OpenShield playbook. + +## Compliance placeholders + +The repository's CIS Azure Foundations version has no direct controls for all +of these settings. Each rule therefore uses a numbered `TBD-FUNC-*`, `TBD-PE-*`, +or `TBD-BAK-*` placeholder so maintainers can replace it with an approved +benchmark mapping later. NIST, ISO 27001, and SOC 2 mappings use the framework +versions already represented by OpenShield. + +## References + +- [Azure Functions security](https://learn.microsoft.com/azure/azure-functions/security-concepts) +- [Azure Private Endpoint overview](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) +- [Azure Storage private endpoints](https://learn.microsoft.com/azure/storage/common/storage-private-endpoints) +- [Azure Backup security best practices](https://learn.microsoft.com/azure/backup/azure-backup-data-protection-best-practices) +- [Azure Backup multiuser authorization](https://learn.microsoft.com/azure/backup/multi-user-authorization-concept) diff --git a/playbooks/cli/fix_az_bak_001.sh b/playbooks/cli/fix_az_bak_001.sh new file mode 100644 index 0000000..9f52f5e --- /dev/null +++ b/playbooks/cli/fix_az_bak_001.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-BAK-001 "$@" diff --git a/playbooks/cli/fix_az_bak_002.sh b/playbooks/cli/fix_az_bak_002.sh new file mode 100644 index 0000000..4cb0742 --- /dev/null +++ b/playbooks/cli/fix_az_bak_002.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-BAK-002 "$@" diff --git a/playbooks/cli/fix_az_bak_004.sh b/playbooks/cli/fix_az_bak_004.sh new file mode 100644 index 0000000..84e8c72 --- /dev/null +++ b/playbooks/cli/fix_az_bak_004.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-BAK-004 "$@" diff --git a/playbooks/cli/fix_az_bak_006.sh b/playbooks/cli/fix_az_bak_006.sh new file mode 100644 index 0000000..fec87f2 --- /dev/null +++ b/playbooks/cli/fix_az_bak_006.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-BAK-006 "$@" diff --git a/playbooks/cli/fix_az_func_001.sh b/playbooks/cli/fix_az_func_001.sh new file mode 100644 index 0000000..9862d1b --- /dev/null +++ b/playbooks/cli/fix_az_func_001.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-FUNC-001 "$@" diff --git a/playbooks/cli/fix_az_func_002.sh b/playbooks/cli/fix_az_func_002.sh new file mode 100644 index 0000000..641f4f0 --- /dev/null +++ b/playbooks/cli/fix_az_func_002.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-FUNC-002 "$@" diff --git a/playbooks/cli/fix_az_func_003.sh b/playbooks/cli/fix_az_func_003.sh new file mode 100644 index 0000000..841b8dd --- /dev/null +++ b/playbooks/cli/fix_az_func_003.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-FUNC-003 "$@" diff --git a/playbooks/cli/fix_az_func_004.sh b/playbooks/cli/fix_az_func_004.sh new file mode 100644 index 0000000..f6352e6 --- /dev/null +++ b/playbooks/cli/fix_az_func_004.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-FUNC-004 "$@" diff --git a/playbooks/cli/fix_az_func_005.sh b/playbooks/cli/fix_az_func_005.sh new file mode 100644 index 0000000..aa31cae --- /dev/null +++ b/playbooks/cli/fix_az_func_005.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-FUNC-005 "$@" diff --git a/playbooks/cli/fix_az_pe_001.sh b/playbooks/cli/fix_az_pe_001.sh new file mode 100644 index 0000000..426ec95 --- /dev/null +++ b/playbooks/cli/fix_az_pe_001.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-001 "$@" diff --git a/playbooks/cli/fix_az_pe_002.sh b/playbooks/cli/fix_az_pe_002.sh new file mode 100644 index 0000000..811502b --- /dev/null +++ b/playbooks/cli/fix_az_pe_002.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-002 "$@" diff --git a/playbooks/cli/fix_az_pe_003.sh b/playbooks/cli/fix_az_pe_003.sh new file mode 100644 index 0000000..0b13f9a --- /dev/null +++ b/playbooks/cli/fix_az_pe_003.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-003 "$@" diff --git a/playbooks/cli/fix_az_pe_004.sh b/playbooks/cli/fix_az_pe_004.sh new file mode 100644 index 0000000..d9d1594 --- /dev/null +++ b/playbooks/cli/fix_az_pe_004.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-004 "$@" diff --git a/playbooks/cli/fix_az_pe_005.sh b/playbooks/cli/fix_az_pe_005.sh new file mode 100644 index 0000000..80a9b86 --- /dev/null +++ b/playbooks/cli/fix_az_pe_005.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-005 "$@" diff --git a/playbooks/cli/fix_az_pe_006.sh b/playbooks/cli/fix_az_pe_006.sh new file mode 100644 index 0000000..65f600e --- /dev/null +++ b/playbooks/cli/fix_az_pe_006.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec "$(dirname "$0")/review_enterprise_resilience.sh" AZ-PE-006 "$@" diff --git a/playbooks/cli/review_enterprise_resilience.sh b/playbooks/cli/review_enterprise_resilience.sh new file mode 100644 index 0000000..7f84c2e --- /dev/null +++ b/playbooks/cli/review_enterprise_resilience.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Shared safety gate for enterprise-resilience remediations. +set -euo pipefail +RULE_ID=${1:-}; RESOURCE_ID=${2:-} +if [ -z "$RULE_ID" ] || [ -z "$RESOURCE_ID" ]; then echo "Usage: $0 "; exit 1; fi +az account show --output none +az resource show --ids "$RESOURCE_ID" --output none +echo "Rule: $RULE_ID" +echo "WARNING: Network and backup changes can interrupt production or become irreversible." +echo "Validate private DNS, routing, clients, backup policies, and rollback procedures first." +read -r -p "Type APPLY to confirm that the target and impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +case "$RULE_ID" in + AZ-FUNC-001) az resource update --ids "$RESOURCE_ID" --set properties.httpsOnly=true --output none ;; + AZ-FUNC-002) az resource update --ids "$RESOURCE_ID/config/web" --set properties.minTlsVersion=1.2 --output none ;; + AZ-FUNC-003) az resource update --ids "$RESOURCE_ID/config/web" --set properties.ftpsState=Disabled --output none ;; + AZ-FUNC-004) az resource update --ids "$RESOURCE_ID/config/web" --set properties.remoteDebuggingEnabled=false --output none ;; + AZ-FUNC-005) az webapp identity assign --ids "$RESOURCE_ID" --output none ;; + *) echo "Review completed. This control needs service-specific DNS, networking, or security-admin inputs; no automatic change was made." ;; +esac +az resource show --ids "$RESOURCE_ID" --output json diff --git a/requirements.txt b/requirements.txt index 321fcb1..cdf4549 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ azure-monitor-ingestion==1.0.3 azure-mgmt-monitor==6.0.0 azure-mgmt-dns==8.0.0 azure-mgmt-containerservice==41.3.0 +azure-mgmt-recoveryservices==4.1.0 psycopg2-binary==2.9.9 python-dotenv==1.2.2 pyjwt==2.13.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index c44aa9d..47a20a3 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -35,6 +35,9 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No self.subscription_id = subscription_id self.credential = credential or DefaultAzureCredential() self._managed_clusters_cache: Any = _UNSET + self._function_apps_cache: Any = _UNSET + self._private_endpoint_posture_cache: Any = _UNSET + self._recovery_vaults_cache: Any = _UNSET # ------------------------------------------------------------------ # # Static helpers # @@ -349,6 +352,118 @@ def get_web_apps(self) -> List[Any]: logger.error("get_web_apps failed: %s", exc) return [] + def get_function_app_security_posture(self) -> Optional[List[Dict[str, Any]]]: + """Return a cached, secret-free posture for Function Apps.""" + if self._function_apps_cache is not _UNSET: + return self._function_apps_cache + try: + from azure.mgmt.web import WebSiteManagementClient + client = WebSiteManagementClient(self.credential, self.subscription_id) + result: List[Dict[str, Any]] = [] + for app in client.web_apps.list(): + if "functionapp" not in str(getattr(app, "kind", "")).lower(): + continue + parsed = self.parse_resource_id(getattr(app, "id", "")) + config = client.web_apps.get_configuration(parsed.get("resource_group", ""), app.name) + identity = getattr(app, "identity", None) + result.append({"id": app.id, "name": app.name, "kind": getattr(app, "kind", None), + "https_only": getattr(app, "https_only", None), + "min_tls_version": getattr(config, "min_tls_version", None), + "ftps_state": getattr(config, "ftps_state", None), + "remote_debugging_enabled": getattr(config, "remote_debugging_enabled", None), + "identity_type": getattr(identity, "type", None) if identity is not None else "None"}) + self._function_apps_cache = result + except Exception as exc: + logger.error("get_function_app_security_posture failed: %s", exc) + self._function_apps_cache = None + return self._function_apps_cache + + def get_private_endpoint_posture(self) -> Optional[List[Dict[str, Any]]]: + """Return public-access and approved Private Link state for supported PaaS resources.""" + if self._private_endpoint_posture_cache is not _UNSET: + return self._private_endpoint_posture_cache + try: + network = NetworkManagementClient(self.credential, self.subscription_id) + endpoints = list(network.private_endpoints.list_by_subscription()) + approved: Dict[str, set[str]] = {} + records: List[Dict[str, Any]] = [] + for endpoint in endpoints: + endpoint_connections = list(getattr(endpoint, "private_link_service_connections", None) or []) + endpoint_connections.extend(getattr(endpoint, "manual_private_link_service_connections", None) or []) + for connection in endpoint_connections: + target = str(getattr(connection, "private_link_service_id", "") or "").lower() + state = getattr(connection, "private_link_service_connection_state", None) + status = str(getattr(state, "status", "") or "") + groups = {str(value).lower() for value in (getattr(connection, "group_ids", None) or [])} + if target and status.lower() == "approved": + approved.setdefault(target, set()).update(groups) + elif target and status.lower() in {"pending", "rejected", "disconnected"}: + records.append({"id": endpoint.id, "name": endpoint.name, "service": "connection", "status": status}) + + def add(resource: Any, service: str, public: Any, required: set[str]) -> None: + rid = str(getattr(resource, "id", "") or "") + records.append({"id": rid, "name": getattr(resource, "name", ""), "service": service, + "public_network_access": public, "approved_groups": sorted(approved.get(rid.lower(), set())), + "required_groups": sorted(required)}) + + for item in StorageManagementClient(self.credential, self.subscription_id).storage_accounts.list(): + add(item, "storage", getattr(item, "public_network_access", None), {"blob"}) + for item in SqlManagementClient(self.credential, self.subscription_id).servers.list(): + add(item, "sql", getattr(item, "public_network_access", None), {"sqlserver"}) + try: + from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexiblePostgreSQLClient + for item in FlexiblePostgreSQLClient(self.credential, self.subscription_id).servers.list(): + add(item, "postgresql", getattr(getattr(item, "network", None), "public_network_access", None), {"postgresqlserver"}) + except Exception as exc: + logger.warning("PostgreSQL Flexible Server posture unavailable: %s", exc) + from azure.mgmt.web import WebSiteManagementClient + web = WebSiteManagementClient(self.credential, self.subscription_id) + for item in web.web_apps.list(): + parsed = self.parse_resource_id(getattr(item, "id", "")) + config = web.web_apps.get_configuration(parsed.get("resource_group", ""), item.name) + public = getattr(item, "public_network_access", None) or getattr(config, "public_network_access", None) + if str(getattr(config, "ip_security_restrictions_default_action", "")).lower() == "deny": + public = "Disabled" + add(item, "web", public, {"sites"}) + try: + from azure.mgmt.recoveryservices import RecoveryServicesClient + for item in RecoveryServicesClient(self.credential, self.subscription_id).vaults.list_by_subscription_id(): + add(item, "recovery", getattr(getattr(item, "properties", None), "public_network_access", None), {"azurebackup"}) + except Exception as exc: + logger.warning("Recovery Services network posture unavailable: %s", exc) + self._private_endpoint_posture_cache = records + except Exception as exc: + logger.error("get_private_endpoint_posture failed: %s", exc) + self._private_endpoint_posture_cache = None + return self._private_endpoint_posture_cache + + def get_recovery_vault_security_posture(self) -> Optional[List[Dict[str, Any]]]: + """Return cached Recovery Services security settings without backup contents.""" + if self._recovery_vaults_cache is not _UNSET: + return self._recovery_vaults_cache + try: + from azure.mgmt.recoveryservices import RecoveryServicesClient + result: List[Dict[str, Any]] = [] + for vault in RecoveryServicesClient(self.credential, self.subscription_id).vaults.list_by_subscription_id(): + props = getattr(vault, "properties", None) + security = getattr(props, "security_settings", None) + soft = getattr(security, "soft_delete_settings", None) + immutable = getattr(security, "immutability_settings", None) + monitoring = getattr(props, "monitoring_settings", None) + monitor_alerts = getattr(monitoring, "azure_monitor_alert_settings", None) + result.append({"id": vault.id, "name": vault.name, + "soft_delete_state": getattr(soft, "soft_delete_state", None), + "soft_delete_retention_days": getattr(soft, "soft_delete_retention_period_in_days", None), + "immutability_state": getattr(immutable, "state", None), + "multi_user_authorization": getattr(security, "multi_user_authorization", None), + "resource_guard_operations": getattr(props, "resource_guard_operation_requests", None), + "monitoring_alerts_for_job_failures": getattr(monitor_alerts, "alerts_for_all_job_failures", None)}) + self._recovery_vaults_cache = result + except Exception as exc: + logger.error("get_recovery_vault_security_posture failed: %s", exc) + self._recovery_vaults_cache = None + return self._recovery_vaults_cache + def get_vm_extensions(self, resource_group: str, vm_name: str) -> Optional[List[Any]]: """List all extensions installed on a virtual machine.""" try: diff --git a/scanner/rules/_enterprise_resilience_common.py b/scanner/rules/_enterprise_resilience_common.py new file mode 100644 index 0000000..efd0615 --- /dev/null +++ b/scanner/rules/_enterprise_resilience_common.py @@ -0,0 +1,78 @@ +"""Shared evaluators for the enterprise resilience rule packs.""" + +import logging +from typing import Any, Dict, List, Mapping + +logger = logging.getLogger(__name__) + +FRAMEWORKS = {"CIS": "TBD", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} + + +def _value(module: Any, name: str) -> Any: + return module[name] if isinstance(module, Mapping) else getattr(module, name) + + +def finding(item: Mapping[str, Any], module: Any, resource_type: str, metadata: Mapping[str, Any]) -> Dict[str, Any]: + return {"rule_id": _value(module, "RULE_ID"), "rule_name": _value(module, "RULE_NAME"), + "severity": _value(module, "SEVERITY"), "category": _value(module, "CATEGORY"), + "resource_id": item["id"], "resource_name": item["name"], "resource_type": resource_type, + "description": _value(module, "DESCRIPTION"), "remediation": _value(module, "REMEDIATION"), + "playbook": _value(module, "PLAYBOOK"), "frameworks": dict(_value(module, "FRAMEWORKS")), "metadata": dict(metadata)} + + +def scan_functions(client: Any, module: Any, field: str, unsafe: Any) -> List[Dict[str, Any]]: + apps = client.get_function_app_security_posture() + if apps is None: + return [] + results = [] + for app in apps: + value = app.get(field) + if value is None: + logger.warning("%s: %s is unknown for %s", _value(module, "RULE_ID"), field, app.get("name")) + continue + if unsafe(value): + results.append(finding(app, module, "Microsoft.Web/sites", {field: value})) + return results + + +RESOURCE_TYPES = {"storage": "Microsoft.Storage/storageAccounts", "sql": "Microsoft.Sql/servers", + "postgresql": "Microsoft.DBforPostgreSQL/flexibleServers", "web": "Microsoft.Web/sites", + "recovery": "Microsoft.RecoveryServices/vaults", "connection": "Microsoft.Network/privateEndpoints"} + + +def scan_private(client: Any, module: Any, service: str) -> List[Dict[str, Any]]: + posture = client.get_private_endpoint_posture() + if posture is None: + return [] + results = [] + for item in posture: + if item.get("service") != service: + continue + if service == "connection": + results.append(finding(item, module, RESOURCE_TYPES[service], {"connection_status": item.get("status")})) + continue + public = str(item.get("public_network_access") or "").lower() + if public not in {"enabled", "disabled"}: + logger.warning("%s: public network state unknown for %s", _value(module, "RULE_ID"), item.get("name")) + continue + required = set(item.get("required_groups") or []) + approved = set(item.get("approved_groups") or []) + if public == "enabled": + results.append(finding(item, module, RESOURCE_TYPES[service], + {"public_network_access": item.get("public_network_access"), "missing_private_link_groups": sorted(required - approved)})) + return results + + +def scan_backup(client: Any, module: Any, unsafe: Any, metadata_fields: List[str]) -> List[Dict[str, Any]]: + vaults = client.get_recovery_vault_security_posture() + if vaults is None: + return [] + results = [] + for vault in vaults: + verdict = unsafe(vault) + if verdict is None: + logger.warning("%s: required state unknown for %s", _value(module, "RULE_ID"), vault.get("name")) + elif verdict: + results.append(finding(vault, module, "Microsoft.RecoveryServices/vaults", + {key: vault.get(key) for key in metadata_fields})) + return results diff --git a/scanner/rules/az_bak_001.py b/scanner/rules/az_bak_001.py new file mode 100644 index 0000000..e5bd107 --- /dev/null +++ b/scanner/rules/az_bak_001.py @@ -0,0 +1,5 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup +RULE_ID="AZ-BAK-001"; RULE_NAME="Backup Soft Delete Disabled or Below 35 Days"; SEVERITY="CRITICAL"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-001","NIST":"PR.IP-4","ISO27001":"A.12.3.1","SOC2":"A1.2"}; DESCRIPTION="The Recovery Services vault lacks the approved soft-delete recovery window."; REMEDIATION="Enable enhanced soft delete and set retention to at least 35 days after reviewing cost and retention requirements."; PLAYBOOK="playbooks/cli/fix_az_bak_001.sh" +def _unsafe(v): + s=v.get("soft_delete_state"); d=v.get("soft_delete_retention_days"); return None if s is None or d is None else str(s).lower() not in {"enabled","alwayson"} or d < 35 +def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["soft_delete_state","soft_delete_retention_days"]) diff --git a/scanner/rules/az_bak_002.py b/scanner/rules/az_bak_002.py new file mode 100644 index 0000000..7ec7d26 --- /dev/null +++ b/scanner/rules/az_bak_002.py @@ -0,0 +1,5 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup +RULE_ID="AZ-BAK-002"; RULE_NAME="Backup Vault Immutability Disabled"; SEVERITY="HIGH"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-002","NIST":"PR.IP-4","ISO27001":"A.12.3.1","SOC2":"A1.2"}; DESCRIPTION="Vault immutability is disabled, allowing destructive changes to protected recovery points."; REMEDIATION="Enable immutability after reviewing protected items and policies; do not lock it automatically."; PLAYBOOK="playbooks/cli/fix_az_bak_002.sh" +def _unsafe(v): + s=v.get("immutability_state"); return None if s is None else str(s).lower() in {"disabled","unlocked"} +def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["immutability_state"]) diff --git a/scanner/rules/az_bak_004.py b/scanner/rules/az_bak_004.py new file mode 100644 index 0000000..1e40e38 --- /dev/null +++ b/scanner/rules/az_bak_004.py @@ -0,0 +1,5 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup +RULE_ID="AZ-BAK-004"; RULE_NAME="Backup Multiuser Authorization Missing"; SEVERITY="HIGH"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-004","NIST":"PR.AC-4","ISO27001":"A.9.2.3","SOC2":"CC6.1"}; DESCRIPTION="The vault does not enable Resource Guard multiuser authorization."; REMEDIATION="Configure a separately owned Resource Guard and protect critical operations with MUA."; PLAYBOOK="playbooks/cli/fix_az_bak_004.sh" +def _unsafe(v): + s=v.get("multi_user_authorization"); return None if s is None else str(s).lower() != "enabled" +def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["multi_user_authorization","resource_guard_operations"]) diff --git a/scanner/rules/az_bak_006.py b/scanner/rules/az_bak_006.py new file mode 100644 index 0000000..2dbb8fe --- /dev/null +++ b/scanner/rules/az_bak_006.py @@ -0,0 +1,5 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup +RULE_ID="AZ-BAK-006"; RULE_NAME="Backup Security Monitoring Disabled"; SEVERITY="MEDIUM"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-006","NIST":"DE.CM-1","ISO27001":"A.12.4.1","SOC2":"CC7.2"}; DESCRIPTION="The Recovery Services vault does not enable built-in monitoring for backup job failures."; REMEDIATION="Enable Azure Monitor alerts and route backup diagnostics to the approved monitoring destination."; PLAYBOOK="playbooks/cli/fix_az_bak_006.sh" +def _unsafe(v): + a=v.get("monitoring_alerts_for_job_failures"); return None if a is None else str(a).lower() != "enabled" +def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["monitoring_alerts_for_job_failures"]) diff --git a/scanner/rules/az_func_001.py b/scanner/rules/az_func_001.py new file mode 100644 index 0000000..c3dbd98 --- /dev/null +++ b/scanner/rules/az_func_001.py @@ -0,0 +1,5 @@ +"""AZ-FUNC-001: Function App permits HTTP.""" +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions +RULE_ID="AZ-FUNC-001"; RULE_NAME="Function App HTTPS Only Disabled"; SEVERITY="HIGH"; CATEGORY="Serverless" +FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-001"}; DESCRIPTION="The Function App accepts unencrypted HTTP traffic."; REMEDIATION="Enable HTTPS Only after validating clients."; PLAYBOOK="playbooks/cli/fix_az_func_001.sh" +def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "https_only", lambda v: v is False) diff --git a/scanner/rules/az_func_002.py b/scanner/rules/az_func_002.py new file mode 100644 index 0000000..c0b7dd5 --- /dev/null +++ b/scanner/rules/az_func_002.py @@ -0,0 +1,5 @@ +"""AZ-FUNC-002: Function App allows legacy TLS.""" +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions +RULE_ID="AZ-FUNC-002"; RULE_NAME="Function App Minimum TLS Below 1.2"; SEVERITY="HIGH"; CATEGORY="Serverless" +FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-002"}; DESCRIPTION="The Function App permits TLS older than 1.2."; REMEDIATION="Set the minimum inbound TLS version to 1.2 or newer."; PLAYBOOK="playbooks/cli/fix_az_func_002.sh" +def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "min_tls_version", lambda v: str(v) in {"1.0","1.1"}) diff --git a/scanner/rules/az_func_003.py b/scanner/rules/az_func_003.py new file mode 100644 index 0000000..8564d0c --- /dev/null +++ b/scanner/rules/az_func_003.py @@ -0,0 +1,5 @@ +"""AZ-FUNC-003: Function App FTP publishing is enabled.""" +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions +RULE_ID="AZ-FUNC-003"; RULE_NAME="Function App FTP Publishing Enabled"; SEVERITY="MEDIUM"; CATEGORY="Serverless" +FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-003"}; DESCRIPTION="The Function App exposes an FTP or FTPS deployment channel."; REMEDIATION="Disable FTP/FTPS publishing after migrating deployment automation."; PLAYBOOK="playbooks/cli/fix_az_func_003.sh" +def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "ftps_state", lambda v: str(v).lower() != "disabled") diff --git a/scanner/rules/az_func_004.py b/scanner/rules/az_func_004.py new file mode 100644 index 0000000..5cc9965 --- /dev/null +++ b/scanner/rules/az_func_004.py @@ -0,0 +1,5 @@ +"""AZ-FUNC-004: Function App remote debugging is enabled.""" +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions +RULE_ID="AZ-FUNC-004"; RULE_NAME="Function App Remote Debugging Enabled"; SEVERITY="HIGH"; CATEGORY="Serverless" +FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-004"}; DESCRIPTION="Remote debugging expands the Function App management attack surface."; REMEDIATION="Disable remote debugging outside a controlled troubleshooting window."; PLAYBOOK="playbooks/cli/fix_az_func_004.sh" +def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "remote_debugging_enabled", lambda v: v is True) diff --git a/scanner/rules/az_func_005.py b/scanner/rules/az_func_005.py new file mode 100644 index 0000000..1773912 --- /dev/null +++ b/scanner/rules/az_func_005.py @@ -0,0 +1,5 @@ +"""AZ-FUNC-005: Function App has no managed identity.""" +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions +RULE_ID="AZ-FUNC-005"; RULE_NAME="Function App Managed Identity Missing"; SEVERITY="MEDIUM"; CATEGORY="Serverless" +FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-005"}; DESCRIPTION="The Function App has no Azure managed identity for secretless resource access."; REMEDIATION="Assign a managed identity and migrate supported credentials to identity-based access."; PLAYBOOK="playbooks/cli/fix_az_func_005.sh" +def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "identity_type", lambda v: str(v).lower() == "none") diff --git a/scanner/rules/az_pe_001.py b/scanner/rules/az_pe_001.py new file mode 100644 index 0000000..4194032 --- /dev/null +++ b/scanner/rules/az_pe_001.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-001"; RULE_NAME="Storage Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-001"}; DESCRIPTION="A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint."; REMEDIATION="Validate private DNS and connectivity, add the required endpoint, then restrict public access."; PLAYBOOK="playbooks/cli/fix_az_pe_001.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "storage") diff --git a/scanner/rules/az_pe_002.py b/scanner/rules/az_pe_002.py new file mode 100644 index 0000000..ea9f27e --- /dev/null +++ b/scanner/rules/az_pe_002.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-002"; RULE_NAME="Azure SQL Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-002"}; DESCRIPTION="An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists."; REMEDIATION="Validate private DNS and clients before disabling public access."; PLAYBOOK="playbooks/cli/fix_az_pe_002.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "sql") diff --git a/scanner/rules/az_pe_003.py b/scanner/rules/az_pe_003.py new file mode 100644 index 0000000..e999eca --- /dev/null +++ b/scanner/rules/az_pe_003.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-003"; RULE_NAME="PostgreSQL Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-003"}; DESCRIPTION="A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only."; REMEDIATION="Validate supported networking mode and private DNS before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_003.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "postgresql") diff --git a/scanner/rules/az_pe_004.py b/scanner/rules/az_pe_004.py new file mode 100644 index 0000000..4be1421 --- /dev/null +++ b/scanner/rules/az_pe_004.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-004"; RULE_NAME="Web or Function App Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-004"}; DESCRIPTION="An App Service workload remains publicly reachable without a default-deny access policy."; REMEDIATION="Validate the hosting tier, private DNS, and clients before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_004.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "web") diff --git a/scanner/rules/az_pe_005.py b/scanner/rules/az_pe_005.py new file mode 100644 index 0000000..b95fa8b --- /dev/null +++ b/scanner/rules/az_pe_005.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-005"; RULE_NAME="Recovery Vault Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-005"}; DESCRIPTION="A Recovery Services vault permits public access, even if a private endpoint also exists."; REMEDIATION="Validate Backup private DNS and agents before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_005.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "recovery") diff --git a/scanner/rules/az_pe_006.py b/scanner/rules/az_pe_006.py new file mode 100644 index 0000000..a35057c --- /dev/null +++ b/scanner/rules/az_pe_006.py @@ -0,0 +1,3 @@ +from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private +RULE_ID="AZ-PE-006"; RULE_NAME="Private Endpoint Connection Not Approved"; SEVERITY="MEDIUM"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-006"}; DESCRIPTION="A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path."; REMEDIATION="Review ownership, approval state, target resource, and private DNS before approving or replacing the connection."; PLAYBOOK="playbooks/cli/fix_az_pe_006.sh" +def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "connection") diff --git a/tests/test_rules_enterprise_resilience.py b/tests/test_rules_enterprise_resilience.py new file mode 100644 index 0000000..380d7ad --- /dev/null +++ b/tests/test_rules_enterprise_resilience.py @@ -0,0 +1,117 @@ +"""Tests for Functions, Private Endpoint, and Backup enterprise resilience rules.""" + +from unittest.mock import MagicMock + +import pytest + +from scanner.rules import ( + az_bak_001, az_bak_002, az_bak_004, az_bak_006, + az_func_001, az_func_002, az_func_003, az_func_004, az_func_005, + az_pe_001, az_pe_002, az_pe_003, az_pe_004, az_pe_005, az_pe_006, +) + + +FUNCTION_RULES = [az_func_001, az_func_002, az_func_003, az_func_004, az_func_005] +PRIVATE_RULES = [az_pe_001, az_pe_002, az_pe_003, az_pe_004, az_pe_005] +BACKUP_RULES = [az_bak_001, az_bak_002, az_bak_004, az_bak_006] + + +def function_app(**changes): + item = {"id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Web/sites/fn", "name": "fn", + "https_only": True, "min_tls_version": "1.2", "ftps_state": "Disabled", + "remote_debugging_enabled": False, "identity_type": "SystemAssigned"} + item.update(changes) + return item + + +@pytest.mark.parametrize("rule", FUNCTION_RULES) +def test_function_compliant_and_inventory_failure(rule): + client = MagicMock(); client.get_function_app_security_posture.return_value = [function_app()] + assert rule.scan(client, "s") == [] + client.get_function_app_security_posture.return_value = None + assert rule.scan(client, "s") == [] + + +@pytest.mark.parametrize(("rule", "change"), [ + (az_func_001, {"https_only": False}), (az_func_002, {"min_tls_version": "1.0"}), + (az_func_003, {"ftps_state": "FtpsOnly"}), (az_func_004, {"remote_debugging_enabled": True}), + (az_func_005, {"identity_type": "None"}), +]) +def test_function_noncompliance(rule, change): + client = MagicMock(); client.get_function_app_security_posture.return_value = [function_app(**change)] + finding = rule.scan(client, "s")[0] + assert finding["rule_id"] == rule.RULE_ID + assert finding["resource_type"] == "Microsoft.Web/sites" + assert finding["frameworks"]["CIS"].startswith("TBD-FUNC-") + + +@pytest.mark.parametrize("rule", FUNCTION_RULES) +def test_function_unknown_property_is_not_false_positive(rule): + client = MagicMock(); app = function_app(); fields = { + az_func_001: "https_only", az_func_002: "min_tls_version", az_func_003: "ftps_state", + az_func_004: "remote_debugging_enabled", az_func_005: "identity_type"} + app[fields[rule]] = None; client.get_function_app_security_posture.return_value = [app] + assert rule.scan(client, "s") == [] + + +@pytest.mark.parametrize(("rule", "service"), zip(PRIVATE_RULES, ["storage", "sql", "postgresql", "web", "recovery"])) +def test_private_endpoint_rules(rule, service): + client = MagicMock(); base = {"id": f"/subscriptions/s/{service}/one", "name": "one", "service": service, + "public_network_access": "Enabled", "required_groups": ["target"], "approved_groups": []} + client.get_private_endpoint_posture.return_value = [base] + assert len(rule.scan(client, "s")) == 1 + client.get_private_endpoint_posture.return_value = [{**base, "approved_groups": ["target"]}] + finding = rule.scan(client, "s")[0] + assert finding["metadata"]["missing_private_link_groups"] == [] + client.get_private_endpoint_posture.return_value = [{**base, "public_network_access": "Disabled"}] + assert rule.scan(client, "s") == [] + + +@pytest.mark.parametrize("rule", PRIVATE_RULES + [az_pe_006]) +def test_private_inventory_failure_is_indeterminate(rule): + client = MagicMock(); client.get_private_endpoint_posture.return_value = None + assert rule.scan(client, "s") == [] + + +def test_unhealthy_private_endpoint_connection_is_reported(): + client = MagicMock(); client.get_private_endpoint_posture.return_value = [{"id": "/pe/one", "name": "one", + "service": "connection", "status": "Rejected"}] + finding = az_pe_006.scan(client, "s")[0] + assert finding["metadata"]["connection_status"] == "Rejected" + + +def vault(**changes): + item = {"id": "/subscriptions/s/providers/Microsoft.RecoveryServices/vaults/v", "name": "v", + "soft_delete_state": "AlwaysON", "soft_delete_retention_days": 35, "immutability_state": "Locked", + "multi_user_authorization": "Enabled", "resource_guard_operations": ["Microsoft.RecoveryServices/vaults/backupconfig/write"], + "monitoring_alerts_for_job_failures": "Enabled"} + item.update(changes); return item + + +@pytest.mark.parametrize("rule", BACKUP_RULES) +def test_backup_compliant_and_inventory_failure(rule): + client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault()] + assert rule.scan(client, "s") == [] + client.get_recovery_vault_security_posture.return_value = None + assert rule.scan(client, "s") == [] + + +@pytest.mark.parametrize(("rule", "change"), [ + (az_bak_001, {"soft_delete_retention_days": 14}), (az_bak_002, {"immutability_state": "Disabled"}), + (az_bak_004, {"multi_user_authorization": "Disabled", "resource_guard_operations": []}), + (az_bak_006, {"monitoring_alerts_for_job_failures": "Disabled"}), +]) +def test_backup_noncompliance(rule, change): + client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault(**change)] + finding = rule.scan(client, "s")[0] + assert finding["rule_id"] == rule.RULE_ID + assert finding["resource_type"] == "Microsoft.RecoveryServices/vaults" + + +@pytest.mark.parametrize(("rule", "change"), [ + (az_bak_001, {"soft_delete_state": None}), (az_bak_002, {"immutability_state": None}), + (az_bak_004, {"multi_user_authorization": None}), (az_bak_006, {"monitoring_alerts_for_job_failures": None}), +]) +def test_backup_unknown_is_not_false_positive(rule, change): + client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault(**change)] + assert rule.scan(client, "s") == [] From cc13cd15bec9911ac777afa559ba3e8350850420 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Wed, 15 Jul 2026 15:08:52 +0100 Subject: [PATCH 2/5] style(scanner): format enterprise resilience rules --- scanner/azure_client.py | 79 +++++++--- .../rules/_enterprise_resilience_common.py | 50 +++++-- scanner/rules/az_bak_001.py | 23 ++- scanner/rules/az_bak_002.py | 20 ++- scanner/rules/az_bak_004.py | 20 ++- scanner/rules/az_bak_006.py | 20 ++- scanner/rules/az_func_001.py | 17 ++- scanner/rules/az_func_002.py | 17 ++- scanner/rules/az_func_003.py | 17 ++- scanner/rules/az_func_004.py | 17 ++- scanner/rules/az_func_005.py | 17 ++- scanner/rules/az_pe_001.py | 18 ++- scanner/rules/az_pe_002.py | 17 ++- scanner/rules/az_pe_003.py | 15 +- scanner/rules/az_pe_004.py | 15 +- scanner/rules/az_pe_005.py | 15 +- scanner/rules/az_pe_006.py | 19 ++- tests/test_rules_enterprise_resilience.py | 139 +++++++++++++----- 18 files changed, 425 insertions(+), 110 deletions(-) diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 47a20a3..f28bb55 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -358,6 +358,7 @@ def get_function_app_security_posture(self) -> Optional[List[Dict[str, Any]]]: return self._function_apps_cache try: from azure.mgmt.web import WebSiteManagementClient + client = WebSiteManagementClient(self.credential, self.subscription_id) result: List[Dict[str, Any]] = [] for app in client.web_apps.list(): @@ -366,12 +367,18 @@ def get_function_app_security_posture(self) -> Optional[List[Dict[str, Any]]]: parsed = self.parse_resource_id(getattr(app, "id", "")) config = client.web_apps.get_configuration(parsed.get("resource_group", ""), app.name) identity = getattr(app, "identity", None) - result.append({"id": app.id, "name": app.name, "kind": getattr(app, "kind", None), - "https_only": getattr(app, "https_only", None), - "min_tls_version": getattr(config, "min_tls_version", None), - "ftps_state": getattr(config, "ftps_state", None), - "remote_debugging_enabled": getattr(config, "remote_debugging_enabled", None), - "identity_type": getattr(identity, "type", None) if identity is not None else "None"}) + result.append( + { + "id": app.id, + "name": app.name, + "kind": getattr(app, "kind", None), + "https_only": getattr(app, "https_only", None), + "min_tls_version": getattr(config, "min_tls_version", None), + "ftps_state": getattr(config, "ftps_state", None), + "remote_debugging_enabled": getattr(config, "remote_debugging_enabled", None), + "identity_type": getattr(identity, "type", None) if identity is not None else "None", + } + ) self._function_apps_cache = result except Exception as exc: logger.error("get_function_app_security_posture failed: %s", exc) @@ -398,13 +405,22 @@ def get_private_endpoint_posture(self) -> Optional[List[Dict[str, Any]]]: if target and status.lower() == "approved": approved.setdefault(target, set()).update(groups) elif target and status.lower() in {"pending", "rejected", "disconnected"}: - records.append({"id": endpoint.id, "name": endpoint.name, "service": "connection", "status": status}) + records.append( + {"id": endpoint.id, "name": endpoint.name, "service": "connection", "status": status} + ) def add(resource: Any, service: str, public: Any, required: set[str]) -> None: rid = str(getattr(resource, "id", "") or "") - records.append({"id": rid, "name": getattr(resource, "name", ""), "service": service, - "public_network_access": public, "approved_groups": sorted(approved.get(rid.lower(), set())), - "required_groups": sorted(required)}) + records.append( + { + "id": rid, + "name": getattr(resource, "name", ""), + "service": service, + "public_network_access": public, + "approved_groups": sorted(approved.get(rid.lower(), set())), + "required_groups": sorted(required), + } + ) for item in StorageManagementClient(self.credential, self.subscription_id).storage_accounts.list(): add(item, "storage", getattr(item, "public_network_access", None), {"blob"}) @@ -412,11 +428,18 @@ def add(resource: Any, service: str, public: Any, required: set[str]) -> None: add(item, "sql", getattr(item, "public_network_access", None), {"sqlserver"}) try: from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexiblePostgreSQLClient + for item in FlexiblePostgreSQLClient(self.credential, self.subscription_id).servers.list(): - add(item, "postgresql", getattr(getattr(item, "network", None), "public_network_access", None), {"postgresqlserver"}) + add( + item, + "postgresql", + getattr(getattr(item, "network", None), "public_network_access", None), + {"postgresqlserver"}, + ) except Exception as exc: logger.warning("PostgreSQL Flexible Server posture unavailable: %s", exc) from azure.mgmt.web import WebSiteManagementClient + web = WebSiteManagementClient(self.credential, self.subscription_id) for item in web.web_apps.list(): parsed = self.parse_resource_id(getattr(item, "id", "")) @@ -427,8 +450,16 @@ def add(resource: Any, service: str, public: Any, required: set[str]) -> None: add(item, "web", public, {"sites"}) try: from azure.mgmt.recoveryservices import RecoveryServicesClient - for item in RecoveryServicesClient(self.credential, self.subscription_id).vaults.list_by_subscription_id(): - add(item, "recovery", getattr(getattr(item, "properties", None), "public_network_access", None), {"azurebackup"}) + + for item in RecoveryServicesClient( + self.credential, self.subscription_id + ).vaults.list_by_subscription_id(): + add( + item, + "recovery", + getattr(getattr(item, "properties", None), "public_network_access", None), + {"azurebackup"}, + ) except Exception as exc: logger.warning("Recovery Services network posture unavailable: %s", exc) self._private_endpoint_posture_cache = records @@ -443,6 +474,7 @@ def get_recovery_vault_security_posture(self) -> Optional[List[Dict[str, Any]]]: return self._recovery_vaults_cache try: from azure.mgmt.recoveryservices import RecoveryServicesClient + result: List[Dict[str, Any]] = [] for vault in RecoveryServicesClient(self.credential, self.subscription_id).vaults.list_by_subscription_id(): props = getattr(vault, "properties", None) @@ -451,13 +483,20 @@ def get_recovery_vault_security_posture(self) -> Optional[List[Dict[str, Any]]]: immutable = getattr(security, "immutability_settings", None) monitoring = getattr(props, "monitoring_settings", None) monitor_alerts = getattr(monitoring, "azure_monitor_alert_settings", None) - result.append({"id": vault.id, "name": vault.name, - "soft_delete_state": getattr(soft, "soft_delete_state", None), - "soft_delete_retention_days": getattr(soft, "soft_delete_retention_period_in_days", None), - "immutability_state": getattr(immutable, "state", None), - "multi_user_authorization": getattr(security, "multi_user_authorization", None), - "resource_guard_operations": getattr(props, "resource_guard_operation_requests", None), - "monitoring_alerts_for_job_failures": getattr(monitor_alerts, "alerts_for_all_job_failures", None)}) + result.append( + { + "id": vault.id, + "name": vault.name, + "soft_delete_state": getattr(soft, "soft_delete_state", None), + "soft_delete_retention_days": getattr(soft, "soft_delete_retention_period_in_days", None), + "immutability_state": getattr(immutable, "state", None), + "multi_user_authorization": getattr(security, "multi_user_authorization", None), + "resource_guard_operations": getattr(props, "resource_guard_operation_requests", None), + "monitoring_alerts_for_job_failures": getattr( + monitor_alerts, "alerts_for_all_job_failures", None + ), + } + ) self._recovery_vaults_cache = result except Exception as exc: logger.error("get_recovery_vault_security_posture failed: %s", exc) diff --git a/scanner/rules/_enterprise_resilience_common.py b/scanner/rules/_enterprise_resilience_common.py index efd0615..183cf2a 100644 --- a/scanner/rules/_enterprise_resilience_common.py +++ b/scanner/rules/_enterprise_resilience_common.py @@ -13,11 +13,20 @@ def _value(module: Any, name: str) -> Any: def finding(item: Mapping[str, Any], module: Any, resource_type: str, metadata: Mapping[str, Any]) -> Dict[str, Any]: - return {"rule_id": _value(module, "RULE_ID"), "rule_name": _value(module, "RULE_NAME"), - "severity": _value(module, "SEVERITY"), "category": _value(module, "CATEGORY"), - "resource_id": item["id"], "resource_name": item["name"], "resource_type": resource_type, - "description": _value(module, "DESCRIPTION"), "remediation": _value(module, "REMEDIATION"), - "playbook": _value(module, "PLAYBOOK"), "frameworks": dict(_value(module, "FRAMEWORKS")), "metadata": dict(metadata)} + return { + "rule_id": _value(module, "RULE_ID"), + "rule_name": _value(module, "RULE_NAME"), + "severity": _value(module, "SEVERITY"), + "category": _value(module, "CATEGORY"), + "resource_id": item["id"], + "resource_name": item["name"], + "resource_type": resource_type, + "description": _value(module, "DESCRIPTION"), + "remediation": _value(module, "REMEDIATION"), + "playbook": _value(module, "PLAYBOOK"), + "frameworks": dict(_value(module, "FRAMEWORKS")), + "metadata": dict(metadata), + } def scan_functions(client: Any, module: Any, field: str, unsafe: Any) -> List[Dict[str, Any]]: @@ -35,9 +44,14 @@ def scan_functions(client: Any, module: Any, field: str, unsafe: Any) -> List[Di return results -RESOURCE_TYPES = {"storage": "Microsoft.Storage/storageAccounts", "sql": "Microsoft.Sql/servers", - "postgresql": "Microsoft.DBforPostgreSQL/flexibleServers", "web": "Microsoft.Web/sites", - "recovery": "Microsoft.RecoveryServices/vaults", "connection": "Microsoft.Network/privateEndpoints"} +RESOURCE_TYPES = { + "storage": "Microsoft.Storage/storageAccounts", + "sql": "Microsoft.Sql/servers", + "postgresql": "Microsoft.DBforPostgreSQL/flexibleServers", + "web": "Microsoft.Web/sites", + "recovery": "Microsoft.RecoveryServices/vaults", + "connection": "Microsoft.Network/privateEndpoints", +} def scan_private(client: Any, module: Any, service: str) -> List[Dict[str, Any]]: @@ -58,8 +72,17 @@ def scan_private(client: Any, module: Any, service: str) -> List[Dict[str, Any]] required = set(item.get("required_groups") or []) approved = set(item.get("approved_groups") or []) if public == "enabled": - results.append(finding(item, module, RESOURCE_TYPES[service], - {"public_network_access": item.get("public_network_access"), "missing_private_link_groups": sorted(required - approved)})) + results.append( + finding( + item, + module, + RESOURCE_TYPES[service], + { + "public_network_access": item.get("public_network_access"), + "missing_private_link_groups": sorted(required - approved), + }, + ) + ) return results @@ -73,6 +96,9 @@ def scan_backup(client: Any, module: Any, unsafe: Any, metadata_fields: List[str if verdict is None: logger.warning("%s: required state unknown for %s", _value(module, "RULE_ID"), vault.get("name")) elif verdict: - results.append(finding(vault, module, "Microsoft.RecoveryServices/vaults", - {key: vault.get(key) for key in metadata_fields})) + results.append( + finding( + vault, module, "Microsoft.RecoveryServices/vaults", {key: vault.get(key) for key in metadata_fields} + ) + ) return results diff --git a/scanner/rules/az_bak_001.py b/scanner/rules/az_bak_001.py index e5bd107..4226be0 100644 --- a/scanner/rules/az_bak_001.py +++ b/scanner/rules/az_bak_001.py @@ -1,5 +1,22 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup -RULE_ID="AZ-BAK-001"; RULE_NAME="Backup Soft Delete Disabled or Below 35 Days"; SEVERITY="CRITICAL"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-001","NIST":"PR.IP-4","ISO27001":"A.12.3.1","SOC2":"A1.2"}; DESCRIPTION="The Recovery Services vault lacks the approved soft-delete recovery window."; REMEDIATION="Enable enhanced soft delete and set retention to at least 35 days after reviewing cost and retention requirements."; PLAYBOOK="playbooks/cli/fix_az_bak_001.sh" + +RULE_ID = "AZ-BAK-001" +RULE_NAME = "Backup Soft Delete Disabled or Below 35 Days" +SEVERITY = "CRITICAL" +CATEGORY = "Backup" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-BAK-001", "NIST": "PR.IP-4", "ISO27001": "A.12.3.1", "SOC2": "A1.2"} +DESCRIPTION = "The Recovery Services vault lacks the approved soft-delete recovery window." +REMEDIATION = ( + "Enable enhanced soft delete and set retention to at least 35 days after reviewing cost and retention requirements." +) +PLAYBOOK = "playbooks/cli/fix_az_bak_001.sh" + + def _unsafe(v): - s=v.get("soft_delete_state"); d=v.get("soft_delete_retention_days"); return None if s is None or d is None else str(s).lower() not in {"enabled","alwayson"} or d < 35 -def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["soft_delete_state","soft_delete_retention_days"]) + s = v.get("soft_delete_state") + d = v.get("soft_delete_retention_days") + return None if s is None or d is None else str(s).lower() not in {"enabled", "alwayson"} or d < 35 + + +def scan(azure_client, subscription_id): + return scan_backup(azure_client, globals(), _unsafe, ["soft_delete_state", "soft_delete_retention_days"]) diff --git a/scanner/rules/az_bak_002.py b/scanner/rules/az_bak_002.py index 7ec7d26..d23ba98 100644 --- a/scanner/rules/az_bak_002.py +++ b/scanner/rules/az_bak_002.py @@ -1,5 +1,19 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup -RULE_ID="AZ-BAK-002"; RULE_NAME="Backup Vault Immutability Disabled"; SEVERITY="HIGH"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-002","NIST":"PR.IP-4","ISO27001":"A.12.3.1","SOC2":"A1.2"}; DESCRIPTION="Vault immutability is disabled, allowing destructive changes to protected recovery points."; REMEDIATION="Enable immutability after reviewing protected items and policies; do not lock it automatically."; PLAYBOOK="playbooks/cli/fix_az_bak_002.sh" + +RULE_ID = "AZ-BAK-002" +RULE_NAME = "Backup Vault Immutability Disabled" +SEVERITY = "HIGH" +CATEGORY = "Backup" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-BAK-002", "NIST": "PR.IP-4", "ISO27001": "A.12.3.1", "SOC2": "A1.2"} +DESCRIPTION = "Vault immutability is disabled, allowing destructive changes to protected recovery points." +REMEDIATION = "Enable immutability after reviewing protected items and policies; do not lock it automatically." +PLAYBOOK = "playbooks/cli/fix_az_bak_002.sh" + + def _unsafe(v): - s=v.get("immutability_state"); return None if s is None else str(s).lower() in {"disabled","unlocked"} -def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["immutability_state"]) + s = v.get("immutability_state") + return None if s is None else str(s).lower() in {"disabled", "unlocked"} + + +def scan(azure_client, subscription_id): + return scan_backup(azure_client, globals(), _unsafe, ["immutability_state"]) diff --git a/scanner/rules/az_bak_004.py b/scanner/rules/az_bak_004.py index 1e40e38..c90d587 100644 --- a/scanner/rules/az_bak_004.py +++ b/scanner/rules/az_bak_004.py @@ -1,5 +1,19 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup -RULE_ID="AZ-BAK-004"; RULE_NAME="Backup Multiuser Authorization Missing"; SEVERITY="HIGH"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-004","NIST":"PR.AC-4","ISO27001":"A.9.2.3","SOC2":"CC6.1"}; DESCRIPTION="The vault does not enable Resource Guard multiuser authorization."; REMEDIATION="Configure a separately owned Resource Guard and protect critical operations with MUA."; PLAYBOOK="playbooks/cli/fix_az_bak_004.sh" + +RULE_ID = "AZ-BAK-004" +RULE_NAME = "Backup Multiuser Authorization Missing" +SEVERITY = "HIGH" +CATEGORY = "Backup" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-BAK-004", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.1"} +DESCRIPTION = "The vault does not enable Resource Guard multiuser authorization." +REMEDIATION = "Configure a separately owned Resource Guard and protect critical operations with MUA." +PLAYBOOK = "playbooks/cli/fix_az_bak_004.sh" + + def _unsafe(v): - s=v.get("multi_user_authorization"); return None if s is None else str(s).lower() != "enabled" -def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["multi_user_authorization","resource_guard_operations"]) + s = v.get("multi_user_authorization") + return None if s is None else str(s).lower() != "enabled" + + +def scan(azure_client, subscription_id): + return scan_backup(azure_client, globals(), _unsafe, ["multi_user_authorization", "resource_guard_operations"]) diff --git a/scanner/rules/az_bak_006.py b/scanner/rules/az_bak_006.py index 2dbb8fe..e4969ee 100644 --- a/scanner/rules/az_bak_006.py +++ b/scanner/rules/az_bak_006.py @@ -1,5 +1,19 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_backup -RULE_ID="AZ-BAK-006"; RULE_NAME="Backup Security Monitoring Disabled"; SEVERITY="MEDIUM"; CATEGORY="Backup"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-BAK-006","NIST":"DE.CM-1","ISO27001":"A.12.4.1","SOC2":"CC7.2"}; DESCRIPTION="The Recovery Services vault does not enable built-in monitoring for backup job failures."; REMEDIATION="Enable Azure Monitor alerts and route backup diagnostics to the approved monitoring destination."; PLAYBOOK="playbooks/cli/fix_az_bak_006.sh" + +RULE_ID = "AZ-BAK-006" +RULE_NAME = "Backup Security Monitoring Disabled" +SEVERITY = "MEDIUM" +CATEGORY = "Backup" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-BAK-006", "NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = "The Recovery Services vault does not enable built-in monitoring for backup job failures." +REMEDIATION = "Enable Azure Monitor alerts and route backup diagnostics to the approved monitoring destination." +PLAYBOOK = "playbooks/cli/fix_az_bak_006.sh" + + def _unsafe(v): - a=v.get("monitoring_alerts_for_job_failures"); return None if a is None else str(a).lower() != "enabled" -def scan(azure_client, subscription_id): return scan_backup(azure_client, globals(), _unsafe, ["monitoring_alerts_for_job_failures"]) + a = v.get("monitoring_alerts_for_job_failures") + return None if a is None else str(a).lower() != "enabled" + + +def scan(azure_client, subscription_id): + return scan_backup(azure_client, globals(), _unsafe, ["monitoring_alerts_for_job_failures"]) diff --git a/scanner/rules/az_func_001.py b/scanner/rules/az_func_001.py index c3dbd98..1734a1e 100644 --- a/scanner/rules/az_func_001.py +++ b/scanner/rules/az_func_001.py @@ -1,5 +1,16 @@ """AZ-FUNC-001: Function App permits HTTP.""" + from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions -RULE_ID="AZ-FUNC-001"; RULE_NAME="Function App HTTPS Only Disabled"; SEVERITY="HIGH"; CATEGORY="Serverless" -FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-001"}; DESCRIPTION="The Function App accepts unencrypted HTTP traffic."; REMEDIATION="Enable HTTPS Only after validating clients."; PLAYBOOK="playbooks/cli/fix_az_func_001.sh" -def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "https_only", lambda v: v is False) + +RULE_ID = "AZ-FUNC-001" +RULE_NAME = "Function App HTTPS Only Disabled" +SEVERITY = "HIGH" +CATEGORY = "Serverless" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-001"} +DESCRIPTION = "The Function App accepts unencrypted HTTP traffic." +REMEDIATION = "Enable HTTPS Only after validating clients." +PLAYBOOK = "playbooks/cli/fix_az_func_001.sh" + + +def scan(azure_client, subscription_id): + return scan_functions(azure_client, globals(), "https_only", lambda v: v is False) diff --git a/scanner/rules/az_func_002.py b/scanner/rules/az_func_002.py index c0b7dd5..36af3d9 100644 --- a/scanner/rules/az_func_002.py +++ b/scanner/rules/az_func_002.py @@ -1,5 +1,16 @@ """AZ-FUNC-002: Function App allows legacy TLS.""" + from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions -RULE_ID="AZ-FUNC-002"; RULE_NAME="Function App Minimum TLS Below 1.2"; SEVERITY="HIGH"; CATEGORY="Serverless" -FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-002"}; DESCRIPTION="The Function App permits TLS older than 1.2."; REMEDIATION="Set the minimum inbound TLS version to 1.2 or newer."; PLAYBOOK="playbooks/cli/fix_az_func_002.sh" -def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "min_tls_version", lambda v: str(v) in {"1.0","1.1"}) + +RULE_ID = "AZ-FUNC-002" +RULE_NAME = "Function App Minimum TLS Below 1.2" +SEVERITY = "HIGH" +CATEGORY = "Serverless" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-002"} +DESCRIPTION = "The Function App permits TLS older than 1.2." +REMEDIATION = "Set the minimum inbound TLS version to 1.2 or newer." +PLAYBOOK = "playbooks/cli/fix_az_func_002.sh" + + +def scan(azure_client, subscription_id): + return scan_functions(azure_client, globals(), "min_tls_version", lambda v: str(v) in {"1.0", "1.1"}) diff --git a/scanner/rules/az_func_003.py b/scanner/rules/az_func_003.py index 8564d0c..4ed3230 100644 --- a/scanner/rules/az_func_003.py +++ b/scanner/rules/az_func_003.py @@ -1,5 +1,16 @@ """AZ-FUNC-003: Function App FTP publishing is enabled.""" + from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions -RULE_ID="AZ-FUNC-003"; RULE_NAME="Function App FTP Publishing Enabled"; SEVERITY="MEDIUM"; CATEGORY="Serverless" -FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-003"}; DESCRIPTION="The Function App exposes an FTP or FTPS deployment channel."; REMEDIATION="Disable FTP/FTPS publishing after migrating deployment automation."; PLAYBOOK="playbooks/cli/fix_az_func_003.sh" -def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "ftps_state", lambda v: str(v).lower() != "disabled") + +RULE_ID = "AZ-FUNC-003" +RULE_NAME = "Function App FTP Publishing Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Serverless" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-003"} +DESCRIPTION = "The Function App exposes an FTP or FTPS deployment channel." +REMEDIATION = "Disable FTP/FTPS publishing after migrating deployment automation." +PLAYBOOK = "playbooks/cli/fix_az_func_003.sh" + + +def scan(azure_client, subscription_id): + return scan_functions(azure_client, globals(), "ftps_state", lambda v: str(v).lower() != "disabled") diff --git a/scanner/rules/az_func_004.py b/scanner/rules/az_func_004.py index 5cc9965..3270d81 100644 --- a/scanner/rules/az_func_004.py +++ b/scanner/rules/az_func_004.py @@ -1,5 +1,16 @@ """AZ-FUNC-004: Function App remote debugging is enabled.""" + from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions -RULE_ID="AZ-FUNC-004"; RULE_NAME="Function App Remote Debugging Enabled"; SEVERITY="HIGH"; CATEGORY="Serverless" -FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-004"}; DESCRIPTION="Remote debugging expands the Function App management attack surface."; REMEDIATION="Disable remote debugging outside a controlled troubleshooting window."; PLAYBOOK="playbooks/cli/fix_az_func_004.sh" -def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "remote_debugging_enabled", lambda v: v is True) + +RULE_ID = "AZ-FUNC-004" +RULE_NAME = "Function App Remote Debugging Enabled" +SEVERITY = "HIGH" +CATEGORY = "Serverless" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-004"} +DESCRIPTION = "Remote debugging expands the Function App management attack surface." +REMEDIATION = "Disable remote debugging outside a controlled troubleshooting window." +PLAYBOOK = "playbooks/cli/fix_az_func_004.sh" + + +def scan(azure_client, subscription_id): + return scan_functions(azure_client, globals(), "remote_debugging_enabled", lambda v: v is True) diff --git a/scanner/rules/az_func_005.py b/scanner/rules/az_func_005.py index 1773912..e729ae0 100644 --- a/scanner/rules/az_func_005.py +++ b/scanner/rules/az_func_005.py @@ -1,5 +1,16 @@ """AZ-FUNC-005: Function App has no managed identity.""" + from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_functions -RULE_ID="AZ-FUNC-005"; RULE_NAME="Function App Managed Identity Missing"; SEVERITY="MEDIUM"; CATEGORY="Serverless" -FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-FUNC-005"}; DESCRIPTION="The Function App has no Azure managed identity for secretless resource access."; REMEDIATION="Assign a managed identity and migrate supported credentials to identity-based access."; PLAYBOOK="playbooks/cli/fix_az_func_005.sh" -def scan(azure_client, subscription_id): return scan_functions(azure_client, globals(), "identity_type", lambda v: str(v).lower() == "none") + +RULE_ID = "AZ-FUNC-005" +RULE_NAME = "Function App Managed Identity Missing" +SEVERITY = "MEDIUM" +CATEGORY = "Serverless" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-005"} +DESCRIPTION = "The Function App has no Azure managed identity for secretless resource access." +REMEDIATION = "Assign a managed identity and migrate supported credentials to identity-based access." +PLAYBOOK = "playbooks/cli/fix_az_func_005.sh" + + +def scan(azure_client, subscription_id): + return scan_functions(azure_client, globals(), "identity_type", lambda v: str(v).lower() == "none") diff --git a/scanner/rules/az_pe_001.py b/scanner/rules/az_pe_001.py index 4194032..cbad9c9 100644 --- a/scanner/rules/az_pe_001.py +++ b/scanner/rules/az_pe_001.py @@ -1,3 +1,17 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-001"; RULE_NAME="Storage Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-001"}; DESCRIPTION="A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint."; REMEDIATION="Validate private DNS and connectivity, add the required endpoint, then restrict public access."; PLAYBOOK="playbooks/cli/fix_az_pe_001.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "storage") + +RULE_ID = "AZ-PE-001" +RULE_NAME = "Storage Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-001"} +DESCRIPTION = ( + "A Storage Account remains publicly reachable; an approved private endpoint alone " + "does not disable its public endpoint." +) +REMEDIATION = "Validate private DNS and connectivity, add the required endpoint, then restrict public access." +PLAYBOOK = "playbooks/cli/fix_az_pe_001.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "storage") diff --git a/scanner/rules/az_pe_002.py b/scanner/rules/az_pe_002.py index ea9f27e..6c02c57 100644 --- a/scanner/rules/az_pe_002.py +++ b/scanner/rules/az_pe_002.py @@ -1,3 +1,16 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-002"; RULE_NAME="Azure SQL Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-002"}; DESCRIPTION="An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists."; REMEDIATION="Validate private DNS and clients before disabling public access."; PLAYBOOK="playbooks/cli/fix_az_pe_002.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "sql") + +RULE_ID = "AZ-PE-002" +RULE_NAME = "Azure SQL Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-002"} +DESCRIPTION = ( + "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists." +) +REMEDIATION = "Validate private DNS and clients before disabling public access." +PLAYBOOK = "playbooks/cli/fix_az_pe_002.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "sql") diff --git a/scanner/rules/az_pe_003.py b/scanner/rules/az_pe_003.py index e999eca..4d1dd24 100644 --- a/scanner/rules/az_pe_003.py +++ b/scanner/rules/az_pe_003.py @@ -1,3 +1,14 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-003"; RULE_NAME="PostgreSQL Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-003"}; DESCRIPTION="A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only."; REMEDIATION="Validate supported networking mode and private DNS before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_003.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "postgresql") + +RULE_ID = "AZ-PE-003" +RULE_NAME = "PostgreSQL Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-003"} +DESCRIPTION = "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only." +REMEDIATION = "Validate supported networking mode and private DNS before restricting public access." +PLAYBOOK = "playbooks/cli/fix_az_pe_003.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "postgresql") diff --git a/scanner/rules/az_pe_004.py b/scanner/rules/az_pe_004.py index 4be1421..00a61ec 100644 --- a/scanner/rules/az_pe_004.py +++ b/scanner/rules/az_pe_004.py @@ -1,3 +1,14 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-004"; RULE_NAME="Web or Function App Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-004"}; DESCRIPTION="An App Service workload remains publicly reachable without a default-deny access policy."; REMEDIATION="Validate the hosting tier, private DNS, and clients before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_004.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "web") + +RULE_ID = "AZ-PE-004" +RULE_NAME = "Web or Function App Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-004"} +DESCRIPTION = "An App Service workload remains publicly reachable without a default-deny access policy." +REMEDIATION = "Validate the hosting tier, private DNS, and clients before restricting public access." +PLAYBOOK = "playbooks/cli/fix_az_pe_004.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "web") diff --git a/scanner/rules/az_pe_005.py b/scanner/rules/az_pe_005.py index b95fa8b..5acc49d 100644 --- a/scanner/rules/az_pe_005.py +++ b/scanner/rules/az_pe_005.py @@ -1,3 +1,14 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-005"; RULE_NAME="Recovery Vault Public Network Access Enabled"; SEVERITY="HIGH"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-005"}; DESCRIPTION="A Recovery Services vault permits public access, even if a private endpoint also exists."; REMEDIATION="Validate Backup private DNS and agents before restricting public access."; PLAYBOOK="playbooks/cli/fix_az_pe_005.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "recovery") + +RULE_ID = "AZ-PE-005" +RULE_NAME = "Recovery Vault Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-005"} +DESCRIPTION = "A Recovery Services vault permits public access, even if a private endpoint also exists." +REMEDIATION = "Validate Backup private DNS and agents before restricting public access." +PLAYBOOK = "playbooks/cli/fix_az_pe_005.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "recovery") diff --git a/scanner/rules/az_pe_006.py b/scanner/rules/az_pe_006.py index a35057c..6d88618 100644 --- a/scanner/rules/az_pe_006.py +++ b/scanner/rules/az_pe_006.py @@ -1,3 +1,18 @@ from scanner.rules._enterprise_resilience_common import FRAMEWORKS, scan_private -RULE_ID="AZ-PE-006"; RULE_NAME="Private Endpoint Connection Not Approved"; SEVERITY="MEDIUM"; CATEGORY="Network"; FRAMEWORKS={**FRAMEWORKS,"CIS":"TBD-PE-006"}; DESCRIPTION="A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path."; REMEDIATION="Review ownership, approval state, target resource, and private DNS before approving or replacing the connection."; PLAYBOOK="playbooks/cli/fix_az_pe_006.sh" -def scan(azure_client, subscription_id): return scan_private(azure_client, globals(), "connection") + +RULE_ID = "AZ-PE-006" +RULE_NAME = "Private Endpoint Connection Not Approved" +SEVERITY = "MEDIUM" +CATEGORY = "Network" +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-PE-006"} +DESCRIPTION = ( + "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path." +) +REMEDIATION = ( + "Review ownership, approval state, target resource, and private DNS before approving or replacing the connection." +) +PLAYBOOK = "playbooks/cli/fix_az_pe_006.sh" + + +def scan(azure_client, subscription_id): + return scan_private(azure_client, globals(), "connection") diff --git a/tests/test_rules_enterprise_resilience.py b/tests/test_rules_enterprise_resilience.py index 380d7ad..c190da0 100644 --- a/tests/test_rules_enterprise_resilience.py +++ b/tests/test_rules_enterprise_resilience.py @@ -5,9 +5,21 @@ import pytest from scanner.rules import ( - az_bak_001, az_bak_002, az_bak_004, az_bak_006, - az_func_001, az_func_002, az_func_003, az_func_004, az_func_005, - az_pe_001, az_pe_002, az_pe_003, az_pe_004, az_pe_005, az_pe_006, + az_bak_001, + az_bak_002, + az_bak_004, + az_bak_006, + az_func_001, + az_func_002, + az_func_003, + az_func_004, + az_func_005, + az_pe_001, + az_pe_002, + az_pe_003, + az_pe_004, + az_pe_005, + az_pe_006, ) @@ -17,28 +29,41 @@ def function_app(**changes): - item = {"id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Web/sites/fn", "name": "fn", - "https_only": True, "min_tls_version": "1.2", "ftps_state": "Disabled", - "remote_debugging_enabled": False, "identity_type": "SystemAssigned"} + item = { + "id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Web/sites/fn", + "name": "fn", + "https_only": True, + "min_tls_version": "1.2", + "ftps_state": "Disabled", + "remote_debugging_enabled": False, + "identity_type": "SystemAssigned", + } item.update(changes) return item @pytest.mark.parametrize("rule", FUNCTION_RULES) def test_function_compliant_and_inventory_failure(rule): - client = MagicMock(); client.get_function_app_security_posture.return_value = [function_app()] + client = MagicMock() + client.get_function_app_security_posture.return_value = [function_app()] assert rule.scan(client, "s") == [] client.get_function_app_security_posture.return_value = None assert rule.scan(client, "s") == [] -@pytest.mark.parametrize(("rule", "change"), [ - (az_func_001, {"https_only": False}), (az_func_002, {"min_tls_version": "1.0"}), - (az_func_003, {"ftps_state": "FtpsOnly"}), (az_func_004, {"remote_debugging_enabled": True}), - (az_func_005, {"identity_type": "None"}), -]) +@pytest.mark.parametrize( + ("rule", "change"), + [ + (az_func_001, {"https_only": False}), + (az_func_002, {"min_tls_version": "1.0"}), + (az_func_003, {"ftps_state": "FtpsOnly"}), + (az_func_004, {"remote_debugging_enabled": True}), + (az_func_005, {"identity_type": "None"}), + ], +) def test_function_noncompliance(rule, change): - client = MagicMock(); client.get_function_app_security_posture.return_value = [function_app(**change)] + client = MagicMock() + client.get_function_app_security_posture.return_value = [function_app(**change)] finding = rule.scan(client, "s")[0] assert finding["rule_id"] == rule.RULE_ID assert finding["resource_type"] == "Microsoft.Web/sites" @@ -47,17 +72,31 @@ def test_function_noncompliance(rule, change): @pytest.mark.parametrize("rule", FUNCTION_RULES) def test_function_unknown_property_is_not_false_positive(rule): - client = MagicMock(); app = function_app(); fields = { - az_func_001: "https_only", az_func_002: "min_tls_version", az_func_003: "ftps_state", - az_func_004: "remote_debugging_enabled", az_func_005: "identity_type"} - app[fields[rule]] = None; client.get_function_app_security_posture.return_value = [app] + client = MagicMock() + app = function_app() + fields = { + az_func_001: "https_only", + az_func_002: "min_tls_version", + az_func_003: "ftps_state", + az_func_004: "remote_debugging_enabled", + az_func_005: "identity_type", + } + app[fields[rule]] = None + client.get_function_app_security_posture.return_value = [app] assert rule.scan(client, "s") == [] @pytest.mark.parametrize(("rule", "service"), zip(PRIVATE_RULES, ["storage", "sql", "postgresql", "web", "recovery"])) def test_private_endpoint_rules(rule, service): - client = MagicMock(); base = {"id": f"/subscriptions/s/{service}/one", "name": "one", "service": service, - "public_network_access": "Enabled", "required_groups": ["target"], "approved_groups": []} + client = MagicMock() + base = { + "id": f"/subscriptions/s/{service}/one", + "name": "one", + "service": service, + "public_network_access": "Enabled", + "required_groups": ["target"], + "approved_groups": [], + } client.get_private_endpoint_posture.return_value = [base] assert len(rule.scan(client, "s")) == 1 client.get_private_endpoint_posture.return_value = [{**base, "approved_groups": ["target"]}] @@ -69,49 +108,71 @@ def test_private_endpoint_rules(rule, service): @pytest.mark.parametrize("rule", PRIVATE_RULES + [az_pe_006]) def test_private_inventory_failure_is_indeterminate(rule): - client = MagicMock(); client.get_private_endpoint_posture.return_value = None + client = MagicMock() + client.get_private_endpoint_posture.return_value = None assert rule.scan(client, "s") == [] def test_unhealthy_private_endpoint_connection_is_reported(): - client = MagicMock(); client.get_private_endpoint_posture.return_value = [{"id": "/pe/one", "name": "one", - "service": "connection", "status": "Rejected"}] + client = MagicMock() + client.get_private_endpoint_posture.return_value = [ + {"id": "/pe/one", "name": "one", "service": "connection", "status": "Rejected"} + ] finding = az_pe_006.scan(client, "s")[0] assert finding["metadata"]["connection_status"] == "Rejected" def vault(**changes): - item = {"id": "/subscriptions/s/providers/Microsoft.RecoveryServices/vaults/v", "name": "v", - "soft_delete_state": "AlwaysON", "soft_delete_retention_days": 35, "immutability_state": "Locked", - "multi_user_authorization": "Enabled", "resource_guard_operations": ["Microsoft.RecoveryServices/vaults/backupconfig/write"], - "monitoring_alerts_for_job_failures": "Enabled"} - item.update(changes); return item + item = { + "id": "/subscriptions/s/providers/Microsoft.RecoveryServices/vaults/v", + "name": "v", + "soft_delete_state": "AlwaysON", + "soft_delete_retention_days": 35, + "immutability_state": "Locked", + "multi_user_authorization": "Enabled", + "resource_guard_operations": ["Microsoft.RecoveryServices/vaults/backupconfig/write"], + "monitoring_alerts_for_job_failures": "Enabled", + } + item.update(changes) + return item @pytest.mark.parametrize("rule", BACKUP_RULES) def test_backup_compliant_and_inventory_failure(rule): - client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault()] + client = MagicMock() + client.get_recovery_vault_security_posture.return_value = [vault()] assert rule.scan(client, "s") == [] client.get_recovery_vault_security_posture.return_value = None assert rule.scan(client, "s") == [] -@pytest.mark.parametrize(("rule", "change"), [ - (az_bak_001, {"soft_delete_retention_days": 14}), (az_bak_002, {"immutability_state": "Disabled"}), - (az_bak_004, {"multi_user_authorization": "Disabled", "resource_guard_operations": []}), - (az_bak_006, {"monitoring_alerts_for_job_failures": "Disabled"}), -]) +@pytest.mark.parametrize( + ("rule", "change"), + [ + (az_bak_001, {"soft_delete_retention_days": 14}), + (az_bak_002, {"immutability_state": "Disabled"}), + (az_bak_004, {"multi_user_authorization": "Disabled", "resource_guard_operations": []}), + (az_bak_006, {"monitoring_alerts_for_job_failures": "Disabled"}), + ], +) def test_backup_noncompliance(rule, change): - client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault(**change)] + client = MagicMock() + client.get_recovery_vault_security_posture.return_value = [vault(**change)] finding = rule.scan(client, "s")[0] assert finding["rule_id"] == rule.RULE_ID assert finding["resource_type"] == "Microsoft.RecoveryServices/vaults" -@pytest.mark.parametrize(("rule", "change"), [ - (az_bak_001, {"soft_delete_state": None}), (az_bak_002, {"immutability_state": None}), - (az_bak_004, {"multi_user_authorization": None}), (az_bak_006, {"monitoring_alerts_for_job_failures": None}), -]) +@pytest.mark.parametrize( + ("rule", "change"), + [ + (az_bak_001, {"soft_delete_state": None}), + (az_bak_002, {"immutability_state": None}), + (az_bak_004, {"multi_user_authorization": None}), + (az_bak_006, {"monitoring_alerts_for_job_failures": None}), + ], +) def test_backup_unknown_is_not_false_positive(rule, change): - client = MagicMock(); client.get_recovery_vault_security_posture.return_value = [vault(**change)] + client = MagicMock() + client.get_recovery_vault_security_posture.return_value = [vault(**change)] assert rule.scan(client, "s") == [] From 647e55218cf0849bb0cb8c849971f141d16768a2 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 18 Jul 2026 14:17:00 +0100 Subject: [PATCH 3/5] fix: restore dev content dropped by a stash mishap during the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier git stash/pop during the origin/dev merge silently reverted several of dev's non-conflicting changes back toward this branch's pre-merge state, even though the merge itself reported success. This restores everything that was lost: - scanner/azure_client.py: enum_str() helper, get_applications(), get_managed_identity_service_principals(), get_subscription_role_assignments(), their cache fields, and the hardened parse_resource_id(). - compliance/frameworks/cis_azure_benchmark.json: dev's AZ-IDN-010..015 placeholder entries. - scanner/rules/az_idn_006.py, az_db_002.py, az_net_003.py: dev's refactors to use the new cached accessors and enum_str() instead of ad hoc Graph calls / naive str(). - tests/helpers/mock_azure.py, tests/test_rules_identity.py, tests/test_engine_integration.py, tests/test_rules_database.py, tests/test_rules_network.py: matching test updates. - .github/workflows/ci.yml: the missing SAST (Semgrep) job and the correct --cov-fail-under=80 gate. - Various docs, README, frontend files, and website/content.js that had reverted to pre-merge wording/values. Also fixes two bugs an independent review surfaced in this PR's own new code: get_private_endpoint_posture() and get_function_app_security_posture() each wrapped multiple independent Azure API calls in one try/except, so one resource type failing (e.g. SQL RP not registered) silently discarded every other resource type's already-collected results. Each fetch is now isolated. Extends compliance/frameworks/{nist_csf,iso27001,soc2}.json and docs/rules-reference.md, architecture.md, adding-a-rule.md, CONTRIBUTING.md, and website/content.js with this PR's 15 new AZ-BAK/AZ-FUNC/AZ-PE rules, which previously only had CIS mappings — without NIST/ISO/SOC2 entries their findings were invisible to those frameworks' compliance rollups. Signed-off-by: Tanvir Farhad --- .github/SECURITY.md | 9 +- .github/workflows/ci.yml | 55 +++++- CHANGELOG.md | 1 + CONTRIBUTING.md | 15 +- Dockerfile | 6 + README.md | 17 +- .../frameworks/cis_azure_benchmark.json | 30 +++ compliance/frameworks/iso27001.json | 75 ++++++++ compliance/frameworks/nist_csf.json | 75 ++++++++ compliance/frameworks/soc2.json | 75 ++++++++ docs/adding-a-rule.md | 6 + docs/architecture.md | 18 +- docs/ci-pipeline.md | 31 +++- docs/learn/index.html | 4 +- docs/rules-reference.md | 24 ++- frontend/package.json | 2 +- frontend/src/components/ai/ChatPanel.jsx | 2 +- frontend/src/components/layout/Header.jsx | 2 +- frontend/src/pages/AILayer.jsx | 6 +- frontend/src/pages/DetailedScan.jsx | 4 +- scanner/azure_client.py | 171 ++++++++++++++---- scanner/rules/az_db_002.py | 29 ++- scanner/rules/az_idn_006.py | 22 +-- scanner/rules/az_net_003.py | 22 ++- tests/helpers/mock_azure.py | 38 +++- tests/test_engine_integration.py | 4 +- tests/test_rules_database.py | 90 +++++++++ tests/test_rules_identity.py | 6 +- tests/test_rules_network.py | 121 ++++++++++++- website/content.js | 40 +++- 30 files changed, 889 insertions(+), 111 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index e8e98d2..315eebd 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -26,9 +26,10 @@ The more detail you provide, the faster we can respond. | Version | Supported | |---------|-----------| -| 0.1.x | Yes | +| 0.3.x | Yes | +| 0.1.x | No | -Older versions are not patched. If you are running a version below 0.1.x, upgrade to the latest release before filing a report. +Older versions are not patched unless a GitHub Security Advisory explicitly says otherwise. Upgrade to the latest release before filing a report. --- @@ -71,7 +72,7 @@ We ask that you do not publicly disclose the vulnerability until step 6 is compl We value responsible disclosure. Researchers who report valid vulnerabilities will be: - Acknowledged by name (or pseudonym if preferred) in the release notes for the fix -- Listed in a `SECURITY_ACKNOWLEDGEMENTS.md` file we maintain in this repository +- Listed in [`SECURITY_ACKNOWLEDGEMENTS.md`](../SECURITY_ACKNOWLEDGEMENTS.md) We do not currently offer a bug bounty programme, but we are grateful for every report. @@ -79,4 +80,4 @@ We do not currently offer a bug bounty programme, but we are grateful for every ## Contact -**Email: vishnu.ajith@owasp.org** \ No newline at end of file +**Email: vishnu.ajith@owasp.org** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ddbb30..8d47b1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,6 +397,54 @@ jobs: - name: Run bandit run: bandit -r api/ scanner/ ai/ -ll + # ── SAST (Semgrep) ────────────────────────────────────────────────────────── + sast-semgrep: + name: SAST (Semgrep) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + cache: pip + + - name: Install semgrep + run: pip install semgrep + + - name: Run semgrep + id: semgrep + continue-on-error: true + run: | + semgrep scan \ + --config p/security-audit \ + --config p/owasp-top-ten \ + --config p/python \ + --config p/javascript \ + --exclude venv \ + --exclude frontend/node_modules \ + --exclude frontend/dist \ + --metrics=off \ + --sarif --output semgrep.sarif \ + --error \ + . + + - name: Upload SARIF to GitHub code scanning + if: always() + uses: github/codeql-action/upload-sarif@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3 + with: + sarif_file: semgrep.sarif + category: semgrep + + - name: Fail job if Semgrep found issues + if: steps.semgrep.outcome == 'failure' + run: exit 1 + # ── SCA / dependency scanning (pip-audit) ───────────────────────────────── sca-pip-audit: name: SCA (pip-audit) @@ -531,7 +579,7 @@ jobs: env: DATABASE_URL: "postgresql://ci:ci@localhost:5432/ci_db" run: | - pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-report=xml --cov-fail-under=25 + pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-report=xml --cov-fail-under=80 # ── Frontend lint + build ──────────────────────────────────────────────── frontend: @@ -602,7 +650,7 @@ jobs: name: CI Summary runs-on: ubuntu-latest needs: - [lint, rule-validation, secret-scan, sast-bandit, sca-pip-audit, sbom, container-scan, backend-tests, frontend, website, enforce-source-branch] + [lint, rule-validation, secret-scan, sast-bandit, sast-semgrep, sca-pip-audit, sbom, container-scan, backend-tests, frontend, website, enforce-source-branch] if: always() steps: - name: Build summary @@ -611,6 +659,7 @@ jobs: RULE_VALIDATION: ${{ needs.rule-validation.result }} SECRET_SCAN: ${{ needs.secret-scan.result }} SAST_BANDIT: ${{ needs.sast-bandit.result }} + SAST_SEMGREP: ${{ needs.sast-semgrep.result }} SCA_PIP_AUDIT: ${{ needs.sca-pip-audit.result }} SBOM: ${{ needs.sbom.result }} CONTAINER_SCAN: ${{ needs.container-scan.result }} @@ -627,6 +676,7 @@ jobs: ("Rule & Compliance Validation", os.environ["RULE_VALIDATION"]), ("Secret Scan (Gitleaks)", os.environ["SECRET_SCAN"]), ("SAST (Bandit)", os.environ["SAST_BANDIT"]), + ("SAST (Semgrep)", os.environ["SAST_SEMGREP"]), ("SCA (pip-audit)", os.environ["SCA_PIP_AUDIT"]), ("SBOM (Syft)", os.environ["SBOM"]), ("Container Scan (Trivy, INFRA 1 pending)", os.environ["CONTAINER_SCAN"]), @@ -679,6 +729,7 @@ jobs: needs.rule-validation.result != 'success' || needs.secret-scan.result != 'success' || needs.sast-bandit.result != 'success' || + needs.sast-semgrep.result != 'success' || needs.sca-pip-audit.result != 'success' || needs.sbom.result != 'success' || needs.backend-tests.result != 'success' || diff --git a/CHANGELOG.md b/CHANGELOG.md index 7992b49..408c9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Semgrep SAST integrated into GitHub Actions CI as an open-source, account-free complement to CodeQL - OpenSSF Best Practices Passing Badge achieved with 100% of applicable Passing-level criteria completed - Official live OpenSSF badge and verified project record added to project documentation - CBOM endpoints with per-asset quantum risk scoring and migration guidance diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c26b8e8..4b1ba3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -203,8 +203,14 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `azure_client.get_sql_server_auditing_policy(resource_group, server_name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Key Vault objects | | `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | +| `azure_client.get_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of role assignments for service principals | | `azure_client.get_conditional_access_policies()` | List of Conditional Access policy dicts from Microsoft Graph | +| `azure_client.get_function_app_security_posture()` | Cached, secret-free Function App posture dicts, or `None` on API failure | +| `azure_client.get_private_endpoint_posture()` | Public-access and approved Private Link state for supported PaaS resources, or `None` on API failure | +| `azure_client.get_recovery_vault_security_posture()` | Cached Recovery Services vault security settings, or `None` on API failure | Most list methods return an empty list on failure. Methods that fetch one resource or one policy return `None` when the result cannot be determined. @@ -218,7 +224,7 @@ pip install -r requirements.txt # Installs Flask, Alembic, Azure SDK clients, requests, psycopg2, PyJWT, and PyYAML for CI workflow validation. # Frontend -# The frontend directory is currently a scaffold. The React dashboard MVP is on the roadmap. +# The React dashboard lives in frontend/ and uses the repository's npm scripts. # Database (Docker) docker run --name openshield-db \ @@ -268,6 +274,12 @@ All contributions must meet these standards before a pull request will be review - Follow Conventional Commits using prefixes such as `feat:`, `fix:`, `docs:`, `chore:`, and `test:`. - Reference the related issue number where applicable. +**Developer Certificate of Origin** + +- By adding `Signed-off-by: Your Name ` to a commit, a contributor certifies the [Developer Certificate of Origin 1.1](https://developercertificate.org/). +- Use `git commit -s` to add the sign-off. +- The project lead must approve and enable DCO enforcement before this becomes a required merge check; until then, sign-off is requested but not represented as enforced. + **Branch naming** - Use `feat/description` for new features. @@ -285,6 +297,7 @@ All contributions must meet these standards before a pull request will be review - Add a CLI remediation playbook for each new scanner rule. - Follow `.github/PULL_REQUEST_TEMPLATE.md`. - Obtain at least one reviewer approval before merge. +- Add regression tests for bug fixes whenever the behavior can be reproduced automatically. Major functionality must include automated tests. ## OpenSSF Best Practices diff --git a/Dockerfile b/Dockerfile index a88354d..9dceebf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,12 @@ RUN pip install --no-cache-dir --upgrade \ COPY . . +RUN groupadd --system openshield && \ + useradd --system --gid openshield --no-create-home openshield && \ + chown -R openshield:openshield /app + +USER openshield + EXPOSE 8000 CMD ["gunicorn", "--workers", "2", "--threads", "2", "--timeout", "120", "--bind", "0.0.0.0:8000", "api.app:app"] diff --git a/README.md b/README.md index e3f43a5..29bd43c 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,10 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Runs 39 Azure security rules across storage, network, identity, database, compute, Key Vault, and post-quantum cryptography | +| **Misconfiguration Scanner** | Runs 72 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, and post-quantum cryptography | | **Compliance Mapper** | Maps findings to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 framework JSON files | | **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, compliance posture, drift, and resource inventory over REST | -| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (36 playbooks) | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (72 playbooks) | | **Security Dashboard** | Full React dashboard deployed on Vercel - live monitoring, findings, compliance, drift, prioritization, and AI-layer views | | **Project Website** | Documentation and reference site at [openshield-website.vercel.app](https://openshield-website.vercel.app) - blog, rules gallery, docs, roadmap, releases, and interactive playground | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | @@ -76,6 +76,15 @@ The project's OpenSSF status is publicly verifiable through the official OpenSSF **[View OpenShield's verified OpenSSF Best Practices record](https://www.bestpractices.dev/projects/13618)** +Project policies and assurance evidence: + +- [Governance](GOVERNANCE.md) and [maintainer responsibilities](MAINTAINERS.md) +- [July 2026–June 2027 roadmap](ROADMAP.md) +- [Support and upgrade policy](SUPPORT.md) +- [Security requirements](docs/security-requirements.md) and [security assurance case](docs/security-assurance-case.md) +- [Release security](docs/release-security.md) and [accessibility/i18n policy](docs/accessibility-and-i18n.md) +- [OpenSSF Silver evidence register](docs/openssf-silver-evidence.md) + --- ## Architecture @@ -84,11 +93,11 @@ The project's OpenSSF status is publicly verifiable through the official OpenSSF flowchart TD A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n39 Python rules"] + C["Scanner Engine\n72 Python rules"] D["Azure Subscription\nScanned via Azure SDK + Graph"] E["Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] F["PostgreSQL Database\nFindings · Scans"] - G["Azure CLI Playbooks\n39 remediation scripts"] + G["Azure CLI Playbooks\n72 remediation scripts"] H["sentinel/ingest.py\nNormalise + HMAC upload"] I["Microsoft Sentinel\nOpenShieldFindings_CL · KQL rules"] diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index b15147d..dfb0f3b 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -258,6 +258,36 @@ "control_name": "AKS node OS upgrade baseline (not mapped in CIS Azure Foundations 2.0.0)", "description": "Microsoft recommends a managed node OS upgrade channel for timely security patches. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." }, + "AZ-IDN-010": { + "control_id": "TBD-IDN-010", + "control_name": "App Registration ownership (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends accountable App Registration ownership. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-011": { + "control_id": "TBD-IDN-011", + "control_name": "App Registration redirect URI security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft requires secure redirect URI handling. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-012": { + "control_id": "TBD-IDN-012", + "control_name": "OAuth implicit grant security (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends authorization code flow instead of implicit grant. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-013": { + "control_id": "TBD-IDN-013", + "control_name": "App Registration password credentials (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends managed identity, federation, or certificates instead of client secrets. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-014": { + "control_id": "TBD-IDN-014", + "control_name": "Application-instance property lock (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends locking sensitive service-principal instance properties. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, + "AZ-IDN-015": { + "control_id": "TBD-IDN-015", + "control_name": "Managed Identity least privilege (not mapped in CIS Azure Foundations 2.0.0)", + "description": "Microsoft recommends least-privilege roles and scopes for managed identities. This check has no direct control in the repository's CIS Azure Foundations 2.0.0 benchmark." + }, "AZ-FUNC-001": {"control_id":"TBD-FUNC-001","control_name":"Function HTTPS baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, "AZ-FUNC-002": {"control_id":"TBD-FUNC-002","control_name":"Function TLS baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, "AZ-FUNC-003": {"control_id":"TBD-FUNC-003","control_name":"Function publishing baseline placeholder","description":"Numbered placeholder pending maintainer approval of a direct CIS mapping."}, diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 87a30fd..5da8b16 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -257,6 +257,81 @@ "control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", "description": "Automatic AKS node OS upgrades help deploy tested security patches within a managed maintenance process." + }, + "AZ-BAK-001": { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "A.12.3.1", + "control_name": "Information backup", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "A.9.2.3", + "control_name": "Management of privileged access rights", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + }, + "AZ-FUNC-003": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index faf101b..bc8b2ac 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -257,6 +257,81 @@ "control_id": "PR.IP-12", "control_name": "A vulnerability management plan is developed and implemented", "description": "Managed node OS upgrade channels apply tested security updates to reduce exposure to known operating-system vulnerabilities." + }, + "AZ-BAK-001": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "PR.AC-4", + "control_name": "Access permissions and authorizations are managed", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored to detect potential cybersecurity events", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + }, + "AZ-FUNC-003": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index e931e9f..6bb2863 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -257,6 +257,81 @@ "control_id": "CC7.1", "control_name": "Detects and Monitors Configuration Changes", "description": "Managed node OS upgrade channels maintain worker-node security patches through an observable Azure-controlled process." + }, + "AZ-BAK-001": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "The Recovery Services vault lacks the approved soft-delete recovery window, risking permanent loss of backup data before it can be restored." + }, + "AZ-BAK-002": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points and undermining the integrity of backup copies." + }, + "AZ-BAK-004": { + "control_id": "CC6.1", + "control_name": "Logical Access Security Measures", + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." + }, + "AZ-BAK-006": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." + }, + "AZ-FUNC-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + }, + "AZ-FUNC-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + }, + "AZ-FUNC-003": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App exposes an FTP or FTPS deployment channel, widening the network attack surface beyond the primary HTTPS endpoint." + }, + "AZ-FUNC-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "Remote debugging expands the Function App management attack surface by opening an additional network-reachable control channel." + }, + "AZ-FUNC-005": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "The Function App has no Azure managed identity for secretless resource access, pushing workloads toward long-lived credentials that cross network and service boundaries." + }, + "AZ-PE-001": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint, leaving the network boundary uncontrolled." + }, + "AZ-PE-002": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-003": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only, leaving the network boundary uncontrolled." + }, + "AZ-PE-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "An App Service workload remains publicly reachable without a default-deny access policy, leaving the network boundary uncontrolled." + }, + "AZ-PE-005": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A Recovery Services vault permits public access, even if a private endpoint also exists, leaving the network boundary uncontrolled." + }, + "AZ-PE-006": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." } } } diff --git a/docs/adding-a-rule.md b/docs/adding-a-rule.md index a8ee856..4e1dc29 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -125,8 +125,14 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `azure_client.get_sql_server_auditing_policy(rg, name)` | ServerBlobAuditingPolicy or None | | `azure_client.get_key_vaults()` | List of Vault objects (with full properties) | | `azure_client.get_managed_clusters()` | List of AKS ManagedCluster objects, or `None` on API failure | +| `azure_client.get_applications()` | Paginated App Registration dictionaries, or `None` on Graph failure | +| `azure_client.get_managed_identity_service_principals()` | Managed Identity service principals, or `None` on Graph failure | +| `azure_client.get_subscription_role_assignments()` | Subscription RBAC assignments, or `None` on API failure | | `azure_client.get_service_principals()` | List of RoleAssignment objects for service principals | | `azure_client.get_conditional_access_policies()` | List of CA policy dicts from MS Graph | +| `azure_client.get_function_app_security_posture()` | Cached, secret-free Function App posture dicts, or `None` on API failure | +| `azure_client.get_private_endpoint_posture()` | Public-access and approved Private Link state for supported PaaS resources, or `None` on API failure | +| `azure_client.get_recovery_vault_security_posture()` | Cached Recovery Services vault security settings, or `None` on API failure | | `azure_client.parse_resource_id(id)` | Dict with `resource_group` and `name` | List methods return an empty list on failure. Single-resource methods return `None` when the resource cannot be fetched. Three-state checks, such as `get_storage_lifecycle_policy()`, return `True` for compliant, `False` for non-compliant, and `None` when the scanner cannot determine the state. diff --git a/docs/architecture.md b/docs/architecture.md index a920329..83b08b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 36 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 72 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. --- @@ -43,8 +43,9 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ┌───────────▼──────────────────────────────────────────────────────┐ │ Rule Modules (scanner/rules/) │ │ │ -│ 20 rule files across Storage, Network, Identity, Database, │ -│ Compute, and Key Vault │ +│ 72 rule files across Storage, Network, Identity, Database, │ +│ Compute, Key Vault, AKS, post-quantum cryptography, Backup, │ +│ Serverless, and Private Endpoint posture │ └───────────┬───────────────────────────────────────────────────────┘ │ calls ┌───────────▼──────────────────────────────────────────────────────┐ @@ -110,16 +111,21 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 36 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 72 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| | Storage | 5 | AZ-STOR-001 to 005 | -| Network | 14 | AZ-NET-001 to 014 | -| Identity | 4 | AZ-IDN-001 to 004 | +| Network | 15 | AZ-NET-001 to 015 | +| Identity | 15 | AZ-IDN-001 to 015 | | Database | 4 | AZ-DB-001 to 004 | | Compute | 4 | AZ-CMP-001 to 004 | | Key Vault | 5 | AZ-KV-001 to 005 | +| Kubernetes | 6 | AZ-AKS-001 to 006 | +| Post-quantum | 3 | AZ-PQC-001 to 003 | +| Backup | 4 | AZ-BAK-001, 002, 004, 006 | +| Serverless | 5 | AZ-FUNC-001 to 005 | +| Private Endpoint | 6 | AZ-PE-001 to 006 | Every rule has a matching Azure CLI playbook in `playbooks/cli/`. diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md index ba11313..0b5c015 100644 --- a/docs/ci-pipeline.md +++ b/docs/ci-pipeline.md @@ -16,10 +16,11 @@ This document explains each job, how to reproduce every check locally before ope | **Rule & Compliance Validation** | 7 static checks (below) | Rule/playbook/compliance-JSON problems | | **Secret Scan (Gitleaks)** | `gitleaks detect` | A hardcoded secret in the working tree | | **SAST (Bandit)** | `bandit -r api/ scanner/ ai/ -ll` | A medium+ severity insecure-code pattern | +| **SAST (Semgrep)** | `semgrep scan --config p/security-audit --config p/owasp-top-ten --config p/python --config p/javascript --error` | A finding from the security-audit / OWASP Top 10 / Python / JS rulesets | | **SCA (pip-audit)** | `pip-audit -r requirements.txt` | A dependency with a known CVE (minus documented ignores) | | **SBOM (Syft)** | CycloneDX SBOM generated + uploaded as an artifact | SBOM generation error | | **Container Scan (Trivy)** | Dormant scaffold — skips until a `Dockerfile` exists (INFRA 1 / #154) | (nothing today) | -| **Backend Tests (pytest + coverage)** | Full `tests/` suite against an ephemeral Postgres, `--cov-fail-under=25` | A failing test or coverage below the floor | +| **Backend Tests (pytest + coverage)** | Full `tests/` suite against an ephemeral Postgres, `--cov-fail-under=80` | A failing test or coverage below the Silver-level floor | | **Frontend (lint + build)** | `npm ci` → `eslint` → `vite build` | An eslint error or a broken dashboard build | | **Enforce dev to main source** | `main` PRs must come from `dev` | A non-`dev` branch opening a PR into `main` | | **CI Summary** | Aggregates all job results into the run summary and fails if any required job failed | Any required job failing | @@ -37,7 +38,7 @@ The **Container Scan** job is intentionally **not** a required check yet: no `Do ```bash python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -pip install ruff bandit pip-audit # lint / SAST / SCA tools +pip install ruff bandit pip-audit semgrep # lint / SAST / SCA tools ``` A local PostgreSQL is needed for the backend tests. Any Postgres 14+ works; create the CI-matching role/db once: @@ -96,6 +97,21 @@ bandit -r api/ scanner/ ai/ -ll `-ll` reports medium severity and above. Confirmed false positives are annotated inline with `# nosec ` and a one-line justification (e.g. binding `0.0.0.0` inside a container, a parameterized SQL string). +### SAST (Semgrep) + +```bash +semgrep scan \ + --config p/security-audit \ + --config p/owasp-top-ten \ + --config p/python \ + --config p/javascript \ + --exclude venv --exclude frontend/node_modules --exclude frontend/dist \ + --metrics=off \ + --error . +``` + +Uses the public Semgrep Registry rulesets (`p/...`) directly, not `--config auto`, since `auto` nudges toward a semgrep.dev account/login, which this project deliberately avoids. `--metrics=off` disables Semgrep's anonymous telemetry. `venv/`, `node_modules/`, and `dist/` are already excluded by Semgrep's built-in default ignores and this repo's `.gitignore`, so no `.semgrepignore` is needed; the `--exclude` flags above just make that exclusion visible in the command itself. In CI, findings are also written to SARIF and uploaded to the GitHub Security → Code scanning tab alongside CodeQL's results. + ### SCA (pip-audit) ```bash @@ -109,10 +125,10 @@ The three ignores are advisories in `transformers` (a transitive dependency of ` ```bash DATABASE_URL="postgresql://ci:ci@localhost/ci_db" OPENSHIELD_ENV="testing" \ - pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-fail-under=25 + pytest tests/ -v --tb=short --cov=api --cov=scanner --cov-report=term --cov-fail-under=80 ``` -Runs the **entire** `tests/` suite once (not just rule tests). Tests requiring a vector store or an AI API key skip cleanly. `--cov-fail-under=25` is a floor to prevent backsliding; raise it as coverage grows. +Runs the **entire** `tests/` suite once (not just rule tests). Tests requiring a vector store or an AI API key skip cleanly. `--cov-fail-under=80` enforces the OpenSSF Silver statement-coverage requirement for the measured API and scanner packages. ### Frontend (lint + build) @@ -144,8 +160,10 @@ CI runs at **both** merge points (`on: pull_request` targets `dev` and `main`), - **Gitleaks via the release binary, not `gitleaks-action`.** The Action requires a (free) `GITLEAKS_LICENSE` for **organization**-owned repos; the CLI binary is Apache-2.0 with no key, so it runs with zero license friction. - **All third-party actions are pinned to a full commit SHA** with a `# vX.Y.Z` comment (GitHub's supply-chain hardening standard). `aquasecurity/trivy-action` is SHA-pinned specifically because its mutable tags were compromised in 2026. - **Dependabot is notify-only** (`open-pull-requests-limit: 0` for pip / github-actions / npm): it still raises alerts but does not auto-open version-bump PRs. -- **Least-privilege token:** `ci.yml` declares `permissions: contents: read`; CodeQL scopes its own `security-events: write`. +- **Least-privilege token:** `ci.yml` declares `permissions: contents: read`; CodeQL and Semgrep each scope their own `security-events: write`. - **`concurrency` cancels superseded runs** on the same PR to save runner minutes. +- **Semgrep added as a second, open-source SAST layer**, run as a pure CLI invocation (`semgrep scan --config p/...`) with `--metrics=off`, no semgrep.dev account, login, or telemetry dependency. It complements CodeQL's deeper data-flow/taint analysis with fast, pattern-based rules (OWASP Top 10, general security-audit, Python/JS-specific), uploading SARIF to the same GitHub code scanning UI. Added per maintainer direction to reduce reliance on GitHub's proprietary, soon-to-be-paid "Code Quality" product in favor of open-source tooling. +- **The `p/...` rulesets are pulled from the public Semgrep Registry at run time**, not vendored, so a registry outage fails the `SAST (Semgrep)` job. Accepted as a normal SAST-gate network dependency; a spurious red on that job is worth checking against Semgrep Registry status before assuming a real finding. --- @@ -180,8 +198,9 @@ The `ci-summary` job uses `needs: [...]` + `if: always()` so it runs after every |---|---| | `ruff check` / `format` fails | `ruff format .` then `ruff check --fix .`; re-run both | | `bandit` medium+ finding | Fix it, or if a confirmed false positive add `# nosec ` with a one-line reason | +| `semgrep` finding | Fix the flagged pattern, or if a confirmed false positive add a `# nosemgrep: ` (Python) / `// nosemgrep: ` (JS) comment with a one-line justification on the flagged line | | `pip-audit` reports a CVE | Bump the pin to a fixed version; only add `--ignore-vuln` with a documented reason | -| `pytest` coverage below 25% | Add tests, or investigate the regression that removed coverage | +| `pytest` coverage below 80% | Add tests, or investigate the regression that removed coverage | | Frontend eslint error | Fix the reported rule (e.g. remove an unused import); warnings do not fail CI | | `Enforce dev to main source` fails | Open the PR from `dev`; merge feature work into `dev` first | | `missing field 'RULE_ID'` | Add `RULE_ID = "AZ-XXX-000"` at module level in the rule file | diff --git a/docs/learn/index.html b/docs/learn/index.html index 7ca6219..f4014ba 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -841,7 +841,7 @@

Production-shaped, MVP-friendly architecture

Rule coverage

-

39 Azure security rules

+

72 Azure security rules

OpenShield currently has 39 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, strengthens validation, or improves remediation quality. @@ -931,7 +931,7 @@

Current cleanup items

Documentation drift

    -
  • Some README/docs references still mention 20 rules while the repo has 39.
  • +
  • Rule coverage and documentation counts are checked and updated with each release.
  • Some startup commands assume python, but local environments may only expose python3.
  • API docs and implementation should stay aligned, especially score response shape.
diff --git a/docs/rules-reference.md b/docs/rules-reference.md index e8cd964..d311196 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 44 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 72 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -21,6 +21,12 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-IDN-007 | Active User with No MFA Registered in Entra ID | HIGH | Identity | 1.1 | PR.AC-7 | A.9.4.2 | | AZ-IDN-008 | Custom RBAC Role with Wildcard Permissions at Subscription Scope | HIGH | Identity | 1.23 | PR.AC-4 | A.9.2.3 | | AZ-IDN-009 | No Activity Log Alert for Role Assignment Changes | MEDIUM | Identity | 5.2.1 | DE.CM-3 | A.12.4.1 | +| AZ-IDN-010 | App Registration Has No Owner | MEDIUM | Identity | TBD-IDN-010 | PR.AC-4 | A.9.2.1 | +| AZ-IDN-011 | App Registration Uses Insecure Redirect URI | HIGH | Identity | TBD-IDN-011 | PR.DS-2 | A.14.1.2 | +| AZ-IDN-012 | App Registration Enables OAuth Implicit Grant | MEDIUM | Identity | TBD-IDN-012 | PR.AC-3 | A.9.4.2 | +| AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | TBD-IDN-013 | PR.AC-1 | A.9.4.3 | +| AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | TBD-IDN-014 | PR.IP-1 | A.12.1.2 | +| AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | TBD-IDN-015 | PR.AC-4 | A.9.2.3 | | AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | 8.8 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.7 | AC-17 | A.13.1.1 | | AZ-KV-003 | Key Vault Without Diagnostic Logging Enabled | MEDIUM | Key Vault | 8.4 | DE.CM-7 | A.12.4.1 | @@ -40,6 +46,7 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-NET-012 | NSG Flow Logs Not Enabled | MEDIUM | Network | 6.7 | DE.CM-1 | A.12.4.1 | | AZ-NET-013 | Azure Firewall Not Enabled on Virtual Network | HIGH | Network | 6.4 | PR.AC-5 | A.13.1.1 | | AZ-NET-014 | VNet Peering Configured Without Gateway Transit Restrictions | MEDIUM | Network | 6.6 | PR.AC-5 | A.13.1.1 | +| AZ-NET-015 | Public DNS Zone Exposes Internal Infrastructure Details | MEDIUM | Network | 9.8 | PR.AC-5 | A.13.1.1 | | AZ-PQC-001 | TLS Using Classical Key Exchange Algorithm | HIGH | PostQuantum | 9.9 | PR.DS-2 | A.10.1.1 | | AZ-PQC-002 | Key Vault Key Using Non-Quantum-Safe Algorithm | HIGH | PostQuantum | 8.1 | PR.DS-2 | A.10.1.1 | | AZ-PQC-003 | Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm | MEDIUM | PostQuantum | 8.9 | PR.DS-2 | A.10.1.1 | @@ -54,6 +61,21 @@ OpenShield currently ships 44 Azure scan rules. This table is generated from the | AZ-AKS-004 | AKS Workload Identity Not Fully Enabled | MEDIUM | Kubernetes | N/A-AKS-004 | PR.AC-4 | A.9.2.3 | | AZ-AKS-005 | AKS Azure Policy Add-on Not Enabled | MEDIUM | Kubernetes | N/A-AKS-005 | PR.IP-1 | A.12.1.2 | | AZ-AKS-006 | AKS Node OS Automatic Upgrades Disabled | HIGH | Kubernetes | N/A-AKS-006 | PR.IP-12 | A.12.6.1 | +| AZ-BAK-001 | Backup Soft Delete Disabled or Below 35 Days | CRITICAL | Backup | TBD-BAK-001 | PR.IP-4 | A.12.3.1 | +| AZ-BAK-002 | Backup Vault Immutability Disabled | HIGH | Backup | TBD-BAK-002 | PR.IP-4 | A.12.3.1 | +| AZ-BAK-004 | Backup Multiuser Authorization Missing | HIGH | Backup | TBD-BAK-004 | PR.AC-4 | A.9.2.3 | +| AZ-BAK-006 | Backup Security Monitoring Disabled | MEDIUM | Backup | TBD-BAK-006 | DE.CM-1 | A.12.4.1 | +| AZ-FUNC-001 | Function App HTTPS Only Disabled | HIGH | Serverless | TBD-FUNC-001 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-002 | Function App Minimum TLS Below 1.2 | HIGH | Serverless | TBD-FUNC-002 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-003 | Function App FTP Publishing Enabled | MEDIUM | Serverless | TBD-FUNC-003 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-004 | Function App Remote Debugging Enabled | HIGH | Serverless | TBD-FUNC-004 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-005 | Function App Managed Identity Missing | MEDIUM | Serverless | TBD-FUNC-005 | PR.AC-5 | A.13.1.1 | +| AZ-PE-001 | Storage Public Network Access Enabled | HIGH | Network | TBD-PE-001 | PR.AC-5 | A.13.1.1 | +| AZ-PE-002 | Azure SQL Public Network Access Enabled | HIGH | Network | TBD-PE-002 | PR.AC-5 | A.13.1.1 | +| AZ-PE-003 | PostgreSQL Public Network Access Enabled | HIGH | Network | TBD-PE-003 | PR.AC-5 | A.13.1.1 | +| AZ-PE-004 | Web or Function App Public Network Access Enabled | HIGH | Network | TBD-PE-004 | PR.AC-5 | A.13.1.1 | +| AZ-PE-005 | Recovery Vault Public Network Access Enabled | HIGH | Network | TBD-PE-005 | PR.AC-5 | A.13.1.1 | +| AZ-PE-006 | Private Endpoint Connection Not Approved | MEDIUM | Network | TBD-PE-006 | PR.AC-5 | A.13.1.1 | SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. diff --git a/frontend/package.json b/frontend/package.json index 62091c6..631146a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "lint": "eslint .", + "lint": "eslint . --max-warnings=0", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/src/components/ai/ChatPanel.jsx b/frontend/src/components/ai/ChatPanel.jsx index 9566694..5a302e4 100644 --- a/frontend/src/components/ai/ChatPanel.jsx +++ b/frontend/src/components/ai/ChatPanel.jsx @@ -37,7 +37,7 @@ const ChatPanel = forwardRef(function ChatPanel({ initialMessages = [], contextF timestamp: new Date().toISOString(), }; setMessages((prev) => [...prev, msg]); - }, [contextFinding?.ruleId]); + }, [contextFinding]); async function handleSend(text) { if (!text?.trim()) return; diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx index 8d9770d..feb635f 100644 --- a/frontend/src/components/layout/Header.jsx +++ b/frontend/src/components/layout/Header.jsx @@ -63,7 +63,7 @@ function ScanToast({ result, error, onClose }) { useEffect(() => { const t = setTimeout(onClose, 6000); return () => clearTimeout(t); - }, []); + }, [onClose]); const isSuccess = !!result; diff --git a/frontend/src/pages/AILayer.jsx b/frontend/src/pages/AILayer.jsx index 63afa18..ed37840 100644 --- a/frontend/src/pages/AILayer.jsx +++ b/frontend/src/pages/AILayer.jsx @@ -98,13 +98,13 @@ export default function AILayer() { const [cveData, setCveData] = useState(null); const [cveLoading, setCveLoading] = useState(true); const [settingsKey, setSettingsKey] = useState(0); // force re-render on settings save + const initialFinding = location.state?.finding; useEffect(() => { api.getFindings() .then((scans) => { setFindings(scans); - const fromScan = location.state?.finding; - if (fromScan) setSelectedFinding(fromScan); + if (initialFinding) setSelectedFinding(initialFinding); aiApi.getSummary(scans).then(setSummary).finally(() => setSummaryLoading(false)); }) .catch(() => { @@ -112,7 +112,7 @@ export default function AILayer() { aiApi.getSummary([]).then(setSummary).finally(() => setSummaryLoading(false)); }); aiApi.getCVEAnalysis().then(setCveData).finally(() => setCveLoading(false)); - }, []); + }, [initialFinding]); const handleFindingSelect = (f) => setSelectedFinding((prev) => (prev?.id === f.id ? null : f)); diff --git a/frontend/src/pages/DetailedScan.jsx b/frontend/src/pages/DetailedScan.jsx index a2a7e66..41c8fee 100644 --- a/frontend/src/pages/DetailedScan.jsx +++ b/frontend/src/pages/DetailedScan.jsx @@ -81,18 +81,18 @@ export default function DetailedScan() { const [selected, setSelected] = useState(null); const [playbook, setPlaybook] = useState(null); const [showBanner, setShowBanner] = useState(!!location.state?.fromPrioritization); + const preSelectRuleId = location.state?.ruleId; // Load findings on mount useEffect(() => { api.getFindings().then((data) => { setFindings(data); - const preSelectRuleId = location.state?.ruleId; const initial = preSelectRuleId ? (data.find((f) => f.ruleId === preSelectRuleId) ?? data[0]) : data[0]; selectFinding(initial); }); - }, []); + }, [preSelectRuleId]); // Fetch playbook whenever selected finding changes async function selectFinding(f) { diff --git a/scanner/azure_client.py b/scanner/azure_client.py index f28bb55..c69f75c 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -23,6 +23,22 @@ _UNSET = object() +def enum_str(value: Any, default: str = "") -> str: + """Safely coerce an Azure SDK field to its plain string form. + + Azure SDK models often return fields typed as enums (e.g. + SecurityRuleDirection, BlobAuditingPolicyState) rather than plain + strings. ``str(enum_member)`` yields something like + "SecurityRuleDirection.INBOUND", not the underlying value "Inbound", + which breaks naive string comparisons. This prefers ``.value`` when + present (covers real SDK enums and enum-like objects) and falls back + to ``str()`` for plain strings, None, or anything else. + """ + if value is None: + return default + return str(getattr(value, "value", value)) + + class AzureClient: """Wraps Azure SDK management clients for all CSPM scan operations. @@ -38,6 +54,9 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No self._function_apps_cache: Any = _UNSET self._private_endpoint_posture_cache: Any = _UNSET self._recovery_vaults_cache: Any = _UNSET + self._applications_cache: Any = _UNSET + self._managed_identity_principals_cache: Any = _UNSET + self._subscription_role_assignments_cache: Any = _UNSET # ------------------------------------------------------------------ # # Static helpers # @@ -45,9 +64,17 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No @staticmethod def parse_resource_id(resource_id: str) -> Dict[str, str]: - """Return resource_group and name parsed from an Azure resource ID.""" - parts = resource_id.split("/") - result: Dict[str, str] = {"name": parts[-1] if parts else ""} + """Return resource_group and name parsed from an Azure resource ID. + + Always returns both keys, even for malformed or empty IDs, so + callers can safely use parsed["resource_group"] without risking + a KeyError. + """ + parts = (resource_id or "").split("/") + result: Dict[str, str] = { + "name": parts[-1] if parts else "", + "resource_group": "", + } for idx, segment in enumerate(parts): if segment.lower() == "resourcegroups" and idx + 1 < len(parts): result["resource_group"] = parts[idx + 1] @@ -364,21 +391,24 @@ def get_function_app_security_posture(self) -> Optional[List[Dict[str, Any]]]: for app in client.web_apps.list(): if "functionapp" not in str(getattr(app, "kind", "")).lower(): continue - parsed = self.parse_resource_id(getattr(app, "id", "")) - config = client.web_apps.get_configuration(parsed.get("resource_group", ""), app.name) - identity = getattr(app, "identity", None) - result.append( - { - "id": app.id, - "name": app.name, - "kind": getattr(app, "kind", None), - "https_only": getattr(app, "https_only", None), - "min_tls_version": getattr(config, "min_tls_version", None), - "ftps_state": getattr(config, "ftps_state", None), - "remote_debugging_enabled": getattr(config, "remote_debugging_enabled", None), - "identity_type": getattr(identity, "type", None) if identity is not None else "None", - } - ) + try: + parsed = self.parse_resource_id(getattr(app, "id", "")) + config = client.web_apps.get_configuration(parsed.get("resource_group", ""), app.name) + identity = getattr(app, "identity", None) + result.append( + { + "id": app.id, + "name": app.name, + "kind": getattr(app, "kind", None), + "https_only": getattr(app, "https_only", None), + "min_tls_version": getattr(config, "min_tls_version", None), + "ftps_state": getattr(config, "ftps_state", None), + "remote_debugging_enabled": getattr(config, "remote_debugging_enabled", None), + "identity_type": getattr(identity, "type", None) if identity is not None else "None", + } + ) + except Exception as exc: + logger.warning("get_function_app_security_posture: skipping %s: %s", getattr(app, "name", "?"), exc) self._function_apps_cache = result except Exception as exc: logger.error("get_function_app_security_posture failed: %s", exc) @@ -422,10 +452,16 @@ def add(resource: Any, service: str, public: Any, required: set[str]) -> None: } ) - for item in StorageManagementClient(self.credential, self.subscription_id).storage_accounts.list(): - add(item, "storage", getattr(item, "public_network_access", None), {"blob"}) - for item in SqlManagementClient(self.credential, self.subscription_id).servers.list(): - add(item, "sql", getattr(item, "public_network_access", None), {"sqlserver"}) + try: + for item in StorageManagementClient(self.credential, self.subscription_id).storage_accounts.list(): + add(item, "storage", getattr(item, "public_network_access", None), {"blob"}) + except Exception as exc: + logger.warning("Storage account network posture unavailable: %s", exc) + try: + for item in SqlManagementClient(self.credential, self.subscription_id).servers.list(): + add(item, "sql", getattr(item, "public_network_access", None), {"sqlserver"}) + except Exception as exc: + logger.warning("Azure SQL network posture unavailable: %s", exc) try: from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexiblePostgreSQLClient @@ -438,16 +474,21 @@ def add(resource: Any, service: str, public: Any, required: set[str]) -> None: ) except Exception as exc: logger.warning("PostgreSQL Flexible Server posture unavailable: %s", exc) - from azure.mgmt.web import WebSiteManagementClient - - web = WebSiteManagementClient(self.credential, self.subscription_id) - for item in web.web_apps.list(): - parsed = self.parse_resource_id(getattr(item, "id", "")) - config = web.web_apps.get_configuration(parsed.get("resource_group", ""), item.name) - public = getattr(item, "public_network_access", None) or getattr(config, "public_network_access", None) - if str(getattr(config, "ip_security_restrictions_default_action", "")).lower() == "deny": - public = "Disabled" - add(item, "web", public, {"sites"}) + try: + from azure.mgmt.web import WebSiteManagementClient + + web = WebSiteManagementClient(self.credential, self.subscription_id) + for item in web.web_apps.list(): + parsed = self.parse_resource_id(getattr(item, "id", "")) + config = web.web_apps.get_configuration(parsed.get("resource_group", ""), item.name) + public = getattr(item, "public_network_access", None) or getattr( + config, "public_network_access", None + ) + if str(getattr(config, "ip_security_restrictions_default_action", "")).lower() == "deny": + public = "Disabled" + add(item, "web", public, {"sites"}) + except Exception as exc: + logger.warning("Web/Function App network posture unavailable: %s", exc) try: from azure.mgmt.recoveryservices import RecoveryServicesClient @@ -645,16 +686,72 @@ def get_diagnostic_settings(self, resource_id: str) -> Optional[bool]: # Identity / Authorization # # ------------------------------------------------------------------ # - def get_service_principals(self) -> List[Any]: - """Return role assignments whose principal type is ServicePrincipal.""" + def _get_graph_collection(self, url: str, operation: str) -> Optional[List[Dict[str, Any]]]: + """Fetch a paginated Microsoft Graph collection or return ``None`` on failure.""" + import requests + + items: List[Dict[str, Any]] = [] + try: + token = self.credential.get_token("https://graph.microsoft.com/.default") + headers = { + "Authorization": f"Bearer {token.token}", + "ConsistencyLevel": "eventual", + } + while url: + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + data = response.json() + items.extend(data.get("value", [])) + url = data.get("@odata.nextLink", "") + return items + except Exception as exc: + logger.error("%s failed: %s", operation, exc) + return None + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + """Return cached App Registrations with security-relevant properties and owner IDs.""" + if self._applications_cache is _UNSET: + select = ( + "id,displayName,appId,signInAudience,passwordCredentials,keyCredentials," + "web,spa,publicClient,servicePrincipalLockConfiguration" + ) + url = f"https://graph.microsoft.com/v1.0/applications?$select={select}&$expand=owners($select=id)&$top=100" + self._applications_cache = self._get_graph_collection(url, "get_applications") + return self._applications_cache + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + """Return cached Microsoft Entra service principals representing managed identities.""" + if self._managed_identity_principals_cache is _UNSET: + url = ( + "https://graph.microsoft.com/v1.0/servicePrincipals" + "?$filter=servicePrincipalType eq 'ManagedIdentity'" + "&$select=id,displayName,servicePrincipalType&$count=true&$top=100" + ) + self._managed_identity_principals_cache = self._get_graph_collection( + url, + "get_managed_identity_service_principals", + ) + return self._managed_identity_principals_cache + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + """Return cached subscription-scope RBAC assignments, preserving API failure as ``None``.""" + if self._subscription_role_assignments_cache is not _UNSET: + return self._subscription_role_assignments_cache try: client = AuthorizationManagementClient(self.credential, self.subscription_id) scope = f"/subscriptions/{self.subscription_id}" - assignments = list(client.role_assignments.list_for_scope(scope)) - return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] + self._subscription_role_assignments_cache = list(client.role_assignments.list_for_scope(scope)) except Exception as exc: - logger.error("get_service_principals failed: %s", exc) + logger.error("get_subscription_role_assignments failed: %s", exc) + self._subscription_role_assignments_cache = None + return self._subscription_role_assignments_cache + + def get_service_principals(self) -> List[Any]: + """Return role assignments whose principal type is ServicePrincipal.""" + assignments = self.get_subscription_role_assignments() + if assignments is None: return [] + return [a for a in assignments if getattr(a, "principal_type", "") == "ServicePrincipal"] def get_postgresql_flexible_servers(self) -> List[Any]: """List all PostgreSQL Flexible Server instances in the subscription.""" diff --git a/scanner/rules/az_db_002.py b/scanner/rules/az_db_002.py index 6c0752b..67b697b 100644 --- a/scanner/rules/az_db_002.py +++ b/scanner/rules/az_db_002.py @@ -1,7 +1,10 @@ """AZ-DB-002: Azure SQL server has no auditing configured.""" +import logging from typing import Any, Dict, List +from scanner.azure_client import enum_str + RULE_ID = "AZ-DB-002" RULE_NAME = "Azure SQL Server Has No Auditing Configured" SEVERITY = "MEDIUM" @@ -19,6 +22,8 @@ ) PLAYBOOK = "playbooks/cli/fix_az_db_002.sh" +logger = logging.getLogger(__name__) + def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect SQL servers where server-level blob auditing is disabled.""" @@ -28,15 +33,27 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: parsed = azure_client.parse_resource_id(server.id) resource_group = parsed.get("resource_group", "") if not resource_group: + logger.warning( + "Skipping AZ-DB-002 check for %s: could not parse resource group from malformed ARM ID %r", + getattr(server, "name", ""), + getattr(server, "id", ""), + ) continue policy = azure_client.get_sql_server_auditing_policy(resource_group, server.name) if policy is None: - # Could not retrieve policy — treat as unaudited - is_disabled = True - else: - state = str(getattr(policy, "state", "Disabled")) - is_disabled = state.lower() != "enabled" + # Could not retrieve the policy (API/auth failure, throttling, etc). + # Skip this resource rather than flagging it — we don't actually + # know its auditing state, so treating a failed call as + # "disabled" produces a false positive. + logger.warning( + "Skipping AZ-DB-002 check for %s: could not retrieve auditing policy", + server.name, + ) + continue + + state = enum_str(getattr(policy, "state", None), default="Disabled") + is_disabled = state.lower() != "enabled" if is_disabled: findings.append( @@ -54,7 +71,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "frameworks": FRAMEWORKS, "metadata": { "resource_group": resource_group, - "auditing_state": getattr(policy, "state", "Unknown") if policy else "Unknown", + "auditing_state": state, }, } ) diff --git a/scanner/rules/az_idn_006.py b/scanner/rules/az_idn_006.py index 9a8065a..ebffe97 100644 --- a/scanner/rules/az_idn_006.py +++ b/scanner/rules/az_idn_006.py @@ -33,25 +33,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: """Detect service principals with stale or non-expiring client secrets.""" findings: List[Dict[str, Any]] = [] - try: - import requests - - token = azure_client.credential.get_token("https://graph.microsoft.com/.default") - headers = {"Authorization": f"Bearer {token.token}"} - - next_url = ( - "https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,passwordCredentials&$top=100" - ) - applications = [] - while next_url: - response = requests.get(next_url, headers=headers, timeout=30) - response.raise_for_status() - data = response.json() - applications.extend(data.get("value", [])) - next_url = data.get("@odata.nextLink") - - except Exception as exc: - logger.error("AZ-IDN-006: Failed to fetch applications from Graph API: %s", exc) + applications = azure_client.get_applications() + if applications is None: + logger.error("AZ-IDN-006: Application inventory is unavailable") logger.warning( "AZ-IDN-006: Ensure the service principal has Application.Read.All permission on Microsoft Graph." ) diff --git a/scanner/rules/az_net_003.py b/scanner/rules/az_net_003.py index f2d1bb4..f6a042b 100644 --- a/scanner/rules/az_net_003.py +++ b/scanner/rules/az_net_003.py @@ -3,6 +3,8 @@ import logging from typing import Any, Dict, List +from scanner.azure_client import enum_str + RULE_ID = "AZ-NET-003" RULE_NAME = "NSG allows unrestricted inbound on port 443" SEVERITY = "HIGH" @@ -33,10 +35,21 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: for nsg in azure_client.get_network_security_groups(): for rule in getattr(nsg, "security_rules", []) or []: + direction = enum_str(getattr(rule, "direction", None)) + access = enum_str(getattr(rule, "access", None)) + allowed_sources = {"*", "0.0.0.0/0", "internet", "any"} + single_prefix = enum_str(getattr(rule, "source_address_prefix", None)) + plural_prefixes = getattr(rule, "source_address_prefixes", None) or [] + matched_plural_prefix = next( + (prefix for prefix in plural_prefixes if enum_str(prefix).lower() in allowed_sources), + None, + ) + source_matches = single_prefix.lower() in allowed_sources or matched_plural_prefix is not None + if ( - getattr(rule, "direction", "") == "Inbound" - and getattr(rule, "access", "") == "Allow" - and getattr(rule, "source_address_prefix", "") in ("*", "0.0.0.0/0", "Internet", "Any") + direction.lower() == "inbound" + and access.lower() == "allow" + and source_matches and getattr(rule, "destination_port_range", "") in ("443", "*") ): findings.append( @@ -54,7 +67,8 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "frameworks": FRAMEWORKS, "metadata": { "rule_name": getattr(rule, "name", ""), - "source_prefix": getattr(rule, "source_address_prefix", ""), + "source_prefix": single_prefix if single_prefix.lower() in allowed_sources else "", + "matched_source_address_prefix": matched_plural_prefix, }, } ) diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 508619e..7cb3aa2 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -82,6 +82,9 @@ def __init__(self) -> None: self._dns_record_sets: Dict[Tuple[str, str], List[Any]] = {} self._web_apps: List[Any] = [] self._managed_clusters: Optional[List[Any]] = [] + self._applications: Optional[List[Dict[str, Any]]] = [] + self._managed_identity_principals: Optional[List[Dict[str, Any]]] = [] + self._subscription_role_assignments: Optional[List[Any]] = [] # Some rules read azure_client.subscription_id when constructing an # SDK management client inside scan() (e.g. AZ-NET-007..010). self.subscription_id = "00000000-0000-0000-0000-000000000001" @@ -98,6 +101,27 @@ def set_managed_clusters(self, clusters: Optional[List[Any]]) -> "MockAzureClien def get_managed_clusters(self) -> Optional[List[Any]]: return self._managed_clusters + def set_applications(self, applications: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._applications = applications + return self + + def get_applications(self) -> Optional[List[Dict[str, Any]]]: + return self._applications + + def set_managed_identity_service_principals(self, principals: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._managed_identity_principals = principals + return self + + def get_managed_identity_service_principals(self) -> Optional[List[Dict[str, Any]]]: + return self._managed_identity_principals + + def set_subscription_role_assignments(self, assignments: Optional[List[Any]]) -> "MockAzureClient": + self._subscription_role_assignments = assignments + return self + + def get_subscription_role_assignments(self) -> Optional[List[Any]]: + return self._subscription_role_assignments + def set_network_security_groups(self, nsgs: List[Any]) -> "MockAzureClient": self._network_security_groups = nsgs return self @@ -346,9 +370,17 @@ def get_web_apps(self) -> List[Any]: @staticmethod def parse_resource_id(resource_id: str) -> Dict[str, str]: - """Parse an Azure resource ID into a dict with name and resource_group.""" - parts = resource_id.split("/") - result: Dict[str, str] = {"name": parts[-1] if parts else ""} + """Parse an Azure resource ID into a dict with name and resource_group. + + Always returns both keys, even for malformed or empty IDs, so + callers can safely use parsed["resource_group"] without risking + a KeyError. + """ + parts = (resource_id or "").split("/") + result: Dict[str, str] = { + "name": parts[-1] if parts else "", + "resource_group": "", + } for idx, segment in enumerate(parts): if segment.lower() == "resourcegroups" and idx + 1 < len(parts): result["resource_group"] = parts[idx + 1] diff --git a/tests/test_engine_integration.py b/tests/test_engine_integration.py index aa797a8..90401ef 100644 --- a/tests/test_engine_integration.py +++ b/tests/test_engine_integration.py @@ -43,11 +43,11 @@ def _nsg_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/networkSecurityGroups/{name}" -def test_engine_loads_all_51_rules(monkeypatch): +def test_engine_loads_all_57_rules(monkeypatch): """The engine must dynamically load the complete rule set.""" _patch_engine_client(monkeypatch, _offline_mock()) eng = ScanEngine(_SUB) - assert len(eng.rules) >= 51 + assert len(eng.rules) >= 57 # Every loaded rule must expose a callable scan() and a RULE_ID. for rule in eng.rules: assert callable(getattr(rule, "scan", None)) diff --git a/tests/test_rules_database.py b/tests/test_rules_database.py index cef4e73..a89d240 100644 --- a/tests/test_rules_database.py +++ b/tests/test_rules_database.py @@ -1,11 +1,20 @@ """Rule regression tests for the database rules AZ-DB-001 .. AZ-DB-004.""" +import pytest + import scanner.rules.az_db_001 as az_db_001 import scanner.rules.az_db_002 as az_db_002 import scanner.rules.az_db_003 as az_db_003 import scanner.rules.az_db_004 as az_db_004 from tests.helpers.mock_azure import make_resource +try: + from azure.mgmt.sql.models import BlobAuditingPolicyState, ServerBlobAuditingPolicy + + _AZURE_SQL_SDK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only when SDK isn't installed + _AZURE_SQL_SDK_AVAILABLE = False + _REQUIRED_FIELDS = { "rule_id", "rule_name", @@ -73,6 +82,87 @@ def test_db_004_no_firewall_rules_returns_no_findings(mock_azure, subscription_i assert findings == [] +def test_db_002_disabled_policy_returns_one_finding(mock_azure, subscription_id): + """A SQL Server with auditing explicitly disabled must produce one finding.""" + server = make_resource(id=_sql_id("sql-unaudited"), name="sql-unaudited") + policy = make_resource(state="Disabled") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-unaudited", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-DB-002" + + +def test_db_002_enabled_policy_returns_no_findings(mock_azure, subscription_id): + """A SQL Server with auditing enabled must produce no findings.""" + server = make_resource(id=_sql_id("sql-audited"), name="sql-audited") + policy = make_resource(state="Enabled") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-audited", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_002_api_failure_returns_no_findings(mock_azure, subscription_id): + """COR-003: a failed policy lookup (None) must be skipped, not flagged. + + Previously, a None policy (API/auth failure) was treated the same as an + explicitly disabled policy, producing a false positive. + """ + server = make_resource(id=_sql_id("sql-api-failed"), name="sql-api-failed") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-api-failed", None) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_002_malformed_arm_id_does_not_raise(mock_azure, subscription_id): + """COR-004: a server with a malformed ARM ID must not raise an error.""" + server = make_resource(id="not-a-valid-arm-id", name="sql-malformed") + mock_azure.set_sql_servers([server]) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + +def test_db_004_malformed_arm_id_does_not_raise_keyerror(mock_azure, subscription_id): + """COR-004: az_db_004 indexes parsed["resource_group"] directly, so a + malformed ARM ID with no resourceGroups segment previously raised a + KeyError. parse_resource_id must always include the key. + """ + server = make_resource(id="not-a-valid-arm-id", name="sql-malformed") + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_firewall_rules("", "sql-malformed", []) + findings = az_db_004.scan(mock_azure, subscription_id) + assert findings == [] + + +@pytest.mark.skipif(not _AZURE_SQL_SDK_AVAILABLE, reason="azure-mgmt-sql not installed") +def test_db_002_disabled_policy_with_real_sdk_enum_returns_one_finding(mock_azure, subscription_id): + """COR-003 (SDK model): a real ServerBlobAuditingPolicy with state as a + BlobAuditingPolicyState.DISABLED enum, not a plain string, must still be + flagged. str(enum) yields e.g. "BlobAuditingPolicyState.DISABLED" rather + than "Disabled", so the rule must normalise via .value instead of naive str().""" + server = make_resource(id=_sql_id("sql-unaudited-enum"), name="sql-unaudited-enum") + policy = ServerBlobAuditingPolicy(state=BlobAuditingPolicyState.DISABLED) + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-unaudited-enum", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["auditing_state"] == "Disabled" + + +@pytest.mark.skipif(not _AZURE_SQL_SDK_AVAILABLE, reason="azure-mgmt-sql not installed") +def test_db_002_enabled_policy_with_real_sdk_enum_returns_no_findings(mock_azure, subscription_id): + """COR-003 (SDK model): a real enabled policy using the SDK enum must + not be falsely flagged as disabled.""" + server = make_resource(id=_sql_id("sql-audited-enum"), name="sql-audited-enum") + policy = ServerBlobAuditingPolicy(state=BlobAuditingPolicyState.ENABLED) + mock_azure.set_sql_servers([server]) + mock_azure.set_sql_server_auditing_policy(_RG, "sql-audited-enum", policy) + findings = az_db_002.scan(mock_azure, subscription_id) + assert findings == [] + + def _pg_id(name, provider="Microsoft.DBforPostgreSQL/servers"): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/{provider}/{name}" diff --git a/tests/test_rules_identity.py b/tests/test_rules_identity.py index 18be4ef..b2ea214 100644 --- a/tests/test_rules_identity.py +++ b/tests/test_rules_identity.py @@ -258,7 +258,7 @@ def test_idn_006_compliant_fresh_secret_returns_no_findings(mock_azure, subscrip } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) assert az_idn_006.scan(mock_azure, subscription_id) == [] @@ -280,7 +280,7 @@ def test_idn_006_noncompliant_secret_no_expiry_returns_finding(mock_azure, subsc } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) findings = az_idn_006.scan(mock_azure, subscription_id) assert len(findings) == 1 assert findings[0]["rule_id"] == "AZ-IDN-006" @@ -311,7 +311,7 @@ def test_idn_006_malformed_end_date_time_does_not_log_key_id(mock_azure, subscri } ] } - _install_router(monkeypatch, [("/applications", _Resp(apps))]) + mock_azure.set_applications(apps["value"]) with caplog.at_level("DEBUG", logger="scanner.rules.az_idn_006"): findings = az_idn_006.scan(mock_azure, subscription_id) diff --git a/tests/test_rules_network.py b/tests/test_rules_network.py index 6f876f3..f8dcfa2 100644 --- a/tests/test_rules_network.py +++ b/tests/test_rules_network.py @@ -1,4 +1,4 @@ -"""Rule regression tests for the network rules AZ-NET-001 .. AZ-NET-014. +"""Rule regression tests for the network rules AZ-NET-001 .. AZ-NET-015. AZ-NET-007/009/010 construct a NetworkManagementClient inside scan(); those tests monkeypatch azure.mgmt.network.NetworkManagementClient. The rest read data @@ -7,6 +7,8 @@ from types import SimpleNamespace +import pytest + import scanner.rules.az_net_001 as az_net_001 import scanner.rules.az_net_002 as az_net_002 import scanner.rules.az_net_003 as az_net_003 @@ -24,6 +26,13 @@ import scanner.rules.az_net_015 as az_net_015 from tests.helpers.mock_azure import make_resource +try: + from azure.mgmt.network.models import SecurityRule, SecurityRuleAccess, SecurityRuleDirection + + _AZURE_SDK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised only when SDK isn't installed + _AZURE_SDK_AVAILABLE = False + def _install_network_client(monkeypatch, **collections): """Patch azure.mgmt.network.NetworkManagementClient with a fake exposing @@ -149,6 +158,17 @@ def _vnet_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/virtualNetworks/{name}" +def _net_003_rule(name, direction="Inbound", access="Allow", source="0.0.0.0/0", source_list=None, port="443"): + return make_resource( + name=name, + direction=direction, + access=access, + source_address_prefix=source, + source_address_prefixes=source_list or [], + destination_port_range=port, + ) + + # ── AZ-NET-003: unrestricted inbound on 443 ───────────────────────────────── @@ -175,6 +195,105 @@ def test_net_003_noncompliant_returns_one_finding(mock_azure, subscription_id): assert findings[0]["severity"] == "HIGH" +def test_net_003_direction_is_case_insensitive(mock_azure, subscription_id): + """COR-001: lowercase/mixed-case direction values must still be detected.""" + nsg = make_resource( + id=_nsg_id("nsg-lowercase-direction"), + name="nsg-lowercase-direction", + security_rules=[_net_003_rule("AllowHTTPSLower", direction="inbound")], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_net_003_detects_plural_source_prefixes(mock_azure, subscription_id): + """COR-002: an open source listed only in source_address_prefixes must be detected.""" + nsg = make_resource( + id=_nsg_id("nsg-plural-prefix"), + name="nsg-plural-prefix", + security_rules=[ + _net_003_rule( + "AllowHTTPSPluralOpen", + source="", + source_list=["0.0.0.0/0"], + ) + ], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_detects_finding_with_real_sdk_enum_direction_and_access(mock_azure, subscription_id): + """COR-001 (SDK model): real SecurityRuleDirection/Access enums, not plain strings, + must still be detected. str(enum) yields e.g. "SecurityRuleDirection.INBOUND" rather + than "Inbound", so the rule must normalise via .value instead of naive str().""" + rule = SecurityRule( + name="AllowHTTPSFromInternetEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix="0.0.0.0/0", + source_address_prefixes=[], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-443-open-enum"), + name="nsg-443-open-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-NET-003" + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_compliant_with_real_sdk_enum_returns_no_findings(mock_azure, subscription_id): + """COR-001 (SDK model): real SDK enums on a restricted rule must produce no findings.""" + rule = SecurityRule( + name="AllowHTTPSFromTrustedEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix="10.0.0.0/24", + source_address_prefixes=[], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-443-restricted-enum"), + name="nsg-443-restricted-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert findings == [] + + +@pytest.mark.skipif(not _AZURE_SDK_AVAILABLE, reason="azure-mgmt-network not installed") +def test_net_003_detects_plural_source_prefixes_with_real_sdk_model(mock_azure, subscription_id): + """COR-002 (SDK model): an open source only in source_address_prefixes, using a real + SecurityRule instance with SDK enum fields, must still be detected and reported in + finding metadata.""" + rule = SecurityRule( + name="AllowHTTPSPluralOpenEnum", + direction=SecurityRuleDirection.INBOUND, + access=SecurityRuleAccess.ALLOW, + source_address_prefix=None, + source_address_prefixes=["0.0.0.0/0"], + destination_port_range="443", + ) + nsg = make_resource( + id=_nsg_id("nsg-plural-prefix-enum"), + name="nsg-plural-prefix-enum", + security_rules=[rule], + ) + mock_azure.set_network_security_groups([nsg]) + findings = az_net_003.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["matched_source_address_prefix"] == "0.0.0.0/0" + + # ── AZ-NET-004: NSG with no rules ─────────────────────────────────────────── diff --git a/website/content.js b/website/content.js index e0d989b..da36e32 100644 --- a/website/content.js +++ b/website/content.js @@ -82,6 +82,17 @@ const siteContent = { {"id": "AZ-IDN-002", "name": "No MFA Enforced on Admin Accounts via Conditional Access", "severity": "HIGH", "category": "Identity", "description": "No Conditional Access policy is enabled that requires multi-factor authentication for administrator accounts. Without MFA enforcement, a single compromised password is sufficient for an attacker to gain privileged access.", "frameworks": {"CIS": "1.2.4", "NIST": "PR.AC-1", "ISO27001": "A.9.4.2"}}, {"id": "AZ-IDN-003", "name": "Guest User Invitations Not Restricted to Admins in Entra ID", "severity": "MEDIUM", "category": "Identity", "description": "Guest user invitations in Entra ID are not restricted to administrators. Any organisation member can invite external users without centralised review, bypassing formal external identity provisioning controls.", "frameworks": {"CIS": "1.15", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, {"id": "AZ-IDN-004", "name": "No Privileged Identity Management for Admin Roles", "severity": "HIGH", "category": "Identity", "description": "Privileged Identity Management (PIM) is not configured for one or more admin roles in Entra ID. Without PIM, admin roles are permanently assigned with no just-in-time access controls or time-bound activation.", "frameworks": {"CIS": "1.16", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"}}, + {"id": "AZ-IDN-005", "name": "Guest User with High Privilege Role in Entra ID", "severity": "HIGH", "category": "Identity", "description": "A guest identity holds a privileged directory role, increasing the impact of external account compromise.", "frameworks": {"CIS": "1.3", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-IDN-006", "name": "Service Principal Client Secret Older Than 90 Days", "severity": "HIGH", "category": "Identity", "description": "A service principal credential is older than the approved rotation period.", "frameworks": {"CIS": "1.14", "NIST": "PR.AC-1", "ISO27001": "A.9.4.3"}}, + {"id": "AZ-IDN-007", "name": "Active User with No MFA Registered in Entra ID", "severity": "HIGH", "category": "Identity", "description": "An active member account has no registered multi-factor authentication method.", "frameworks": {"CIS": "1.1", "NIST": "PR.AC-7", "ISO27001": "A.9.4.2"}}, + {"id": "AZ-IDN-008", "name": "Custom RBAC Role with Wildcard Permissions at Subscription Scope", "severity": "HIGH", "category": "Identity", "description": "A custom role grants wildcard permissions at subscription scope and can exceed intended least privilege.", "frameworks": {"CIS": "1.23", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-IDN-009", "name": "No Activity Log Alert for Role Assignment Changes", "severity": "MEDIUM", "category": "Identity", "description": "No enabled activity-log alert detects changes to Azure role assignments.", "frameworks": {"CIS": "5.2.1", "NIST": "DE.CM-3", "ISO27001": "A.12.4.1"}}, + {"id": "AZ-IDN-010", "name": "App Registration Has No Owner", "severity": "MEDIUM", "category": "Identity", "description": "The App Registration has no assigned owner. Unowned applications can escape periodic review, credential rotation, permission cleanup, and accountable incident response.", "frameworks": {"NIST": "PR.AC-4", "ISO27001": "A.9.2.1", "SOC2": "CC6.2"}}, + {"id": "AZ-IDN-011", "name": "App Registration Uses Insecure Redirect URI", "severity": "HIGH", "category": "Identity", "description": "The App Registration contains an HTTP redirect URI for a non-loopback host. Authorization responses can be intercepted or modified before reaching the application.", "frameworks": {"NIST": "PR.DS-2", "ISO27001": "A.14.1.2", "SOC2": "CC6.7"}}, + {"id": "AZ-IDN-012", "name": "App Registration Enables OAuth Implicit Grant", "severity": "MEDIUM", "category": "Identity", "description": "The App Registration enables access-token or ID-token issuance through the legacy implicit grant flow. Tokens can be exposed to browser history, extensions, or front-channel leakage.", "frameworks": {"NIST": "PR.AC-3", "ISO27001": "A.9.4.2", "SOC2": "CC6.1"}}, + {"id": "AZ-IDN-013", "name": "App Registration Uses Password Credentials", "severity": "MEDIUM", "category": "Identity", "description": "The App Registration has one or more password credentials. Client secrets are commonly copied, logged, leaked, or left unrotated and are weaker than managed identity or certificate authentication.", "frameworks": {"NIST": "PR.AC-1", "ISO27001": "A.9.4.3", "SOC2": "CC6.1"}}, + {"id": "AZ-IDN-014", "name": "Multi-Tenant App Registration Lacks Property Lock", "severity": "HIGH", "category": "Identity", "description": "The multi-tenant App Registration does not lock all sensitive properties on its service-principal instances. Tenant administrators can modify credentials or token-encryption settings unexpectedly.", "frameworks": {"NIST": "PR.IP-1", "ISO27001": "A.12.1.2", "SOC2": "CC6.6"}}, + {"id": "AZ-IDN-015", "name": "Managed Identity Has Privileged Subscription Role", "severity": "HIGH", "category": "Identity", "description": "A managed identity holds Owner or Contributor at subscription scope. Compromise of any resource that can use the identity would provide an unnecessarily large Azure control-plane blast radius.", "frameworks": {"NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.3"}}, // KeyVault (5) {"id": "AZ-KV-001", "name": "Key Vault with Soft Delete Disabled", "severity": "MEDIUM", "category": "KeyVault", "description": "Azure Key Vault soft delete is disabled. Without soft delete, secrets, keys, and certificates can be permanently destroyed immediately upon deletion by accident, a disgruntled insider, or an attacker.", "frameworks": {"CIS": "8.8", "NIST": "PR.IP-4", "ISO27001": "A.17.2.1"}}, {"id": "AZ-KV-002", "name": "Key Vault Allows Public Network Access Without Private Endpoint", "severity": "HIGH", "category": "KeyVault", "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.", "frameworks": {"CIS": "8.7", "NIST": "AC-17", "ISO27001": "A.13.1.1"}}, @@ -103,6 +114,7 @@ const siteContent = { {"id": "AZ-NET-012", "name": "NSG Flow Logs Not Enabled", "severity": "MEDIUM", "category": "Network", "description": "Network Security Group flow logs are not enabled. Without flow logs, network traffic is not auditable and attacker movement through the network cannot be reconstructed.", "frameworks": {"CIS": "6.7", "NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"}}, {"id": "AZ-NET-013", "name": "Azure Firewall Not Enabled on Virtual Network", "severity": "HIGH", "category": "Network", "description": "The virtual network has no Azure Firewall deployed or associated. Relying only on NSGs leaves the network without a centralized perimeter inspection, logging, and threat-filtering layer.", "frameworks": {"CIS": "6.4", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, {"id": "AZ-NET-014", "name": "VNet Peering Configured Without Gateway Transit Restrictions", "severity": "MEDIUM", "category": "Network", "description": "A Virtual Network peering connection has gateway transit enabled, potentially enabling lateral movement between network zones that should be isolated from each other.", "frameworks": {"CIS": "6.6", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-NET-015", "name": "Public DNS Zone Exposes Internal Infrastructure Details", "severity": "MEDIUM", "category": "Network", "description": "A public DNS zone exposes private RFC1918 addresses or internal-service names that assist infrastructure reconnaissance.", "frameworks": {"CIS": "9.8", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, // Storage (5) {"id": "AZ-STOR-001", "name": "Public Blob Access Enabled on Storage Account", "severity": "HIGH", "category": "Storage", "description": "Storage accounts with public blob access enabled allow unauthenticated read access to blob data over the internet. This setting can expose sensitive files, backups, or configuration data to any external actor.", "frameworks": {"CIS": "3.5", "NIST": "PR.AC-3", "ISO27001": "A.9.4.1"}}, {"id": "AZ-STOR-002", "name": "Storage Account Allows HTTP Traffic (Not HTTPS-Only)", "severity": "HIGH", "category": "Storage", "description": "Storage accounts that do not enforce HTTPS-only traffic allow data to be transmitted in plaintext over HTTP. This exposes credentials and data to man-in-the-middle attacks and interception.", "frameworks": {"CIS": "3.1", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1"}}, @@ -112,7 +124,31 @@ const siteContent = { // Post-Quantum Cryptography (3) — unique to OpenShield; no competitor OSS CSPM tool scans for quantum-vulnerable assets {"id": "AZ-PQC-001", "name": "TLS Using Classical Key Exchange Algorithm", "severity": "HIGH", "category": "PostQuantum", "description": "The resource is configured with TLS using classical key exchange algorithms such as RSA or ECDH. Adversaries are executing Harvest Now Decrypt Later attacks — collecting encrypted traffic today to decrypt once quantum computers are available. Maps to NIST FIPS 203 (ML-KEM) migration requirements.", "frameworks": {"CIS": "9.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.1", "SOC2": "CC6.7"}}, {"id": "AZ-PQC-002", "name": "Key Vault Key Using Non-Quantum-Safe Algorithm", "severity": "HIGH", "category": "PostQuantum", "description": "The Key Vault contains RSA or ECC keys vulnerable to Shor's algorithm on quantum computers. A sufficiently powerful quantum computer can break these keys, compromising all data encrypted or signed with them. Maps to NIST FIPS 204 (ML-DSA) migration requirements.", "frameworks": {"CIS": "8.1", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}}, - {"id": "AZ-PQC-003", "name": "Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm", "severity": "MEDIUM", "category": "PostQuantum", "description": "The Key Vault contains certificates signed using RSA or ECDSA algorithms, vulnerable to Shor's algorithm. Certificates used for TLS, authentication, and code signing will need migration to ML-DSA (FIPS 204) or SLH-DSA (FIPS 205) as certificate authorities add post-quantum support.", "frameworks": {"CIS": "8.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}} + {"id": "AZ-PQC-003", "name": "Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm", "severity": "MEDIUM", "category": "PostQuantum", "description": "The Key Vault contains certificates signed using RSA or ECDSA algorithms, vulnerable to Shor's algorithm. Certificates used for TLS, authentication, and code signing will need migration to ML-DSA (FIPS 204) or SLH-DSA (FIPS 205) as certificate authorities add post-quantum support.", "frameworks": {"CIS": "8.9", "NIST": "PR.DS-2", "ISO27001": "A.10.1.2", "SOC2": "CC6.7"}}, + {"id": "AZ-AKS-001", "name": "AKS Private Cluster Not Enabled", "severity": "HIGH", "category": "Kubernetes", "description": "The AKS control-plane API is reachable through a public endpoint.", "frameworks": {"CIS": "N/A-AKS-001", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"}}, + {"id": "AZ-AKS-002", "name": "AKS Local Accounts Enabled", "severity": "HIGH", "category": "Kubernetes", "description": "Local AKS accounts permit authentication outside centrally governed Entra identities.", "frameworks": {"CIS": "N/A-AKS-002", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, + {"id": "AZ-AKS-003", "name": "AKS Cluster Not Using Managed Identity", "severity": "HIGH", "category": "Kubernetes", "description": "The AKS control plane does not use an Azure managed identity.", "frameworks": {"CIS": "N/A-AKS-003", "NIST": "PR.AC-1", "ISO27001": "A.9.2.1"}}, + {"id": "AZ-AKS-004", "name": "AKS Workload Identity Not Fully Enabled", "severity": "MEDIUM", "category": "Kubernetes", "description": "OIDC issuer or Workload Identity support is not fully enabled for the cluster.", "frameworks": {"CIS": "N/A-AKS-004", "NIST": "PR.AC-4", "ISO27001": "A.9.2.3"}}, + {"id": "AZ-AKS-005", "name": "AKS Azure Policy Add-on Not Enabled", "severity": "MEDIUM", "category": "Kubernetes", "description": "The cluster does not use the Azure Policy add-on for centralized governance.", "frameworks": {"CIS": "N/A-AKS-005", "NIST": "PR.IP-1", "ISO27001": "A.12.1.2"}}, + {"id": "AZ-AKS-006", "name": "AKS Node OS Automatic Upgrades Disabled", "severity": "HIGH", "category": "Kubernetes", "description": "The cluster lacks a managed node operating-system security upgrade channel.", "frameworks": {"CIS": "N/A-AKS-006", "NIST": "PR.IP-12", "ISO27001": "A.12.6.1"}}, + // Backup (4) + {"id": "AZ-BAK-001", "name": "Backup Soft Delete Disabled or Below 35 Days", "severity": "CRITICAL", "category": "Backup", "description": "The Recovery Services vault lacks the approved soft-delete recovery window.", "frameworks": {"NIST": "PR.IP-4", "ISO27001": "A.12.3.1", "SOC2": "A1.2"}}, + {"id": "AZ-BAK-002", "name": "Backup Vault Immutability Disabled", "severity": "HIGH", "category": "Backup", "description": "Vault immutability is disabled, allowing destructive changes to protected recovery points.", "frameworks": {"NIST": "PR.IP-4", "ISO27001": "A.12.3.1", "SOC2": "A1.2"}}, + {"id": "AZ-BAK-004", "name": "Backup Multiuser Authorization Missing", "severity": "HIGH", "category": "Backup", "description": "The vault does not enable Resource Guard multiuser authorization.", "frameworks": {"NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.1"}}, + {"id": "AZ-BAK-006", "name": "Backup Security Monitoring Disabled", "severity": "MEDIUM", "category": "Backup", "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures.", "frameworks": {"NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"}}, + // Serverless (5) + {"id": "AZ-FUNC-001", "name": "Function App HTTPS Only Disabled", "severity": "HIGH", "category": "Serverless", "description": "The Function App accepts unencrypted HTTP traffic.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-002", "name": "Function App Minimum TLS Below 1.2", "severity": "HIGH", "category": "Serverless", "description": "The Function App permits TLS older than 1.2.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-003", "name": "Function App FTP Publishing Enabled", "severity": "MEDIUM", "category": "Serverless", "description": "The Function App exposes an FTP or FTPS deployment channel.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-004", "name": "Function App Remote Debugging Enabled", "severity": "HIGH", "category": "Serverless", "description": "Remote debugging expands the Function App management attack surface.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-005", "name": "Function App Managed Identity Missing", "severity": "MEDIUM", "category": "Serverless", "description": "The Function App has no Azure managed identity for secretless resource access.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + // Private Endpoint (6) + {"id": "AZ-PE-001", "name": "Storage Public Network Access Enabled", "severity": "HIGH", "category": "Network", "description": "A Storage Account remains publicly reachable; an approved private endpoint alone does not disable its public endpoint.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-PE-002", "name": "Azure SQL Public Network Access Enabled", "severity": "HIGH", "category": "Network", "description": "An Azure SQL logical server remains publicly reachable, regardless of whether a private endpoint also exists.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-PE-003", "name": "PostgreSQL Public Network Access Enabled", "severity": "HIGH", "category": "Network", "description": "A PostgreSQL Flexible Server remains publicly reachable instead of using private networking only.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-PE-004", "name": "Web or Function App Public Network Access Enabled", "severity": "HIGH", "category": "Network", "description": "An App Service workload remains publicly reachable without a default-deny access policy.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-PE-005", "name": "Recovery Vault Public Network Access Enabled", "severity": "HIGH", "category": "Network", "description": "A Recovery Services vault permits public access, even if a private endpoint also exists.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-PE-006", "name": "Private Endpoint Connection Not Approved", "severity": "MEDIUM", "category": "Network", "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}} ], ecosystem: [ { @@ -464,7 +500,7 @@ const siteContent = { "Flask REST API with JWT authentication and CORS", "Scanner engine with 20 Azure misconfiguration rules across Storage, Network, Identity, Database, Compute, and Key Vault", "Compliance framework mappings for CIS Azure Benchmark, NIST CSF, ISO 27001, and SOC 2", - "36 Azure CLI remediation playbooks — one per scanner rule", + "72 Azure CLI remediation playbooks — one per scanner rule", "PostgreSQL persistence for scan history and findings", "Microsoft Sentinel integration via Log Analytics custom table and KQL analytics rules", "GitHub Actions CI pipeline with 7 automated checks" From 2fa338f63f1dee2c3f0332173372f5d2f8bb0d13 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 18 Jul 2026 14:21:00 +0100 Subject: [PATCH 4/5] test: add regression coverage for partial-failure isolation Covers the two get_private_endpoint_posture / get_function_app_security_posture fixes from the previous commit: one resource type or one app failing must not discard results already collected for the others. Signed-off-by: Tanvir Farhad --- tests/test_azure_client_management.py | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py index a0f6552..14c4e72 100644 --- a/tests/test_azure_client_management.py +++ b/tests/test_azure_client_management.py @@ -94,3 +94,55 @@ def test_vm_extensions_normalize_sdk_page_and_failure(client): assert client.get_vm_extensions("rg", "vm")[0].name == "agent" constructor.return_value.virtual_machine_extensions.list.side_effect = RuntimeError("denied") assert client.get_vm_extensions("rg", "vm") is None + + +def test_private_endpoint_posture_one_service_failure_does_not_drop_others(client): + """One resource type's API call failing (e.g. SQL RP not registered) must + not discard records already collected for other resource types in the + same call — only that service's records should be missing.""" + with ( + patch("scanner.azure_client.NetworkManagementClient") as network_ctor, + patch("scanner.azure_client.StorageManagementClient") as storage_ctor, + patch("scanner.azure_client.SqlManagementClient") as sql_ctor, + patch("azure.mgmt.web.WebSiteManagementClient") as web_ctor, + patch("azure.mgmt.postgresqlflexibleservers.PostgreSQLManagementClient") as pg_ctor, + patch("azure.mgmt.recoveryservices.RecoveryServicesClient") as recovery_ctor, + ): + network_ctor.return_value.private_endpoints.list_by_subscription.return_value = [] + storage_ctor.return_value.storage_accounts.list.return_value = [ + SimpleNamespace(id="/sa1", name="sa1", public_network_access="Enabled") + ] + sql_ctor.return_value.servers.list.side_effect = RuntimeError("SQL RP not registered") + web_ctor.return_value.web_apps.list.return_value = [] + pg_ctor.return_value.servers.list.return_value = [] + recovery_ctor.return_value.vaults.list_by_subscription_id.return_value = [] + + records = client.get_private_endpoint_posture() + + assert records is not None + services = {r["service"] for r in records} + assert "storage" in services + assert "sql" not in services + + +def test_function_app_security_posture_one_app_failure_does_not_drop_others(client): + """A single Function App's get_configuration() call failing (e.g. the app + is stopped) must not discard posture data already collected for other + Function Apps in the same subscription.""" + with patch("azure.mgmt.web.WebSiteManagementClient") as web_ctor: + good_app = SimpleNamespace(id="/apps/good", name="good", kind="functionapp", https_only=True, identity=None) + bad_app = SimpleNamespace(id="/apps/bad", name="bad", kind="functionapp", https_only=True, identity=None) + web_ctor.return_value.web_apps.list.return_value = [good_app, bad_app] + + def get_configuration(resource_group, name): + if name == "bad": + raise RuntimeError("app is stopped") + return SimpleNamespace(min_tls_version="1.2", ftps_state="Disabled", remote_debugging_enabled=False) + + web_ctor.return_value.web_apps.get_configuration.side_effect = get_configuration + + result = client.get_function_app_security_posture() + + assert result is not None + names = {app["name"] for app in result} + assert names == {"good"} From 811e484b1a48e6be2aa6ffebcf0c1a7a0736888f Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 18 Jul 2026 14:23:41 +0100 Subject: [PATCH 5/5] fix(rules): map AZ-FUNC-001/002 to data-in-transit controls Per Ibrahim's non-blocking review note: AZ-FUNC-001 (HTTPS-only) and AZ-FUNC-002 (min TLS version) are data-in-transit concerns, not the generic network-boundary default the enterprise resilience rule pack inherits for everything else. Map both to NIST PR.DS-2 / ISO A.13.2.1 instead, matching the existing AZ-STOR-002/AZ-DB-003 pattern. Signed-off-by: Tanvir Farhad --- compliance/frameworks/iso27001.json | 12 ++++++------ compliance/frameworks/nist_csf.json | 12 ++++++------ docs/rules-reference.md | 4 ++-- scanner/rules/az_func_001.py | 2 +- scanner/rules/az_func_002.py | 2 +- website/content.js | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 5da8b16..154cd85 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -279,14 +279,14 @@ "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." }, "AZ-FUNC-001": { - "control_id": "A.13.1.1", - "control_name": "Network controls", - "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + "control_id": "A.13.2.1", + "control_name": "Information transfer policies and procedures", + "description": "The Function App accepts unencrypted HTTP traffic, so requests and responses can cross the network without encryption in transit." }, "AZ-FUNC-002": { - "control_id": "A.13.1.1", - "control_name": "Network controls", - "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + "control_id": "A.13.2.1", + "control_name": "Information transfer policies and procedures", + "description": "The Function App permits TLS older than 1.2, weakening the encryption protecting traffic in transit." }, "AZ-FUNC-003": { "control_id": "A.13.1.1", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index bc8b2ac..c31ae50 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -279,14 +279,14 @@ "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures, so a failed or tampered backup could go undetected." }, "AZ-FUNC-001": { - "control_id": "PR.AC-5", - "control_name": "Network integrity is protected", - "description": "The Function App accepts unencrypted HTTP traffic, allowing requests and responses to cross the network boundary without encryption in transit." + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "The Function App accepts unencrypted HTTP traffic, so requests and responses can cross the network without encryption in transit." }, "AZ-FUNC-002": { - "control_id": "PR.AC-5", - "control_name": "Network integrity is protected", - "description": "The Function App permits TLS older than 1.2, weakening the network controls that protect traffic crossing the network boundary." + "control_id": "PR.DS-2", + "control_name": "Data-in-transit is protected", + "description": "The Function App permits TLS older than 1.2, weakening the encryption protecting traffic in transit." }, "AZ-FUNC-003": { "control_id": "PR.AC-5", diff --git a/docs/rules-reference.md b/docs/rules-reference.md index d311196..319d8f7 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -65,8 +65,8 @@ OpenShield currently ships 72 Azure scan rules. This table is generated from the | AZ-BAK-002 | Backup Vault Immutability Disabled | HIGH | Backup | TBD-BAK-002 | PR.IP-4 | A.12.3.1 | | AZ-BAK-004 | Backup Multiuser Authorization Missing | HIGH | Backup | TBD-BAK-004 | PR.AC-4 | A.9.2.3 | | AZ-BAK-006 | Backup Security Monitoring Disabled | MEDIUM | Backup | TBD-BAK-006 | DE.CM-1 | A.12.4.1 | -| AZ-FUNC-001 | Function App HTTPS Only Disabled | HIGH | Serverless | TBD-FUNC-001 | PR.AC-5 | A.13.1.1 | -| AZ-FUNC-002 | Function App Minimum TLS Below 1.2 | HIGH | Serverless | TBD-FUNC-002 | PR.AC-5 | A.13.1.1 | +| AZ-FUNC-001 | Function App HTTPS Only Disabled | HIGH | Serverless | TBD-FUNC-001 | PR.DS-2 | A.13.2.1 | +| AZ-FUNC-002 | Function App Minimum TLS Below 1.2 | HIGH | Serverless | TBD-FUNC-002 | PR.DS-2 | A.13.2.1 | | AZ-FUNC-003 | Function App FTP Publishing Enabled | MEDIUM | Serverless | TBD-FUNC-003 | PR.AC-5 | A.13.1.1 | | AZ-FUNC-004 | Function App Remote Debugging Enabled | HIGH | Serverless | TBD-FUNC-004 | PR.AC-5 | A.13.1.1 | | AZ-FUNC-005 | Function App Managed Identity Missing | MEDIUM | Serverless | TBD-FUNC-005 | PR.AC-5 | A.13.1.1 | diff --git a/scanner/rules/az_func_001.py b/scanner/rules/az_func_001.py index 1734a1e..13400c2 100644 --- a/scanner/rules/az_func_001.py +++ b/scanner/rules/az_func_001.py @@ -6,7 +6,7 @@ RULE_NAME = "Function App HTTPS Only Disabled" SEVERITY = "HIGH" CATEGORY = "Serverless" -FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-001"} +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-001", "NIST": "PR.DS-2", "ISO27001": "A.13.2.1"} DESCRIPTION = "The Function App accepts unencrypted HTTP traffic." REMEDIATION = "Enable HTTPS Only after validating clients." PLAYBOOK = "playbooks/cli/fix_az_func_001.sh" diff --git a/scanner/rules/az_func_002.py b/scanner/rules/az_func_002.py index 36af3d9..a6aef17 100644 --- a/scanner/rules/az_func_002.py +++ b/scanner/rules/az_func_002.py @@ -6,7 +6,7 @@ RULE_NAME = "Function App Minimum TLS Below 1.2" SEVERITY = "HIGH" CATEGORY = "Serverless" -FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-002"} +FRAMEWORKS = {**FRAMEWORKS, "CIS": "TBD-FUNC-002", "NIST": "PR.DS-2", "ISO27001": "A.13.2.1"} DESCRIPTION = "The Function App permits TLS older than 1.2." REMEDIATION = "Set the minimum inbound TLS version to 1.2 or newer." PLAYBOOK = "playbooks/cli/fix_az_func_002.sh" diff --git a/website/content.js b/website/content.js index da36e32..6a6abfd 100644 --- a/website/content.js +++ b/website/content.js @@ -137,8 +137,8 @@ const siteContent = { {"id": "AZ-BAK-004", "name": "Backup Multiuser Authorization Missing", "severity": "HIGH", "category": "Backup", "description": "The vault does not enable Resource Guard multiuser authorization.", "frameworks": {"NIST": "PR.AC-4", "ISO27001": "A.9.2.3", "SOC2": "CC6.1"}}, {"id": "AZ-BAK-006", "name": "Backup Security Monitoring Disabled", "severity": "MEDIUM", "category": "Backup", "description": "The Recovery Services vault does not enable built-in monitoring for backup job failures.", "frameworks": {"NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"}}, // Serverless (5) - {"id": "AZ-FUNC-001", "name": "Function App HTTPS Only Disabled", "severity": "HIGH", "category": "Serverless", "description": "The Function App accepts unencrypted HTTP traffic.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, - {"id": "AZ-FUNC-002", "name": "Function App Minimum TLS Below 1.2", "severity": "HIGH", "category": "Serverless", "description": "The Function App permits TLS older than 1.2.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-001", "name": "Function App HTTPS Only Disabled", "severity": "HIGH", "category": "Serverless", "description": "The Function App accepts unencrypted HTTP traffic.", "frameworks": {"NIST": "PR.DS-2", "ISO27001": "A.13.2.1", "SOC2": "CC6.6"}}, + {"id": "AZ-FUNC-002", "name": "Function App Minimum TLS Below 1.2", "severity": "HIGH", "category": "Serverless", "description": "The Function App permits TLS older than 1.2.", "frameworks": {"NIST": "PR.DS-2", "ISO27001": "A.13.2.1", "SOC2": "CC6.6"}}, {"id": "AZ-FUNC-003", "name": "Function App FTP Publishing Enabled", "severity": "MEDIUM", "category": "Serverless", "description": "The Function App exposes an FTP or FTPS deployment channel.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, {"id": "AZ-FUNC-004", "name": "Function App Remote Debugging Enabled", "severity": "HIGH", "category": "Serverless", "description": "Remote debugging expands the Function App management attack surface.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}}, {"id": "AZ-FUNC-005", "name": "Function App Managed Identity Missing", "severity": "MEDIUM", "category": "Serverless", "description": "The Function App has no Azure managed identity for secretless resource access.", "frameworks": {"NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"}},