diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index be50f996..afb3f567 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 16462fc4..5955df05 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 5dd6df70..a4bb9760 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": "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."}, "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 ef259f71..19a4803e 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 85dc4ef4..b612fee0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,10 +115,12 @@ There are 80 rule files in `scanner/rules/`. See `docs/rules-reference.md` for t | 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 c9824b96..abd2c026 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -55,6 +55,16 @@ OpenShield currently ships 80 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 | 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 | | 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..43ea8acb --- /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 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. +- `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 e519b699..e1ab3cdf 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..443809d5 --- /dev/null +++ b/playbooks/cli/fix_az_cache_001.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Rule: AZ-CACHE-001 - Managed Cache Public or Non-TLS Access +set -euo pipefail +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 new file mode 100644 index 00000000..0b8ed35b --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_001.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Rule: AZ-COSMOS-001 - Cosmos DB Local Authentication Enabled +set -euo pipefail +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 new file mode 100644 index 00000000..9a9694a0 --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_002.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Rule: AZ-COSMOS-002 - Cosmos DB Public Network Access Enabled +set -euo pipefail +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 new file mode 100644 index 00000000..aa7acc8c --- /dev/null +++ b/playbooks/cli/fix_az_db_005.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Rule: AZ-DB-005 - SQL Server Entra-Only Authentication Not Enforced +set -euo pipefail +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 new file mode 100644 index 00000000..07eea5a7 --- /dev/null +++ b/playbooks/cli/fix_az_db_006.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Rule: AZ-DB-006 - SQL Server Vulnerability Assessment Not Configured +set -euo pipefail +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 new file mode 100644 index 00000000..eba93383 --- /dev/null +++ b/playbooks/cli/fix_az_db_007.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Rule: AZ-DB-007 - SQL Auditing Retention Below Minimum +set -euo pipefail +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/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..54f1453e --- /dev/null +++ b/playbooks/cli/fix_az_stor_008.sh @@ -0,0 +1,33 @@ +#!/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." + +# 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/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 425acc9b..5498f6d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ azure-mgmt-compute==30.0.0 azure-mgmt-resource==23.0.0 azure-mgmt-resourcegraph==8.0.1 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 ab2bacdd..8c739e9c 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -693,10 +693,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: @@ -706,6 +729,39 @@ 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 ResourceNotFoundError: + return False + 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_common.py b/scanner/rules/_storage_common.py new file mode 100644 index 00000000..42a532ce --- /dev/null +++ b/scanner/rules/_storage_common.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..49d8286a --- /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_common 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..7b9e165e --- /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_common 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..42cf6028 --- /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_common 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..39babfa4 --- /dev/null +++ b/scanner/rules/az_db_005.py @@ -0,0 +1,52 @@ +"""AZ-DB-005: required Microsoft Entra-only authentication is disabled.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_common 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]] = [] + 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 + 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 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..270b524a --- /dev/null +++ b/scanner/rules/az_db_006.py @@ -0,0 +1,50 @@ +"""AZ-DB-006: required SQL vulnerability assessment is not enabled.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_common 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 = False if policy is False else getattr(policy, "is_enabled", True) + 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..50bbfe66 --- /dev/null +++ b/scanner/rules/az_db_007.py @@ -0,0 +1,59 @@ +"""AZ-DB-007: required SQL audit retention is below policy.""" + +from typing import Any, Dict, List + +from scanner.rules._storage_common 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": "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" + + +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: + 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, + "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": {"state": state, "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..2e16d38d --- /dev/null +++ b/scanner/rules/az_stor_006.py @@ -0,0 +1,59 @@ +"""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 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 False: + 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..23c3673c --- /dev/null +++ b/scanner/rules/az_stor_007.py @@ -0,0 +1,71 @@ +"""AZ-STOR-007: Storage account permits a TLS version below TLS 1.2.""" + +import logging +from typing import Any, Dict, List + +from scanner.azure_client import enum_str + +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 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: + normalized = "TLS1_0" + else: + normalized = enum_str(value).strip().upper() + if 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..34f04d0c --- /dev/null +++ b/scanner/rules/az_stor_008.py @@ -0,0 +1,55 @@ +"""AZ-STOR-008: required customer-managed key protection is absent.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_common 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..911e0017 --- /dev/null +++ b/scanner/rules/az_stor_009.py @@ -0,0 +1,74 @@ +"""AZ-STOR-009: required blob-container immutability is absent.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_common 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 + account_required = policy_required(account, "oshield:immutability-required") + for container in containers: + container_required = policy_required(container, "oshield:immutability-required") + if not account_required and not container_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_azure_client_management.py b/tests/test_azure_client_management.py index b541f1d6..69434e5c 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 new file mode 100644 index 00000000..5a0c1e79 --- /dev/null +++ b/tests/test_rules_data_protection.py @@ -0,0 +1,119 @@ +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_azure_ad_only_authentication(self, *_): + return self.server.azure_ad_only_authentication + + 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_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(): + 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..9195fa97 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,126 @@ 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_is_not_flagged(mock_azure, subscription_id): + disabled = make_resource(id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False) + 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]) + 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_is_not_flagged(mock_azure, subscription_id): + secure = make_resource(id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2") + 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"), + 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) == []