diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950bc21d..a5a7dec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,7 +477,9 @@ jobs: pip-audit -r requirements.txt \ --ignore-vuln PYSEC-2025-217 \ --ignore-vuln CVE-2026-1839 \ - --ignore-vuln CVE-2026-4372 + --ignore-vuln CVE-2026-4372 \ + --ignore-vuln CVE-2026-45830 \ + --ignore-vuln CVE-2026-45833 # ── Software Bill of Materials (Syft, CycloneDX) ────────────────────────── sbom: diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index be50f996..5acc7eb9 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", @@ -313,6 +339,17 @@ "AZ-BAK-002": {"control_id":"N/A-BAK-002","control_name":"Backup vault immutability","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup immutability control."}, "AZ-BAK-004": {"control_id":"N/A-BAK-004","control_name":"Backup multi-user authorization","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup authorization control."}, "AZ-BAK-006": {"control_id":"N/A-BAK-006","control_name":"Backup security monitoring","description":"No direct CIS Azure recommendation is assigned; OpenShield evaluates the Azure Backup monitoring control."}, + "AZ-IDN-016": {"control_id": "N/A-IDN-016", "control_name": "Privileged User Missing Phishing-Resistant MFA", "description": "Service-specific OpenShield control for phishing-resistant MFA enforcement on privileged users."}, + "AZ-IDN-017": {"control_id": "N/A-IDN-017", "control_name": "Global Administrator Permanently Assigned Outside PIM", "description": "Service-specific OpenShield control for PIM-governed Global Administrator assignments."}, + "AZ-IDN-018": {"control_id": "N/A-IDN-018", "control_name": "Privileged Role Assigned Outside PIM", "description": "Service-specific OpenShield control requiring all privileged roles to be managed via PIM."}, + "AZ-IDN-019": {"control_id": "N/A-IDN-019", "control_name": "Stale Privileged Account Retains Active Access", "description": "Service-specific OpenShield control for dormant privileged accounts."}, + "AZ-IDN-020": {"control_id": "N/A-IDN-020", "control_name": "Emergency Access Accounts Missing or Misconfigured", "description": "Service-specific OpenShield control for break-glass account configuration."}, + "AZ-IDN-021": {"control_id": "N/A-IDN-021", "control_name": "Legacy Authentication Not Blocked by Conditional Access", "description": "Service-specific OpenShield control for blocking legacy authentication protocols."}, + "AZ-IDN-022": {"control_id": "N/A-IDN-022", "control_name": "Azure Management Not Protected by Conditional Access", "description": "Service-specific OpenShield control requiring CA protection for Azure management."}, + "AZ-IDN-023": {"control_id": "N/A-IDN-023", "control_name": "Identity Protection Risk Policies Not Enabled", "description": "Service-specific OpenShield control for Identity Protection risk policy enablement."}, + "AZ-IDN-024": {"control_id": "N/A-IDN-024", "control_name": "Workload Identities Excluded From Conditional Access", "description": "Service-specific OpenShield control for workload identity CA coverage."}, + "AZ-IDN-025": {"control_id": "N/A-IDN-025", "control_name": "Privileged Role-Assignable Group Has No Owner", "description": "Service-specific OpenShield control for privileged group ownership governance."}, + "AZ-SC-001": { "control_id": "N/A-SC-001", "control_name": "Container Registry admin user baseline (not mapped in CIS Azure Foundations 2.0.0)", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 16462fc4..d37cbdc8 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", @@ -373,6 +399,17 @@ "control_name": "Network controls", "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." }, + "AZ-IDN-016": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Privileged users must use phishing-resistant multi-factor authentication."}, + "AZ-IDN-017": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Global Administrator access must be time-bound through PIM rather than permanently assigned."}, + "AZ-IDN-018": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Privileged role assignments must be managed and audited through PIM."}, + "AZ-IDN-019": {"control_id": "A.9.2.5", "control_name": "Review of user access rights", "description": "Dormant privileged accounts must be periodically reviewed and revoked."}, + "AZ-IDN-020": {"control_id": "A.9.1.2", "control_name": "Access to networks and network services", "description": "Emergency access accounts ensure administrative access during lockout scenarios."}, + "AZ-IDN-021": {"control_id": "A.9.4.2", "control_name": "Secure log-on procedures", "description": "Legacy authentication protocols that bypass MFA controls must be blocked."}, + "AZ-IDN-022": {"control_id": "A.9.4.1", "control_name": "Information access restriction", "description": "Azure management interfaces must require MFA via Conditional Access."}, + "AZ-IDN-023": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "Identity Protection risk policies detect and respond to anomalous sign-in activity."}, + "AZ-IDN-024": {"control_id": "A.9.2.3", "control_name": "Management of privileged access rights", "description": "Workload identities must not be broadly excluded from Conditional Access enforcement."}, + "AZ-IDN-025": {"control_id": "A.9.2.5", "control_name": "Review of user access rights", "description": "Role-assignable groups must have owners to govern privileged membership changes."}, + "AZ-SC-001": { "control_id": "A.9.2.1", "control_name": "User registration and de-registration", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 5dd6df70..f2594455 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -13,6 +13,32 @@ "control_name": "Data-in-transit is protected", "description": "Requiring secure transfer ensures data in transit between clients and Azure Storage is encrypted using HTTPS, protecting against interception and tampering." }, + "AZ-STOR-006": { + "control_id": "N/A-STOR-006", + "control_name": "Storage Account Shared-Key Authorization Enabled", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-007": { + "control_id": "N/A-STOR-007", + "control_name": "Storage Account Allows TLS Below 1.2", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-008": { + "control_id": "N/A-STOR-008", + "control_name": "Required Storage Customer-Managed Key Protection Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-STOR-009": { + "control_id": "N/A-STOR-009", + "control_name": "Required Blob Container Immutability Missing", + "description": "OpenShield checks this service-specific control without claiming an unrelated NIST recommendation." + }, + "AZ-DB-005": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "SQL authentication is restricted to approved Entra identities."}, + "AZ-DB-006": {"control_id": "DE.CM-8", "control_name": "Vulnerability scans are performed", "description": "Required SQL vulnerability assessment is configured."}, + "AZ-DB-007": {"control_id": "A.12.4.1", "control_name": "Event logging", "description": "SQL audit logs are retained according to policy."}, + "AZ-COSMOS-001": {"control_id": "PR.AC-6", "control_name": "Identity proofing and authentication", "description": "Cosmos authentication is restricted to approved Entra identities."}, + "AZ-COSMOS-002": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Cosmos public network access is restricted according to policy."}, + "AZ-CACHE-001": {"control_id": "PR.AC-5", "control_name": "Network integrity is protected", "description": "Managed cache access is private and uses approved TLS."}, "AZ-NET-001": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", @@ -373,6 +399,17 @@ "control_name": "Network integrity is protected", "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." }, + "AZ-IDN-016": {"control_id": "PR.AC-7", "control_name": "Users, devices, and other assets are authenticated", "description": "Privileged users must register phishing-resistant authentication methods."}, + "AZ-IDN-017": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Global Administrator assignments must be time-bound and managed through PIM."}, + "AZ-IDN-018": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "All privileged role assignments must be governed through Privileged Identity Management."}, + "AZ-IDN-019": {"control_id": "PR.AC-1", "control_name": "Identities and credentials are managed", "description": "Dormant privileged accounts must be reviewed and disabled."}, + "AZ-IDN-020": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "At least two correctly configured emergency access accounts must exist."}, + "AZ-IDN-021": {"control_id": "PR.AC-7", "control_name": "Users, devices, and other assets are authenticated", "description": "Legacy authentication protocols that bypass MFA must be blocked."}, + "AZ-IDN-022": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Azure management interfaces require MFA enforcement via Conditional Access."}, + "AZ-IDN-023": {"control_id": "DE.CM-3", "control_name": "Personnel activity is monitored to detect potential cybersecurity events", "description": "Identity Protection risk policies must be enabled to detect and respond to compromised accounts."}, + "AZ-IDN-024": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Workload identities with privileged roles must not be broadly excluded from CA policies."}, + "AZ-IDN-025": {"control_id": "PR.AC-4", "control_name": "Access permissions and authorizations are managed", "description": "Role-assignable groups must have designated owners to control privileged group membership."}, + "AZ-SC-001": { "control_id": "PR.AC-1", "control_name": "Identities and credentials are issued, managed, verified, revoked, and audited", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index ef259f71..49313ddc 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", @@ -373,6 +399,17 @@ "control_name": "Restricts Access from Outside the Network Boundary", "description": "A private endpoint connection is pending, rejected, or disconnected and does not provide an active private path, leaving traffic to traverse the public network boundary instead." }, + "AZ-IDN-016": {"control_id": "CC6.1", "control_name": "Logical and Physical Access Controls", "description": "Privileged users must register phishing-resistant authentication methods."}, + "AZ-IDN-017": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Global Administrator access must be time-bound and governed through PIM."}, + "AZ-IDN-018": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "All privileged role assignments must be managed through PIM with time and approval controls."}, + "AZ-IDN-019": {"control_id": "CC6.2", "control_name": "User Registration and Authorization", "description": "Dormant privileged accounts must be reviewed and access revoked."}, + "AZ-IDN-020": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Emergency access accounts ensure continuity of administrative access."}, + "AZ-IDN-021": {"control_id": "CC6.1", "control_name": "Logical and Physical Access Controls", "description": "Legacy authentication protocols that bypass MFA must be blocked."}, + "AZ-IDN-022": {"control_id": "CC6.6", "control_name": "Logical Access Security Measures", "description": "Azure management portal and API access must be protected by MFA."}, + "AZ-IDN-023": {"control_id": "CC7.2", "control_name": "System Monitoring", "description": "Identity Protection risk policies must detect and respond to anomalous authentication events."}, + "AZ-IDN-024": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Workload identities must not be broadly excluded from Conditional Access MFA requirements."}, + "AZ-IDN-025": {"control_id": "CC6.3", "control_name": "Role-Based Access", "description": "Role-assignable groups must have owners to control privileged access grants."}, + "AZ-SC-001": { "control_id": "CC6.1", "control_name": "Logical Access Security Measures", diff --git a/docs/architecture.md b/docs/architecture.md index 85dc4ef4..e03539e1 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 | +| Identity | 25 | AZ-IDN-001 to 025 | +| 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..649ad0fb 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -27,6 +27,16 @@ OpenShield currently ships 80 Azure scan rules. This table is generated from the | AZ-IDN-013 | App Registration Uses Password Credentials | MEDIUM | Identity | N/A-IDN-013 | PR.AC-1 | A.9.4.3 | | AZ-IDN-014 | Multi-Tenant App Registration Lacks Property Lock | HIGH | Identity | N/A-IDN-014 | PR.IP-1 | A.12.1.2 | | AZ-IDN-015 | Managed Identity Has Privileged Subscription Role | HIGH | Identity | N/A-IDN-015 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-016 | Privileged User Missing Phishing-Resistant MFA | CRITICAL | Identity | N/A-IDN-016 | PR.AC-7 | A.9.4.2 | +| AZ-IDN-017 | Global Administrator Permanently Assigned Outside PIM | HIGH | Identity | N/A-IDN-017 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-018 | Privileged Role Assigned Outside Privileged Identity Management | HIGH | Identity | N/A-IDN-018 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-019 | Stale Privileged Account Retains Active Access | HIGH | Identity | N/A-IDN-019 | PR.AC-1 | A.9.2.5 | +| AZ-IDN-020 | Emergency Access Accounts Missing or Incorrectly Configured | HIGH | Identity | N/A-IDN-020 | PR.AC-4 | A.9.1.2 | +| AZ-IDN-021 | Legacy Authentication Not Blocked by Conditional Access | HIGH | Identity | N/A-IDN-021 | PR.AC-7 | A.9.4.2 | +| AZ-IDN-022 | Azure Management Not Protected by Conditional Access | HIGH | Identity | N/A-IDN-022 | PR.AC-4 | A.9.4.1 | +| AZ-IDN-023 | Identity Protection Risk Policies Not Enabled | MEDIUM | Identity | N/A-IDN-023 | DE.CM-3 | A.12.4.1 | +| AZ-IDN-024 | Workload Identities Excluded From Conditional Access Policies | MEDIUM | Identity | N/A-IDN-024 | PR.AC-4 | A.9.2.3 | +| AZ-IDN-025 | Privileged Role-Assignable Group Has No Owner | MEDIUM | Identity | N/A-IDN-025 | PR.AC-4 | A.9.2.5 | | AZ-KV-001 | Key Vault with Soft Delete Disabled | MEDIUM | KeyVault | N/A-KV-001 | PR.IP-4 | A.17.2.1 | | AZ-KV-002 | Key Vault Allows Public Network Access Without Private Endpoint | HIGH | Key Vault | 8.7 | AC-17 | A.13.1.1 | | AZ-KV-003 | Key Vault Without Diagnostic Logging Enabled | MEDIUM | Key Vault | 8.4 | DE.CM-7 | A.12.4.1 | @@ -55,6 +65,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 | A.12.4.1 | A.12.4.1 | +| AZ-COSMOS-001 | Cosmos DB Local Authentication Enabled | HIGH | Database | N/A-COSMOS-001 | PR.AC-6 | A.9.4.2 | +| AZ-COSMOS-002 | Cosmos DB Public Network Access Enabled | HIGH | Network | N/A-COSMOS-002 | PR.AC-5 | A.13.1.1 | +| AZ-CACHE-001 | Managed Cache Public or Non-TLS Access | HIGH | Network | N/A-CACHE-001 | PR.AC-5 | A.13.1.1 | | AZ-AKS-001 | AKS Private Cluster Not Enabled | HIGH | Kubernetes | N/A-AKS-001 | PR.AC-3 | A.13.1.1 | | AZ-AKS-002 | AKS Local Accounts Enabled | HIGH | Kubernetes | N/A-AKS-002 | PR.AC-1 | A.9.2.1 | | AZ-AKS-003 | AKS Cluster Not Using Managed Identity | HIGH | Kubernetes | N/A-AKS-003 | PR.AC-1 | A.9.2.1 | diff --git a/docs/storage-protection-controls.md b/docs/storage-protection-controls.md new file mode 100644 index 00000000..274a5ca9 --- /dev/null +++ b/docs/storage-protection-controls.md @@ -0,0 +1,27 @@ +# Storage protection controls + +The enterprise Storage controls in issue #261 use explicit opt-in metadata for +requirements that depend on business criticality. OpenShield does not infer that +every account or container needs a customer-managed key or immutability policy. + +Use Azure resource tags as follows: + +- `oshield:cmk-required=true` enables `AZ-STOR-008` for a storage account. +- `oshield:immutability-required=true` enables `AZ-STOR-009` for a blob container. +- `oshield:entra-only-required=true` enables `AZ-DB-005` for a SQL server. +- `oshield:sql-va-required=true` enables `AZ-DB-006` for a SQL server. +- `oshield:sql-audit-required=true` enables `AZ-DB-007` for a SQL server. +- `oshield:cosmos-local-auth-disabled=true` enables `AZ-COSMOS-001`. +- `oshield:cosmos-public-access-disabled=true` enables `AZ-COSMOS-002`. +- `oshield:cache-private-tls-required=true` enables `AZ-CACHE-001`. +- `oshield:exception-approved=true` suppresses either control only when the + organization has separately approved the exception. + +Missing tags are not treated as proof that protection is required. Missing +encryption properties, unknown key sources, or an unavailable container API are +indeterminate and produce no finding. A finding is emitted only when the +requirement is explicitly enabled and an unsafe state is positively observed. + +The tags are policy inputs, not evidence of approval by themselves; operators +must maintain the corresponding exception and criticality records outside the +scanner. diff --git a/docs/validation/SCANNER_VALIDATION.md b/docs/validation/SCANNER_VALIDATION.md index e519b699..32a7f093 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: **64** | 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` | -| 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` | +| 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 | 19 | `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`, `AZ-IDN-016`, `AZ-IDN-017`, `AZ-IDN-018`, `AZ-IDN-019`, `AZ-IDN-020`, `AZ-IDN-021`, `AZ-IDN-022`, `AZ-IDN-023`, `AZ-IDN-024`, `AZ-IDN-025` | | 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/frontend/package-lock.json b/frontend/package-lock.json index a05f815a..7cd454fe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -718,9 +718,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -738,9 +735,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +752,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +769,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -798,9 +786,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -818,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/playbooks/cli/fix_az_cache_001.sh b/playbooks/cli/fix_az_cache_001.sh new file mode 100644 index 00000000..807a8ec9 --- /dev/null +++ b/playbooks/cli/fix_az_cache_001.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable managed cache public access and require TLS 1.2 or later for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_cosmos_001.sh b/playbooks/cli/fix_az_cosmos_001.sh new file mode 100644 index 00000000..91b012a8 --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_001.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable Cosmos DB local authentication for $RESOURCE_NAME after validating Entra clients." diff --git a/playbooks/cli/fix_az_cosmos_002.sh b/playbooks/cli/fix_az_cosmos_002.sh new file mode 100644 index 00000000..d864dd11 --- /dev/null +++ b/playbooks/cli/fix_az_cosmos_002.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Disable Cosmos DB public network access for $RESOURCE_NAME or document an approved exception." diff --git a/playbooks/cli/fix_az_db_005.sh b/playbooks/cli/fix_az_db_005.sh new file mode 100644 index 00000000..47d1e937 --- /dev/null +++ b/playbooks/cli/fix_az_db_005.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Review SQL clients, then enable Microsoft Entra-only authentication for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_db_006.sh b/playbooks/cli/fix_az_db_006.sh new file mode 100644 index 00000000..5a848ab9 --- /dev/null +++ b/playbooks/cli/fix_az_db_006.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Enable SQL vulnerability assessment and configure an approved storage destination for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_db_007.sh b/playbooks/cli/fix_az_db_007.sh new file mode 100644 index 00000000..c2374093 --- /dev/null +++ b/playbooks/cli/fix_az_db_007.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +echo "Enable SQL auditing and set retention to at least 90 days for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_idn_016.sh b/playbooks/cli/fix_az_idn_016.sh new file mode 100644 index 00000000..12a038c5 --- /dev/null +++ b/playbooks/cli/fix_az_idn_016.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-016 - Privileged user missing phishing-resistant MFA +# Usage: ./fix_az_idn_016.sh + +set -euo pipefail + +USER_ID=${1:-} +if [ -z "$USER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Enabling phishing-resistant MFA for privileged user: $USER_ID" +echo "" +echo "Steps:" +echo "1. Navigate to: Entra ID > Security > Authentication methods > Policies > FIDO2 Security Key" +echo "2. Enable the policy and include the target user or their group." +echo "3. Instruct the user to register a FIDO2 key at https://aka.ms/mysecurityinfo" +echo "4. Create a Conditional Access policy requiring Phishing-resistant MFA strength for admin roles." +echo "" +echo "See: https://learn.microsoft.com/en-us/entra/identity/authentication/howto-authentication-passwordless-security-key" diff --git a/playbooks/cli/fix_az_idn_017.sh b/playbooks/cli/fix_az_idn_017.sh new file mode 100644 index 00000000..eedc58f8 --- /dev/null +++ b/playbooks/cli/fix_az_idn_017.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-017 - Global Administrator permanently assigned outside PIM +# Usage: ./fix_az_idn_017.sh + +set -euo pipefail + +USER_ID=${1:-} +if [ -z "$USER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Migrating Global Administrator to PIM eligible assignment for user: $USER_ID" +echo "" +echo "Steps:" +echo "1. Open Entra ID > Identity Governance > Privileged Identity Management > Azure AD roles" +echo "2. Select 'Global Administrator' > Assignments > Add eligible assignment > select the user" +echo "3. Set maximum activation duration and require MFA and justification on activation" +echo "4. Remove the permanent active assignment after confirming the eligible assignment works" +echo "" +echo "See: https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user" diff --git a/playbooks/cli/fix_az_idn_018.sh b/playbooks/cli/fix_az_idn_018.sh new file mode 100644 index 00000000..d155f47a --- /dev/null +++ b/playbooks/cli/fix_az_idn_018.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-018 - Privileged role assigned outside PIM +# Usage: ./fix_az_idn_018.sh + +set -euo pipefail + +USER_ID=${1:-} +ROLE_ID=${2:-} +if [ -z "$USER_ID" ] || [ -z "$ROLE_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Migrating privileged role $ROLE_ID for user $USER_ID to PIM eligible assignment" +echo "" +echo "Steps:" +echo "1. Open Entra ID > Identity Governance > Privileged Identity Management > Azure AD roles" +echo "2. Select the role > Assignments > Add eligible assignment > select the user" +echo "3. Configure activation settings: require MFA, require justification, set time limit" +echo "4. Remove the permanent active assignment" +echo "" +echo "See: https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user" diff --git a/playbooks/cli/fix_az_idn_019.sh b/playbooks/cli/fix_az_idn_019.sh new file mode 100644 index 00000000..984d00ea --- /dev/null +++ b/playbooks/cli/fix_az_idn_019.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-019 - Stale privileged account retains active access +# Usage: ./fix_az_idn_019.sh + +set -euo pipefail + +USER_ID=${1:-} +if [ -z "$USER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "Reviewing stale privileged account: $USER_ID" +az ad user show --id "$USER_ID" \ + --query "{displayName:displayName,accountEnabled:accountEnabled,userPrincipalName:userPrincipalName}" \ + --output table + +read -r -p "Disable this account? Type APPLY to confirm: " CONFIRM +[ "$CONFIRM" = "APPLY" ] || { echo "Cancelled."; exit 1; } + +az ad user update --id "$USER_ID" --account-enabled false +echo "Account disabled. Remove privileged role assignments via PIM or direct role management." +echo "" +echo "See: https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview" diff --git a/playbooks/cli/fix_az_idn_020.sh b/playbooks/cli/fix_az_idn_020.sh new file mode 100644 index 00000000..19a6858a --- /dev/null +++ b/playbooks/cli/fix_az_idn_020.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# AZ-IDN-020 – Create emergency access (break-glass) accounts +set -euo pipefail +echo "Creating emergency access accounts" +echo "1. Create two cloud-only Global Administrator accounts:" +echo " az ad user create --display-name 'Emergency Access 1' --user-principal-name 'emergency1@yourdomain.onmicrosoft.com' --password '' --force-change-password-next-sign-in false" +echo "2. Assign Global Administrator role to each account" +echo "3. Exclude both accounts from ALL Conditional Access policies" +echo "4. Store credentials in a physical safe — not a password manager" +echo "5. Configure sign-in alerts for both accounts in Microsoft Sentinel or Azure Monitor" +echo "6. Review and rotate credentials at least every 90 days" +echo "See: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access" diff --git a/playbooks/cli/fix_az_idn_021.sh b/playbooks/cli/fix_az_idn_021.sh new file mode 100644 index 00000000..b2f0b45b --- /dev/null +++ b/playbooks/cli/fix_az_idn_021.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# AZ-IDN-021 – Block legacy authentication via Conditional Access +set -euo pipefail +echo "Creating Conditional Access policy to block legacy authentication" +echo "1. Open Entra ID > Security > Conditional Access > New policy" +echo "2. Name: Block Legacy Authentication" +echo "3. Users: Include All users" +echo "4. Cloud apps: Include All cloud apps" +echo "5. Conditions > Client apps: select 'Exchange ActiveSync clients' and 'Other clients'" +echo "6. Grant: Block access" +echo "7. Enable policy (run in Report-only mode first to identify affected users)" +echo "See: https://learn.microsoft.com/en-us/entra/identity/conditional-access/block-legacy-authentication" diff --git a/playbooks/cli/fix_az_idn_022.sh b/playbooks/cli/fix_az_idn_022.sh new file mode 100644 index 00000000..ecc54076 --- /dev/null +++ b/playbooks/cli/fix_az_idn_022.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# AZ-IDN-022 – Require MFA for Azure management via Conditional Access +set -euo pipefail +echo "Creating Conditional Access policy to protect Azure management" +echo "1. Open Entra ID > Security > Conditional Access > New policy" +echo "2. Name: Require MFA for Azure Management" +echo "3. Users: Include All users" +echo "4. Cloud apps: Select 'Microsoft Azure Management' (797f4846-ba00-4fd7-ba43-dac1f8f63013)" +echo "5. Grant: Require multi-factor authentication" +echo "6. Enable policy" +echo "See: https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-azure-management" diff --git a/playbooks/cli/fix_az_idn_023.sh b/playbooks/cli/fix_az_idn_023.sh new file mode 100644 index 00000000..8b481030 --- /dev/null +++ b/playbooks/cli/fix_az_idn_023.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# AZ-IDN-023 – Enable Identity Protection risk policies +set -euo pipefail +echo "Enabling Identity Protection risk policies" +echo "1. Open Entra ID > Security > Identity Protection" +echo "User risk policy:" +echo " - Set User risk to 'High'" +echo " - Set Access to 'Allow access' + 'Require password change'" +echo " - Enable the policy" +echo "Sign-in risk policy:" +echo " - Set Sign-in risk to 'Medium and above'" +echo " - Set Access to 'Allow access' + 'Require multi-factor authentication'" +echo " - Enable the policy" +echo "Alternatively configure risk-based Conditional Access policies for more granular control." +echo "See: https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" diff --git a/playbooks/cli/fix_az_idn_024.sh b/playbooks/cli/fix_az_idn_024.sh new file mode 100644 index 00000000..72c6b447 --- /dev/null +++ b/playbooks/cli/fix_az_idn_024.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# AZ-IDN-024 – Remove broad workload identity exclusions from CA policies +set -euo pipefail +echo "Reviewing Conditional Access policies that exclude all workload identities" +echo "1. Open Entra ID > Security > Conditional Access" +echo "2. Review each enabled policy and remove broad 'All service principals' exclusions" +echo "3. Create dedicated Conditional Access for workload identities policies for privileged service principals" +echo "4. Enable Identity Protection monitoring for service principals" +echo "See: https://learn.microsoft.com/en-us/entra/identity/conditional-access/workload-identity" diff --git a/playbooks/cli/fix_az_idn_025.sh b/playbooks/cli/fix_az_idn_025.sh new file mode 100644 index 00000000..a8fbd236 --- /dev/null +++ b/playbooks/cli/fix_az_idn_025.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# OpenShield Remediation Playbook +# Rule: AZ-IDN-025 - Privileged role-assignable group has no owner +# Usage: ./fix_az_idn_025.sh + +set -euo pipefail + +GROUP_ID=${1:-} +OWNER_ID=${2:-} +if [ -z "$GROUP_ID" ] || [ -z "$OWNER_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +az account show --output none +echo "Assigning owner $OWNER_ID to privileged group $GROUP_ID" + +OWNER_ODATA="https://graph.microsoft.com/v1.0/users/$OWNER_ID" +az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/groups/$GROUP_ID/owners/\$ref" \ + --body "{\"@odata.id\": \"$OWNER_ODATA\"}" + +echo "Owner assigned successfully." +echo "Configure an access review: Entra ID > Identity Governance > Access Reviews > New access review" +echo "" +echo "See: https://learn.microsoft.com/en-us/entra/id-governance/access-reviews-overview" diff --git a/playbooks/cli/fix_az_stor_006.sh b/playbooks/cli/fix_az_stor_006.sh new file mode 100644 index 00000000..cff8cedd --- /dev/null +++ b/playbooks/cli/fix_az_stor_006.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Rule: AZ-STOR-006 - Storage Account Shared-Key Authorization Enabled +set -euo pipefail +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +echo "Disabling shared-key authorization on $RESOURCE_NAME. Verify all clients use Entra ID first." +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --allow-shared-key-access false +echo "Remediation complete for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_stor_007.sh b/playbooks/cli/fix_az_stor_007.sh new file mode 100644 index 00000000..ff7a333a --- /dev/null +++ b/playbooks/cli/fix_az_stor_007.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Rule: AZ-STOR-007 - Storage Account Allows TLS Below 1.2 +set -euo pipefail +RESOURCE_GROUP="${1:-}" +RESOURCE_NAME="${2:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --min-tls-version TLS1_2 +echo "Minimum TLS version set to TLS1_2 for $RESOURCE_NAME." diff --git a/playbooks/cli/fix_az_stor_008.sh b/playbooks/cli/fix_az_stor_008.sh new file mode 100644 index 00000000..18359511 --- /dev/null +++ b/playbooks/cli/fix_az_stor_008.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Rule: AZ-STOR-008 - Required Storage Customer-Managed Key Protection Missing +set -euo pipefail +RESOURCE_GROUP="${1:-}"; RESOURCE_NAME="${2:-}"; KEY_URI="${3:-}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$RESOURCE_NAME" ] || [ -z "$KEY_URI" ]; then + echo "Usage: $0 "; exit 1 +fi +echo "Customer-managed-key remediation requires a validated Key Vault key URI and operator review." +az storage account update --name "$RESOURCE_NAME" --resource-group "$RESOURCE_GROUP" --encryption-key-source Microsoft.Keyvault --encryption-key-vault "${KEY_URI%/*}" --encryption-key-name "${KEY_URI##*/}" diff --git a/playbooks/cli/fix_az_stor_009.sh b/playbooks/cli/fix_az_stor_009.sh new file mode 100644 index 00000000..b026d50f --- /dev/null +++ b/playbooks/cli/fix_az_stor_009.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Rule: AZ-STOR-009 - Required Blob Container Immutability Missing +set -euo pipefail +RESOURCE_GROUP="${1:-}"; ACCOUNT_NAME="${2:-}"; CONTAINER_NAME="${3:-}"; DAYS="${4:-30}" +if [ -z "$RESOURCE_GROUP" ] || [ -z "$ACCOUNT_NAME" ] || [ -z "$CONTAINER_NAME" ]; then + echo "Usage: $0 [retention-days]"; exit 1 +fi +case "$DAYS" in ''|*[!0-9]*) echo "Retention days must be a positive integer."; exit 1;; esac +if [ "$DAYS" -le 0 ]; then echo "Retention days must be positive."; exit 1; fi +echo "Creating an unlocked immutability policy; lock requires separate operator confirmation." +az storage container immutability-policy create --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" --resource-group "$RESOURCE_GROUP" --period "$DAYS" diff --git a/requirements.txt b/requirements.txt index 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..70c815d4 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) -> Optional[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 None + + def get_managed_caches(self) -> Optional[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 None + # ------------------------------------------------------------------ # # Key Vault # # ------------------------------------------------------------------ # @@ -1052,3 +1108,175 @@ def get_blob_service_properties(self, resource_group: str, account_name: str) -> exc, ) return None + + # ------------------------------------------------------------------ # + # Privileged Access & Identity (issue #258) # + # ------------------------------------------------------------------ # + + def get_privileged_role_members(self) -> Optional[List[Dict[str, Any]]]: + """Return users assigned to top privileged directory roles. + + Each item: {userId, userDisplayName, userPrincipalName, accountEnabled, + lastSignInDateTime, roleId, roleName} + + Requires RoleManagement.Read.Directory and AuditLog.Read.All. + Returns None on permission failure so rules return no findings. + """ + _PRIVILEGED_ROLE_IDS = { + "62e90394-69f5-4237-9190-012177145e10": "Global Administrator", + "e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator", + "194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator", + "29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator", + } + url = ( + "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" + "?$expand=principal($select=id,displayName,userPrincipalName,accountEnabled,signInActivity)" + "&$filter=roleDefinitionId ne null&$top=100" + ) + raw = self._get_graph_collection(url, "get_privileged_role_members") + if raw is None: + return None + members = [] + for assignment in raw: + role_def_id = (assignment.get("roleDefinitionId") or "").split("/")[-1].lower() + role_name = _PRIVILEGED_ROLE_IDS.get(role_def_id) + if not role_name: + continue + principal = assignment.get("principal") or {} + user_id = principal.get("id") or assignment.get("principalId", "") + if not user_id: + continue + sign_in = principal.get("signInActivity") or {} + members.append( + { + "userId": user_id, + "userDisplayName": principal.get("displayName", user_id), + "userPrincipalName": principal.get("userPrincipalName", ""), + "accountEnabled": principal.get("accountEnabled", True), + "lastSignInDateTime": sign_in.get("lastSignInDateTime"), + "roleId": role_def_id, + "roleName": role_name, + } + ) + return members + + def get_privileged_users_mfa_methods(self) -> Optional[List[Dict[str, Any]]]: + """Return authentication method types registered for admin users. + + Each item: {userId, userDisplayName, userPrincipalName, authMethodTypes: [...]} + + Requires UserAuthenticationMethod.Read.All. + Returns None on permission failure. + """ + url = ( + "https://graph.microsoft.com/v1.0/reports/authenticationMethods" + "/userRegistrationDetails?$filter=isAdmin eq true&$top=100" + ) + raw = self._get_graph_collection(url, "get_privileged_users_mfa_methods") + if raw is None: + return None + return [ + { + "userId": item.get("id", ""), + "userDisplayName": item.get("userDisplayName", item.get("userPrincipalName", "")), + "userPrincipalName": item.get("userPrincipalName", ""), + "authMethodTypes": item.get("methodsRegistered") or [], + } + for item in raw + ] + + def get_pim_role_assignments(self) -> Optional[List[Dict[str, Any]]]: + """Return PIM eligible and active role assignment schedules. + + Queries both roleAssignmentSchedules (Active assignments) and + roleEligibilitySchedules (Eligible assignments) and combines them. + + Each item: {principalId, principalType, roleId, assignmentType} where + assignmentType is 'Active' or 'Eligible' and principalType is one of + 'user', 'group', or 'servicePrincipal'. + + Requires RoleManagement.Read.Directory. + Returns None on permission failure. + """ + results = [] + active_url = "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentSchedules?$top=100" + active_raw = self._get_graph_collection(active_url, "get_pim_role_assignments/active") + if active_raw is None: + return None + for item in active_raw: + role_def_id = (item.get("roleDefinitionId") or "").split("/")[-1].lower() + results.append( + { + "principalId": item.get("principalId", ""), + "principalType": item.get("principalType", "user"), + "roleId": role_def_id, + "assignmentType": "Active", + } + ) + eligible_url = "https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?$top=100" + eligible_raw = self._get_graph_collection(eligible_url, "get_pim_role_assignments/eligible") + if eligible_raw is None: + return None + for item in eligible_raw: + role_def_id = (item.get("roleDefinitionId") or "").split("/")[-1].lower() + results.append( + { + "principalId": item.get("principalId", ""), + "principalType": item.get("principalType", "user"), + "roleId": role_def_id, + "assignmentType": "Eligible", + } + ) + return results + + def get_identity_protection_policies(self) -> Optional[Dict[str, Any]]: + """Detect risk-based protection via Conditional Access risk conditions. + + Rather than querying the unreliable identityProtection/policies endpoints, + this method inspects Conditional Access policies for risk-based conditions, + which is the recommended approach for modern tenants. + + Returns dict with keys: userRiskPolicy, signInRiskPolicy — each {isEnabled: bool}. + Returns None on permission failure. + """ + policies = self.get_conditional_access_policies() + if policies is None: + return None + user_risk_covered = any( + p.get("state") == "enabled" and (p.get("conditions") or {}).get("userRiskLevels") for p in policies + ) + sign_in_risk_covered = any( + p.get("state") == "enabled" and (p.get("conditions") or {}).get("signInRiskLevels") for p in policies + ) + return { + "userRiskPolicy": {"isEnabled": user_risk_covered}, + "signInRiskPolicy": {"isEnabled": sign_in_risk_covered}, + } + + def get_privileged_groups(self) -> Optional[List[Dict[str, Any]]]: + """Return Entra ID groups that are assignable to directory roles. + + Each item: {id, displayName, ownerCount} + + Requires GroupMember.Read.All and RoleManagement.Read.Directory. + Returns None on permission failure. + """ + url = ( + "https://graph.microsoft.com/v1.0/groups" + "?$filter=isAssignableToRole eq true" + "&$select=id,displayName&$expand=owners($select=id)&$top=100" + ) + raw = self._get_graph_collection(url, "get_privileged_groups") + if raw is None: + return None + return [ + { + "id": grp.get("id", ""), + "displayName": grp.get("displayName", ""), + "ownerCount": len(grp.get("owners") or []), + } + for grp in raw + if grp.get("id") + ] 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..d0279b48 --- /dev/null +++ b/scanner/rules/az_cache_001.py @@ -0,0 +1,63 @@ +"""AZ-CACHE-001: managed cache is public or permits TLS below 1.2.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_common import policy_required + +logger = logging.getLogger(__name__) + +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 + caches = getter() + if caches is None: + logger.warning("%s: managed cache inventory unavailable; result is indeterminate", RULE_ID) + return findings + for cache in caches: + 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..b86f1435 --- /dev/null +++ b/scanner/rules/az_cosmos_001.py @@ -0,0 +1,51 @@ +"""AZ-COSMOS-001: required Cosmos DB local authentication remains enabled.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_common import policy_required + +logger = logging.getLogger(__name__) + +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 + accounts = getter() + if accounts is None: + logger.warning("%s: Cosmos DB inventory unavailable; result is indeterminate", RULE_ID) + return findings + for account in accounts: + 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..98321aec --- /dev/null +++ b/scanner/rules/az_cosmos_002.py @@ -0,0 +1,51 @@ +"""AZ-COSMOS-002: required Cosmos DB private access is not enforced.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._storage_common import policy_required + +logger = logging.getLogger(__name__) + +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 + accounts = getter() + if accounts is None: + logger.warning("%s: Cosmos DB inventory unavailable; result is indeterminate", RULE_ID) + return findings + for account in accounts: + 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..86687804 --- /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.azure_client import enum_str +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": "A.12.4.1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = "An explicitly protected Azure SQL server retains audit logs for less than the required 90 days." +REMEDIATION = "Enable SQL auditing and set retention to at least 90 days in an approved destination." +PLAYBOOK = "playbooks/cli/fix_az_db_007.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + get_audit = getattr(azure_client, "get_sql_server_auditing_policy", None) + if get_audit is None: + return findings + for server in azure_client.get_sql_servers(): + if not policy_required(server, "oshield:sql-audit-required"): + continue + parsed = azure_client.parse_resource_id(getattr(server, "id", "")) + if not parsed.get("resource_group"): + continue + policy = get_audit(parsed["resource_group"], server.name) + if policy is None: + continue + state = "missing" if policy is False else enum_str(getattr(policy, "state", None)).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 + 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_idn_016.py b/scanner/rules/az_idn_016.py new file mode 100644 index 00000000..af09542a --- /dev/null +++ b/scanner/rules/az_idn_016.py @@ -0,0 +1,76 @@ +"""AZ-IDN-016: Privileged user has no phishing-resistant MFA method registered.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-016" +RULE_NAME = "Privileged User Missing Phishing-Resistant MFA" +SEVERITY = "CRITICAL" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-016", + "NIST": "PR.AC-7", + "ISO27001": "A.9.4.2", + "SOC2": "CC6.1", +} +DESCRIPTION = ( + "A user assigned to a privileged directory role has no phishing-resistant " + "authentication method registered. Phishing-resistant methods (FIDO2 security " + "keys, Windows Hello for Business, certificate-based authentication) cannot be " + "intercepted by adversary-in-the-middle attacks, unlike TOTP or SMS-based MFA. " + "Privileged accounts are the highest-value targets in a tenant and must use the " + "strongest available authentication." +) +REMEDIATION = ( + "Register a FIDO2 security key, configure Windows Hello for Business, or enrol " + "a certificate-based authentication method for every privileged user. In Entra ID: " + "Security > Authentication methods > Policies > enable FIDO2 or Certificate-based " + "authentication. Then create a Conditional Access policy requiring " + "'Phishing-resistant MFA strength' for administrator roles." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_016.sh" + +_PHISHING_RESISTANT = { + "fido2", + "windowshelloforbusiness", + "x509certificate", + "microsoftauthenticatorpasswordless", +} + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + users = azure_client.get_privileged_users_mfa_methods() + if users is None: + logger.warning("%s: privileged MFA method inventory unavailable", RULE_ID) + return findings + + for user in users: + user_id = user.get("userId", "") + if not user_id: + continue + methods = [m.lower() for m in (user.get("authMethodTypes") or [])] + if any(pr in m for pr in _PHISHING_RESISTANT for m in methods): + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/users/{user_id}", + "resource_name": user.get("userDisplayName", user_id), + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "user_principal_name": user.get("userPrincipalName", ""), + "registered_methods": user.get("authMethodTypes") or [], + }, + } + ) + return findings diff --git a/scanner/rules/az_idn_017.py b/scanner/rules/az_idn_017.py new file mode 100644 index 00000000..9dc633b9 --- /dev/null +++ b/scanner/rules/az_idn_017.py @@ -0,0 +1,83 @@ +"""AZ-IDN-017: Global Administrator role has permanent direct assignments outside PIM.""" + +import logging +from typing import Any, Dict, List, Set + +RULE_ID = "AZ-IDN-017" +RULE_NAME = "Global Administrator Permanently Assigned Outside PIM" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-017", + "NIST": "PR.AC-4", + "ISO27001": "A.9.2.3", + "SOC2": "CC6.3", +} +DESCRIPTION = ( + "A user holds the Global Administrator role as a permanent direct assignment " + "rather than through a time-bound Privileged Identity Management eligible " + "assignment. Permanent role assignments keep the full attack surface active " + "at all times. An account compromise immediately grants unrestricted tenant " + "control with no approval gate or time limit." +) +REMEDIATION = ( + "Remove the permanent Global Administrator assignment and replace it with a " + "PIM-eligible assignment. In Entra ID: Identity Governance > Privileged Identity " + "Management > Azure AD roles > Global Administrator > Assignments > Add eligible " + "assignment. Set maximum activation duration and require approval and MFA for " + "activation." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_017.sh" + +GLOBAL_ADMIN_ROLE_ID = "62e90394-69f5-4237-9190-012177145e10" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + members = azure_client.get_privileged_role_members() + pim = azure_client.get_pim_role_assignments() + if members is None: + logger.warning("%s: privileged role member inventory unavailable", RULE_ID) + return findings + if pim is None: + logger.warning("%s: PIM assignment inventory unavailable; result is UNKNOWN", RULE_ID) + return findings + + pim_eligible_principals: Set[str] = set() + for a in pim: + if a.get("roleId", "").lower() == GLOBAL_ADMIN_ROLE_ID and a.get("assignmentType") == "Eligible": + pim_eligible_principals.add(a.get("principalId", "")) + + for member in members: + if member.get("roleId", "").lower() != GLOBAL_ADMIN_ROLE_ID: + continue + principal_id = member.get("userId", "") + if not principal_id: + continue + if principal_id in pim_eligible_principals: + continue + principal_type = member.get("principalType", "user") + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/{principal_type}/{principal_id}", + "resource_name": member.get("userDisplayName", principal_id), + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "principal_name": member.get("userPrincipalName", ""), + "displayName": member.get("userDisplayName", ""), + "role_name": member.get("roleName", "Global Administrator"), + "pim_coverage": False, + }, + } + ) + return findings diff --git a/scanner/rules/az_idn_018.py b/scanner/rules/az_idn_018.py new file mode 100644 index 00000000..064ba65a --- /dev/null +++ b/scanner/rules/az_idn_018.py @@ -0,0 +1,78 @@ +"""AZ-IDN-018: Privileged role assigned directly without Privileged Identity Management.""" + +import logging +from typing import Any, Dict, List, Set, Tuple + +RULE_ID = "AZ-IDN-018" +RULE_NAME = "Privileged Role Assigned Outside Privileged Identity Management" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-018", + "NIST": "PR.AC-4", + "ISO27001": "A.9.2.3", + "SOC2": "CC6.3", +} +DESCRIPTION = ( + "A user holds a privileged directory role through a permanent direct assignment " + "rather than through Privileged Identity Management. Without PIM, privileged access " + "is always-on with no activation approval, no time limit, no MFA step-up, and no " + "audit trail for when the privilege was actually used. Any account compromise " + "instantly delivers standing administrative access." +) +REMEDIATION = ( + "Migrate all privileged role assignments to PIM eligible assignments. In Entra ID: " + "Identity Governance > Privileged Identity Management > Azure AD roles > select the " + "role > Assignments > Add eligible assignment. Remove the corresponding permanent " + "active assignment after the eligible assignment is confirmed. Set activation " + "settings to require MFA and justification." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_018.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + members = azure_client.get_privileged_role_members() + pim = azure_client.get_pim_role_assignments() + if members is None: + logger.warning("%s: privileged role member inventory unavailable", RULE_ID) + return findings + + pim_covered: Set[Tuple[str, str]] = set() + if pim is not None: + for a in pim: + if a.get("assignmentType") == "Eligible": + pim_covered.add((a.get("principalId", ""), a.get("roleId", "").lower())) + + for member in members: + principal_id = member.get("userId", "") + role_id = member.get("roleId", "").lower() + if not principal_id or not role_id: + continue + if (principal_id, role_id) in pim_covered: + continue + principal_type = member.get("principalType", "user") + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/{principal_type}/{principal_id}", + "resource_name": member.get("userDisplayName", principal_id), + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "principal_name": member.get("userPrincipalName", ""), + "displayName": member.get("userDisplayName", ""), + "role_name": member.get("roleName", ""), + "role_id": role_id, + }, + } + ) + return findings diff --git a/scanner/rules/az_idn_019.py b/scanner/rules/az_idn_019.py new file mode 100644 index 00000000..f5a0c498 --- /dev/null +++ b/scanner/rules/az_idn_019.py @@ -0,0 +1,88 @@ +"""AZ-IDN-019: Stale privileged account has not signed in for more than 90 days.""" + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-019" +RULE_NAME = "Stale Privileged Account Retains Active Access" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-019", + "NIST": "PR.AC-1", + "ISO27001": "A.9.2.5", + "SOC2": "CC6.2", +} +DESCRIPTION = ( + "An enabled account holding a privileged directory role has not signed in for " + "more than 90 days. Dormant privileged accounts represent a persistent attack " + "surface. Credentials may be stale, MFA devices may be lost or reassigned, and " + "the account may belong to a former employee who should no longer have access. " + "An attacker who obtains these credentials gains unmonitored privileged access." +) +REMEDIATION = ( + "Review each stale privileged account and either confirm it is still required " + "or disable and remove its role assignments. In Entra ID: Users > select the " + "account > Block sign-in. Remove role assignments via Identity Governance > " + "Privileged Identity Management or directly from the role membership. Implement " + "a Conditional Access policy to enforce access reviews for privileged users at " + "least every 90 days." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_019.sh" + +STALE_DAYS = 90 + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + members = azure_client.get_privileged_role_members() + if members is None: + logger.warning("%s: privileged role member inventory unavailable", RULE_ID) + return findings + + cutoff = datetime.now(timezone.utc) - timedelta(days=STALE_DAYS) + + for member in members: + user_id = member.get("userId", "") + if not user_id: + continue + if not member.get("accountEnabled", True): + continue + last_sign_in_raw = member.get("lastSignInDateTime") + if last_sign_in_raw is None: + # No sign-in record — treat as stale (never signed in) + days_inactive = None + else: + try: + last_sign_in = datetime.fromisoformat(last_sign_in_raw.replace("Z", "+00:00")) + if last_sign_in >= cutoff: + continue + days_inactive = (datetime.now(timezone.utc) - last_sign_in).days + except (ValueError, AttributeError): + days_inactive = None + + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/users/{user_id}", + "resource_name": member.get("userDisplayName", user_id), + "resource_type": "Microsoft.Graph/users", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "user_principal_name": member.get("userPrincipalName", ""), + "role_name": member.get("roleName", ""), + "last_sign_in_datetime": last_sign_in_raw, + "days_inactive": days_inactive, + }, + } + ) + return findings diff --git a/scanner/rules/az_idn_020.py b/scanner/rules/az_idn_020.py new file mode 100644 index 00000000..4282fdc5 --- /dev/null +++ b/scanner/rules/az_idn_020.py @@ -0,0 +1,79 @@ +"""AZ-IDN-020: Emergency access (break-glass) accounts missing or misconfigured.""" + +import logging +import re +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-020" +RULE_NAME = "Emergency Access Accounts Missing or Incorrectly Configured" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-020", + "NIST": "PR.AC-4", + "ISO27001": "A.9.1.2", + "SOC2": "CC6.3", +} +DESCRIPTION = ( + "No emergency access (break-glass) Global Administrator accounts were detected " + "in the tenant, or the detected accounts are misconfigured. Break-glass accounts " + "are cloud-only, permanently assigned Global Administrator accounts used only when " + "all other administrative access paths fail (e.g. MFA outage, Conditional Access " + "misconfiguration). Without correctly configured break-glass accounts, an " + "administrative lockout could make the tenant unrecoverable." +) +REMEDIATION = ( + "Create at least two cloud-only Global Administrator accounts with 'emergency', " + "'breakglass', or 'break-glass' in the user principal name. Ensure each account: " + "is cloud-only (not synced from on-premises); has a strong, randomly generated " + "password stored in a physical safe; is excluded from all Conditional Access " + "policies; has sign-in alerts configured; and its credentials are reviewed and " + "rotated at least every 90 days. Document the accounts and their location in your " + "incident response plan." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_020.sh" + +GLOBAL_ADMIN_ROLE_ID = "62e90394-69f5-4237-9190-012177145e10" +_BREAK_GLASS_PATTERN = re.compile(r"emergency|breakglass|break[-_]?glass", re.IGNORECASE) + +logger = logging.getLogger(__name__) + + +def _is_break_glass(member: Dict[str, Any]) -> bool: + upn = member.get("userPrincipalName", "") + display = member.get("userDisplayName", "") + return bool(_BREAK_GLASS_PATTERN.search(upn) or _BREAK_GLASS_PATTERN.search(display)) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + members = azure_client.get_privileged_role_members() + if members is None: + logger.warning("%s: privileged role member inventory unavailable", RULE_ID) + return [] + + global_admins = [m for m in members if m.get("roleId", "").lower() == GLOBAL_ADMIN_ROLE_ID] + break_glass_accounts = [m for m in global_admins if _is_break_glass(m)] + + if len(break_glass_accounts) >= 2: + return [] + + return [ + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/emergencyAccess", + "resource_name": "Emergency Access Accounts", + "resource_type": "Microsoft.AzureActiveDirectory/emergencyAccess", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "break_glass_accounts_found": len(break_glass_accounts), + "global_admin_count": len(global_admins), + "minimum_required": 2, + }, + } + ] diff --git a/scanner/rules/az_idn_021.py b/scanner/rules/az_idn_021.py new file mode 100644 index 00000000..76a6670d --- /dev/null +++ b/scanner/rules/az_idn_021.py @@ -0,0 +1,81 @@ +"""AZ-IDN-021: No Conditional Access policy blocks legacy authentication protocols.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-021" +RULE_NAME = "Legacy Authentication Not Blocked by Conditional Access" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-021", + "NIST": "PR.AC-7", + "ISO27001": "A.9.4.2", + "SOC2": "CC6.1", +} +DESCRIPTION = ( + "No enabled Conditional Access policy blocks legacy authentication protocols " + "such as Basic authentication, Exchange ActiveSync, and other clients that do " + "not support modern authentication. Legacy protocols cannot enforce MFA, making " + "them a direct bypass for any MFA-based Conditional Access policy. The majority " + "of password-spray attacks targeting Microsoft 365 use legacy authentication." +) +REMEDIATION = ( + "Create a Conditional Access policy that blocks all legacy authentication. " + "In Entra ID: Security > Conditional Access > New policy. Set Users to include " + "all users, set Conditions > Client apps > select Exchange ActiveSync clients " + "and Other clients, set Grant to Block access, and enable the policy. " + "Run in Report-only mode first to identify affected users and clients." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_021.sh" + +_LEGACY_CLIENT_TYPES = {"exchangeActiveSync", "other"} + + +def _covers_all_users(policy: Dict[str, Any]) -> bool: + conditions = policy.get("conditions") or {} + users = conditions.get("users") or {} + include_users = users.get("includeUsers") or [] + return "All" in include_users + + +def _blocks_legacy_auth(policy: Dict[str, Any]) -> bool: + if policy.get("state") != "enabled": + return False + if not _covers_all_users(policy): + return False + grant = policy.get("grantControls") or {} + built_in = grant.get("builtInControls") or [] + if "block" not in built_in: + return False + conditions = policy.get("conditions") or {} + client_apps = conditions.get("clientAppTypes") or [] + if "all" in [c.lower() for c in client_apps]: + return True + return bool(_LEGACY_CLIENT_TYPES.intersection(set(client_apps))) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + policies = azure_client.get_conditional_access_policies() + if any(_blocks_legacy_auth(p) for p in (policies or [])): + return [] + reason = ( + "No Conditional Access policies found — Graph API may be inaccessible." + if not policies + else "No enabled policy blocks legacy authentication client types." + ) + return [ + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/conditionalAccess", + "resource_name": "Conditional Access Policies", + "resource_type": "Microsoft.AzureActiveDirectory/conditionalAccessPolicies", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"reason": reason, "policies_found": len(policies or [])}, + } + ] diff --git a/scanner/rules/az_idn_022.py b/scanner/rules/az_idn_022.py new file mode 100644 index 00000000..9e8a92c0 --- /dev/null +++ b/scanner/rules/az_idn_022.py @@ -0,0 +1,81 @@ +"""AZ-IDN-022: No Conditional Access policy protects Azure management operations.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-022" +RULE_NAME = "Azure Management Not Protected by Conditional Access" +SEVERITY = "HIGH" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-022", + "NIST": "PR.AC-4", + "ISO27001": "A.9.4.1", + "SOC2": "CC6.6", +} +DESCRIPTION = ( + "No enabled Conditional Access policy requires MFA or other controls when " + "accessing Azure management interfaces (Azure portal, Azure CLI, Azure PowerShell, " + "ARM API). Without this control, any authenticated user — including compromised " + "accounts — can access Azure management without additional verification, enabling " + "resource modification, data exfiltration, and privilege escalation." +) +REMEDIATION = ( + "Create a Conditional Access policy targeting the Microsoft Azure Management " + "cloud app. In Entra ID: Security > Conditional Access > New policy. Set Users " + "to include all users, set Cloud apps to 'Microsoft Azure Management' " + "(app ID: 797f4846-ba00-4fd7-ba43-dac1f8f63013), set Grant to require MFA, " + "and enable the policy. Test in Report-only mode first." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_022.sh" + +# Well-known app ID for Microsoft Azure Management (portal, CLI, PS, ARM) +_AZURE_MGMT_APP_ID = "797f4846-ba00-4fd7-ba43-dac1f8f63013" + + +def _covers_all_users(policy: Dict[str, Any]) -> bool: + conditions = policy.get("conditions") or {} + users = conditions.get("users") or {} + include_users = users.get("includeUsers") or [] + return "All" in include_users + + +def _protects_azure_management(policy: Dict[str, Any]) -> bool: + if policy.get("state") != "enabled": + return False + if not _covers_all_users(policy): + return False + grant = policy.get("grantControls") or {} + built_in = grant.get("builtInControls") or [] + if "mfa" not in built_in and "block" not in built_in: + return False + conditions = policy.get("conditions") or {} + apps = conditions.get("applications") or {} + include_apps = apps.get("includeApplications") or [] + return "All" in include_apps or _AZURE_MGMT_APP_ID in include_apps + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + policies = azure_client.get_conditional_access_policies() + if any(_protects_azure_management(p) for p in (policies or [])): + return [] + reason = ( + "No Conditional Access policies found — Graph API may be inaccessible." + if not policies + else "No enabled policy requires MFA or block for Azure management access." + ) + return [ + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/conditionalAccess", + "resource_name": "Conditional Access Policies", + "resource_type": "Microsoft.AzureActiveDirectory/conditionalAccessPolicies", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"reason": reason, "policies_found": len(policies or [])}, + } + ] diff --git a/scanner/rules/az_idn_023.py b/scanner/rules/az_idn_023.py new file mode 100644 index 00000000..524c20a9 --- /dev/null +++ b/scanner/rules/az_idn_023.py @@ -0,0 +1,72 @@ +"""AZ-IDN-023: Identity Protection risk policies are disabled.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-023" +RULE_NAME = "Identity Protection Risk Policies Not Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-023", + "NIST": "DE.CM-3", + "ISO27001": "A.12.4.1", + "SOC2": "CC7.2", +} +DESCRIPTION = ( + "Microsoft Entra Identity Protection risk policies (user risk and sign-in risk) " + "are not enabled. Risk policies automatically respond to detected anomalous " + "activity — such as leaked credentials, impossible travel, or unfamiliar sign-in " + "locations — by requiring MFA step-up or blocking access. Without these policies, " + "compromised accounts and risky sign-ins proceed unimpeded until manually reviewed." +) +REMEDIATION = ( + "Enable both user risk and sign-in risk policies in Entra Identity Protection. " + "In Entra ID: Security > Identity Protection > User risk policy: set User risk " + "to High, require password change. Sign-in risk policy: set Sign-in risk to " + "Medium and above, require MFA. Alternatively, configure equivalent risk-based " + "Conditional Access policies which offer more granular control." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_023.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + policies = azure_client.get_identity_protection_policies() + if policies is None: + logger.warning("%s: Identity Protection policy data unavailable", RULE_ID) + return [] + + user_risk = policies.get("userRiskPolicy") or {} + sign_in_risk = policies.get("signInRiskPolicy") or {} + + if user_risk.get("isEnabled") and sign_in_risk.get("isEnabled"): + return [] + + disabled = [] + if not user_risk.get("isEnabled"): + disabled.append("userRiskPolicy") + if not sign_in_risk.get("isEnabled"): + disabled.append("signInRiskPolicy") + + return [ + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/identityProtection", + "resource_name": "Identity Protection Policies", + "resource_type": "Microsoft.AzureActiveDirectory/identityProtectionPolicies", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "disabled_policies": disabled, + "user_risk_enabled": user_risk.get("isEnabled", False), + "sign_in_risk_enabled": sign_in_risk.get("isEnabled", False), + }, + } + ] diff --git a/scanner/rules/az_idn_024.py b/scanner/rules/az_idn_024.py new file mode 100644 index 00000000..f2b0cd43 --- /dev/null +++ b/scanner/rules/az_idn_024.py @@ -0,0 +1,89 @@ +"""AZ-IDN-024: Workload identities (service principals) excluded from all CA policies.""" + +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-024" +RULE_NAME = "Workload Identities Excluded From Conditional Access Policies" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-024", + "NIST": "PR.AC-4", + "ISO27001": "A.9.2.3", + "SOC2": "CC6.3", +} +DESCRIPTION = ( + "All enabled Conditional Access policies that require MFA or other grant controls " + "explicitly exclude workload identities (service principals). Workload identities " + "with over-privileged roles that are excluded from access controls represent a " + "persistent threat vector — if their credentials are compromised, they provide " + "unrestricted access without any Conditional Access enforcement." +) +REMEDIATION = ( + "Review Conditional Access policies to ensure that workload identities with " + "privileged roles are not broadly excluded. Use Conditional Access for workload " + "identities (requires Entra ID P1): Security > Conditional Access > New policy > " + "set 'Workload identities' as the user type and apply appropriate controls. " + "At minimum, monitor service principal sign-ins via Identity Protection." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_024.sh" + + +def _enforcing_policy_excludes_workloads(policy: Dict[str, Any]) -> bool: + """Return True if an enabled enforcing policy does not cover service principals.""" + if policy.get("state") != "enabled": + return False + grant = policy.get("grantControls") or {} + built_in = grant.get("builtInControls") or [] + if not built_in: + return False + conditions = policy.get("conditions") or {} + client_apps = conditions.get("clientApplications") or {} + include_sps = client_apps.get("includeServicePrincipals") or client_apps.get("includeServicePrincipalIds") or [] + exclude_sps = client_apps.get("excludeServicePrincipals") or client_apps.get("excludeServicePrincipalIds") or [] + if not include_sps: + return True + if "All" in exclude_sps: + return True + return False + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + policies = azure_client.get_conditional_access_policies() + if not policies: + return [] + + enforcing = [] + for policy in policies: + conditions = policy.get("conditions") or {} + if policy.get("state") != "enabled" or not conditions.get("clientApplications"): + continue + if not ((policy.get("grantControls") or {}).get("builtInControls") or []): + continue + enforcing.append(policy) + if not enforcing: + return [] + + all_exclude_workloads = all(_enforcing_policy_excludes_workloads(p) for p in enforcing) + if not all_exclude_workloads: + return [] + + return [ + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/tenants/{subscription_id}/conditionalAccess", + "resource_name": "Conditional Access Policies", + "resource_type": "Microsoft.AzureActiveDirectory/conditionalAccessPolicies", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "enforcing_policies_count": len(enforcing), + "all_exclude_workload_identities": True, + }, + } + ] diff --git a/scanner/rules/az_idn_025.py b/scanner/rules/az_idn_025.py new file mode 100644 index 00000000..d8179cdb --- /dev/null +++ b/scanner/rules/az_idn_025.py @@ -0,0 +1,65 @@ +"""AZ-IDN-025: Role-assignable privileged group has no owner configured.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-IDN-025" +RULE_NAME = "Privileged Role-Assignable Group Has No Owner" +SEVERITY = "MEDIUM" +CATEGORY = "Identity" +FRAMEWORKS = { + "CIS": "N/A-IDN-025", + "NIST": "PR.AC-4", + "ISO27001": "A.9.2.5", + "SOC2": "CC6.3", +} +DESCRIPTION = ( + "An Entra ID group configured with 'isAssignableToRole=true' has no owner. " + "Role-assignable groups can be used to grant directory role membership to every " + "member of the group simultaneously. Without an owner, membership changes are " + "uncontrolled: any administrator who can modify group membership can indirectly " + "escalate privileges to the associated role. Ownerless privileged groups also " + "prevent access reviews from functioning correctly." +) +REMEDIATION = ( + "Assign at least one owner to every role-assignable group. In Entra ID: " + "Groups > select the group > Owners > Add owners. The owner should be a " + "named individual (not a service account) who is responsible for approving " + "membership changes. Configure access reviews for the group: Identity Governance " + "> Access Reviews > New access review > select the group." +) +PLAYBOOK = "playbooks/cli/fix_az_idn_025.sh" + +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + groups = azure_client.get_privileged_groups() + if groups is None: + logger.warning("%s: privileged group inventory unavailable", RULE_ID) + return findings + + for group in groups: + group_id = group.get("id", "") + if not group_id: + continue + if group.get("ownerCount", 0) > 0: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"/groups/{group_id}", + "resource_name": group.get("displayName", group_id), + "resource_type": "Microsoft.Graph/groups", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"owner_count": 0}, + } + ) + return findings diff --git a/scanner/rules/az_stor_006.py b/scanner/rules/az_stor_006.py new file mode 100644 index 00000000..0c8c3533 --- /dev/null +++ b/scanner/rules/az_stor_006.py @@ -0,0 +1,57 @@ +"""AZ-STOR-006: Storage account shared-key authorization remains enabled.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-006" +RULE_NAME = "Storage Account Shared-Key Authorization Enabled" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "N/A-STOR-006", + "NIST": "N/A-STOR-006", + "ISO27001": "N/A-STOR-006", + "SOC2": "N/A-STOR-006", +} +DESCRIPTION = ( + "Shared-key authorization is enabled on this storage account. Shared keys " + "are long-lived account-wide credentials and bypass identity-based access " + "controls when disclosed." +) +REMEDIATION = ( + "Disable shared-key authorization after confirming that all clients use " + "Microsoft Entra ID or an approved alternative. Navigate to Storage Account " + "> Configuration > Allow storage account key access and set it to Disabled." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_006.sh" + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag only accounts whose shared-key state is explicitly enabled.""" + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + state = getattr(account, "allow_shared_key_access", None) + if state is None: + logger.warning("%s: shared-key state unavailable; skipping", RULE_ID) + continue + if state is not True: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"allow_shared_key_access": state}, + } + ) + return findings diff --git a/scanner/rules/az_stor_007.py b/scanner/rules/az_stor_007.py new file mode 100644 index 00000000..5bb9429a --- /dev/null +++ b/scanner/rules/az_stor_007.py @@ -0,0 +1,62 @@ +"""AZ-STOR-007: Storage account permits a TLS version below TLS 1.2.""" + +import logging +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +RULE_ID = "AZ-STOR-007" +RULE_NAME = "Storage Account Allows TLS Below 1.2" +SEVERITY = "HIGH" +CATEGORY = "Storage" +FRAMEWORKS = { + "CIS": "N/A-STOR-007", + "NIST": "N/A-STOR-007", + "ISO27001": "N/A-STOR-007", + "SOC2": "N/A-STOR-007", +} +DESCRIPTION = ( + "The storage account permits a minimum TLS version below TLS 1.2. Older " + "protocols no longer provide an approved transport-security baseline." +) +REMEDIATION = ( + "Set the storage account minimum TLS version to TLS 1.2 or later. Navigate " + "to Storage Account > Configuration > Minimum TLS version." +) +PLAYBOOK = "playbooks/cli/fix_az_stor_007.sh" + +_INSECURE_TLS = {"TLS1_0", "TLS1_1", "1.0", "1.1"} +_SECURE_TLS = {"TLS1_2", "TLS1_3", "1.2", "1.3"} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag explicit TLS 1.0/1.1 values; skip missing or unknown values.""" + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + value = getattr(account, "minimum_tls_version", None) + if value is None: + logger.warning("%s: minimum TLS version unavailable; skipping", RULE_ID) + continue + normalized = str(value).strip().upper() + if normalized in _SECURE_TLS or normalized not in _INSECURE_TLS: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": account.id, + "resource_name": account.name, + "resource_type": "Microsoft.Storage/storageAccounts", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "minimum_tls_version": str(value), + "approved_minimum": "TLS1_2", + }, + } + ) + return findings diff --git a/scanner/rules/az_stor_008.py b/scanner/rules/az_stor_008.py new file mode 100644 index 00000000..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..b3b3404e --- /dev/null +++ b/scanner/rules/az_stor_009.py @@ -0,0 +1,73 @@ +"""AZ-STOR-009: required blob-container immutability is absent.""" + +import logging +from typing import Any, Dict, List + +from scanner.azure_client import enum_str +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 = enum_str(getattr(policy, "state", None)).lower() + retention = getattr(policy, "immutability_period_since_creation_in_days", None) + return state in {"locked", "unlocked"} and isinstance(retention, int) and retention > 0 + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for account in azure_client.get_storage_accounts(): + resource_id = getattr(account, "id", "") + account_name = getattr(account, "name", "") + parsed = azure_client.parse_resource_id(resource_id) + resource_group = parsed.get("resource_group", "") + if not resource_group or not account_name: + continue + containers = azure_client.get_blob_containers(resource_group, account_name) + if containers is None: + logger.warning("%s: blob containers unavailable for %s; skipping", RULE_ID, account_name) + continue + for container in containers: + if not policy_required(container, "oshield:immutability-required"): + continue + if _has_immutability(container): + continue + name = getattr(container, "name", "") + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": f"{resource_id}/blobServices/default/containers/{name}", + "resource_name": f"{account_name}/{name}", + "resource_type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": {"requirement_tag": "oshield:immutability-required"}, + } + ) + return findings diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 5b3c3ebb..f9af2702 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -95,6 +95,15 @@ def __init__(self) -> None: self._blob_service_properties: Dict[Tuple[str, str], Optional[Any]] = {} # None by default, matching AzureClient.devops_client's "not configured" state. self.devops_client: Optional[Any] = None + # Privileged access / identity collectors (issue #258) + self._privileged_role_members: Optional[List[Dict[str, Any]]] = [] + self._privileged_users_mfa_methods: Optional[List[Dict[str, Any]]] = [] + self._pim_role_assignments: Optional[List[Dict[str, Any]]] = [] + self._identity_protection_policies: Optional[Dict[str, Any]] = { + "userRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + "signInRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + } + self._privileged_groups: Optional[List[Dict[str, Any]]] = [] # Some rules read azure_client.subscription_id when constructing an # SDK management client inside scan() (e.g. AZ-NET-007..010). self.subscription_id = "00000000-0000-0000-0000-000000000001" @@ -443,6 +452,45 @@ def set_web_apps(self, apps: List[Any]) -> "MockAzureClient": def get_web_apps(self) -> List[Any]: return self._web_apps + # ------------------------------------------------------------------ # + # Privileged Access & Identity (issue #258) # + # ------------------------------------------------------------------ # + + def set_privileged_role_members(self, members: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._privileged_role_members = members + return self + + def get_privileged_role_members(self) -> Optional[List[Dict[str, Any]]]: + return self._privileged_role_members + + def set_privileged_users_mfa_methods(self, methods: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._privileged_users_mfa_methods = methods + return self + + def get_privileged_users_mfa_methods(self) -> Optional[List[Dict[str, Any]]]: + return self._privileged_users_mfa_methods + + def set_pim_role_assignments(self, assignments: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._pim_role_assignments = assignments + return self + + def get_pim_role_assignments(self) -> Optional[List[Dict[str, Any]]]: + return self._pim_role_assignments + + def set_identity_protection_policies(self, policies: Optional[Dict[str, Any]]) -> "MockAzureClient": + self._identity_protection_policies = policies + return self + + def get_identity_protection_policies(self) -> Optional[Dict[str, Any]]: + return self._identity_protection_policies + + def set_privileged_groups(self, groups: Optional[List[Dict[str, Any]]]) -> "MockAzureClient": + self._privileged_groups = groups + return self + + def get_privileged_groups(self) -> Optional[List[Dict[str, Any]]]: + return self._privileged_groups + @staticmethod def parse_resource_id(resource_id: str) -> Dict[str, str]: """Parse an Azure resource ID into a dict with name and resource_group. diff --git a/tests/test_ai_hallucination_guard.py b/tests/test_ai_hallucination_guard.py index 496455f7..1919ec98 100644 --- a/tests/test_ai_hallucination_guard.py +++ b/tests/test_ai_hallucination_guard.py @@ -30,7 +30,10 @@ ] -@pytest.mark.skipif(not Path("ai/vectorstore").exists(), reason="Vector store not found. Run embedding first.") +_BM25_MISSING = not Path("ai/vectorstore/bm25_index.json").exists() + + +@pytest.mark.skipif(_BM25_MISSING, reason="BM25 index not found. Run 'python ai/embed.py' first.") class TestHallucinationGuard: """ Regression test suite to ensure the AI stays 'Azure Pure' diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py index b541f1d6..c89191be 100644 --- a/tests/test_azure_client_management.py +++ b/tests/test_azure_client_management.py @@ -67,6 +67,16 @@ 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_identity_priv.py b/tests/test_rules_identity_priv.py new file mode 100644 index 00000000..9bb13380 --- /dev/null +++ b/tests/test_rules_identity_priv.py @@ -0,0 +1,606 @@ +"""Tests for privileged access and identity rules AZ-IDN-016 through AZ-IDN-025.""" + +from datetime import datetime, timedelta, timezone + + +from scanner.rules import ( + az_idn_016, + az_idn_017, + az_idn_018, + az_idn_019, + az_idn_020, + az_idn_021, + az_idn_022, + az_idn_023, + az_idn_024, + az_idn_025, +) + +_GLOBAL_ADMIN_ROLE_ID = "62e90394-69f5-4237-9190-012177145e10" +_SEC_ADMIN_ROLE_ID = "194ae4cb-b126-40b2-bd5b-6091b380977d" +_AZURE_MGMT_APP_ID = "797f4846-ba00-4fd7-ba43-dac1f8f63013" + + +# ── helpers ──────────────────────────────────────────────────────────────────── + + +def priv_user(**overrides): + base = { + "userId": "user-1", + "userDisplayName": "Alice Admin", + "userPrincipalName": "alice@example.com", + "accountEnabled": True, + "lastSignInDateTime": datetime.now(timezone.utc).isoformat(), + "roleId": _GLOBAL_ADMIN_ROLE_ID, + "roleName": "Global Administrator", + } + base.update(overrides) + return base + + +def mfa_user(**overrides): + base = { + "userId": "user-1", + "userDisplayName": "Alice Admin", + "userPrincipalName": "alice@example.com", + "authMethodTypes": ["fido2"], + } + base.update(overrides) + return base + + +def pim_assignment(**overrides): + base = { + "principalId": "user-1", + "roleId": _GLOBAL_ADMIN_ROLE_ID, + "assignmentType": "Eligible", + } + base.update(overrides) + return base + + +def ca_policy(**overrides): + import copy + + base = { + "id": "policy-1", + "displayName": "Test Policy", + "state": "enabled", + "conditions": { + "users": {"includeUsers": ["All"], "excludeUsers": [], "excludeServicePrincipals": []}, + "applications": {"includeApplications": ["All"]}, + "clientAppTypes": ["all"], + }, + "grantControls": {"operator": "OR", "builtInControls": ["mfa"]}, + } + result = copy.deepcopy(base) + if "conditions" in overrides: + result["conditions"].update(overrides.pop("conditions")) + result.update(overrides) + return result + + +def priv_group(**overrides): + base = {"id": "group-1", "displayName": "Privileged Group", "ownerCount": 1} + base.update(overrides) + return base + + +_REQUIRED_FIELDS = { + "rule_id", + "rule_name", + "severity", + "category", + "resource_id", + "resource_name", + "resource_type", + "description", + "remediation", + "playbook", + "frameworks", + "metadata", +} + + +def assert_valid_finding(finding): + assert _REQUIRED_FIELDS.issubset(finding.keys()) + assert finding["frameworks"] + + +# ── AZ-IDN-016: phishing-resistant MFA ──────────────────────────────────────── + + +def test_idn_016_fido2_method_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(authMethodTypes=["fido2"])]) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +def test_idn_016_whfb_method_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(authMethodTypes=["windowsHelloForBusiness"])]) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +def test_idn_016_x509_method_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(authMethodTypes=["x509Certificate"])]) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +def test_idn_016_totp_only_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(authMethodTypes=["microsoftAuthenticator"])]) + findings = az_idn_016.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["registered_methods"] == ["microsoftAuthenticator"] + assert_valid_finding(findings[0]) + + +def test_idn_016_no_methods_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(authMethodTypes=[])]) + findings = az_idn_016.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_idn_016_empty_inventory_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([]) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +def test_idn_016_api_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods(None) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +def test_idn_016_user_without_id_is_skipped(mock_azure, subscription_id): + mock_azure.set_privileged_users_mfa_methods([mfa_user(userId="", authMethodTypes=[])]) + assert az_idn_016.scan(mock_azure, subscription_id) == [] + + +# ── AZ-IDN-017: Global Admin permanent assignment ────────────────────────────── + + +def test_idn_017_pim_eligible_admin_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments([pim_assignment(assignmentType="Eligible")]) + assert az_idn_017.scan(mock_azure, subscription_id) == [] + + +def test_idn_017_permanent_admin_without_pim_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments([]) + findings = az_idn_017.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["role_name"] == "Global Administrator" + assert_valid_finding(findings[0]) + + +def test_idn_017_non_global_admin_role_not_flagged(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(roleId=_SEC_ADMIN_ROLE_ID, roleName="Security Administrator")]) + mock_azure.set_pim_role_assignments([]) + assert az_idn_017.scan(mock_azure, subscription_id) == [] + + +def test_idn_017_pim_unavailable_is_unknown(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments(None) + assert az_idn_017.scan(mock_azure, subscription_id) == [] + + +def test_idn_017_member_inventory_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members(None) + assert az_idn_017.scan(mock_azure, subscription_id) == [] + + +def test_idn_017_empty_members_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([]) + mock_azure.set_pim_role_assignments([]) + assert az_idn_017.scan(mock_azure, subscription_id) == [] + + +# ── AZ-IDN-018: privileged roles outside PIM ─────────────────────────────────── + + +def test_idn_018_pim_eligible_member_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments([pim_assignment()]) + assert az_idn_018.scan(mock_azure, subscription_id) == [] + + +def test_idn_018_no_pim_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments([]) + findings = az_idn_018.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["role_id"] == _GLOBAL_ADMIN_ROLE_ID + assert_valid_finding(findings[0]) + + +def test_idn_018_active_pim_not_eligible_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + mock_azure.set_pim_role_assignments([pim_assignment(assignmentType="Active")]) + findings = az_idn_018.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_idn_018_member_inventory_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members(None) + assert az_idn_018.scan(mock_azure, subscription_id) == [] + + +def test_idn_018_empty_inventory_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([]) + mock_azure.set_pim_role_assignments([]) + assert az_idn_018.scan(mock_azure, subscription_id) == [] + + +# ── AZ-IDN-019: stale privileged accounts ───────────────────────────────────── + + +def _recent(): + return (datetime.now(timezone.utc) - timedelta(days=10)).isoformat() + + +def _stale(): + return (datetime.now(timezone.utc) - timedelta(days=100)).isoformat() + + +def test_idn_019_recent_sign_in_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(lastSignInDateTime=_recent())]) + assert az_idn_019.scan(mock_azure, subscription_id) == [] + + +def test_idn_019_stale_sign_in_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(lastSignInDateTime=_stale())]) + findings = az_idn_019.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["days_inactive"] >= 90 + assert_valid_finding(findings[0]) + + +def test_idn_019_no_sign_in_record_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(lastSignInDateTime=None)]) + findings = az_idn_019.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["days_inactive"] is None + + +def test_idn_019_disabled_account_not_flagged(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(lastSignInDateTime=_stale(), accountEnabled=False)]) + assert az_idn_019.scan(mock_azure, subscription_id) == [] + + +def test_idn_019_api_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members(None) + assert az_idn_019.scan(mock_azure, subscription_id) == [] + + +def test_idn_019_empty_inventory_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([]) + assert az_idn_019.scan(mock_azure, subscription_id) == [] + + +def test_idn_019_malformed_date_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user(lastSignInDateTime="not-a-date")]) + findings = az_idn_019.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["days_inactive"] is None + + +# ── AZ-IDN-020: emergency access accounts ────────────────────────────────────── + + +def test_idn_020_two_break_glass_accounts_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_role_members( + [ + priv_user(userId="bg-1", userPrincipalName="emergency1@example.com"), + priv_user(userId="bg-2", userPrincipalName="breakglass2@example.com"), + ] + ) + assert az_idn_020.scan(mock_azure, subscription_id) == [] + + +def test_idn_020_one_break_glass_is_insufficient(mock_azure, subscription_id): + mock_azure.set_privileged_role_members( + [ + priv_user(userId="bg-1", userPrincipalName="emergency@example.com"), + priv_user(userId="user-2", userPrincipalName="alice@example.com"), + ] + ) + findings = az_idn_020.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["break_glass_accounts_found"] == 1 + assert_valid_finding(findings[0]) + + +def test_idn_020_no_break_glass_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_role_members([priv_user()]) + findings = az_idn_020.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["break_glass_accounts_found"] == 0 + + +def test_idn_020_break_glass_in_display_name_detected(mock_azure, subscription_id): + mock_azure.set_privileged_role_members( + [ + priv_user(userId="bg-1", userDisplayName="Break-Glass Account 1"), + priv_user(userId="bg-2", userDisplayName="Break-Glass Account 2"), + ] + ) + assert az_idn_020.scan(mock_azure, subscription_id) == [] + + +def test_idn_020_api_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_role_members(None) + assert az_idn_020.scan(mock_azure, subscription_id) == [] + + +def test_idn_020_non_global_admin_break_glass_not_counted(mock_azure, subscription_id): + mock_azure.set_privileged_role_members( + [ + priv_user(userId="bg-1", userPrincipalName="emergency@example.com", roleId=_SEC_ADMIN_ROLE_ID), + priv_user(userId="bg-2", userPrincipalName="breakglass@example.com", roleId=_SEC_ADMIN_ROLE_ID), + ] + ) + findings = az_idn_020.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["global_admin_count"] == 0 + + +# ── AZ-IDN-021: block legacy auth ───────────────────────────────────────────── + + +def test_idn_021_policy_blocking_other_clients_is_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"clientAppTypes": ["exchangeActiveSync", "other"]}, + grantControls={"operator": "OR", "builtInControls": ["block"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_021.scan(mock_azure, subscription_id) == [] + + +def test_idn_021_policy_covering_all_clients_is_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"clientAppTypes": ["all"]}, + grantControls={"operator": "OR", "builtInControls": ["block"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_021.scan(mock_azure, subscription_id) == [] + + +def test_idn_021_no_policy_returns_finding(mock_azure, subscription_id): + mock_azure.set_conditional_access_policies([]) + findings = az_idn_021.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert_valid_finding(findings[0]) + + +def test_idn_021_disabled_policy_returns_finding(mock_azure, subscription_id): + policy = ca_policy( + state="disabled", + conditions={"clientAppTypes": ["other"]}, + grantControls={"operator": "OR", "builtInControls": ["block"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + findings = az_idn_021.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_idn_021_mfa_policy_not_blocking_is_non_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"clientAppTypes": ["browser", "mobileAppsAndDesktopClients"]}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + findings = az_idn_021.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_idn_021_mfa_only_policy_for_legacy_clients_is_non_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"clientAppTypes": ["exchangeActiveSync", "other"]}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + assert len(az_idn_021.scan(mock_azure, subscription_id)) == 1 + + +# ── AZ-IDN-022: CA protects Azure management ────────────────────────────────── + + +def test_idn_022_all_apps_policy_is_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"applications": {"includeApplications": ["All"]}}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_022.scan(mock_azure, subscription_id) == [] + + +def test_idn_022_azure_mgmt_app_id_policy_is_compliant(mock_azure, subscription_id): + policy = ca_policy( + conditions={"applications": {"includeApplications": [_AZURE_MGMT_APP_ID]}}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_022.scan(mock_azure, subscription_id) == [] + + +def test_idn_022_no_policy_returns_finding(mock_azure, subscription_id): + mock_azure.set_conditional_access_policies([]) + findings = az_idn_022.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert_valid_finding(findings[0]) + + +def test_idn_022_policy_for_different_app_returns_finding(mock_azure, subscription_id): + policy = ca_policy( + conditions={"applications": {"includeApplications": ["other-app-id"]}}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + findings = az_idn_022.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +def test_idn_022_disabled_policy_returns_finding(mock_azure, subscription_id): + policy = ca_policy( + state="disabled", + conditions={"applications": {"includeApplications": ["All"]}}, + grantControls={"operator": "OR", "builtInControls": ["mfa"]}, + ) + mock_azure.set_conditional_access_policies([policy]) + findings = az_idn_022.scan(mock_azure, subscription_id) + assert len(findings) == 1 + + +# ── AZ-IDN-023: Identity Protection risk policies ───────────────────────────── + + +def test_idn_023_both_policies_enabled_is_compliant(mock_azure, subscription_id): + mock_azure.set_identity_protection_policies( + { + "userRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + "signInRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + } + ) + assert az_idn_023.scan(mock_azure, subscription_id) == [] + + +def test_idn_023_user_risk_disabled_returns_finding(mock_azure, subscription_id): + mock_azure.set_identity_protection_policies( + { + "userRiskPolicy": {"isEnabled": False, "riskLevel": "none"}, + "signInRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + } + ) + findings = az_idn_023.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert "userRiskPolicy" in findings[0]["metadata"]["disabled_policies"] + assert_valid_finding(findings[0]) + + +def test_idn_023_sign_in_risk_disabled_returns_finding(mock_azure, subscription_id): + mock_azure.set_identity_protection_policies( + { + "userRiskPolicy": {"isEnabled": True, "riskLevel": "medium"}, + "signInRiskPolicy": {"isEnabled": False, "riskLevel": "none"}, + } + ) + findings = az_idn_023.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert "signInRiskPolicy" in findings[0]["metadata"]["disabled_policies"] + + +def test_idn_023_both_disabled_returns_one_finding(mock_azure, subscription_id): + mock_azure.set_identity_protection_policies( + { + "userRiskPolicy": {"isEnabled": False}, + "signInRiskPolicy": {"isEnabled": False}, + } + ) + findings = az_idn_023.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert len(findings[0]["metadata"]["disabled_policies"]) == 2 + + +def test_idn_023_api_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_identity_protection_policies(None) + assert az_idn_023.scan(mock_azure, subscription_id) == [] + + +# ── AZ-IDN-024: workload identities excluded from CA ────────────────────────── + + +def test_idn_024_policy_including_service_principals_is_compliant(mock_azure, subscription_id): + policy = ca_policy() + policy["conditions"]["clientApplications"] = { + "includeServicePrincipals": ["All"], + "excludeServicePrincipals": [], + } + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_024.scan(mock_azure, subscription_id) == [] + + +def test_idn_024_all_sps_excluded_returns_finding(mock_azure, subscription_id): + policy = ca_policy() + policy["conditions"]["clientApplications"] = { + "includeServicePrincipals": ["All"], + "excludeServicePrincipals": ["All"], + } + mock_azure.set_conditional_access_policies([policy]) + findings = az_idn_024.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["all_exclude_workload_identities"] is True + assert_valid_finding(findings[0]) + + +def test_idn_024_no_enforcing_policies_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_conditional_access_policies([]) + assert az_idn_024.scan(mock_azure, subscription_id) == [] + + +def test_idn_024_disabled_policy_not_counted(mock_azure, subscription_id): + policy = ca_policy(state="disabled") + policy["conditions"]["clientApplications"] = { + "includeServicePrincipals": ["All"], + "excludeServicePrincipals": ["All"], + } + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_024.scan(mock_azure, subscription_id) == [] + + +def test_idn_024_guest_user_exclusion_is_not_workload_exclusion(mock_azure, subscription_id): + policy = ca_policy() + policy["conditions"]["users"]["excludeUsers"] = ["GuestsOrExternalUsers"] + policy["conditions"]["clientApplications"] = { + "includeServicePrincipals": ["All"], + "excludeServicePrincipals": [], + } + mock_azure.set_conditional_access_policies([policy]) + assert az_idn_024.scan(mock_azure, subscription_id) == [] + + +# ── AZ-IDN-025: privileged groups governance ─────────────────────────────────── + + +def test_idn_025_group_with_owner_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_groups([priv_group(ownerCount=1)]) + assert az_idn_025.scan(mock_azure, subscription_id) == [] + + +def test_idn_025_group_with_multiple_owners_is_compliant(mock_azure, subscription_id): + mock_azure.set_privileged_groups([priv_group(ownerCount=3)]) + assert az_idn_025.scan(mock_azure, subscription_id) == [] + + +def test_idn_025_ownerless_group_returns_finding(mock_azure, subscription_id): + mock_azure.set_privileged_groups([priv_group(ownerCount=0)]) + findings = az_idn_025.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["owner_count"] == 0 + assert_valid_finding(findings[0]) + + +def test_idn_025_multiple_ownerless_groups_return_multiple_findings(mock_azure, subscription_id): + mock_azure.set_privileged_groups( + [ + priv_group(id="g1", ownerCount=0), + priv_group(id="g2", ownerCount=0), + priv_group(id="g3", ownerCount=2), + ] + ) + findings = az_idn_025.scan(mock_azure, subscription_id) + assert len(findings) == 2 + + +def test_idn_025_api_failure_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_groups(None) + assert az_idn_025.scan(mock_azure, subscription_id) == [] + + +def test_idn_025_empty_inventory_returns_no_findings(mock_azure, subscription_id): + mock_azure.set_privileged_groups([]) + assert az_idn_025.scan(mock_azure, subscription_id) == [] + + +def test_idn_025_group_without_id_is_skipped(mock_azure, subscription_id): + mock_azure.set_privileged_groups([priv_group(id="", ownerCount=0)]) + assert az_idn_025.scan(mock_azure, subscription_id) == [] diff --git a/tests/test_rules_storage.py b/tests/test_rules_storage.py index 7948c16b..5f3a2d8a 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,95 @@ def test_stor_005_noncompliant_lrs_returns_one_finding(mock_azure, subscription_ assert findings[0]["rule_id"] == "AZ-STOR-005" assert findings[0]["severity"] == "MEDIUM" assert findings[0]["resource_name"] == "sa-lrs" + + +def test_stor_006_shared_key_enabled_returns_one_finding(mock_azure, subscription_id): + account = make_resource( + id=_storage_id("sa-shared-key"), + name="sa-shared-key", + allow_shared_key_access=True, + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-006" + + +def test_stor_006_disabled_or_unknown_is_not_flagged(mock_azure, subscription_id): + disabled = make_resource(id=_storage_id("sa-entra"), name="sa-entra", allow_shared_key_access=False) + unknown = make_resource(id=_storage_id("sa-unknown"), name="sa-unknown") + mock_azure.set_storage_accounts([disabled, unknown]) + assert az_stor_006.scan(mock_azure, subscription_id) == [] + + +def test_stor_007_tls_below_12_returns_one_finding(mock_azure, subscription_id): + account = make_resource(id=_storage_id("sa-tls10"), name="sa-tls10", minimum_tls_version="TLS1_0") + mock_azure.set_storage_accounts([account]) + findings = az_stor_007.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-007" + + +def test_stor_007_secure_or_unknown_is_not_flagged(mock_azure, subscription_id): + secure = make_resource(id=_storage_id("sa-tls12"), name="sa-tls12", minimum_tls_version="TLS1_2") + unknown = make_resource(id=_storage_id("sa-tls-unknown"), name="sa-tls-unknown") + mock_azure.set_storage_accounts([secure, unknown]) + assert az_stor_007.scan(mock_azure, subscription_id) == [] + + +def test_stor_008_required_cmk_missing_returns_finding(mock_azure, subscription_id): + account = make_resource( + id=_storage_id("sa-cmk"), + name="sa-cmk", + tags={"oshield:cmk-required": "true"}, + encryption=make_resource(key_source="Microsoft.Storage"), + ) + mock_azure.set_storage_accounts([account]) + findings = az_stor_008.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-008" + + +def test_stor_008_cmk_or_unknown_is_not_flagged(mock_azure, subscription_id): + compliant = make_resource( + id=_storage_id("sa-cmk-ok"), + name="sa-cmk-ok", + tags={"oshield:cmk-required": "true"}, + encryption=make_resource(key_source="Microsoft.Keyvault"), + ) + unknown = make_resource( + id=_storage_id("sa-cmk-unknown"), + name="sa-cmk-unknown", + tags={"oshield:cmk-required": "true"}, + ) + mock_azure.set_storage_accounts([compliant, unknown]) + assert az_stor_008.scan(mock_azure, subscription_id) == [] + + +def test_stor_009_required_container_without_policy_returns_finding(mock_azure, subscription_id): + account = make_resource(id=_storage_id("sa-immutable"), name="sa-immutable") + container = make_resource( + name="critical-data", + tags={"oshield:immutability-required": "true"}, + immutability_policy=None, + ) + mock_azure.set_storage_accounts([account]) + mock_azure.set_blob_containers(_RG, "sa-immutable", [container]) + findings = az_stor_009.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["rule_id"] == "AZ-STOR-009" + + +def test_stor_009_policy_or_api_failure_is_not_flagged(mock_azure, subscription_id): + account = make_resource(id=_storage_id("sa-immutable-ok"), name="sa-immutable-ok") + policy = make_resource(state="Locked", immutability_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) == []