From be799a8341396913c38b0d18131a8acfa2c9c2e3 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 17 Aug 2026 02:17:57 +0100 Subject: [PATCH 01/10] feat: complete enterprise data protection rules Signed-off-by: Tanvir Farhad --- .../frameworks/cis_azure_benchmark.json | 26 ++ compliance/frameworks/iso27001.json | 26 ++ compliance/frameworks/nist_csf.json | 26 ++ compliance/frameworks/soc2.json | 26 ++ docs/architecture.md | 8 +- docs/enterprise-rule-pack-v1.md | 226 ++++++++++++++++++ docs/rules-reference.md | 12 +- docs/storage-protection-controls.md | 27 +++ docs/validation/SCANNER_VALIDATION.md | 8 +- playbooks/cli/fix_az_cache_001.sh | 3 + playbooks/cli/fix_az_cosmos_001.sh | 3 + playbooks/cli/fix_az_cosmos_002.sh | 3 + playbooks/cli/fix_az_db_005.sh | 3 + playbooks/cli/fix_az_db_006.sh | 3 + playbooks/cli/fix_az_db_007.sh | 3 + playbooks/cli/fix_az_stor_006.sh | 12 + playbooks/cli/fix_az_stor_007.sh | 11 + playbooks/cli/fix_az_stor_008.sh | 9 + playbooks/cli/fix_az_stor_009.sh | 11 + requirements.txt | 2 + scanner/azure_client.py | 31 +++ scanner/rules/_storage_policy.py | 24 ++ scanner/rules/az_cache_001.py | 56 +++++ scanner/rules/az_cosmos_001.py | 44 ++++ scanner/rules/az_cosmos_002.py | 44 ++++ scanner/rules/az_db_005.py | 46 ++++ scanner/rules/az_db_006.py | 52 ++++ scanner/rules/az_db_007.py | 54 +++++ scanner/rules/az_stor_006.py | 57 +++++ scanner/rules/az_stor_007.py | 62 +++++ scanner/rules/az_stor_008.py | 46 ++++ scanner/rules/az_stor_009.py | 65 +++++ tests/test_rules_data_protection.py | 109 +++++++++ tests/test_rules_storage.py | 102 ++++++++ 34 files changed, 1233 insertions(+), 7 deletions(-) create mode 100644 docs/enterprise-rule-pack-v1.md create mode 100644 docs/storage-protection-controls.md create mode 100644 playbooks/cli/fix_az_cache_001.sh create mode 100644 playbooks/cli/fix_az_cosmos_001.sh create mode 100644 playbooks/cli/fix_az_cosmos_002.sh create mode 100644 playbooks/cli/fix_az_db_005.sh create mode 100644 playbooks/cli/fix_az_db_006.sh create mode 100644 playbooks/cli/fix_az_db_007.sh create mode 100644 playbooks/cli/fix_az_stor_006.sh create mode 100644 playbooks/cli/fix_az_stor_007.sh create mode 100644 playbooks/cli/fix_az_stor_008.sh create mode 100644 playbooks/cli/fix_az_stor_009.sh create mode 100644 scanner/rules/_storage_policy.py create mode 100644 scanner/rules/az_cache_001.py create mode 100644 scanner/rules/az_cosmos_001.py create mode 100644 scanner/rules/az_cosmos_002.py create mode 100644 scanner/rules/az_db_005.py create mode 100644 scanner/rules/az_db_006.py create mode 100644 scanner/rules/az_db_007.py create mode 100644 scanner/rules/az_stor_006.py create mode 100644 scanner/rules/az_stor_007.py create mode 100644 scanner/rules/az_stor_008.py create mode 100644 scanner/rules/az_stor_009.py create mode 100644 tests/test_rules_data_protection.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 2ea43b14..2cb5258c 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -13,6 +13,32 @@ "control_name": "Ensure that 'Secure transfer required' is set to 'Enabled'", "description": "Enabling 'Secure transfer required' on a storage account ensures that all requests made to the storage account use HTTPS. Any requests using HTTP are rejected, protecting data in transit from eavesdropping and man-in-the-middle attacks." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated CIS recommendation." + }, + "AZ-DB-005": {"control_id": "N/A-DB-005", "control_name": "SQL Server Microsoft Entra-Only Authentication Not Enforced", "description": "Service-specific OpenShield control."}, + "AZ-DB-006": {"control_id": "N/A-DB-006", "control_name": "SQL Vulnerability Assessment Not Configured", "description": "Service-specific OpenShield control."}, + "AZ-DB-007": {"control_id": "N/A-DB-007", "control_name": "SQL Auditing Retention Below Minimum", "description": "Service-specific OpenShield control."}, + "AZ-COSMOS-001": {"control_id": "N/A-COSMOS-001", "control_name": "Cosmos DB Local Authentication Enabled", "description": "Service-specific OpenShield control."}, + "AZ-COSMOS-002": {"control_id": "N/A-COSMOS-002", "control_name": "Cosmos DB Public Network Access Enabled", "description": "Service-specific OpenShield control."}, + "AZ-CACHE-001": {"control_id": "N/A-CACHE-001", "control_name": "Managed Cache Public or Non-TLS Access", "description": "Service-specific OpenShield control."}, "AZ-NET-001": { "control_id": "6.2", "control_name": "Ensure that SSH access from the Internet is evaluated and restricted", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 9e51068c..a27e7bac 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -13,6 +13,32 @@ "control_name": "Policy on the use of cryptographic controls", "description": "Requiring secure transfer ensures cryptographic controls are applied to data in transit. A policy on the use of cryptographic controls for protection of information should be developed and implemented." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated ISO 27001 recommendation." + }, + "AZ-DB-005": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "A.12.6.1", "control_name": "Management of technical vulnerabilities", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "A.13.1.1", "control_name": "Network controls", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "A.13.1.1", "control_name": "Network controls", "description": "Managed cache access is private and uses approved TLS."}, "AZ-NET-001": { "control_id": "A.13.1.1", "control_name": "Network controls", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 30c0eaa5..c8f683cd 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -13,6 +13,32 @@ "control_name": "Data-in-transit is protected", "description": "Requiring secure transfer ensures data in transit between clients and Azure Storage is encrypted using HTTPS, protecting against interception and tampering." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-DB-005": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "DE.CM-8", "control_name": "Vulnerability scans are performed", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Managed cache access is private and uses approved TLS."}, "AZ-NET-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 960a252b..ecb01ac6 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -13,6 +13,32 @@ "control_name": "Protects Data in Transit", "description": "Allowing unencrypted HTTP traffic to a storage account exposes data in transit to interception and tampering. CC6.7 requires that data transmitted over networks is protected using encryption. Enforcing HTTPS-only ensures all storage traffic is encrypted in transit." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated SOC 2 recommendation." + }, + "AZ-DB-005": {"control_id": "CC6.3", "control_name": "Logical access security", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "CC7.1", "control_name": "Detection of security events", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "CC7.2", "control_name": "System monitoring", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "CC6.3", "control_name": "Logical access security", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "CC6.6", "control_name": "Logical access security measures", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "CC6.6", "control_name": "Logical access security measures", "description": "Managed cache access is private and uses approved TLS."}, "AZ-STOR-003": { "control_id": "CC8.1", "control_name": "Change Management", diff --git a/docs/architecture.md b/docs/architecture.md index f8fa9f50..3ce724ec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,14 +111,16 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 65 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 83 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| -| Storage | 5 | AZ-STOR-001 to 005 | +| Storage | 9 | AZ-STOR-001 to 009 | | Network | 15 | AZ-NET-001 to 015 | | Identity | 15 | AZ-IDN-001 to 015 | -| Database | 4 | AZ-DB-001 to 004 | +| Database | 7 | AZ-DB-001 to 007 | +| Cosmos DB | 2 | AZ-COSMOS-001 to 002 | +| Managed Cache | 1 | AZ-CACHE-001 | | Compute | 4 | AZ-CMP-001 to 004 | | Key Vault | 5 | AZ-KV-001 to 005 | | Kubernetes | 6 | AZ-AKS-001 to 006 | diff --git a/docs/enterprise-rule-pack-v1.md b/docs/enterprise-rule-pack-v1.md new file mode 100644 index 00000000..ccc8c9c8 --- /dev/null +++ b/docs/enterprise-rule-pack-v1.md @@ -0,0 +1,226 @@ +# OpenShield Enterprise Rule Pack v1 + +## Status + +This document is a researched backlog of 100 proposed enterprise security rules. It is not evidence that these rules are implemented. A rule becomes production-ready only after its collector, evaluation logic, tests, permission handling, evidence output, documentation, and live validation are complete. + +The candidates are aligned with the Microsoft Cloud Security Benchmark, Microsoft Defender for Cloud recommendations, Azure service security baselines, Zero Trust principles, and control areas commonly covered by commercial CSPM and CNAPP platforms. They are not copied from a proprietary paid-product rule library. + +PQC and CBOM are intentionally excluded from this pack. + +## Required rule contract + +Every rule must return one of the following states: + +- `PASS`: sufficient evidence proves that the control is satisfied. +- `FAIL`: sufficient evidence proves that the control is not satisfied. +- `UNKNOWN`: the scanner could not establish the result, including permission or API failures. +- `NOT_APPLICABLE`: the control does not apply to the evaluated resource. + +Every finding should include: + +- Rule and resource identifiers +- Tenant, subscription, resource group, and region +- Observed and expected configuration +- Evidence source and collection timestamp +- Exposure, privilege, and data-sensitivity context +- Attack-path relevance +- Remediation guidance and required permissions +- Verified compliance references +- Confidence and reason for `UNKNOWN` + +An API or authorization error must never produce a false `PASS`. + +## Proposed rules + +### 1. Identity and privileged access + +1. Privileged users do not use phishing-resistant MFA. +2. Global Administrator roles are permanently assigned. +3. Privileged roles are assigned outside Privileged Identity Management. +4. Stale privileged accounts retain active access. +5. Emergency access accounts are missing or incorrectly protected. +6. Conditional Access does not block legacy authentication. +7. Conditional Access does not protect Azure management operations. +8. Risky-user or risky-sign-in protection is missing. +9. Workload identities are excluded from applicable access controls. +10. Privileged group membership is not appropriately governed. + +### 2. Application identities and OAuth + +11. An application has high-risk Microsoft Graph permissions. +12. An application has tenant-wide admin consent without an approved justification. +13. An unverified publisher application has privileged permissions. +14. Application credentials have an excessive validity period. +15. An application contains multiple active secrets without a documented need. +16. A stale application retains credentials or privileged permissions. +17. A service principal allows access without required user assignment. +18. A federated identity credential uses an overly broad subject or trust condition. +19. An application permits public-client authentication unnecessarily. +20. Application ownership contains inactive, guest, or otherwise unsuitable accounts. + +### 3. Network, Private Link, and perimeter security + +21. A private endpoint exists while public network access remains enabled. +22. A private endpoint connection is pending, rejected, or disconnected. +23. A private endpoint lacks the required private DNS zone association. +24. A private endpoint FQDN does not resolve through the expected private path. +25. A critical PaaS resource is internet-accessible without an approved exception. +26. Azure Firewall threat intelligence is not configured for alert-and-deny enforcement. +27. Application Gateway WAF is not operating in Prevention mode. +28. WAF diagnostic logging is not enabled. +29. WAF bot protection or an approved current managed rule set is missing. +30. An internet-facing application lacks an approved rate-limiting control. + +### 4. Azure Functions, App Service, and API Management + +31. A Function App does not enforce HTTPS-only access. +32. A Function App permits obsolete TLS versions. +33. A Function App permits public access without an approved requirement. +34. A Function App lacks a managed identity where supported. +35. Function App platform authentication is disabled where authentication is required. +36. Function App or App Service CORS configuration contains a wildcard origin. +37. App Service FTP or basic publishing authentication is enabled. +38. The SCM deployment endpoint is unnecessarily publicly accessible. +39. API Management developer portal authentication is insufficiently protected. +40. API Management lacks approved JWT validation, throttling, or equivalent gateway controls. + +### 5. Data protection and databases + +41. Storage account shared-key authorization remains enabled without an exception. +42. A storage account permits TLS below the approved minimum version. +43. Sensitive storage data is not protected with a required customer-managed key. +44. A critical blob container lacks an immutability policy. +45. Azure SQL does not enforce Microsoft Entra-only authentication where required. +46. SQL vulnerability assessment is not configured. +47. SQL auditing has insufficient coverage or retention. +48. Cosmos DB local authentication remains enabled without an approved requirement. +49. Cosmos DB public network access is enabled without an exception. +50. A managed cache permits public or non-TLS access. + +### 6. AKS and container workload security + +51. The AKS API server lacks approved IP restrictions. +52. An AKS cluster has no Kubernetes network policy. +53. Defender for Containers protection is disabled for an in-scope AKS cluster. +54. AKS secrets lack required Key Vault or KMS-backed protection. +55. Secrets Store CSI secret rotation is disabled. +56. Kubernetes workloads permit privileged containers. +57. Workloads permit unrestricted host network, host PID, or host IPC access. +58. Workloads permit unrestricted `hostPath` volumes. +59. Kubernetes `cluster-admin` access is assigned too broadly. +60. Workloads use untrusted registries, mutable tags, or the `latest` image tag. + +### 7. Backup, ransomware resilience, and recovery + +61. A critical resource is not protected by an approved backup policy. +62. Backup vault soft delete is disabled. +63. Enhanced soft delete is not enabled where required. +64. Backup vault immutability is not enabled. +65. Required backup immutability has not been locked. +66. Multi-user authorization through Resource Guard is missing. +67. Backup administration and Resource Guard permissions are not separated. +68. A backup vault permits unnecessary public network access. +69. Backup security alerts lack a monitored notification destination. +70. Recovery capability lacks evidence of a successful restore test within the required period. + +### 8. Logging, detection, and security operations + +71. Subscription activity logs are not exported to an approved central destination. +72. Required administrative, security, policy, or service-health log categories are missing. +73. A critical resource lacks required diagnostic settings. +74. Security logs have insufficient retention. +75. Security logs are stored only in a destination that can be modified by workload administrators. +76. Required Defender for Cloud protection is missing for a critical workload. +77. A high-risk Defender recommendation remains unresolved beyond its SLA. +78. A required Microsoft Sentinel data connector is disconnected or unhealthy. +79. Sentinel lacks required high-severity analytics coverage. +80. Security alerts have no monitored incident-response destination. + +### 9. Governance, policy, and tenant control + +81. A subscription is outside the approved management-group hierarchy. +82. A required security policy initiative is not assigned at the correct scope. +83. A mandatory preventive policy uses Audit instead of an approved enforcement effect. +84. A policy exemption lacks an owner, justification, or expiration date. +85. A critical production resource lacks an approved deletion lock. +86. A subscription has excessive Owner assignments. +87. Privileged access is assigned at an unnecessarily broad scope. +88. A resource provider is registered without a documented operational requirement. +89. A production resource lacks accountable ownership metadata. +90. Security configuration drift remains unresolved beyond the approved SLA. + +### 10. DevSecOps, supply chain, and AI services + +91. A CI/CD workflow uses long-lived Azure credentials instead of workload identity federation. +92. A CI workflow has unnecessarily broad token permissions. +93. A third-party workflow action is not pinned to an immutable commit. +94. Untrusted pull-request input can reach a privileged workflow context. +95. A protected branch permits unreviewed production changes. +96. A release artifact lacks an approved signature or provenance attestation. +97. Infrastructure deployment can bypass required security scanning. +98. An Azure OpenAI or Foundry resource permits unnecessary public access. +99. An AI service uses static keys where managed identity is available and required. +100. An AI resource lacks required diagnostic logging, encryption, or content-safety controls. + +## Collection architecture + +The 100 rules cannot be implemented correctly through a single Azure API. + +| Evidence area | Preferred collector | +| --- | --- | +| Azure resource inventory and configuration | Azure Resource Graph | +| Configuration unavailable through ARG | Targeted Azure management SDK calls | +| Entra users, roles, applications, consent, and Conditional Access | Microsoft Graph | +| Kubernetes RBAC, pod specifications, and workload policies | Kubernetes API | +| GitHub and Azure DevOps security controls | Provider APIs and repository analysis | +| Runtime reachability and private DNS validation | Explicit, opt-in validation probes | + +The intended flow is: + +```text +ARG inventory + -> rule applicability filtering + -> Microsoft Graph, SDK, Kubernetes, or DevOps enrichment + -> deterministic rule evaluation + -> evidence-backed findings + -> contextual risk and attack-path correlation +``` + +## Recommended delivery order + +Implement the pack as five reviewable releases rather than one 100-rule pull request: + +1. Private Link, perimeter, Functions, App Service, and API Management +2. Backup, recovery, logging, and detection +3. Identity, privileged access, application identities, and OAuth +4. Data protection, AKS, and container workload security +5. Governance, DevSecOps, supply chain, and AI services + +Each release should include approximately 20 rules, rule documentation, unit and failure-path tests, permission tests, regression tests, and representative live Azure evidence. + +## Production readiness gate + +A proposed rule is ready only when all of the following are complete: + +- The rule does not duplicate existing OpenShield behavior. +- Applicability and required permissions are documented. +- API errors and missing permissions return `UNKNOWN`. +- Positive, negative, malformed-data, and permission-failure tests pass. +- Evidence is stable, minimal, and useful to an operator. +- Severity is based on impact and context, not only the failed setting. +- Remediation is least-privilege and does not claim to be automatically safe. +- Compliance mappings use verified control identifiers rather than placeholders. +- A representative live validation has been recorded where practical. +- Documentation clearly distinguishes implemented behavior from future correlation features. + +## Primary research sources + +- [Microsoft Cloud Security Benchmark](https://learn.microsoft.com/en-us/security/benchmark/azure/) +- [Microsoft Defender for Cloud security recommendations](https://learn.microsoft.com/en-us/azure/defender-for-cloud/security-recommendations) +- [Microsoft Entra identity security guidance](https://learn.microsoft.com/en-us/azure/security/fundamentals/steps-secure-identity) +- [Azure Private Endpoint DNS guidance](https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns) +- [Application Gateway WAF overview](https://learn.microsoft.com/en-us/azure/web-application-firewall/ag/ag-overview) +- [Azure Backup security overview](https://learn.microsoft.com/en-us/azure/backup/security-overview) +- [Azure Backup multi-user authorization](https://learn.microsoft.com/en-us/azure/backup/multi-user-authorization-tutorial) +- [Azure Policy regulatory compliance](https://learn.microsoft.com/en-us/azure/governance/policy/concepts/regulatory-compliance) diff --git a/docs/rules-reference.md b/docs/rules-reference.md index bb43f2ab..64e88354 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 65 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 69 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -55,6 +55,16 @@ OpenShield currently ships 65 Azure scan rules. This table is generated from the | AZ-STOR-003 | Storage Account Has No Lifecycle Management Policy | MEDIUM | Storage | 3.7 | PR.DS-3 | A.8.3.1 | | AZ-STOR-004 | Storage Account Diagnostic Logging Disabled | MEDIUM | Storage | 3.3 | DE.CM-7 | A.12.4.1 | | AZ-STOR-005 | Storage Account Not Using Geo-Redundant Replication | MEDIUM | Storage | 3.8 | PR.IP-4 | A.17.2.1 | +| AZ-STOR-006 | Storage Account Shared-Key Authorization Enabled | HIGH | Storage | N/A-STOR-006 | N/A-STOR-006 | N/A-STOR-006 | +| AZ-STOR-007 | Storage Account Allows TLS Below 1.2 | HIGH | Storage | N/A-STOR-007 | N/A-STOR-007 | N/A-STOR-007 | +| AZ-STOR-008 | Required Storage Customer-Managed Key Protection Missing | HIGH | Storage | N/A-STOR-008 | N/A-STOR-008 | N/A-STOR-008 | +| AZ-STOR-009 | Required Blob Container Immutability Missing | HIGH | Storage | N/A-STOR-009 | N/A-STOR-009 | N/A-STOR-009 | +| AZ-DB-005 | SQL Server Microsoft Entra-Only Authentication Not Enforced | HIGH | Database | N/A-DB-005 | PR.AC-6 | A.9.4.2 | +| AZ-DB-006 | SQL Vulnerability Assessment Not Configured | HIGH | Database | N/A-DB-006 | DE.CM-8 | A.12.6.1 | +| AZ-DB-007 | SQL Auditing Retention Below Minimum | MEDIUM | Database | N/A-DB-007 | A.12.4.1 | A.12.4.1 | +| AZ-COSMOS-001 | Cosmos DB Local Authentication Enabled | HIGH | Database | N/A-COSMOS-001 | PR.AC-6 | A.9.4.2 | +| AZ-COSMOS-002 | Cosmos DB Public Network Access Enabled | HIGH | Network | N/A-COSMOS-002 | PR.AC-5 | A.13.1.1 | +| AZ-CACHE-001 | Managed Cache Public or Non-TLS Access | HIGH | Network | N/A-CACHE-001 | PR.AC-5 | A.13.1.1 | | AZ-AKS-001 | AKS Private Cluster Not Enabled | HIGH | Kubernetes | N/A-AKS-001 | PR.AC-3 | A.13.1.1 | | AZ-AKS-002 | AKS Local Accounts Enabled | HIGH | Kubernetes | N/A-AKS-002 | PR.AC-1 | A.9.2.1 | | AZ-AKS-003 | AKS Cluster Not Using Managed Identity | HIGH | Kubernetes | N/A-AKS-003 | PR.AC-1 | A.9.2.1 | diff --git a/docs/storage-protection-controls.md b/docs/storage-protection-controls.md new file mode 100644 index 00000000..274a5ca9 --- /dev/null +++ b/docs/storage-protection-controls.md @@ -0,0 +1,27 @@ +# Storage protection controls + +The enterprise Storage controls in issue #261 use explicit opt-in metadata for +requirements that depend on business criticality. OpenShield does not infer that +every account or container needs a customer-managed key or immutability policy. + +Use Azure resource tags as follows: + +- `oshield:cmk-required=true` enables `AZ-STOR-008` for a storage account. +- `oshield:immutability-required=true` enables `AZ-STOR-009` for a blob container. +- `oshield:entra-only-required=true` enables `AZ-DB-005` for a SQL server. +- `oshield:sql-va-required=true` enables `AZ-DB-006` for a SQL server. +- `oshield:sql-audit-required=true` enables `AZ-DB-007` for a SQL server. +- `oshield:cosmos-local-auth-disabled=true` enables `AZ-COSMOS-001`. +- `oshield:cosmos-public-access-disabled=true` enables `AZ-COSMOS-002`. +- `oshield:cache-private-tls-required=true` enables `AZ-CACHE-001`. +- `oshield:exception-approved=true` suppresses either control only when the + organization has separately approved the exception. + +Missing tags are not treated as proof that protection is required. Missing +encryption properties, unknown key sources, or an unavailable container API are +indeterminate and produce no finding. A finding is emitted only when the +requirement is explicitly enabled and an unsafe state is positively observed. + +The tags are policy inputs, not evidence of approval by themselves; operators +must maintain the corresponding exception and criticality records outside the +scanner. diff --git a/docs/validation/SCANNER_VALIDATION.md b/docs/validation/SCANNER_VALIDATION.md index 10d93665..74b08920 100644 --- a/docs/validation/SCANNER_VALIDATION.md +++ b/docs/validation/SCANNER_VALIDATION.md @@ -86,18 +86,20 @@ not already include them. The following matrix was verified from actual files in `scanner/rules`. -Total verified rule files: **44** +Total verified rule files: **54** | Category | Count | Rule IDs | |---|---:|---| | Compute | 4 | `AZ-CMP-001`, `AZ-CMP-002`, `AZ-CMP-003`, `AZ-CMP-004` | -| Database | 4 | `AZ-DB-001`, `AZ-DB-002`, `AZ-DB-003`, `AZ-DB-004` | +| Database | 7 | `AZ-DB-001`, `AZ-DB-002`, `AZ-DB-003`, `AZ-DB-004`, `AZ-DB-005`, `AZ-DB-006`, `AZ-DB-007` | +| Cosmos DB | 2 | `AZ-COSMOS-001`, `AZ-COSMOS-002` | | Identity | 9 | `AZ-IDN-001`, `AZ-IDN-002`, `AZ-IDN-003`, `AZ-IDN-004`, `AZ-IDN-005`, `AZ-IDN-006`, `AZ-IDN-007`, `AZ-IDN-008`, `AZ-IDN-009` | | Key Vault | 4 | `AZ-KV-002`, `AZ-KV-003`, `AZ-KV-004`, `AZ-KV-005` | | KeyVault | 1 | `AZ-KV-001` | | Network | 14 | `AZ-NET-001`, `AZ-NET-002`, `AZ-NET-003`, `AZ-NET-004`, `AZ-NET-005`, `AZ-NET-006`, `AZ-NET-007`, `AZ-NET-008`, `AZ-NET-009`, `AZ-NET-010`, `AZ-NET-011`, `AZ-NET-012`, `AZ-NET-013`, `AZ-NET-014` | | PostQuantum | 3 | `AZ-PQC-001`, `AZ-PQC-002`, `AZ-PQC-003` | -| Storage | 5 | `AZ-STOR-001`, `AZ-STOR-002`, `AZ-STOR-003`, `AZ-STOR-004`, `AZ-STOR-005` | +| Storage | 9 | `AZ-STOR-001`, `AZ-STOR-002`, `AZ-STOR-003`, `AZ-STOR-004`, `AZ-STOR-005`, `AZ-STOR-006`, `AZ-STOR-007`, `AZ-STOR-008`, `AZ-STOR-009` | +| Managed Cache | 1 | `AZ-CACHE-001` | ## Initial Live Validation Candidates diff --git a/playbooks/cli/fix_az_cache_001.sh b/playbooks/cli/fix_az_cache_001.sh new file mode 100644 index 00000000..807a8ec9 --- /dev/null +++ b/playbooks/cli/fix_az_cache_001.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable managed cache public access and require TLS 1.2 or later for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_cosmos_001.sh b/playbooks/cli/fix_az_cosmos_001.sh new file mode 100644 index 00000000..91b012a8 --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_001.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable Cosmos DB local authentication for $RESOURCE_NAME after validating Entra clients." diff --git a/playbooks/cli/fix_az_cosmos_002.sh b/playbooks/cli/fix_az_cosmos_002.sh new file mode 100644 index 00000000..d864dd11 --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_002.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable Cosmos DB public network access for $RESOURCE_NAME or document an approved exception." diff --git a/playbooks/cli/fix_az_db_005.sh b/playbooks/cli/fix_az_db_005.sh new file mode 100644 index 00000000..47d1e937 --- /dev/null +++ b/playbooks/cli/fix_az_db_005.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Review SQL clients, then enable Microsoft Entra-only authentication for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_db_006.sh b/playbooks/cli/fix_az_db_006.sh new file mode 100644 index 00000000..5a848ab9 --- /dev/null +++ b/playbooks/cli/fix_az_db_006.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Enable SQL vulnerability assessment and configure an approved storage destination for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_db_007.sh b/playbooks/cli/fix_az_db_007.sh new file mode 100644 index 00000000..c2374093 --- /dev/null +++ b/playbooks/cli/fix_az_db_007.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Enable SQL auditing and set retention to at least 90 days for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_stor_006.sh b/playbooks/cli/fix_az_stor_006.sh new file mode 100644 index 00000000..cff8cedd --- /dev/null +++ b/playbooks/cli/fix_az_stor_006.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Rule: AZ-STOR-006 - Storage Account Shared-Key Authorization Enabled +set -euo pipefail +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +echo "Disabling shared-key authorization on $RESOURCE_NAME. Verify all clients use Entra ID first." +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --allow-shared-key-access false +echo "Remediation complete for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_stor_007.sh b/playbooks/cli/fix_az_stor_007.sh new file mode 100644 index 00000000..ff7a333a --- /dev/null +++ b/playbooks/cli/fix_az_stor_007.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Rule: AZ-STOR-007 - Storage Account Allows TLS Below 1.2 +set -euo pipefail +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --min-tls-version TLS1_2 +echo "Minimum TLS version set to TLS1_2 for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_stor_008.sh b/playbooks/cli/fix_az_stor_008.sh new file mode 100644 index 00000000..18359511 --- /dev/null +++ b/playbooks/cli/fix_az_stor_008.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Rule: AZ-STOR-008 - Required Storage Customer-Managed Key Protection Missing +set -euo pipefail +RESOURCE_GROUP="${1:-}"; RESOURCE_NAME="${2:-}"; KEY_URI="${3:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ] || [ -z "$KEY_URI" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "Customer-managed-key remediation requires a validated Key Vault key URI and operator review." +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --encryption-key-source Microsoft.Keyvault --encryption-key-vault "${KEY_URI%/*}" --encryption-key-name "${KEY_URI##*/}" diff --git a/playbooks/cli/fix_az_stor_009.sh b/playbooks/cli/fix_az_stor_009.sh new file mode 100644 index 00000000..b026d50f --- /dev/null +++ b/playbooks/cli/fix_az_stor_009.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Rule: AZ-STOR-009 - Required Blob Container Immutability Missing +set -euo pipefail +RESOURCE_GROUP="${1:-}"; ACCOUNT_NAME="${2:-}"; CONTAINER_NAME="${3:-}"; DAYS="${4:-30}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$ACCOUNT_NAME" ] || [ -z "$CONTAINER_NAME" ]; then + echo "Usage: $0 [retention-days]"; exit 1 +fi +case "$DAYS" in ''|*[!0-9]*) echo "Retention days must be a positive integer."; exit 1;; esac +if [ "$DAYS" -le 0 ]; then echo "Retention days must be positive."; exit 1; fi +echo "Creating an unlocked immutability policy; lock requires separate operator confirmation." +az storage container immutability-policy create --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" --resource-group "$RESOURCE_GROUP" --period "$DAYS" diff --git a/requirements.txt b/requirements.txt index bb2abccc..acca9536 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,8 @@ azure-mgmt-network==25.0.0 azure-mgmt-compute==30.0.0 azure-mgmt-resource==23.0.0 azure-mgmt-sql==3.0.1 +azure-mgmt-cosmosdb==10.0.0 +azure-mgmt-redis==14.5.0 azure-mgmt-keyvault==10.3.0 azure-mgmt-rdbms==10.1.0 azure-mgmt-authorization==4.0.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 4ae0e312..973277f8 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -516,6 +516,37 @@ def get_sql_server_firewall_rules(self, resource_group: str, server_name: str) - logger.error("get_sql_server_firewall_rules(%s) failed: %s", server_name, exc) return [] + def get_sql_server_vulnerability_assessment(self, resource_group: str, server_name: str) -> Optional[Any]: + """Fetch the default vulnerability-assessment configuration for a SQL server.""" + try: + client = SqlManagementClient(self.credential, self.subscription_id) + return client.server_vulnerability_assessments.get(resource_group, server_name, "default") + except Exception as exc: + logger.error("get_sql_server_vulnerability_assessment(%s) failed: %s", server_name, exc) + return None + + def get_cosmos_accounts(self) -> List[Any]: + """List Cosmos DB accounts, if the optional management SDK is available.""" + try: + from azure.mgmt.cosmosdb import CosmosDBManagementClient + + client = CosmosDBManagementClient(self.credential, self.subscription_id) + return list(client.database_accounts.list()) + except Exception as exc: + logger.error("get_cosmos_accounts failed: %s", exc) + return [] + + def get_managed_caches(self) -> List[Any]: + """List Azure Managed Redis/Cache accounts, if the SDK is available.""" + try: + from azure.mgmt.redis import RedisManagementClient + + client = RedisManagementClient(self.credential, self.subscription_id) + return list(client.redis.list()) + except Exception as exc: + logger.error("get_managed_caches failed: %s", exc) + return [] + # ------------------------------------------------------------------ # # Key Vault # # ------------------------------------------------------------------ # diff --git a/scanner/rules/_storage_policy.py b/scanner/rules/_storage_policy.py new file mode 100644 index 00000000..42a532ce --- /dev/null +++ b/scanner/rules/_storage_policy.py @@ -0,0 +1,24 @@ +"""Shared opt-in policy metadata for enterprise storage controls.""" + +from typing import Any, Mapping + + +def tags_for(resource: Any) -> Mapping[str, Any]: + """Return resource tags, tolerating SDK objects and test doubles.""" + tags = getattr(resource, "tags", None) + return tags if isinstance(tags, Mapping) else {} + + +def tag_true(resource: Any, name: str) -> bool: + value = tags_for(resource).get(name) + return str(value).strip().lower() in {"1", "true", "yes", "required", "critical"} + + +def approved_exception(resource: Any) -> bool: + """Require an explicit approval marker; free-form exception text is not enough.""" + return tag_true(resource, "oshield:exception-approved") + + +def policy_required(resource: Any, requirement_tag: str) -> bool: + """Use explicit opt-in metadata to avoid assuming every account is critical.""" + return tag_true(resource, requirement_tag) and not approved_exception(resource) diff --git a/scanner/rules/az_cache_001.py b/scanner/rules/az_cache_001.py new file mode 100644 index 00000000..68e79b48 --- /dev/null +++ b/scanner/rules/az_cache_001.py @@ -0,0 +1,56 @@ +"""AZ-CACHE-001: managed cache is public or permits TLS below 1.2.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-CACHE-001" +RULE_NAME = "Managed Cache Public or Non-TLS Access" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-CACHE-001", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "An explicitly protected managed cache permits public network access or a minimum TLS version below 1.2." +REMEDIATION = "Disable public access and require TLS 1.2 or later for the managed cache." +PLAYBOOK = "playbooks/cli/fix_az_cache_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + getter = getattr(azure_client, "get_managed_caches", None) + if getter is None: + return findings + for cache in getter(): + if not policy_required(cache, "oshield:cache-private-tls-required"): + continue + public = getattr(cache, "public_network_access", None) + tls = getattr(cache, "minimum_tls_version", None) + public_enabled = public is not None and str(getattr(public, "value", public)).strip().lower() not in { + "disabled", + "false", + "none", + } + tls_insecure = tls is not None and str(getattr(tls, "value", tls)).strip().lower() in { + "1.0", + "1.1", + "tls1_0", + "tls1_1", + } + if not public_enabled and not tls_insecure: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": cache.id, + "resource_name": cache.name, + "resource_type": "Microsoft.Cache/Redis", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"public_network_access": str(public), "minimum_tls_version": str(tls)}, + } + ) + return findings diff --git a/scanner/rules/az_cosmos_001.py b/scanner/rules/az_cosmos_001.py new file mode 100644 index 00000000..5f9439b5 --- /dev/null +++ b/scanner/rules/az_cosmos_001.py @@ -0,0 +1,44 @@ +"""AZ-COSMOS-001: required Cosmos DB local authentication remains enabled.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-COSMOS-001" +RULE_NAME = "Cosmos DB Local Authentication Enabled" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "N/A-COSMOS-001", "NIST": "PR.AC-6", "ISO27001": "A.9.4.2", "SOC2": "CC6.3"} +DESCRIPTION = "An explicitly protected Cosmos DB account permits local key authentication." +REMEDIATION = "Disable local authentication after confirming all clients use Microsoft Entra authentication." +PLAYBOOK = "playbooks/cli/fix_az_cosmos_001.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + getter = getattr(azure_client, "get_cosmos_accounts", None) + if getter is None: + return findings + for account in getter(): + if not policy_required(account, "oshield:cosmos-local-auth-disabled"): + continue + value = getattr(account, "disable_local_auth", None) + if value is None or value: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.DocumentDB/databaseAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"disable_local_auth": value}, + } + ) + return findings diff --git a/scanner/rules/az_cosmos_002.py b/scanner/rules/az_cosmos_002.py new file mode 100644 index 00000000..6d1f4984 --- /dev/null +++ b/scanner/rules/az_cosmos_002.py @@ -0,0 +1,44 @@ +"""AZ-COSMOS-002: required Cosmos DB private access is not enforced.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-COSMOS-002" +RULE_NAME = "Cosmos DB Public Network Access Enabled" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-COSMOS-002", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "An explicitly protected Cosmos DB account allows public network access." +REMEDIATION = "Disable public network access and use approved private connectivity or an explicit exception." +PLAYBOOK = "playbooks/cli/fix_az_cosmos_002.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + getter = getattr(azure_client, "get_cosmos_accounts", None) + if getter is None: + return findings + for account in getter(): + if not policy_required(account, "oshield:cosmos-public-access-disabled"): + continue + value = getattr(account, "public_network_access", None) + if value is None or str(getattr(value, "value", value)).strip().lower() in {"disabled", "false", "none"}: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.DocumentDB/databaseAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"public_network_access": str(value)}, + } + ) + return findings diff --git a/scanner/rules/az_db_005.py b/scanner/rules/az_db_005.py new file mode 100644 index 00000000..467f300f --- /dev/null +++ b/scanner/rules/az_db_005.py @@ -0,0 +1,46 @@ +"""AZ-DB-005: required Microsoft Entra-only authentication is disabled.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-DB-005" +RULE_NAME = "SQL Server Microsoft Entra-Only Authentication Not Enforced" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "N/A-DB-005", "NIST": "PR.AC-6", "ISO27001": "A.9.4.2", "SOC2": "CC6.3"} +DESCRIPTION = ( + "An explicitly protected Azure SQL server permits SQL authentication instead of " + "enforcing Microsoft Entra-only authentication." +) +REMEDIATION = "Enable Microsoft Entra-only authentication after confirming all clients use Entra identities." +PLAYBOOK = "playbooks/cli/fix_az_db_005.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for server in azure_client.get_sql_servers(): + if not policy_required(server, "oshield:entra-only-required"): + continue + value = getattr(server, "azure_ad_only_authentication", None) + if value is None: + continue + if bool(value): + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.Sql/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"azure_ad_only_authentication": value}, + } + ) + return findings diff --git a/scanner/rules/az_db_006.py b/scanner/rules/az_db_006.py new file mode 100644 index 00000000..1960fc94 --- /dev/null +++ b/scanner/rules/az_db_006.py @@ -0,0 +1,52 @@ +"""AZ-DB-006: required SQL vulnerability assessment is not enabled.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-DB-006" +RULE_NAME = "SQL Vulnerability Assessment Not Configured" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "N/A-DB-006", "NIST": "DE.CM-8", "ISO27001": "A.12.6.1", "SOC2": "CC7.1"} +DESCRIPTION = "An explicitly protected Azure SQL server has no enabled vulnerability-assessment configuration." +REMEDIATION = "Enable SQL vulnerability assessment and configure an approved storage destination." +PLAYBOOK = "playbooks/cli/fix_az_db_006.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + get_assessment = getattr(azure_client, "get_sql_server_vulnerability_assessment", None) + if get_assessment is None: + return findings + for server in azure_client.get_sql_servers(): + if not policy_required(server, "oshield:sql-va-required"): + continue + parsed = azure_client.parse_resource_id(getattr(server, "id", "")) + if not parsed.get("resource_group"): + continue + policy = get_assessment(parsed["resource_group"], server.name) + if policy is None: + continue + enabled = getattr(policy, "is_enabled", None) + if enabled is None: + enabled = str(getattr(policy, "state", "") or "").strip().lower() in {"enabled", "active"} + if enabled: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.Sql/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"vulnerability_assessment_enabled": enabled}, + } + ) + return findings diff --git a/scanner/rules/az_db_007.py b/scanner/rules/az_db_007.py new file mode 100644 index 00000000..938d20f8 --- /dev/null +++ b/scanner/rules/az_db_007.py @@ -0,0 +1,54 @@ +"""AZ-DB-007: required SQL audit retention is below policy.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +RULE_ID = "AZ-DB-007" +RULE_NAME = "SQL Auditing Retention Below Minimum" +SEVERITY = "MEDIUM" +CATEGORY = "Database" +FRAMEWORKS = {"CIS": "N/A-DB-007", "NIST": "A.12.4.1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = "An explicitly protected Azure SQL server retains audit logs for less than the required 90 days." +REMEDIATION = "Enable SQL auditing and set retention to at least 90 days in an approved destination." +PLAYBOOK = "playbooks/cli/fix_az_db_007.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + get_audit = getattr(azure_client, "get_sql_server_auditing_policy", None) + if get_audit is None: + return findings + for server in azure_client.get_sql_servers(): + if not policy_required(server, "oshield:sql-audit-required"): + continue + parsed = azure_client.parse_resource_id(getattr(server, "id", "")) + if not parsed.get("resource_group"): + continue + policy = get_audit(parsed["resource_group"], server.name) + if policy is None or str(getattr(policy, "state", "") or "").lower() != "enabled": + continue + retention = getattr(policy, "retention_days", None) + if retention is None: + retention = getattr(policy, "retention_period", None) + if retention is None or not isinstance(retention, int): + continue + if retention >= 90: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": server.id, + "resource_name": server.name, + "resource_type": "Microsoft.Sql/servers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"retention_days": retention, "minimum_days": 90}, + } + ) + return findings diff --git a/scanner/rules/az_stor_006.py b/scanner/rules/az_stor_006.py new file mode 100644 index 00000000..0c8c3533 --- /dev/null +++ b/scanner/rules/az_stor_006.py @@ -0,0 +1,57 @@ +"""AZ-STOR-006: Storage account shared-key authorization remains enabled.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-006" +RULE_NAME = "Storage Account Shared-Key Authorization Enabled" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "N/A-STOR-006", + "NIST": "N/A-STOR-006", + "ISO27001": "N/A-STOR-006", + "SOC2": "N/A-STOR-006", +} +DESCRIPTION = ( + "Shared-key authorization is enabled on this storage account. Shared keys " + "are long-lived account-wide credentials and bypass identity-based access " + "controls when disclosed." +) +REMEDIATION = ( + "Disable shared-key authorization after confirming that all clients use " + "Microsoft Entra ID or an approved alternative. Navigate to Storage Account " + "> Configuration > Allow storage account key access and set it to Disabled." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_006.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag only accounts whose shared-key state is explicitly enabled.""" + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + state = getattr(account, "allow_shared_key_access", None) + if state is None: + logger.warning("%s: shared-key state unavailable; skipping", RULE_ID) + continue + if state is not True: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"allow_shared_key_access": state}, + } + ) + return findings diff --git a/scanner/rules/az_stor_007.py b/scanner/rules/az_stor_007.py new file mode 100644 index 00000000..5bb9429a --- /dev/null +++ b/scanner/rules/az_stor_007.py @@ -0,0 +1,62 @@ +"""AZ-STOR-007: Storage account permits a TLS version below TLS 1.2.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-007" +RULE_NAME = "Storage Account Allows TLS Below 1.2" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "N/A-STOR-007", + "NIST": "N/A-STOR-007", + "ISO27001": "N/A-STOR-007", + "SOC2": "N/A-STOR-007", +} +DESCRIPTION = ( + "The storage account permits a minimum TLS version below TLS 1.2. Older " + "protocols no longer provide an approved transport-security baseline." +) +REMEDIATION = ( + "Set the storage account minimum TLS version to TLS 1.2 or later. Navigate " + "to Storage Account > Configuration > Minimum TLS version." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_007.sh" + +_INSECURE_TLS = {"TLS1_0", "TLS1_1", "1.0", "1.1"} +_SECURE_TLS = {"TLS1_2", "TLS1_3", "1.2", "1.3"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag explicit TLS 1.0/1.1 values; skip missing or unknown values.""" + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + value = getattr(account, "minimum_tls_version", None) + if value is None: + logger.warning("%s: minimum TLS version unavailable; skipping", RULE_ID) + continue + normalized = str(value).strip().upper() + if normalized in _SECURE_TLS or normalized not in _INSECURE_TLS: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "minimum_tls_version": str(value), + "approved_minimum": "TLS1_2", + }, + } + ) + return findings diff --git a/scanner/rules/az_stor_008.py b/scanner/rules/az_stor_008.py new file mode 100644 index 00000000..69a09d08 --- /dev/null +++ b/scanner/rules/az_stor_008.py @@ -0,0 +1,46 @@ +"""AZ-STOR-008: required customer-managed key protection is absent.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-008" +RULE_NAME = "Required Storage Customer-Managed Key Protection Missing" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "N/A-STOR-008", "NIST": "N/A-STOR-008", "ISO27001": "N/A-STOR-008", "SOC2": "N/A-STOR-008"} +DESCRIPTION = "This explicitly protected storage account is not using a customer-managed encryption key." +REMEDIATION = ( + "Configure storage encryption with an approved Key Vault customer-managed key " + "after validating key access and rotation ownership." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_008.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + if not policy_required(account, "oshield:cmk-required"): + continue + encryption = getattr(account, "encryption", None) + key_source = getattr(encryption, "key_source", None) if encryption else None + if key_source is None: + logger.warning("%s: encryption source unavailable; skipping", RULE_ID) + continue + source = str(key_source).strip().lower() + if source in {"microsoft.keyvault", "keyvault", "microsoft.keyvaultmanaged"}: + continue + if source != "microsoft.storage": + logger.warning("%s: unknown encryption source %r; skipping", RULE_ID, key_source) + continue + findings.append({ + "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, + "category": CATEGORY, "resource_id": account.id, "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", "description": DESCRIPTION, + "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, + "metadata": {"key_source": str(key_source), "requirement_tag": "oshield:cmk-required"}, + }) + return findings diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py new file mode 100644 index 00000000..8b246036 --- /dev/null +++ b/scanner/rules/az_stor_009.py @@ -0,0 +1,65 @@ +"""AZ-STOR-009: required blob-container immutability is absent.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_policy import policy_required + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-009" +RULE_NAME = "Required Blob Container Immutability Missing" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = {"CIS": "N/A-STOR-009", "NIST": "N/A-STOR-009", "ISO27001": "N/A-STOR-009", "SOC2": "N/A-STOR-009"} +DESCRIPTION = "This explicitly protected blob container has no effective immutability policy." +REMEDIATION = ( + "Configure and, where required, lock a time-based immutability policy for the " + "container after confirming retention requirements." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_009.sh" + + +def _container_properties(container: Any) -> Any: + return getattr(container, "container_properties", None) or container + + +def _has_immutability(container: Any) -> bool: + props = _container_properties(container) + policy = getattr(container, "immutability_policy", None) or getattr(props, "immutability_policy", None) + if policy is None: + return False + state = str(getattr(policy, "state", "") or "").strip().lower() + retention = getattr(policy, "period_since_creation_in_days", None) + return state in {"locked", "unlocked"} and isinstance(retention, int) and retention > 0 + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + if not resource_group or not account_name: + continue + containers = azure_client.get_blob_containers(resource_group, account_name) + if containers is None: + logger.warning("%s: blob containers unavailable for %s; skipping", RULE_ID, account_name) + continue + for container in containers: + if not policy_required(container, "oshield:immutability-required"): + continue + if _has_immutability(container): + continue + name = getattr(container, "name", "") + findings.append({ + "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, + "category": CATEGORY, "resource_id": f"{resource_id}/blobServices/default/containers/{name}", + "resource_name": f"{account_name}/{name}", + "resource_type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "description": DESCRIPTION, "remediation": REMEDIATION, "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"requirement_tag": "oshield:immutability-required"}, + }) + return findings diff --git a/tests/test_rules_data_protection.py b/tests/test_rules_data_protection.py new file mode 100644 index 00000000..91429321 --- /dev/null +++ b/tests/test_rules_data_protection.py @@ -0,0 +1,109 @@ +from types import SimpleNamespace + +import scanner.rules.az_cache_001 as az_cache_001 +import scanner.rules.az_cosmos_001 as az_cosmos_001 +import scanner.rules.az_cosmos_002 as az_cosmos_002 +import scanner.rules.az_db_005 as az_db_005 +import scanner.rules.az_db_006 as az_db_006 +import scanner.rules.az_db_007 as az_db_007 + + +RID = "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Sql/servers/sql" + + +class Client: + def __init__(self, server=None, va=None, audit=None, cosmos=None, cache=None): + self.server = server + self.va = va + self.audit = audit + self.cosmos = cosmos or [] + self.cache = cache or [] + + def get_sql_servers(self): + return [self.server] if self.server else [] + + def parse_resource_id(self, value): + return {"resource_group": "rg", "name": "sql"} + + def get_sql_server_vulnerability_assessment(self, *_): + return self.va + + def get_sql_server_auditing_policy(self, *_): + return self.audit + + def get_cosmos_accounts(self): + return self.cosmos + + def get_managed_caches(self): + return self.cache + + +def server(**kwargs): + values = {"id": RID, "name": "sql", "tags": {}} + values.update(kwargs) + return SimpleNamespace(**values) + + +def account(resource_type, name, **kwargs): + values = {"id": f"/subscriptions/s/{resource_type}/{name}", "name": name, "tags": {}} + values.update(kwargs) + return SimpleNamespace(**values) + + +def test_sql_identity_and_vulnerability_rules_are_opt_in(): + s = server( + tags={"oshield:entra-only-required": "true", "oshield:sql-va-required": "true"}, + azure_ad_only_authentication=False, + ) + c = Client(s, va=SimpleNamespace(is_enabled=False)) + assert az_db_005.scan(c, "s")[0]["rule_id"] == "AZ-DB-005" + assert az_db_006.scan(c, "s")[0]["rule_id"] == "AZ-DB-006" + assert az_db_005.scan(Client(server(azure_ad_only_authentication=False)), "s") == [] + + +def test_sql_audit_retention_flags_only_explicitly_short_retention(): + s = server(tags={"oshield:sql-audit-required": "true"}) + c = Client(s, audit=SimpleNamespace(state="Enabled", retention_days=30)) + assert az_db_007.scan(c, "s")[0]["metadata"]["retention_days"] == 30 + assert az_db_007.scan(Client(s, audit=SimpleNamespace(state="Enabled", retention_days=90)), "s") == [] + + +def test_cosmos_rules_honor_opt_in_tags_and_secure_values(): + local = account( + "Microsoft.DocumentDB/databaseAccounts", + "cosmos", + disable_local_auth=False, + public_network_access="Enabled", + tags={"oshield:cosmos-local-auth-disabled": "true", "oshield:cosmos-public-access-disabled": "true"}, + ) + c = Client(cosmos=[local]) + assert az_cosmos_001.scan(c, "s")[0]["rule_id"] == "AZ-COSMOS-001" + assert az_cosmos_002.scan(c, "s")[0]["rule_id"] == "AZ-COSMOS-002" + secure = account( + "Microsoft.DocumentDB/databaseAccounts", + "secure", + disable_local_auth=True, + public_network_access="Disabled", + tags={"oshield:cosmos-local-auth-disabled": "true", "oshield:cosmos-public-access-disabled": "true"}, + ) + assert az_cosmos_001.scan(Client(cosmos=[secure]), "s") == [] + assert az_cosmos_002.scan(Client(cosmos=[secure]), "s") == [] + + +def test_cache_rule_flags_public_or_weak_tls(): + cache = account( + "Microsoft.Cache/Redis", + "cache", + public_network_access="Enabled", + minimum_tls_version="1.2", + tags={"oshield:cache-private-tls-required": "true"}, + ) + assert az_cache_001.scan(Client(cache=[cache]), "s")[0]["rule_id"] == "AZ-CACHE-001" + secure = account( + "Microsoft.Cache/Redis", + "secure", + public_network_access="Disabled", + minimum_tls_version="1.2", + tags={"oshield:cache-private-tls-required": "true"}, + ) + assert az_cache_001.scan(Client(cache=[secure]), "s") == [] diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 7948c16b..1d7cc305 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -9,6 +9,10 @@ import scanner.rules.az_stor_003 as az_stor_003 import scanner.rules.az_stor_004 as az_stor_004 import scanner.rules.az_stor_005 as az_stor_005 +import scanner.rules.az_stor_006 as az_stor_006 +import scanner.rules.az_stor_007 as az_stor_007 +import scanner.rules.az_stor_008 as az_stor_008 +import scanner.rules.az_stor_009 as az_stor_009 from tests.helpers.mock_azure import make_resource _REQUIRED_FIELDS = { @@ -172,3 +176,101 @@ def test_stor_005_noncompliant_lrs_returns_one_finding(mock_azure, subscription_ assert findings[0]["rule_id"] == "AZ-STOR-005" assert findings[0]["severity"] == "MEDIUM" assert findings[0]["resource_name"] == "sa-lrs" + + +def test_stor_006_shared_key_enabled_returns_one_finding(mock_azure, subscription_id): + account = make_resource( + id=_storage_id("sa-shared-key"), + name="sa-shared-key", + allow_shared_key_access=True, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-006" + + +def test_stor_006_disabled_or_unknown_is_not_flagged(mock_azure, subscription_id): + disabled = make_resource( + id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False + ) + unknown = make_resource(id=_storage_id("sa-unknown"), name="sa-unknown") + mock_azure.set_storage_accounts([disabled, unknown]) + assert az_stor_006.scan(mock_azure, subscription_id) == [] + + +def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): + account = make_resource( + id=_storage_id("sa-tls10"), name="sa-tls10", minimum_tls_version="TLS1_0" + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_007.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-007" + + +def test_stor_007_secure_or_unknown_is_not_flagged(mock_azure, subscription_id): + secure = make_resource( + id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2" + ) + unknown = make_resource(id=_storage_id("sa-tls-unknown"), name="sa-tls-unknown") + mock_azure.set_storage_accounts([secure, unknown]) + assert az_stor_007.scan(mock_azure, subscription_id) == [] + + +def test_stor_008_required_cmk_missing_returns_finding(mock_azure, subscription_id): + account = make_resource( + id=_storage_id("sa-cmk"), + name="sa-cmk", + tags={"oshield:cmk-required": "true"}, + encryption=make_resource(key_source="Microsoft.Storage"), + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-008" + + +def test_stor_008_cmk_or_unknown_is_not_flagged(mock_azure, subscription_id): + compliant = make_resource( + id=_storage_id("sa-cmk-ok"), + name="sa-cmk-ok", + tags={"oshield:cmk-required": "true"}, + encryption=make_resource(key_source="Microsoft.Keyvault"), + ) + unknown = make_resource( + id=_storage_id("sa-cmk-unknown"), + name="sa-cmk-unknown", + tags={"oshield:cmk-required": "true"}, + ) + mock_azure.set_storage_accounts([compliant, unknown]) + assert az_stor_008.scan(mock_azure, subscription_id) == [] + + +def test_stor_009_required_container_without_policy_returns_finding(mock_azure, subscription_id): + account = make_resource(id=_storage_id("sa-immutable"), name="sa-immutable") + container = make_resource( + name="critical-data", + tags={"oshield:immutability-required": "true"}, + immutability_policy=None, + ) + mock_azure.set_storage_accounts([account]) + mock_azure.set_blob_containers(_RG, "sa-immutable", [container]) + findings = az_stor_009.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-009" + + +def test_stor_009_policy_or_api_failure_is_not_flagged(mock_azure, subscription_id): + account = make_resource(id=_storage_id("sa-immutable-ok"), name="sa-immutable-ok") + policy = make_resource(state="Locked", period_since_creation_in_days=30) + container = make_resource( + name="critical-data", + tags={"oshield:immutability-required": "true"}, + immutability_policy=policy, + ) + mock_azure.set_storage_accounts([account]) + mock_azure.set_blob_containers(_RG, "sa-immutable-ok", [container]) + assert az_stor_009.scan(mock_azure, subscription_id) == [] + mock_azure.set_blob_containers(_RG, "sa-immutable-ok", None) + assert az_stor_009.scan(mock_azure, subscription_id) == [] From 1e107dbba99f76e79fa38e62ce3f6b4fdc196dbf Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 17 Aug 2026 02:21:39 +0100 Subject: [PATCH 02/10] style: format storage rule tests Signed-off-by: Tanvir Farhad --- scanner/rules/az_stor_008.py | 23 ++++++++++++++++------- scanner/rules/az_stor_009.py | 25 ++++++++++++++++--------- tests/test_rules_storage.py | 12 +++--------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/scanner/rules/az_stor_008.py b/scanner/rules/az_stor_008.py index 69a09d08..50f939b7 100644 --- a/scanner/rules/az_stor_008.py +++ b/scanner/rules/az_stor_008.py @@ -36,11 +36,20 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if source != "microsoft.storage": logger.warning("%s: unknown encryption source %r; skipping", RULE_ID, key_source) continue - findings.append({ - "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, - "category": CATEGORY, "resource_id": account.id, "resource_name": account.name, - "resource_type": "Microsoft.Storage/storageAccounts", "description": DESCRIPTION, - "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, - "metadata": {"key_source": str(key_source), "requirement_tag": "oshield:cmk-required"}, - }) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"key_source": str(key_source), "requirement_tag": "oshield:cmk-required"}, + } + ) return findings diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py index 8b246036..e56633c7 100644 --- a/scanner/rules/az_stor_009.py +++ b/scanner/rules/az_stor_009.py @@ -53,13 +53,20 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if _has_immutability(container): continue name = getattr(container, "name", "") - findings.append({ - "rule_id": RULE_ID, "rule_name": RULE_NAME, "severity": SEVERITY, - "category": CATEGORY, "resource_id": f"{resource_id}/blobServices/default/containers/{name}", - "resource_name": f"{account_name}/{name}", - "resource_type": "Microsoft.Storage/storageAccounts/blobServices/containers", - "description": DESCRIPTION, "remediation": REMEDIATION, "playbook": PLAYBOOK, - "frameworks": FRAMEWORKS, - "metadata": {"requirement_tag": "oshield:immutability-required"}, - }) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"{resource_id}/blobServices/default/containers/{name}", + "resource_name": f"{account_name}/{name}", + "resource_type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"requirement_tag": "oshield:immutability-required"}, + } + ) return findings diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 1d7cc305..3e91e4f7 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -191,18 +191,14 @@ def test_stor_006_shared_key_enabled_returns_one_finding(mock_azure, subscriptio def test_stor_006_disabled_or_unknown_is_not_flagged(mock_azure, subscription_id): - disabled = make_resource( - id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False - ) + disabled = make_resource(id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False) unknown = make_resource(id=_storage_id("sa-unknown"), name="sa-unknown") mock_azure.set_storage_accounts([disabled, unknown]) assert az_stor_006.scan(mock_azure, subscription_id) == [] def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): - account = make_resource( - id=_storage_id("sa-tls10"), name="sa-tls10", minimum_tls_version="TLS1_0" - ) + account = make_resource(id=_storage_id("sa-tls10"), name="sa-tls10", minimum_tls_version="TLS1_0") mock_azure.set_storage_accounts([account]) findings = az_stor_007.scan(mock_azure, subscription_id) assert len(findings) == 1 @@ -210,9 +206,7 @@ def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): def test_stor_007_secure_or_unknown_is_not_flagged(mock_azure, subscription_id): - secure = make_resource( - id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2" - ) + secure = make_resource(id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2") unknown = make_resource(id=_storage_id("sa-tls-unknown"), name="sa-tls-unknown") mock_azure.set_storage_accounts([secure, unknown]) assert az_stor_007.scan(mock_azure, subscription_id) == [] From 567017c6ebee606e35e039dedf8c7b49b0b97d94 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 17 Aug 2026 02:25:11 +0100 Subject: [PATCH 03/10] fix: satisfy rule validation and refresh image packages Signed-off-by: Tanvir Farhad --- Dockerfile | 4 ++++ scanner/rules/{_storage_policy.py => _storage_common.py} | 0 scanner/rules/az_cache_001.py | 2 +- scanner/rules/az_cosmos_001.py | 2 +- scanner/rules/az_cosmos_002.py | 2 +- scanner/rules/az_db_005.py | 2 +- scanner/rules/az_db_006.py | 2 +- scanner/rules/az_db_007.py | 2 +- scanner/rules/az_stor_008.py | 2 +- scanner/rules/az_stor_009.py | 2 +- 10 files changed, 12 insertions(+), 8 deletions(-) rename scanner/rules/{_storage_policy.py => _storage_common.py} (100%) diff --git a/Dockerfile b/Dockerfile index 9dceebf0..c203ecaf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,10 @@ FROM python:3.11-slim-trixie WORKDIR /app +RUN apt-get update \ + && apt-get dist-upgrade -y \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir --upgrade \ pip==26.1.2 \ diff --git a/scanner/rules/_storage_policy.py b/scanner/rules/_storage_common.py similarity index 100% rename from scanner/rules/_storage_policy.py rename to scanner/rules/_storage_common.py diff --git a/scanner/rules/az_cache_001.py b/scanner/rules/az_cache_001.py index 68e79b48..49d8286a 100644 --- a/scanner/rules/az_cache_001.py +++ b/scanner/rules/az_cache_001.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-CACHE-001" RULE_NAME = "Managed Cache Public or Non-TLS Access" diff --git a/scanner/rules/az_cosmos_001.py b/scanner/rules/az_cosmos_001.py index 5f9439b5..7b9e165e 100644 --- a/scanner/rules/az_cosmos_001.py +++ b/scanner/rules/az_cosmos_001.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-COSMOS-001" RULE_NAME = "Cosmos DB Local Authentication Enabled" diff --git a/scanner/rules/az_cosmos_002.py b/scanner/rules/az_cosmos_002.py index 6d1f4984..42cf6028 100644 --- a/scanner/rules/az_cosmos_002.py +++ b/scanner/rules/az_cosmos_002.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-COSMOS-002" RULE_NAME = "Cosmos DB Public Network Access Enabled" diff --git a/scanner/rules/az_db_005.py b/scanner/rules/az_db_005.py index 467f300f..5e53d138 100644 --- a/scanner/rules/az_db_005.py +++ b/scanner/rules/az_db_005.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-DB-005" RULE_NAME = "SQL Server Microsoft Entra-Only Authentication Not Enforced" diff --git a/scanner/rules/az_db_006.py b/scanner/rules/az_db_006.py index 1960fc94..b8abc7aa 100644 --- a/scanner/rules/az_db_006.py +++ b/scanner/rules/az_db_006.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-DB-006" RULE_NAME = "SQL Vulnerability Assessment Not Configured" diff --git a/scanner/rules/az_db_007.py b/scanner/rules/az_db_007.py index 938d20f8..0fedf9dd 100644 --- a/scanner/rules/az_db_007.py +++ b/scanner/rules/az_db_007.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required RULE_ID = "AZ-DB-007" RULE_NAME = "SQL Auditing Retention Below Minimum" diff --git a/scanner/rules/az_stor_008.py b/scanner/rules/az_stor_008.py index 50f939b7..34f04d0c 100644 --- a/scanner/rules/az_stor_008.py +++ b/scanner/rules/az_stor_008.py @@ -3,7 +3,7 @@ import logging from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required logger = logging.getLogger(__name__) diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py index e56633c7..482821f3 100644 --- a/scanner/rules/az_stor_009.py +++ b/scanner/rules/az_stor_009.py @@ -3,7 +3,7 @@ import logging from typing import Any, Dict, List -from scanner.rules._storage_policy import policy_required +from scanner.rules._storage_common import policy_required logger = logging.getLogger(__name__) From 3f1b28ad5db7151b92c689620c4d568bbf6d4eb6 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 17 Aug 2026 11:54:44 +0100 Subject: [PATCH 04/10] fix(scanner): collect Azure SQL protection settings Signed-off-by: Tanvir Farhad --- docs/architecture.md | 2 +- docs/rules-reference.md | 2 +- scanner/azure_client.py | 25 +++++++++++++++++++++++++ scanner/rules/az_db_005.py | 10 ++++++++-- scanner/rules/az_db_006.py | 4 +--- scanner/rules/az_db_007.py | 23 ++++++++++++++--------- tests/test_azure_client_management.py | 12 ++++++++++++ tests/test_rules_data_protection.py | 10 ++++++++++ 8 files changed, 72 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3ce724ec..b2ed41a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 83 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 80 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 64e88354..525fc9bb 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 69 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 80 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 973277f8..2fecd4f1 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -503,10 +503,33 @@ def get_sql_server_auditing_policy(self, resource_group: str, server_name: str) try: client = SqlManagementClient(self.credential, self.subscription_id) return client.server_blob_auditing_policies.get(resource_group, server_name) + except ResourceNotFoundError: + return False except Exception as exc: logger.error("get_sql_server_auditing_policy(%s) failed: %s", server_name, exc) return None + def get_sql_server_azure_ad_only_authentication(self, resource_group: str, server_name: str) -> Optional[bool]: + """Return whether Microsoft Entra-only authentication is enabled. + + The setting is a child resource and is not populated on objects from + ``servers.list()``. ``False`` represents a confirmed absent/disabled + configuration; ``None`` preserves an indeterminate API failure. + """ + try: + client = SqlManagementClient(self.credential, self.subscription_id) + authentication = client.server_azure_ad_only_authentications.get(resource_group, server_name, "Default") + return bool(getattr(authentication, "azure_ad_only_authentication", False)) + except ResourceNotFoundError: + return False + except Exception as exc: + logger.error( + "get_sql_server_azure_ad_only_authentication(%s) failed: %s", + server_name, + exc, + ) + return None + def get_sql_server_firewall_rules(self, resource_group: str, server_name: str) -> List[Any]: """List all firewall rules for an Azure SQL server.""" try: @@ -521,6 +544,8 @@ def get_sql_server_vulnerability_assessment(self, resource_group: str, server_na try: client = SqlManagementClient(self.credential, self.subscription_id) return client.server_vulnerability_assessments.get(resource_group, server_name, "default") + except ResourceNotFoundError: + return False except Exception as exc: logger.error("get_sql_server_vulnerability_assessment(%s) failed: %s", server_name, exc) return None diff --git a/scanner/rules/az_db_005.py b/scanner/rules/az_db_005.py index 5e53d138..39babfa4 100644 --- a/scanner/rules/az_db_005.py +++ b/scanner/rules/az_db_005.py @@ -19,13 +19,19 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: findings: List[Dict[str, Any]] = [] + get_authentication = getattr(azure_client, "get_sql_server_azure_ad_only_authentication", None) + if get_authentication is None: + return findings for server in azure_client.get_sql_servers(): if not policy_required(server, "oshield:entra-only-required"): continue - value = getattr(server, "azure_ad_only_authentication", None) + parsed = azure_client.parse_resource_id(getattr(server, "id", "")) + if not parsed.get("resource_group"): + continue + value = get_authentication(parsed["resource_group"], server.name) if value is None: continue - if bool(value): + if value: continue findings.append( { diff --git a/scanner/rules/az_db_006.py b/scanner/rules/az_db_006.py index b8abc7aa..270b524a 100644 --- a/scanner/rules/az_db_006.py +++ b/scanner/rules/az_db_006.py @@ -28,9 +28,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: policy = get_assessment(parsed["resource_group"], server.name) if policy is None: continue - enabled = getattr(policy, "is_enabled", None) - if enabled is None: - enabled = str(getattr(policy, "state", "") or "").strip().lower() in {"enabled", "active"} + enabled = False if policy is False else getattr(policy, "is_enabled", True) if enabled: continue findings.append( diff --git a/scanner/rules/az_db_007.py b/scanner/rules/az_db_007.py index 0fedf9dd..af4504c1 100644 --- a/scanner/rules/az_db_007.py +++ b/scanner/rules/az_db_007.py @@ -26,15 +26,20 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if not parsed.get("resource_group"): continue policy = get_audit(parsed["resource_group"], server.name) - if policy is None or str(getattr(policy, "state", "") or "").lower() != "enabled": - continue - retention = getattr(policy, "retention_days", None) - if retention is None: - retention = getattr(policy, "retention_period", None) - if retention is None or not isinstance(retention, int): - continue - if retention >= 90: + if policy is None: continue + state = "missing" if policy is False else str(getattr(policy, "state", "") or "").lower() + if state != "enabled": + retention = 0 + else: + retention = getattr(policy, "retention_days", None) + if retention is None: + retention = getattr(policy, "retention_period", None) + if retention is None or not isinstance(retention, int): + continue + # Azure SQL uses zero to mean unlimited retention. + if retention == 0 or retention >= 90: + continue findings.append( { "rule_id": RULE_ID, @@ -48,7 +53,7 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "remediation": REMEDIATION, "playbook": PLAYBOOK, "frameworks": FRAMEWORKS, - "metadata": {"retention_days": retention, "minimum_days": 90}, + "metadata": {"state": state, "retention_days": retention, "minimum_days": 90}, } ) return findings diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py index 179fb6fe..1abe1b18 100644 --- a/tests/test_azure_client_management.py +++ b/tests/test_azure_client_management.py @@ -67,6 +67,18 @@ def test_single_resource_and_policy_wrappers(client): assert client.get_sql_server_auditing_policy("rg", "sql") is None +def test_sql_server_entra_only_authentication_uses_child_resource(client): + with patch("scanner.azure_client.SqlManagementClient") as constructor: + get_authentication = constructor.return_value.server_azure_ad_only_authentications.get + get_authentication.return_value = SimpleNamespace(azure_ad_only_authentication=True) + + assert client.get_sql_server_azure_ad_only_authentication("rg", "sql") is True + get_authentication.assert_called_once_with("rg", "sql", "Default") + + get_authentication.side_effect = RuntimeError("denied") + assert client.get_sql_server_azure_ad_only_authentication("rg", "sql") is None + + def test_firewall_and_peering_wrappers(client): with patch("scanner.azure_client.NetworkManagementClient") as constructor: sdk = constructor.return_value diff --git a/tests/test_rules_data_protection.py b/tests/test_rules_data_protection.py index 91429321..5a0c1e79 100644 --- a/tests/test_rules_data_protection.py +++ b/tests/test_rules_data_protection.py @@ -28,6 +28,9 @@ def parse_resource_id(self, value): def get_sql_server_vulnerability_assessment(self, *_): return self.va + def get_sql_server_azure_ad_only_authentication(self, *_): + return self.server.azure_ad_only_authentication + def get_sql_server_auditing_policy(self, *_): return self.audit @@ -61,11 +64,18 @@ def test_sql_identity_and_vulnerability_rules_are_opt_in(): assert az_db_005.scan(Client(server(azure_ad_only_authentication=False)), "s") == [] +def test_sql_missing_vulnerability_assessment_is_noncompliant(): + s = server(tags={"oshield:sql-va-required": "true"}, azure_ad_only_authentication=True) + assert az_db_006.scan(Client(s, va=False), "s")[0]["rule_id"] == "AZ-DB-006" + + def test_sql_audit_retention_flags_only_explicitly_short_retention(): s = server(tags={"oshield:sql-audit-required": "true"}) c = Client(s, audit=SimpleNamespace(state="Enabled", retention_days=30)) assert az_db_007.scan(c, "s")[0]["metadata"]["retention_days"] == 30 assert az_db_007.scan(Client(s, audit=SimpleNamespace(state="Enabled", retention_days=90)), "s") == [] + assert az_db_007.scan(Client(s, audit=SimpleNamespace(state="Enabled", retention_days=0)), "s") == [] + assert az_db_007.scan(Client(s, audit=False), "s")[0]["metadata"]["state"] == "missing" def test_cosmos_rules_honor_opt_in_tags_and_secure_values(): From a20c563e656b3ae12c5a60c0269450b3379d382f Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Fri, 21 Aug 2026 01:07:54 +0100 Subject: [PATCH 05/10] fix(scanner): address review blockers in enterprise data-protection rules - Move AZ-STOR-009 opt-in check from BlobContainer (no ARM tags) to the parent storage account, which exposes tags via the SDK; all containers under a tagged account are now evaluated for immutability. - Replace incorrect NIST mapping A.12.4.1 (ISO 27001) on AZ-DB-007 with PR.PT-1 across az_db_007.py, nist_csf.json, and rules-reference. - Add executable az CLI commands to fix_az_cache_001, fix_az_cosmos_001, fix_az_cosmos_002, fix_az_db_005, fix_az_db_006, and fix_az_db_007 playbooks; each validates the target and requires APPLY confirmation before modifying any Azure resource. - Update storage-protection-controls.md to document the account-level tagging scope for AZ-STOR-009. Signed-off-by: Tanvir Farhad --- compliance/frameworks/nist_csf.json | 2 +- docs/rules-reference.md | 2 +- docs/storage-protection-controls.md | 2 +- playbooks/cli/fix_az_cache_001.sh | 17 +++++++++++++++-- playbooks/cli/fix_az_cosmos_001.sh | 16 ++++++++++++++-- playbooks/cli/fix_az_cosmos_002.sh | 16 ++++++++++++++-- playbooks/cli/fix_az_db_005.sh | 15 +++++++++++++-- playbooks/cli/fix_az_db_006.sh | 21 +++++++++++++++++++-- playbooks/cli/fix_az_db_007.sh | 22 ++++++++++++++++++++-- scanner/rules/az_db_007.py | 2 +- scanner/rules/az_stor_009.py | 4 ++-- 11 files changed, 101 insertions(+), 18 deletions(-) diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index c8f683cd..f1f78abf 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -35,7 +35,7 @@ }, "AZ-DB-005": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "SQL authentication is restricted to approved Entra identities."}, "AZ-DB-006": {"control_id": "DE.CM-8", "control_name": "Vulnerability scans are performed", "description": "Required SQL vulnerability assessment is configured."}, - "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, + "AZ-DB-007": {"control_id": "PR.PT-1", "control_name": "Audit/log records are determined, documented, implemented, and reviewed", "description": "SQL audit logs are retained for at least 90 days in accordance with the defined audit policy."}, "AZ-COSMOS-001": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "Cosmos authentication is restricted to approved Entra identities."}, "AZ-COSMOS-002": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Cosmos public network access is restricted according to policy."}, "AZ-CACHE-001": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Managed cache access is private and uses approved TLS."}, diff --git a/docs/rules-reference.md b/docs/rules-reference.md index 525fc9bb..3eb0b2d5 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -61,7 +61,7 @@ OpenShield currently ships 80 Azure scan rules. This table is generated from the | AZ-STOR-009 | Required Blob Container Immutability Missing | HIGH | Storage | N/A-STOR-009 | N/A-STOR-009 | N/A-STOR-009 | | AZ-DB-005 | SQL Server Microsoft Entra-Only Authentication Not Enforced | HIGH | Database | N/A-DB-005 | PR.AC-6 | A.9.4.2 | | AZ-DB-006 | SQL Vulnerability Assessment Not Configured | HIGH | Database | N/A-DB-006 | DE.CM-8 | A.12.6.1 | -| AZ-DB-007 | SQL Auditing Retention Below Minimum | MEDIUM | Database | N/A-DB-007 | A.12.4.1 | A.12.4.1 | +| AZ-DB-007 | SQL Auditing Retention Below Minimum | MEDIUM | Database | N/A-DB-007 | PR.PT-1 | A.12.4.1 | | AZ-COSMOS-001 | Cosmos DB Local Authentication Enabled | HIGH | Database | N/A-COSMOS-001 | PR.AC-6 | A.9.4.2 | | AZ-COSMOS-002 | Cosmos DB Public Network Access Enabled | HIGH | Network | N/A-COSMOS-002 | PR.AC-5 | A.13.1.1 | | AZ-CACHE-001 | Managed Cache Public or Non-TLS Access | HIGH | Network | N/A-CACHE-001 | PR.AC-5 | A.13.1.1 | diff --git a/docs/storage-protection-controls.md b/docs/storage-protection-controls.md index 274a5ca9..43ea8acb 100644 --- a/docs/storage-protection-controls.md +++ b/docs/storage-protection-controls.md @@ -7,7 +7,7 @@ every account or container needs a customer-managed key or immutability policy. Use Azure resource tags as follows: - `oshield:cmk-required=true` enables `AZ-STOR-008` for a storage account. -- `oshield:immutability-required=true` enables `AZ-STOR-009` for a blob container. +- `oshield:immutability-required=true` enables `AZ-STOR-009` for a storage account; all containers under that account are checked for an immutability policy. - `oshield:entra-only-required=true` enables `AZ-DB-005` for a SQL server. - `oshield:sql-va-required=true` enables `AZ-DB-006` for a SQL server. - `oshield:sql-audit-required=true` enables `AZ-DB-007` for a SQL server. diff --git a/playbooks/cli/fix_az_cache_001.sh b/playbooks/cli/fix_az_cache_001.sh index 807a8ec9..443809d5 100644 --- a/playbooks/cli/fix_az_cache_001.sh +++ b/playbooks/cli/fix_az_cache_001.sh @@ -1,3 +1,16 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-CACHE-001 - Managed Cache Public or Non-TLS Access set -euo pipefail -echo "Disable managed cache public access and require TLS 1.2 or later for $RESOURCE_NAME." +RESOURCE_GROUP="${1:-}"; CACHE_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$CACHE_NAME" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "WARNING: Disabling public access or raising the minimum TLS version may interrupt clients that" +echo "connect from approved networks without private endpoints or that use TLS below 1.2." +echo "Validate private endpoint connectivity and client TLS support before applying." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az redis update --resource-group "$RESOURCE_GROUP" --name "$CACHE_NAME" \ + --set publicNetworkAccess=Disabled minimumTlsVersion=1.2 +echo "Done. Verify client connectivity after the update propagates." diff --git a/playbooks/cli/fix_az_cosmos_001.sh b/playbooks/cli/fix_az_cosmos_001.sh index 91b012a8..0b8ed35b 100644 --- a/playbooks/cli/fix_az_cosmos_001.sh +++ b/playbooks/cli/fix_az_cosmos_001.sh @@ -1,3 +1,15 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-COSMOS-001 - Cosmos DB Local Authentication Enabled set -euo pipefail -echo "Disable Cosmos DB local authentication for $RESOURCE_NAME after validating Entra clients." +RESOURCE_GROUP="${1:-}"; ACCOUNT_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$ACCOUNT_NAME" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "WARNING: Disabling local authentication prevents all connection-string and key-based access." +echo "Verify that every client uses Entra-based RBAC before applying." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az cosmosdb update --resource-group "$RESOURCE_GROUP" --name "$ACCOUNT_NAME" \ + --disable-local-auth true +echo "Done. Confirm that all clients authenticate via Entra after the change propagates." diff --git a/playbooks/cli/fix_az_cosmos_002.sh b/playbooks/cli/fix_az_cosmos_002.sh index d864dd11..9a9694a0 100644 --- a/playbooks/cli/fix_az_cosmos_002.sh +++ b/playbooks/cli/fix_az_cosmos_002.sh @@ -1,3 +1,15 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-COSMOS-002 - Cosmos DB Public Network Access Enabled set -euo pipefail -echo "Disable Cosmos DB public network access for $RESOURCE_NAME or document an approved exception." +RESOURCE_GROUP="${1:-}"; ACCOUNT_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$ACCOUNT_NAME" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "WARNING: Disabling public network access blocks all traffic that does not arrive through a" +echo "private endpoint. Ensure private endpoints are in place before applying." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az cosmosdb update --resource-group "$RESOURCE_GROUP" --name "$ACCOUNT_NAME" \ + --public-network-access DISABLED +echo "Done. Verify private endpoint connectivity after the change propagates." diff --git a/playbooks/cli/fix_az_db_005.sh b/playbooks/cli/fix_az_db_005.sh index 47d1e937..aa7acc8c 100644 --- a/playbooks/cli/fix_az_db_005.sh +++ b/playbooks/cli/fix_az_db_005.sh @@ -1,3 +1,14 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-DB-005 - SQL Server Entra-Only Authentication Not Enforced set -euo pipefail -echo "Review SQL clients, then enable Microsoft Entra-only authentication for $RESOURCE_NAME." +RESOURCE_GROUP="${1:-}"; SERVER_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$SERVER_NAME" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "WARNING: Enabling Microsoft Entra-only authentication disables all SQL password logins" +echo "including the server administrator account. Verify every application uses Entra identities." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az sql server ad-only-auth enable --resource-group "$RESOURCE_GROUP" --server "$SERVER_NAME" +echo "Done. Confirm SQL password logins are disabled and Entra clients connect successfully." diff --git a/playbooks/cli/fix_az_db_006.sh b/playbooks/cli/fix_az_db_006.sh index 5a848ab9..07eea5a7 100644 --- a/playbooks/cli/fix_az_db_006.sh +++ b/playbooks/cli/fix_az_db_006.sh @@ -1,3 +1,20 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-DB-006 - SQL Server Vulnerability Assessment Not Configured set -euo pipefail -echo "Enable SQL vulnerability assessment and configure an approved storage destination for $RESOURCE_NAME." +RESOURCE_GROUP="${1:-}"; SERVER_NAME="${2:-}"; STORAGE_ACCOUNT="${3:-}"; EMAIL="${4:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$SERVER_NAME" ] || [ -z "$STORAGE_ACCOUNT" ] || [ -z "$EMAIL" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "WARNING: This enables SQL vulnerability assessment and configures scan result storage." +echo "Confirm the storage account is approved for audit data and the email is a monitored address." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az sql server va-setting update \ + --resource-group "$RESOURCE_GROUP" \ + --server "$SERVER_NAME" \ + --storage-account "$STORAGE_ACCOUNT" \ + --notification-emails "$EMAIL" \ + --email-subscription-admins true \ + --recurring-scans-interval-in-days 7 +echo "Done. Verify the first scheduled scan completes and results are delivered to $EMAIL." diff --git a/playbooks/cli/fix_az_db_007.sh b/playbooks/cli/fix_az_db_007.sh index c2374093..eba93383 100644 --- a/playbooks/cli/fix_az_db_007.sh +++ b/playbooks/cli/fix_az_db_007.sh @@ -1,3 +1,21 @@ -#!/usr/bin/env bash +#!/bin/bash +# Rule: AZ-DB-007 - SQL Auditing Retention Below Minimum set -euo pipefail -echo "Enable SQL auditing and set retention to at least 90 days for $RESOURCE_NAME." +RESOURCE_GROUP="${1:-}"; SERVER_NAME="${2:-}"; STORAGE_ACCOUNT="${3:-}"; DAYS="${4:-90}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$SERVER_NAME" ] || [ -z "$STORAGE_ACCOUNT" ]; then + echo "Usage: $0 [retention-days]"; exit 1 +fi +case "$DAYS" in ''|*[!0-9]*) echo "Retention days must be a positive integer."; exit 1;; esac +if [ "$DAYS" -lt 90 ]; then echo "Retention must be at least 90 days."; exit 1; fi +echo "WARNING: This enables SQL server-level auditing with ${DAYS}-day retention to $STORAGE_ACCOUNT." +echo "Confirm the storage account is approved for audit data and access is logged." +read -r -p "Type APPLY to confirm the target and operational impact were reviewed: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } +az account show --output none +az sql server audit-policy update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$SERVER_NAME" \ + --state Enabled \ + --storage-account "$STORAGE_ACCOUNT" \ + --retention-days "$DAYS" +echo "Done. Verify auditing is active and retention shows ${DAYS} days in the Azure portal." diff --git a/scanner/rules/az_db_007.py b/scanner/rules/az_db_007.py index af4504c1..50bbfe66 100644 --- a/scanner/rules/az_db_007.py +++ b/scanner/rules/az_db_007.py @@ -8,7 +8,7 @@ RULE_NAME = "SQL Auditing Retention Below Minimum" SEVERITY = "MEDIUM" CATEGORY = "Database" -FRAMEWORKS = {"CIS": "N/A-DB-007", "NIST": "A.12.4.1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +FRAMEWORKS = {"CIS": "N/A-DB-007", "NIST": "PR.PT-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} DESCRIPTION = "An explicitly protected Azure SQL server retains audit logs for less than the required 90 days." REMEDIATION = "Enable SQL auditing and set retention to at least 90 days in an approved destination." PLAYBOOK = "playbooks/cli/fix_az_db_007.sh" diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py index 482821f3..c659d8f9 100644 --- a/scanner/rules/az_stor_009.py +++ b/scanner/rules/az_stor_009.py @@ -47,9 +47,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if containers is None: logger.warning("%s: blob containers unavailable for %s; skipping", RULE_ID, account_name) continue + if not policy_required(account, "oshield:immutability-required"): + continue for container in containers: - if not policy_required(container, "oshield:immutability-required"): - continue if _has_immutability(container): continue name = getattr(container, "name", "") From 61b1bd2d209aceb0d95827d0e1d63f83de4039a2 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 27 Aug 2026 22:36:32 +0100 Subject: [PATCH 06/10] fix(scanner): check immutability tag on container, not account (AZ-STOR-009) The policy_required guard was placed at the account level, but the oshield:immutability-required tag is set per container. Moving the check inside the container loop allows containers with the tag to be evaluated regardless of whether the parent account carries it. Signed-off-by: Tanvir Farhad --- scanner/rules/az_stor_009.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py index c659d8f9..482821f3 100644 --- a/scanner/rules/az_stor_009.py +++ b/scanner/rules/az_stor_009.py @@ -47,9 +47,9 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if containers is None: logger.warning("%s: blob containers unavailable for %s; skipping", RULE_ID, account_name) continue - if not policy_required(account, "oshield:immutability-required"): - continue for container in containers: + if not policy_required(container, "oshield:immutability-required"): + continue if _has_immutability(container): continue name = getattr(container, "name", "") From bc0ae0e8250d703796a6049966e04053f68e2392 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 27 Aug 2026 22:40:47 +0100 Subject: [PATCH 07/10] fix(scanner): check immutability tag on container or parent account (AZ-STOR-009) The policy_required guard was placed at the account level only, but the oshield:immutability-required tag may be set per-container or per-account. Now uses OR logic: a container is evaluated if the account carries the requirement tag (protecting all containers) OR if the container itself carries it (per-container opt-in). Both cases were previously broken: the account-level check did not reach container-tagged resources, and no per-container check existed at all. Signed-off-by: Tanvir Farhad --- scanner/rules/az_stor_009.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scanner/rules/az_stor_009.py b/scanner/rules/az_stor_009.py index 482821f3..911e0017 100644 --- a/scanner/rules/az_stor_009.py +++ b/scanner/rules/az_stor_009.py @@ -47,8 +47,10 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if containers is None: logger.warning("%s: blob containers unavailable for %s; skipping", RULE_ID, account_name) continue + account_required = policy_required(account, "oshield:immutability-required") for container in containers: - if not policy_required(container, "oshield:immutability-required"): + container_required = policy_required(container, "oshield:immutability-required") + if not account_required and not container_required: continue if _has_immutability(container): continue From c45645c1e3b24442569b6f90c49522a82a014797 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 27 Aug 2026 23:33:05 +0100 Subject: [PATCH 08/10] ci: ignore chromadb CVE-2026-45830 and CVE-2026-45833 in pip-audit No patched version of chromadb 0.4.24 is available. Removal is tracked in PR #317. Signed-off-by: Tanvir Farhad --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64de7501..cdb0467e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -476,7 +476,9 @@ jobs: pip-audit -r requirements.txt \ --ignore-vuln PYSEC-2025-217 \ --ignore-vuln CVE-2026-1839 \ - --ignore-vuln CVE-2026-4372 + --ignore-vuln CVE-2026-4372 \ + --ignore-vuln CVE-2026-45830 \ + --ignore-vuln CVE-2026-45833 # ── Software Bill of Materials (Syft, CycloneDX) ────────────────────────── sbom: From cbb4f3d7538af57fd53a63b7177e9a28c54cdfbb Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 29 Aug 2026 14:09:17 +0100 Subject: [PATCH 09/10] fix(scanner): address storage rule correctness gaps in AZ-STOR-006/007/008 - AZ-STOR-006: treat allow_shared_key_access=None as insecure (Azure documents unset as equivalent to True); only False is compliant - AZ-STOR-007: treat minimum_tls_version=None as TLS 1.0 (Azure default); use enum_str() instead of str() to handle SDK enum objects correctly - AZ-STOR-008 playbook: fix Key Vault URI parsing; the previous bash expansion passed the wrong segments to --encryption-key-vault and --encryption-key-name; now splits vault URI, key name, and optional key version correctly - ci.yml: remove CVE-2026-45830 and CVE-2026-45833 pip-audit exclusions (chromadb CVEs unrelated to this PR; resolved by PR #317) Adds regression tests for None-as-default behavior and SDK enum handling in AZ-STOR-006 and AZ-STOR-007 (22 storage tests, all passing). Signed-off-by: Tanvir Farhad --- .github/workflows/ci.yml | 4 +-- playbooks/cli/fix_az_stor_008.sh | 26 +++++++++++++++++++- scanner/rules/az_stor_006.py | 12 +++++---- scanner/rules/az_stor_007.py | 19 +++++++++++---- tests/test_rules_storage.py | 42 +++++++++++++++++++++++++++----- 5 files changed, 83 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a7dec4..950bc21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,9 +477,7 @@ jobs: pip-audit -r requirements.txt \ --ignore-vuln PYSEC-2025-217 \ --ignore-vuln CVE-2026-1839 \ - --ignore-vuln CVE-2026-4372 \ - --ignore-vuln CVE-2026-45830 \ - --ignore-vuln CVE-2026-45833 + --ignore-vuln CVE-2026-4372 # ── Software Bill of Materials (Syft, CycloneDX) ────────────────────────── sbom: diff --git a/playbooks/cli/fix_az_stor_008.sh b/playbooks/cli/fix_az_stor_008.sh index 18359511..54f1453e 100644 --- a/playbooks/cli/fix_az_stor_008.sh +++ b/playbooks/cli/fix_az_stor_008.sh @@ -6,4 +6,28 @@ if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ] || [ -z "$KEY_URI" ]; the echo "Usage: $0 "; exit 1 fi echo "Customer-managed-key remediation requires a validated Key Vault key URI and operator review." -az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --encryption-key-source Microsoft.Keyvault --encryption-key-vault "${KEY_URI%/*}" --encryption-key-name "${KEY_URI##*/}" + +# Key Vault URIs have the form: +# https://.vault.azure.net/keys/[/] +# Split into the three separate parts that az storage account update requires. +VAULT_URI="${KEY_URI%%/keys/*}" +KEY_PATH="${KEY_URI#*\/keys\/}" +KEY_NAME="${KEY_PATH%%/*}" +if [[ "$KEY_PATH" == */* ]]; then + KEY_VERSION="${KEY_PATH#*/}" +else + KEY_VERSION="" +fi + +CMD=(az storage account update + --name "$RESOURCE_NAME" + --resource-group "$RESOURCE_GROUP" + --encryption-key-source Microsoft.Keyvault + --encryption-key-vault "$VAULT_URI" + --encryption-key-name "$KEY_NAME") + +if [ -n "$KEY_VERSION" ]; then + CMD+=(--encryption-key-version "$KEY_VERSION") +fi + +"${CMD[@]}" diff --git a/scanner/rules/az_stor_006.py b/scanner/rules/az_stor_006.py index 0c8c3533..2e16d38d 100644 --- a/scanner/rules/az_stor_006.py +++ b/scanner/rules/az_stor_006.py @@ -29,14 +29,16 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: - """Flag only accounts whose shared-key state is explicitly enabled.""" + """Flag accounts where shared-key access is enabled or unset. + + Azure documents allow_shared_key_access=None as equivalent to True: + an account with no explicit setting permits Shared Key authorization. + Only False (explicitly disabled) is compliant. + """ findings: List[Dict[str, Any]] = [] for account in azure_client.get_storage_accounts(): state = getattr(account, "allow_shared_key_access", None) - if state is None: - logger.warning("%s: shared-key state unavailable; skipping", RULE_ID) - continue - if state is not True: + if state is False: continue findings.append( { diff --git a/scanner/rules/az_stor_007.py b/scanner/rules/az_stor_007.py index 5bb9429a..23c3673c 100644 --- a/scanner/rules/az_stor_007.py +++ b/scanner/rules/az_stor_007.py @@ -3,6 +3,8 @@ import logging from typing import Any, Dict, List +from scanner.azure_client import enum_str + logger = logging.getLogger(__name__) RULE_ID = "AZ-STOR-007" @@ -30,15 +32,22 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: - """Flag explicit TLS 1.0/1.1 values; skip missing or unknown values.""" + """Flag accounts with TLS below 1.2 or with an unset minimum TLS version. + + Azure documents an unset minimum_tls_version as TLS 1.0, so None is + treated as insecure. enum_str() is used to handle SDK enum objects + (e.g. MinimumTlsVersion.TLS1_0) so they compare correctly against the + known-insecure set instead of producing a string like + 'MinimumTlsVersion.TLS1_0'. + """ findings: List[Dict[str, Any]] = [] for account in azure_client.get_storage_accounts(): value = getattr(account, "minimum_tls_version", None) if value is None: - logger.warning("%s: minimum TLS version unavailable; skipping", RULE_ID) - continue - normalized = str(value).strip().upper() - if normalized in _SECURE_TLS or normalized not in _INSECURE_TLS: + normalized = "TLS1_0" + else: + normalized = enum_str(value).strip().upper() + if normalized not in _INSECURE_TLS: continue findings.append( { diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 3e91e4f7..a04cd2ea 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -190,13 +190,21 @@ def test_stor_006_shared_key_enabled_returns_one_finding(mock_azure, subscriptio assert findings[0]["rule_id"] == "AZ-STOR-006" -def test_stor_006_disabled_or_unknown_is_not_flagged(mock_azure, subscription_id): +def test_stor_006_disabled_is_not_flagged(mock_azure, subscription_id): disabled = make_resource(id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False) - unknown = make_resource(id=_storage_id("sa-unknown"), name="sa-unknown") - mock_azure.set_storage_accounts([disabled, unknown]) + mock_azure.set_storage_accounts([disabled]) assert az_stor_006.scan(mock_azure, subscription_id) == [] +def test_stor_006_none_is_flagged_as_insecure_default(mock_azure, subscription_id): + # Azure documents allow_shared_key_access=None as equivalent to True. + account = make_resource(id=_storage_id("sa-default"), name="sa-default") + mock_azure.set_storage_accounts([account]) + findings = az_stor_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-006" + + def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): account = make_resource(id=_storage_id("sa-tls10"), name="sa-tls10", minimum_tls_version="TLS1_0") mock_azure.set_storage_accounts([account]) @@ -205,13 +213,35 @@ def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): assert findings[0]["rule_id"] == "AZ-STOR-007" -def test_stor_007_secure_or_unknown_is_not_flagged(mock_azure, subscription_id): +def test_stor_007_secure_is_not_flagged(mock_azure, subscription_id): secure = make_resource(id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2") - unknown = make_resource(id=_storage_id("sa-tls-unknown"), name="sa-tls-unknown") - mock_azure.set_storage_accounts([secure, unknown]) + mock_azure.set_storage_accounts([secure]) assert az_stor_007.scan(mock_azure, subscription_id) == [] +def test_stor_007_none_is_flagged_as_tls10_default(mock_azure, subscription_id): + # Azure documents unset minimum_tls_version as TLS 1.0. + account = make_resource(id=_storage_id("sa-tls-default"), name="sa-tls-default") + mock_azure.set_storage_accounts([account]) + findings = az_stor_007.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-007" + + +def test_stor_007_sdk_enum_tls10_is_flagged(mock_azure, subscription_id): + # SDK may return an enum object; enum_str() must extract the underlying value. + class _FakeTlsEnum: + value = "TLS1_0" + def __str__(self): + return "MinimumTlsVersion.TLS1_0" + + account = make_resource(id=_storage_id("sa-tls-enum"), name="sa-tls-enum", minimum_tls_version=_FakeTlsEnum()) + mock_azure.set_storage_accounts([account]) + findings = az_stor_007.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-007" + + def test_stor_008_required_cmk_missing_returns_finding(mock_azure, subscription_id): account = make_resource( id=_storage_id("sa-cmk"), From a7f180da315aa69f00bb0ecef8f8c87fd57487ee Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 31 Aug 2026 23:50:10 +0100 Subject: [PATCH 10/10] fix(scanner): address all review feedback and CI failures for PR #278 Signed-off-by: Tanvir Farhad --- Dockerfile | 1 - tests/test_rules_storage.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1bdc4145..85b06e52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ RUN apt-get update \ && apt-get dist-upgrade -y \ && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . COPY requirements.txt ./ RUN pip install --no-cache-dir --upgrade \ pip==26.1.2 \ diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index a04cd2ea..9195fa97 100644 --- a/tests/test_rules_storage.py +++ b/tests/test_rules_storage.py @@ -232,6 +232,7 @@ def test_stor_007_sdk_enum_tls10_is_flagged(mock_azure, subscription_id): # SDK may return an enum object; enum_str() must extract the underlying value. class _FakeTlsEnum: value = "TLS1_0" + def __str__(self): return "MinimumTlsVersion.TLS1_0"