diff --git a/README.md b/README.md index d75eb2e..30077a0 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,18 @@ > **Open source Cloud Security Posture Management (CSPM) for Azure - built by the community, for the community.** -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![GitHub Repo stars](https://img.shields.io/github/stars/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/network/members) +[![GitHub contributors](https://img.shields.io/github/contributors/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/graphs/contributors) +[![GitHub last commit](https://img.shields.io/github/last-commit/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/commits/main) +[![GitHub issues](https://img.shields.io/github/issues/openshield-org/openshield?style=flat-square)](https://github.com/openshield-org/openshield/issues) +[![GitHub license](https://img.shields.io/github/license/openshield-org/openshield?style=flat-square)](LICENSE) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![CI](https://github.com/openshield-org/openshield/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/ci.yml) [![Deploy](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml/badge.svg?branch=dev)](https://github.com/openshield-org/openshield/actions/workflows/deploy.yml) [![Security Policy](https://img.shields.io/badge/security-policy-green.svg)](.github/SECURITY.md) [![OWASP](https://img.shields.io/badge/OWASP-listing%20review-orange.svg)](https://owasp.org) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -[![Good First Issues](https://img.shields.io/github/issues/openshield-org/openshield/good-first-issue)](https://github.com/openshield-org/openshield/issues?q=is%3Aissue+label%3Agood-first-issue) [![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289da)](https://discord.gg/openshield) --- diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 68ec4e6..eeb7ca1 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -103,6 +103,11 @@ "control_name": "Ensure that 'Endpoint protection solution' is installed on VMs", "description": "The virtual machine does not have a recognised endpoint protection extension installed. CIS 8.2 requires that an approved endpoint protection solution is installed and running on all virtual machines. Without endpoint protection, malware and ransomware can execute without detection." }, + "AZ-CMP-004": { + "control_id": "8.3", + "control_name": "Ensure that 'OS patching' is enabled for virtual machines", + "description": "The virtual machine does not have automatic OS patching enabled. CIS 8.3 requires that OS patches are applied in a timely manner. Unpatched VMs are vulnerable to known exploits targeting unpatched OS vulnerabilities." + }, "AZ-KV-001": { "control_id": "8.5", "control_name": "Ensure the Key Vault is Recoverable", @@ -118,6 +123,11 @@ "control_name": "Ensure Storage logging is enabled for Blob, Queue, and Table services for read, write, and delete requests", "description": "Enabling diagnostic logging for Azure Storage blob, queue, and table services records read, write, and delete operations. Without logging, unauthorized access, data exfiltration, or destructive operations on storage services cannot be detected or investigated." }, + "AZ-STOR-005": { + "control_id": "3.1", + "control_name": "Ensure that storage accounts use geo-redundant replication", + "description": "Storage accounts configured with locally redundant (LRS) or zone-redundant (ZRS) replication do not replicate data outside the primary region. A regional disaster or prolonged outage could result in data unavailability or data loss. Geo-redundant storage (GRS or GZRS) replicates data asynchronously to a secondary Azure region, protecting against region-wide failures." + }, "AZ-KV-002": { "control_id": "8.3", "control_name": "Ensure that public network access to Key Vault is disabled", @@ -137,6 +147,11 @@ "control_id": "8.6", "control_name": "Ensure that Azure Key Vault Purge Protection is Enabled", "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of vaults and their secrets, keys, and certificates during the soft-delete retention period. Even with soft delete enabled, a malicious insider or privileged account can purge vault objects before the retention period expires. Enabling purge protection prevents this by blocking purge operations for the full retention period." + }, + "AZ-KV-005": { + "control_id": "8.5", + "control_name": "Ensure that the expiration date is set on all certificates", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days and does not have auto-renewal configured. CIS 8.5 requires that expiration dates are monitored and certificates are renewed before expiry to prevent service outages and broken authentication flows." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 71cd134..f716203 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -71,12 +71,12 @@ "AZ-IDN-002": { "control_id": "A.9.4.2", "control_name": "Secure log-on procedures", - "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure including multi-factor authentication." + "description": "MFA enforces secure log-on for privileged accounts. Where required by the access control policy, access to systems and applications should be controlled by a secure log-on procedure." }, "AZ-IDN-003": { "control_id": "A.9.2.1", "control_name": "User registration and de-registration", - "description": "Unrestricted guest user invitations allow any organisation member to register external identities into the tenant without centralised review or approval. A.9.2.1 requires that a formal user registration and de-registration process is implemented. Restricting guest invitations to administrators ensures external identity registration is formally controlled and audited." + "description": "Unrestricted guest user invitations allow any organisation member to register external identities into the tenant without centralised review or approval. A.9.2.1 requires that users and external parties should be registered before access." }, "AZ-DB-001": { "control_id": "A.13.1.1", @@ -86,7 +86,7 @@ "AZ-DB-002": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "SQL Server auditing must be enabled to provide event logs. Event logs recording user activities, exceptions, faults and information security events should be produced, kept and regularly reviewed." + "description": "SQL Server auditing must be enabled to provide event logs. Event logs recording user activities, exceptions, faults and information security events should be produced and kept available." }, "AZ-CMP-001": { "control_id": "A.13.1.1", @@ -96,7 +96,17 @@ "AZ-CMP-002": { "control_id": "A.10.1.1", "control_name": "Policy on the use of cryptographic controls", - "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). A.10.1.1 requires that a policy on the use of cryptographic controls is developed and implemented." + }, + "AZ-CMP-003": { + "control_id": "A.12.2.1", + "control_name": "Controls against malware", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. A.12.2.1 requires that detection, prevention and recovery controls are implemented to protect against malware." + }, + "AZ-CMP-004": { + "control_id": "A.12.6.1", + "control_name": "Management of technical vulnerabilities", + "description": "The virtual machine does not have automatic OS patching enabled. A.12.6.1 requires that information about technical vulnerabilities is obtained and the organisation's exposure evaluated. Without automatic patching, known OS vulnerabilities remain unmitigated." }, "AZ-CMP-003": { "control_id": "A.12.2.1", @@ -106,27 +116,32 @@ "AZ-KV-001": { "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", - "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, reducing availability and recovery options for critical cryptographic material." + "description": "Key Vault soft delete protects against loss of secrets, keys and certificates. Without soft delete, deleted vault objects cannot be recovered, reducing availability and recoverability of cryptographic material." }, "AZ-STOR-003": { "control_id": "A.8.3.1", "control_name": "Management of removable media", - "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal of information assets." + "description": "Storage accounts without lifecycle policies retain data indefinitely with no automated disposal mechanism. Lifecycle management supports formal retention, tiering, and disposal procedures." }, "AZ-STOR-004": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities, exceptions, and information security events should be produced, kept, and regularly reviewed." + "description": "Diagnostic logging must be enabled on Azure Storage blob, queue, and table services to produce event logs for read, write, and delete operations. Event logs recording user activities should be kept available." + }, + "AZ-STOR-005": { + "control_id": "A.17.2.1", + "control_name": "Availability of information processing facilities", + "description": "Storage accounts using LRS or ZRS replication retain data only within a single region, providing no protection against regional outages or disasters. A regional disaster could result in complete data loss." }, "AZ-KV-002": { "control_id": "A.13.1.1", "control_name": "Network controls", - "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive secrets, keys, and certificates to external networks. Access should be restricted to trusted networks using private endpoints or network controls." + "description": "Networks should be managed and controlled to protect information systems and applications. Allowing public network access to Azure Key Vault increases exposure of sensitive cryptographic material." }, "AZ-NET-011": { "control_id": "A.12.4.1", "control_name": "Event logging", - "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activity should be produced and retained to support incident response." + "description": "Network Watcher must be enabled in all regions where resources are deployed to ensure network events are logged and available for investigation. Event logs recording network activities should be produced and kept available." }, "AZ-DB-003": { "control_id": "A.10.1.1", @@ -137,6 +152,16 @@ "control_id": "A.17.2.1", "control_name": "Availability of information processing facilities", "description": "Purge protection prevents permanent deletion of Azure Key Vault secrets, keys, and certificates during the soft-delete retention period. Without it, cryptographic material can be irrecoverably destroyed, threatening the availability of information processing facilities that depend on those keys and secrets." + }, + "AZ-KV-005": { + "control_id": "A.10.1.2", + "control_name": "Key management", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. A.10.1.2 requires that a policy on the use, protection, and lifetime of cryptographic keys is developed and implemented. Certificates approaching expiry without renewal represent a failure in cryptographic key lifecycle management." + }, + "AZ-DB-004": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall bypasses network controls by permitting any Azure-hosted resource to connect to the database server. Networks should be managed and controlled with explicit rules that restrict access to known and trusted sources only." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 18d6376..4178ff8 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -103,6 +103,11 @@ "control_name": "Malicious code is detected", "description": "The virtual machine does not have a recognised endpoint protection extension installed. DE.CM-4 requires that malicious code is detected on organisational systems. Without endpoint protection, malware and ransomware executing on the VM will not be detected or blocked." }, + "AZ-CMP-004": { + "control_id": "PR.IP-12", + "control_name": "A vulnerability management plan is developed and implemented", + "description": "The virtual machine does not have automatic OS patching enabled. PR.IP-12 requires that a vulnerability management plan is developed and implemented. Without automatic patching, known OS vulnerabilities remain unmitigated and exploitable." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", @@ -123,6 +128,11 @@ "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", "description": "Diagnostic logging on Azure Storage services provides the audit trail needed to monitor for unauthorized or anomalous read, write, and delete operations. Without logging, detection of data exfiltration or unauthorized access to blob, queue, or table services is not possible." }, + "AZ-STOR-005": { + "control_id": "PR.IP-4", + "control_name": "Backups of information are conducted, maintained, and tested", + "description": "Storage accounts configured with LRS or ZRS replicate data only within a single region. A regional outage or disaster could result in data unavailability or data loss. PR.IP-4 requires that backups and redundant copies of information are maintained. Geo-redundant replication (GRS or GZRS) ensures a secondary copy of data is maintained in a separate Azure region, satisfying backup and recovery requirements." + }, "AZ-NET-011": { "control_id": "DE.CM-7", "control_name": "Monitoring for unauthorized personnel, connections, devices, and software is performed", @@ -137,6 +147,16 @@ "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", "description": "Purge protection ensures that deleted Key Vault objects can be recovered within the retention period and cannot be permanently destroyed before it expires. Without purge protection, backups of cryptographic material may be rendered unrecoverable if an insider or compromised account issues a purge operation during the soft-delete window." + }, + "AZ-KV-005": { + "control_id": "PR.MA-1", + "control_name": "Maintenance and repair of organisational assets is performed", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. PR.MA-1 requires that maintenance of organisational assets is performed and logged. Certificate renewal is a critical maintenance task and failure to renew before expiry causes immediate service disruption." + }, + "AZ-DB-004": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall permits any Azure-hosted resource to connect to the database remotely without restriction. PR.AC-3 requires that remote access is managed and controlled. Access should be restricted to specific trusted IP ranges or private endpoints to ensure only authorised systems can reach the database." } } -} \ No newline at end of file +} diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 10eb673..e5efa0c 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -18,6 +18,11 @@ "control_name": "Change Management", "description": "A storage account with no lifecycle management policy allows data to accumulate indefinitely with no automatic expiry or tiering. CC8.1 requires that infrastructure and data are managed through formal processes. Implementing a lifecycle policy ensures data retention is controlled and old data is automatically moved or deleted according to organisational policy." }, + "AZ-STOR-005": { + "control_id": "A1.2", + "control_name": "Environmental Threats and Recovery", + "description": "Storage accounts configured with LRS or ZRS replication do not protect against environmental threats at the regional level. A regional outage or disaster could result in data loss and service unavailability. Geo-redundant replication is needed to ensure business continuity." + }, "AZ-NET-001": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", @@ -41,67 +46,77 @@ "AZ-NET-005": { "control_id": "A1.1", "control_name": "Capacity and Performance Monitoring", - "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric attacks that can exhaust capacity and cause service outages. A1.1 requires that current processing capacity is monitored and resources are available to meet objectives. DDoS Protection Standard ensures network availability is maintained under attack conditions." + "description": "Virtual networks without DDoS Protection Standard are vulnerable to volumetric attacks that can exhaust capacity and cause service outages. A1.1 requires that current processes and procedures are performed to manage capacity and performance." }, "AZ-NET-006": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "Unassociated public IP addresses represent unnecessary exposure on the internet and may indicate leftover resources from decommissioned workloads. CC6.6 requires that the network boundary is tightly controlled with only necessary resources exposed. Removing unassociated public IPs reduces the external attack surface." + "description": "Unassociated public IP addresses represent unnecessary exposure on the internet and may indicate leftover resources from decommissioned workloads. CC6.6 requires that the network boundary is managed to restrict logical access from outside sources. Orphaned public IPs should be removed." }, "AZ-NET-007": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "An Application Gateway without WAF enabled provides no protection against web application attacks from external sources including OWASP Top 10 vulnerabilities. CC6.6 requires that access from outside the network boundary is controlled and filtered. WAF in Prevention mode enforces application-layer boundary protection for public-facing services." + "description": "An Application Gateway without WAF enabled provides no protection against web application attacks from external sources including OWASP Top 10 vulnerabilities. CC6.6 requires that access from outside the network boundary is restricted through logical access controls including WAF." }, "AZ-NET-008": { "control_id": "CC8.1", "control_name": "Change Management", - "description": "A load balancer with no backend pool configured is either misconfigured or a leftover resource from a decommissioned workload that was not properly cleaned up. CC8.1 requires that infrastructure changes are managed, tracked and that unused resources are removed through a formal process. Removing empty load balancers maintains an accurate and controlled infrastructure state." + "description": "A load balancer with no backend pool configured is either misconfigured or a leftover resource from a decommissioned workload that was not properly cleaned up. CC8.1 requires that infrastructure is managed through formal change management and resource lifecycle procedures." }, "AZ-NET-009": { "control_id": "CC6.7", "control_name": "Protects Data in Transit", - "description": "VPN gateway connections using IKEv1 use an outdated protocol with known vulnerabilities that weaken the confidentiality and integrity of data transmitted between networks. CC6.7 requires that data transmitted over networks is protected using current secure protocols. Migrating to IKEv2 ensures VPN traffic is protected with a modern and secure key exchange mechanism." + "description": "VPN gateway connections using IKEv1 use an outdated protocol with known vulnerabilities that weaken the confidentiality and integrity of data transmitted between networks. CC6.7 requires that data in transit is protected through encryption using current, secure protocols." }, "AZ-NET-010": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A subnet without an NSG attached has no network layer access controls leaving all resources in that subnet reachable from other subnets or the internet with no filtering. CC6.6 requires that logical access from outside the network boundary is restricted. Attaching an NSG with explicit rules enforces boundary protection at the subnet level." + "description": "A subnet without an NSG attached has no network layer access controls leaving all resources in that subnet reachable from other subnets or the internet with no filtering. CC6.6 requires that access is controlled through network-level restrictions." }, "AZ-IDN-001": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", - "description": "A service principal with Contributor role at subscription scope has unrestricted ability to create, modify and delete any resource in the environment. CC6.1 requires that logical access to information assets is restricted to authorised users and service accounts with least-privilege permissions. Scoping role assignments to the minimum required resource enforces this control." + "description": "A service principal with Contributor role at subscription scope has unrestricted ability to create, modify and delete any resource in the environment. CC6.1 requires that logical access controls restrict authorizations to authenticated and verified users and processes." }, "AZ-IDN-002": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", - "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls include strong authentication mechanisms. Enforcing MFA via Conditional Access policies ensures privileged access requires multiple factors of authentication." + "description": "Without MFA enforced on privileged accounts, a single compromised password grants full administrative access to the Azure environment. CC6.1 requires that logical access controls are implemented to authenticate and authorise users and processes." }, "AZ-IDN-003": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", - "description": "Unrestricted guest user invitations allow any organisation member to introduce unreviewed external identities into the tenant. CC6.1 requires that logical access to information assets is restricted to authorised users. Restricting guest invitations to administrators ensures external identity provisioning is formally controlled and authorised." + "description": "Unrestricted guest user invitations allow any organisation member to introduce unreviewed external identities into the tenant. CC6.1 requires that logical access to information assets is controlled and verified through authentication procedures." }, "AZ-DB-001": { "control_id": "CC6.7", - "control_name": "Protects Data in Transit", - "description": "SQL Server without Transparent Data Encryption stores database files in plain text on disk. CC6.7 requires that data is protected using encryption both in transit and at rest. Enabling TDE ensures database files, backups and transaction logs are encrypted and unreadable without the encryption key." + "control_name": "Protects Data in Transit and At Rest", + "description": "SQL Server without Transparent Data Encryption stores database files in plain text on disk. CC6.7 requires that data is protected using encryption both in transit and at rest against interception and tampering." }, "AZ-DB-002": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A SQL Server firewall rule allowing all IP addresses makes the database reachable from anywhere on the internet. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Locking the firewall to specific application IP ranges ensures only authorised systems can connect to the database." + "description": "A SQL Server firewall rule allowing all IP addresses makes the database reachable from anywhere on the internet. CC6.6 requires that access from outside the network boundary is restricted to authorised sources through explicit firewall rules or private endpoints." }, "AZ-CMP-001": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network perimeter is restricted and controlled. Attaching an NSG with explicit rules enforces the network boundary and controls what traffic can reach the VM." + "description": "A virtual machine with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network boundary is restricted and controlled." }, "AZ-CMP-002": { "control_id": "CC6.7", "control_name": "Protects Data in Transit and At Rest", - "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed encryption does not give the organisation control over the encryption keys. Customer-managed keys or Azure Disk Encryption are required to satisfy this control." + "description": "Virtual machine OS and data disks are using platform-managed encryption only (EncryptionAtRestWithPlatformKey). CC6.7 requires that data is protected using encryption. Platform-managed keys lack customer control and audit capabilities needed for compliance." + }, + "AZ-CMP-003": { + "control_id": "CC6.8", + "control_name": "Prevents or Detects Unauthorized or Malicious Software", + "description": "The virtual machine does not have a recognised endpoint protection extension installed. CC6.8 requires that controls are implemented to prevent or detect and act upon the introduction of unauthorised or malicious software." + }, + "AZ-CMP-004": { + "control_id": "CC7.1", + "control_name": "System Vulnerabilities are Identified and Managed", + "description": "The virtual machine does not have automatic OS patching enabled. CC7.1 requires that vulnerabilities in system components are identified and managed through a defined process. Without automatic patching, known OS vulnerabilities are left unmitigated and exploitable." }, "AZ-CMP-003": { "control_id": "CC6.8", @@ -111,27 +126,37 @@ "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", - "description": "Key Vault without soft delete enabled allows permanent deletion of secrets, keys and certificates with no recovery possible. A1.2 requires that environmental threats to availability are identified and mitigated including protection against accidental or malicious data loss. Enabling soft delete ensures deleted vault objects can be recovered within the retention period." + "description": "Key Vault without soft delete enabled allows permanent deletion of secrets, keys and certificates with no recovery possible. A1.2 requires that environmental threats to availability of information systems are addressed through recovery procedures." }, "AZ-KV-002": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", - "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted and controlled. Locking Key Vault access to private endpoints or specific VNet service endpoints enforces this boundary and protects sensitive credentials from external exposure." + "description": "A Key Vault accessible from the public internet allows any external party to attempt access to secrets, keys and certificates. CC6.6 requires that access from outside the network boundary is restricted. Network rules should deny public access." }, "AZ-NET-011": { "control_id": "CC7.2", "control_name": "System monitoring", - "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, violating the requirement for ongoing monitoring of system components." + "description": "Network Watcher must be enabled in all regions where resources are deployed to support continuous system monitoring. Without it, network-level events cannot be detected or investigated, preventing incident response." }, "AZ-DB-003": { "control_id": "CC6.1", "control_name": "Logical and physical access controls", - "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorized access. Disabling SSL undermines logical access controls by exposing database traffic in plaintext." + "description": "SSL enforcement ensures database connections are encrypted, protecting data in transit from unauthorised access. Disabling SSL undermines logical access controls by exposing credentials and sensitive data to interception." }, "AZ-KV-004": { "control_id": "CC9.1", "control_name": "Risk Mitigation", - "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material by preventing purge operations from executing before the retention period expires." + "description": "Azure Key Vaults without purge protection enabled allow permanent deletion of secrets, keys, and certificates during the soft-delete retention period. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. Enabling purge protection mitigates the risk of irrecoverable loss of cryptographic material." + }, + "AZ-KV-005": { + "control_id": "CC9.1", + "control_name": "Risk Mitigation", + "description": "A certificate stored in Azure Key Vault is expiring within 30 days with no auto-renewal configured. CC9.1 requires that identified risks are mitigated through controls that reduce the likelihood or impact of risk events. An expiring certificate without auto-renewal represents an unmitigated operational risk that will cause service outages if not addressed." + }, + "AZ-DB-004": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "Enabling 'Allow access to Azure services' on a SQL Server firewall creates a rule that permits any Azure-hosted resource — including services from other tenants — to connect to the database. CC6.6 requires that access from outside the network boundary is restricted to authorised sources. Disabling this setting and replacing it with explicit firewall rules or private endpoints enforces the network boundary and ensures only known and trusted systems can reach the SQL Server." } } -} \ No newline at end of file +} diff --git a/playbooks/cli/fix_az_cmp_004.sh b/playbooks/cli/fix_az_cmp_004.sh new file mode 100644 index 0000000..a192682 --- /dev/null +++ b/playbooks/cli/fix_az_cmp_004.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# fix_az_cmp_004.sh +# Enables automatic OS patching on a VM (Windows or Linux) +# Usage: ./fix_az_cmp_004.sh [windows|linux] +# Defaults to windows if OS type is not passed + +set -euo pipefail + +RG=$1 +VM=$2 +OS=${3:-windows} + +if [ -z "$RG" ] || [ -z "$VM" ]; then + echo "Usage: $0 [windows|linux]" + exit 1 +fi + +if [ "${OS,,}" = "linux" ]; then + echo "Enabling AutomaticByPlatform patching on Linux VM $VM..." + + az vm update \ + --resource-group "$RG" \ + --name "$VM" \ + --set osProfile.linuxConfiguration.patchSettings.patchMode=AutomaticByPlatform + + echo "Done. Linux VM $VM will now receive automatic OS patches." +else + echo "Enabling automatic updates on Windows VM $VM..." + + az vm update \ + --resource-group "$RG" \ + --name "$VM" \ + --set osProfile.windowsConfiguration.enableAutomaticUpdates=true \ + --set osProfile.windowsConfiguration.patchSettings.patchMode=AutomaticByPlatform + + echo "Done. Windows VM $VM will now receive automatic OS patches." +fi diff --git a/playbooks/cli/fix_az_db_004.sh b/playbooks/cli/fix_az_db_004.sh new file mode 100644 index 0000000..ba10d47 --- /dev/null +++ b/playbooks/cli/fix_az_db_004.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -euo pipefail +# AZ-DB-004: Remove the 'Allow all Azure services' firewall rule from an Azure SQL Server +# Usage: ./fix_az_db_004.sh +RESOURCE_GROUP=$1 +SERVER_NAME=$2 +if [ -z "$RESOURCE_GROUP" ] || [ -z "$SERVER_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +echo "Removing 'AllowAllWindowsAzureIps' firewall rule from SQL Server: $SERVER_NAME..." +az sql server firewall-rule delete \ + --resource-group "$RESOURCE_GROUP" \ + --server "$SERVER_NAME" \ + --name "AllowAllWindowsAzureIps" +echo "Done. 'Allow access to Azure services' has been disabled for: $SERVER_NAME" +echo "Note: Add explicit firewall rules for trusted IP ranges if needed." \ No newline at end of file diff --git a/playbooks/cli/fix_az_kv_005.sh b/playbooks/cli/fix_az_kv_005.sh new file mode 100644 index 0000000..fb38734 --- /dev/null +++ b/playbooks/cli/fix_az_kv_005.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# fix_az_kv_005.sh +# Enables auto-renewal on an expiring Key Vault certificate +# Usage: ./fix_az_kv_005.sh + +set -euo pipefail + +VAULT=$1 +CERT=$2 + +if [ -z "$VAULT" ] || [ -z "$CERT" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Fetching current policy for certificate $CERT in vault $VAULT..." + +POLICY=$(az keyvault certificate policy show \ + --vault-name "$VAULT" \ + --name "$CERT") + +echo "Updating certificate policy to enable auto-renewal 30 days before expiry..." + +echo "$POLICY" | python3 -c " +import json, sys +policy = json.load(sys.stdin) +policy.setdefault('lifetime_actions', []) +already = any( + a.get('action', {}).get('action_type') == 'AutoRenew' + for a in policy['lifetime_actions'] +) +if not already: + policy['lifetime_actions'].append({ + 'action': {'action_type': 'AutoRenew'}, + 'trigger': {'days_before_expiry': 30} + }) +print(json.dumps(policy)) +" | az keyvault certificate policy update \ + --vault-name "$VAULT" \ + --name "$CERT" \ + --policy @- + +echo "Done. Certificate $CERT will now auto-renew 30 days before expiry." +echo "Note: Auto-renewal requires the certificate issuer to be configured correctly." diff --git a/playbooks/cli/fix_az_stor_005.sh b/playbooks/cli/fix_az_stor_005.sh new file mode 100644 index 0000000..091b006 --- /dev/null +++ b/playbooks/cli/fix_az_stor_005.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-STOR-005 — Storage Account Not Using Geo-Redundant Replication +# Usage: ./fix_az_stor_005.sh [target-sku] +# Severity: MEDIUM + +set -euo pipefail + +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +TARGET_SKU="${3:-Standard_GRS}" + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 [target-sku]" + echo " target-sku defaults to Standard_GRS" + echo " Valid geo-redundant options: Standard_GRS, Standard_RAGRS, Standard_GZRS, Standard_RAGZRS" + exit 1 +fi + +case "$TARGET_SKU" in + Standard_GRS|Standard_RAGRS|Standard_GZRS|Standard_RAGZRS) + ;; + *) + echo "Error: '$TARGET_SKU' is not a supported geo-redundant SKU." + echo "Valid options: Standard_GRS, Standard_RAGRS, Standard_GZRS, Standard_RAGZRS" + exit 1 + ;; +esac + +echo "Checking current SKU for $RESOURCE_NAME..." +CURRENT_SKU=$(az storage account show \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "sku.name" \ + --output tsv) +echo "Current SKU: $CURRENT_SKU" + +echo "Remediating AZ-STOR-005 for $RESOURCE_NAME — updating replication to $TARGET_SKU..." +az storage account update \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --sku "$TARGET_SKU" + +echo "Updated SKU for $RESOURCE_NAME:" +az storage account show \ + --name "$RESOURCE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query "sku.name" \ + --output tsv + +echo "Remediation complete for $RESOURCE_NAME — replication is now $TARGET_SKU." diff --git a/requirements.txt b/requirements.txt index 52f1710..0e34c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ gunicorn==21.2.0 cryptography==42.0.5 msrest==0.7.1 azure-mgmt-postgresqlflexibleservers==1.0.0b1 +azure-keyvault-certificates==4.8.0 diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 5dc9bd0..4b932ce 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -13,7 +13,7 @@ from azure.mgmt.sql import SqlManagementClient from azure.mgmt.monitor import MonitorManagementClient from azure.mgmt.storage import StorageManagementClient -from azure.mgmt.monitor import MonitorManagementClient + logger = logging.getLogger(__name__) @@ -263,14 +263,19 @@ def get_virtual_machines(self) -> List[Any]: logger.error("get_virtual_machines failed: %s", exc) return [] - - - def get_vm_extensions(self, resource_group: str, vm_name: str) -> Optional[List[Any]]: + def get_vm_extensions( + self, resource_group: str, vm_name: str + ) -> Optional[List[Any]]: + """List all extensions installed on a virtual machine.""" try: - result = ComputeManagementClient(self.credential, self.subscription_id).virtual_machine_extensions.list(resource_group, vm_name) + result = ComputeManagementClient( + self.credential, self.subscription_id + ).virtual_machine_extensions.list(resource_group, vm_name) return list(getattr(result, "value", []) or []) except Exception as exc: - logger.error("get_vm_extensions failed for %s/%s: %s", resource_group, vm_name, exc) + logger.error( + "get_vm_extensions failed for %s/%s: %s", resource_group, vm_name, exc + ) return None # ------------------------------------------------------------------ # @@ -308,6 +313,19 @@ def get_sql_server_auditing_policy( ) 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: + client = SqlManagementClient(self.credential, self.subscription_id) + return list(client.firewall_rules.list_by_server(resource_group, server_name)) + except Exception as exc: + logger.error( + "get_sql_server_firewall_rules(%s) failed: %s", server_name, exc + ) + return [] + # ------------------------------------------------------------------ # # Key Vault # # ------------------------------------------------------------------ # @@ -321,6 +339,19 @@ def get_key_vaults(self) -> List[Any]: logger.error("get_key_vaults failed: %s", exc) return [] + def get_key_vault_certificates(self, vault_name: str) -> List[Any]: + """List all certificates in a Key Vault using the Key Vault data plane API.""" + try: + from azure.keyvault.certificates import CertificateClient + vault_url = f"https://{vault_name}.vault.azure.net" + client = CertificateClient(vault_url=vault_url, credential=self.credential) + return list(client.list_properties_of_certificates()) + except Exception as exc: + logger.error( + "get_key_vault_certificates(%s) failed: %s", vault_name, exc + ) + return [] + # ------------------------------------------------------------------ # # Monitoring # # ------------------------------------------------------------------ # @@ -342,21 +373,14 @@ def get_diagnostic_settings(self, resource_id: str) -> Optional[bool]: self.credential, self.subscription_id, ) - - settings = list( - client.diagnostic_settings.list(resource_id) - ) - + settings = list(client.diagnostic_settings.list(resource_id)) if not settings: return False - for setting in settings: logs = getattr(setting, "logs", []) - for log in logs: category = getattr(log, "category", "") enabled = getattr(log, "enabled", False) - if category == "AuditEvent" and enabled: return True return False @@ -399,7 +423,6 @@ def get_service_principals(self) -> List[Any]: logger.error("get_service_principals failed: %s", exc) return [] - def get_postgresql_flexible_servers(self) -> List[Any]: """List all PostgreSQL Flexible Server instances in the subscription.""" try: @@ -410,15 +433,20 @@ def get_postgresql_flexible_servers(self) -> List[Any]: logger.error("get_postgresql_flexible_servers failed: %s", exc) return [] - - def get_postgresql_flexible_server_parameters(self, resource_group: str, server_name: str) -> List[Any]: + def get_postgresql_flexible_server_parameters( + self, resource_group: str, server_name: str + ) -> List[Any]: """List all configuration parameters for a PostgreSQL Flexible Server.""" try: from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient as FlexClient client = FlexClient(self.credential, self.subscription_id) return list(client.configurations.list_by_server(resource_group, server_name)) except Exception as exc: - logger.error("get_postgresql_flexible_server_parameters(%s) failed: %s", server_name, exc) + logger.error( + "get_postgresql_flexible_server_parameters(%s) failed: %s", + server_name, + exc, + ) return [] def get_conditional_access_policies(self) -> List[Any]: @@ -442,6 +470,7 @@ def get_conditional_access_policies(self) -> List[Any]: except Exception as exc: logger.error("get_conditional_access_policies failed: %s", exc) return [] + def get_regions_with_resources(self) -> List[str]: """List all regions that have at least one resource deployed.""" try: @@ -469,4 +498,4 @@ def get_network_watcher_regions(self) -> List[str]: return list(regions) except Exception as exc: logger.error("get_network_watcher_regions failed: %s", exc) - return [] + return [] \ No newline at end of file diff --git a/scanner/rules/az_cmp_004.py b/scanner/rules/az_cmp_004.py new file mode 100644 index 0000000..ec84bc8 --- /dev/null +++ b/scanner/rules/az_cmp_004.py @@ -0,0 +1,80 @@ +"""AZ-CMP-004: VM without automatic OS patching enabled.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-004" +RULE_NAME = "VM Without Automatic OS Patching Enabled" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = { + "CIS": "8.3", + "NIST": "PR.IP-12", + "ISO27001": "A.12.6.1", + "SOC2": "CC7.1", +} +DESCRIPTION = ( + "VM does not have automatic OS patching enabled. " + "Unpatched VMs are vulnerable to known exploits. " + "CIS 8.3 requires OS patches are applied in a timely manner." +) +REMEDIATION = ( + "For Windows VMs enable automatic updates via osProfile.windowsConfiguration " + "or set patchMode to AutomaticByPlatform. " + "For Linux VMs set patchMode to AutomaticByPlatform." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_004.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + parsed = azure_client.parse_resource_id(getattr(vm, "id", "")) + rg = parsed.get("resource_group", "") + vm_name = parsed.get("name", "") + if not rg or not vm_name: + continue + + os_profile = getattr(vm, "os_profile", None) + if not os_profile: + continue + + patching_ok = False + + win_config = getattr(os_profile, "windows_configuration", None) + if win_config is not None: + auto_updates = getattr(win_config, "enable_automatic_updates", False) + patch_settings = getattr(win_config, "patch_settings", None) + patch_mode = getattr(patch_settings, "patch_mode", "") if patch_settings else "" + if auto_updates or (patch_mode or "").lower() == "automaticbyplatform": + patching_ok = True + + linux_config = getattr(os_profile, "linux_configuration", None) + if linux_config is not None: + patch_settings = getattr(linux_config, "patch_settings", None) + patch_mode = getattr(patch_settings, "patch_mode", "") if patch_settings else "" + if (patch_mode or "").lower() == "automaticbyplatform": + patching_ok = True + + if not patching_ok: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vm.id, + "resource_name": vm_name, + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": rg, + }, + }) + + return findings diff --git a/scanner/rules/az_db_004.py b/scanner/rules/az_db_004.py new file mode 100644 index 0000000..161dacd --- /dev/null +++ b/scanner/rules/az_db_004.py @@ -0,0 +1,64 @@ +"""AZ-DB-004: SQL Server firewall allows all Azure services.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-DB-004" +RULE_NAME = "SQL Server Firewall Allows All Azure Services" +SEVERITY = "HIGH" +CATEGORY = "Database" +FRAMEWORKS = { + "CIS": "4.1.2", + "NIST": "PR.AC-3", + "ISO27001": "A.13.1.1", + "SOC2": "CC6.6" +} +DESCRIPTION = ( + "Azure SQL Server has the 'Allow access to Azure services' firewall setting " + "enabled. This creates a firewall rule that permits any resource hosted in " + "Azure — including services from other tenants — to connect to the SQL Server. " + "This significantly increases the attack surface and can allow unauthorised " + "access from compromised or malicious Azure-hosted services." +) +REMEDIATION = ( + "Disable the 'Allow access to Azure services' setting on the SQL Server " + "firewall. Instead, add explicit firewall rules for specific trusted IP " + "ranges or use private endpoints to restrict access to known sources only." +) +PLAYBOOK = "playbooks/cli/fix_az_db_004.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Return a list of findings. Return [] if no issues are found.""" + findings: List[Dict[str, Any]] = [] + + for server in azure_client.get_sql_servers(): + parsed = azure_client.parse_resource_id(server.id) + resource_group = parsed["resource_group"] + server_name = parsed["name"] + + firewall_rules = azure_client.get_sql_server_firewall_rules( + resource_group, server_name + ) + + for rule in firewall_rules: + start_ip = getattr(rule, "start_ip_address", "") + end_ip = getattr(rule, "end_ip_address", "") + + if start_ip == "0.0.0.0" and end_ip == "0.0.0.0": + 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": {"resource_group": resource_group} + }) + break + + return findings \ No newline at end of file diff --git a/scanner/rules/az_kv_005.py b/scanner/rules/az_kv_005.py new file mode 100644 index 0000000..df29cce --- /dev/null +++ b/scanner/rules/az_kv_005.py @@ -0,0 +1,105 @@ +"""AZ-KV-005: Key Vault certificate expiring within 30 days.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +RULE_ID = "AZ-KV-005" +RULE_NAME = "Key Vault Certificate Expiring Within 30 Days" +SEVERITY = "MEDIUM" +CATEGORY = "Key Vault" +FRAMEWORKS = { + "CIS": "8.5", + "NIST": "PR.MA-1", + "ISO27001": "A.10.1.2", + "SOC2": "CC9.1", +} +DESCRIPTION = ( + "A certificate stored in Azure Key Vault is expiring within 30 days " + "and does not have auto-renewal configured. Expired certificates cause " + "immediate service outages, broken HTTPS connections, and failed " + "authentication flows." +) +REMEDIATION = ( + "Enable auto-renewal on the certificate in Azure Key Vault, or manually " + "renew the certificate before it expires. Navigate to: " + "Key Vault > Certificates > select certificate > Issuance Policy > " + "enable Auto-renewal." +) +PLAYBOOK = "playbooks/cli/fix_az_kv_005.sh" + +logger = logging.getLogger(__name__) + +EXPIRY_THRESHOLD_DAYS = 30 + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + + for vault in azure_client.get_key_vaults(): + parsed = azure_client.parse_resource_id(getattr(vault, "id", "")) + rg = parsed.get("resource_group", "") + vault_name = parsed.get("name", "") + if not rg or not vault_name: + continue + + certificates = azure_client.get_key_vault_certificates(vault_name) + for cert in certificates: + try: + cert_name = getattr(cert, "name", "") or getattr( + cert, "id", "" + ).split("/")[-1] + + expires = getattr(cert, "expires_on", None) + if not expires: + continue + + auto_renew = getattr(cert, "policy", None) + lifetime_actions = ( + getattr(auto_renew, "lifetime_actions", []) if auto_renew else [] + ) + has_auto_renew = any( + getattr(getattr(a, "action", None), "action_type", "").lower() + == "autorenew" + for a in (lifetime_actions or []) + ) + + if has_auto_renew: + continue + + now = datetime.now(timezone.utc) + if hasattr(expires, "tzinfo") and expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + + days_until_expiry = (expires - now).days + + if 0 <= days_until_expiry <= EXPIRY_THRESHOLD_DAYS: + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"{vault.id}/certificates/{cert_name}", + "resource_name": cert_name, + "resource_type": "Microsoft.KeyVault/vaults/certificates", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": rg, + "vault_name": vault_name, + "days_until_expiry": days_until_expiry, + "expires": expires.isoformat(), + }, + }) + + except Exception as exc: + logger.error( + "AZ-KV-005: error processing cert in vault %s: %s", + vault_name, + exc, + ) + continue + + return findings diff --git a/scanner/rules/az_stor_005.py b/scanner/rules/az_stor_005.py new file mode 100644 index 0000000..dc5b0bd --- /dev/null +++ b/scanner/rules/az_stor_005.py @@ -0,0 +1,93 @@ +"""AZ-STOR-005: Storage account not using geo-redundant replication.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-005" +RULE_NAME = "Storage Account Not Using Geo-Redundant Replication" +SEVERITY = "MEDIUM" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "3.1", + "NIST": "PR.IP-4", + "ISO27001": "A.17.2.1", + "SOC2": "A1.2", +} +DESCRIPTION = ( + "This storage account is configured with a non-geo-redundant replication " + "SKU ({sku_name}). Locally redundant (LRS) and zone-redundant (ZRS) " + "storage replicate data only within a single region. A regional outage or " + "disaster could result in data unavailability or data loss. Geo-redundant " + "storage (GRS or GZRS) replicates data asynchronously to a secondary " + "Azure region, protecting against region-wide failures." +) +REMEDIATION = ( + "Change the storage account replication to a geo-redundant SKU such as " + "Standard_GRS or Standard_GZRS. Navigate to Storage Account > " + "Configuration > Replication and select Geo-redundant storage (GRS) or " + "Geo-zone-redundant storage (GZRS). Alternatively, run the remediation " + "playbook." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_005.sh" + +_GEO_REDUNDANT_SKUS = { + "Standard_GRS", + "Standard_RAGRS", + "Standard_GZRS", + "Standard_RAGZRS", + "StandardV2_GRS", + "StandardV2_GZRS", +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect storage accounts not configured with geo-redundant replication.""" + findings: List[Dict[str, Any]] = [] + + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + location = getattr(account, "location", "") + + if not resource_id or not account_name: + continue + + sku = getattr(account, "sku", None) + sku_name = getattr(sku, "name", "") if sku else "" + + if not sku_name: + logger.warning( + "AZ-STOR-005: Could not determine SKU for %s — skipping.", + account_name, + ) + continue + + if sku_name in _GEO_REDUNDANT_SKUS: + continue + + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + + findings.append({ + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": account_name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION.format(sku_name=sku_name), + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": resource_group, + "location": location, + "current_sku": sku_name, + "recommended_sku": "Standard_GRS", + }, + }) + + return findings