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/CONTRIBUTING.md b/CONTRIBUTING.md index 7c234f4..4b1ba3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -208,6 +208,9 @@ Use the existing wrapper methods in `scanner/azure_client.py` rather than constr | `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. diff --git a/README.md b/README.md index 0d9c900..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 51 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, 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 (51 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 | @@ -93,11 +93,11 @@ Project policies and assurance evidence: flowchart TD A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n51 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\n51 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 78112c3..dfb0f3b 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -287,6 +287,21 @@ "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."}, + "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/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index c4b88d8..154cd85 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -258,35 +258,80 @@ "control_name": "Management of technical vulnerabilities", "description": "Automatic AKS node OS upgrades help deploy tested security patches within a managed maintenance process." }, - "AZ-IDN-010": { - "control_id": "A.9.2.1", - "control_name": "User registration and de-registration", - "description": "App Registration ownership supports accountable identity lifecycle administration." + "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-IDN-011": { - "control_id": "A.14.1.2", - "control_name": "Securing application services on public networks", - "description": "Secure redirect URIs protect identity protocol responses traversing public networks." + "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-IDN-012": { - "control_id": "A.9.4.2", - "control_name": "Secure log-on procedures", - "description": "Modern authorization code flow with PKCE provides stronger token handling than implicit grant." + "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-IDN-013": { - "control_id": "A.9.4.3", - "control_name": "Password management system", - "description": "Avoiding client secrets reduces password-style application credential exposure." + "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-IDN-014": { - "control_id": "A.12.1.2", - "control_name": "Change management", - "description": "Property lock prevents unauthorized changes to sensitive service-principal instance configuration." + "AZ-FUNC-001": { + "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-IDN-015": { - "control_id": "A.9.2.3", - "control_name": "Management of privileged access rights", - "description": "Subscription Owner and Contributor assignments to managed identities require least-privilege reduction." + "AZ-FUNC-002": { + "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", + "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 563c614..c31ae50 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -258,35 +258,80 @@ "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-IDN-010": { + "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": "Assigned owners establish accountability for reviewing and maintaining application access." + "description": "The vault does not enable Resource Guard multiuser authorization, allowing a single compromised or malicious identity to disable backup protections unilaterally." }, - "AZ-IDN-011": { + "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.DS-2", - "control_name": "Data in transit is protected", - "description": "HTTPS redirect URIs protect authorization responses from interception and modification in transit." + "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-IDN-012": { - "control_id": "PR.AC-3", - "control_name": "Remote access is managed", - "description": "Disabling implicit grant reduces exposure of front-channel tokens used for remote application access." + "AZ-FUNC-002": { + "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-IDN-013": { - "control_id": "PR.AC-1", - "control_name": "Identities and credentials are managed", - "description": "Replacing client secrets with managed credentials reduces credential leakage and rotation risk." + "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-IDN-014": { - "control_id": "PR.IP-1", - "control_name": "A baseline configuration is created and maintained", - "description": "Application-instance property lock preserves the approved sensitive-property baseline across tenants." + "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-IDN-015": { - "control_id": "PR.AC-4", - "control_name": "Access permissions and authorizations are managed", - "description": "Managed identities should receive only the minimum role and scope required by their workloads." + "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 5645793..6bb2863 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -258,35 +258,80 @@ "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-IDN-010": { - "control_id": "CC6.2", - "control_name": "Registers and Authorizes Users", - "description": "Assigned application owners establish responsibility for authorization and lifecycle review." + "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-IDN-011": { - "control_id": "CC6.7", - "control_name": "Protects Data in Transit", - "description": "HTTPS redirect URIs protect authorization responses transmitted between identity and application endpoints." + "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-IDN-012": { + "AZ-BAK-004": { "control_id": "CC6.1", - "control_name": "Logical and Physical Access Controls", - "description": "Disabling implicit grant reduces exposure of browser-delivered access and identity tokens." + "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-IDN-013": { - "control_id": "CC6.1", - "control_name": "Logical and Physical Access Controls", - "description": "Secretless or certificate authentication reduces compromise of application access credentials." + "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-IDN-014": { + "AZ-FUNC-001": { "control_id": "CC6.6", - "control_name": "Restricts Access to Information Assets", - "description": "Property lock prevents tenant service-principal instances from changing sensitive credentials and encryption settings." + "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-IDN-015": { - "control_id": "CC6.3", - "control_name": "Role-Based Access", - "description": "Managed identities should not receive broad subscription roles beyond workload requirements." + "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 e62f43b..4e1dc29 100644 --- a/docs/adding-a-rule.md +++ b/docs/adding-a-rule.md @@ -130,6 +130,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: | `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 2249851..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 51 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/) │ │ │ -│ 51 rule files across Storage, Network, Identity, Database, │ -│ Compute, Key Vault, AKS, and post-quantum cryptography │ +│ 72 rule files across Storage, Network, Identity, Database, │ +│ Compute, Key Vault, AKS, post-quantum cryptography, Backup, │ +│ Serverless, and Private Endpoint posture │ └───────────┬───────────────────────────────────────────────────────┘ │ calls ┌───────────▼──────────────────────────────────────────────────────┐ @@ -110,18 +111,21 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 51 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 | 15 | AZ-NET-001 to 015 | -| Identity | 4 | AZ-IDN-001 to 004 | +| 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/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/docs/learn/index.html b/docs/learn/index.html index a9aaa4f..f4014ba 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -841,7 +841,7 @@

Production-shaped, MVP-friendly architecture

Rule coverage

-

51 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. diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 8b2b4f5..319d8f7 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 51 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 | |---|---|---|---|---|---|---| @@ -61,6 +61,21 @@ OpenShield currently ships 51 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.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 | +| 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/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 f0e4abb..c69f75c 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -51,6 +51,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 self._applications_cache: Any = _UNSET self._managed_identity_principals_cache: Any = _UNSET self._subscription_role_assignments_cache: Any = _UNSET @@ -376,6 +379,171 @@ 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 + 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) + 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), + } + ) + + 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 + + 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) + 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 + + 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..183cf2a --- /dev/null +++ b/scanner/rules/_enterprise_resilience_common.py @@ -0,0 +1,104 @@ +"""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..4226be0 --- /dev/null +++ b/scanner/rules/az_bak_001.py @@ -0,0 +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" + + +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..d23ba98 --- /dev/null +++ b/scanner/rules/az_bak_002.py @@ -0,0 +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" + + +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..c90d587 --- /dev/null +++ b/scanner/rules/az_bak_004.py @@ -0,0 +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" + + +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..e4969ee --- /dev/null +++ b/scanner/rules/az_bak_006.py @@ -0,0 +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" + + +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..13400c2 --- /dev/null +++ b/scanner/rules/az_func_001.py @@ -0,0 +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", "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" + + +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..a6aef17 --- /dev/null +++ b/scanner/rules/az_func_002.py @@ -0,0 +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", "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" + + +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..4ed3230 --- /dev/null +++ b/scanner/rules/az_func_003.py @@ -0,0 +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") diff --git a/scanner/rules/az_func_004.py b/scanner/rules/az_func_004.py new file mode 100644 index 0000000..3270d81 --- /dev/null +++ b/scanner/rules/az_func_004.py @@ -0,0 +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) diff --git a/scanner/rules/az_func_005.py b/scanner/rules/az_func_005.py new file mode 100644 index 0000000..e729ae0 --- /dev/null +++ b/scanner/rules/az_func_005.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_001.py b/scanner/rules/az_pe_001.py new file mode 100644 index 0000000..cbad9c9 --- /dev/null +++ b/scanner/rules/az_pe_001.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_002.py b/scanner/rules/az_pe_002.py new file mode 100644 index 0000000..6c02c57 --- /dev/null +++ b/scanner/rules/az_pe_002.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_003.py b/scanner/rules/az_pe_003.py new file mode 100644 index 0000000..4d1dd24 --- /dev/null +++ b/scanner/rules/az_pe_003.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_004.py b/scanner/rules/az_pe_004.py new file mode 100644 index 0000000..00a61ec --- /dev/null +++ b/scanner/rules/az_pe_004.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_005.py b/scanner/rules/az_pe_005.py new file mode 100644 index 0000000..5acc49d --- /dev/null +++ b/scanner/rules/az_pe_005.py @@ -0,0 +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") diff --git a/scanner/rules/az_pe_006.py b/scanner/rules/az_pe_006.py new file mode 100644 index 0000000..6d88618 --- /dev/null +++ b/scanner/rules/az_pe_006.py @@ -0,0 +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") 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"} diff --git a/tests/test_rules_enterprise_resilience.py b/tests/test_rules_enterprise_resilience.py new file mode 100644 index 0000000..c190da0 --- /dev/null +++ b/tests/test_rules_enterprise_resilience.py @@ -0,0 +1,178 @@ +"""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") == [] diff --git a/website/content.js b/website/content.js index 72af029..6a6abfd 100644 --- a/website/content.js +++ b/website/content.js @@ -87,6 +87,12 @@ const siteContent = { {"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"}}, @@ -124,7 +130,25 @@ const siteContent = { {"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"}} + {"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.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"}}, + // 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: [ { @@ -476,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", - "51 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"