From b6d3c3a5f41f45401ab8401fd8823b518f0b47ab Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 20 Aug 2026 15:02:49 +0100 Subject: [PATCH 1/4] feat: add enterprise governance rule pack (#257) Signed-off-by: Tanvir Farhad --- README.md | 6 +- .../frameworks/cis_azure_benchmark.json | 50 +++ compliance/frameworks/iso27001.json | 50 +++ compliance/frameworks/nist_csf.json | 50 +++ compliance/frameworks/soc2.json | 50 +++ config/governance-policy.example.json | 42 ++ docs/architecture.md | 13 +- docs/governance-rules.md | 27 ++ docs/learn/index.html | 4 +- docs/rules-reference.md | 27 +- playbooks/cli/fix_az_gov_001.sh | 4 + playbooks/cli/fix_az_gov_002.sh | 4 + playbooks/cli/fix_az_gov_003.sh | 4 + playbooks/cli/fix_az_gov_004.sh | 4 + playbooks/cli/fix_az_gov_005.sh | 4 + playbooks/cli/fix_az_gov_006.sh | 4 + playbooks/cli/fix_az_gov_007.sh | 4 + playbooks/cli/fix_az_gov_008.sh | 4 + playbooks/cli/fix_az_gov_009.sh | 4 + playbooks/cli/fix_az_gov_010.sh | 4 + playbooks/cli/fix_az_governance_control.sh | 58 +++ scanner/governance.py | 204 +++++++++ scanner/rules/_governance_common.py | 412 ++++++++++++++++++ scanner/rules/az_gov_001.py | 33 ++ scanner/rules/az_gov_002.py | 31 ++ scanner/rules/az_gov_003.py | 31 ++ scanner/rules/az_gov_004.py | 31 ++ scanner/rules/az_gov_005.py | 31 ++ scanner/rules/az_gov_006.py | 33 ++ scanner/rules/az_gov_007.py | 31 ++ scanner/rules/az_gov_008.py | 33 ++ scanner/rules/az_gov_009.py | 31 ++ scanner/rules/az_gov_010.py | 31 ++ tests/test_governance_foundation.py | 101 +++++ tests/test_rules_governance.py | 193 ++++++++ 35 files changed, 1632 insertions(+), 11 deletions(-) create mode 100644 config/governance-policy.example.json create mode 100644 docs/governance-rules.md create mode 100755 playbooks/cli/fix_az_gov_001.sh create mode 100755 playbooks/cli/fix_az_gov_002.sh create mode 100755 playbooks/cli/fix_az_gov_003.sh create mode 100755 playbooks/cli/fix_az_gov_004.sh create mode 100755 playbooks/cli/fix_az_gov_005.sh create mode 100755 playbooks/cli/fix_az_gov_006.sh create mode 100755 playbooks/cli/fix_az_gov_007.sh create mode 100755 playbooks/cli/fix_az_gov_008.sh create mode 100755 playbooks/cli/fix_az_gov_009.sh create mode 100755 playbooks/cli/fix_az_gov_010.sh create mode 100755 playbooks/cli/fix_az_governance_control.sh create mode 100644 scanner/governance.py create mode 100644 scanner/rules/_governance_common.py create mode 100644 scanner/rules/az_gov_001.py create mode 100644 scanner/rules/az_gov_002.py create mode 100644 scanner/rules/az_gov_003.py create mode 100644 scanner/rules/az_gov_004.py create mode 100644 scanner/rules/az_gov_005.py create mode 100644 scanner/rules/az_gov_006.py create mode 100644 scanner/rules/az_gov_007.py create mode 100644 scanner/rules/az_gov_008.py create mode 100644 scanner/rules/az_gov_009.py create mode 100644 scanner/rules/az_gov_010.py create mode 100644 tests/test_governance_foundation.py create mode 100644 tests/test_rules_governance.py diff --git a/README.md b/README.md index 32fb0c44..aedf7272 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ Findings map to NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), and FIPS 205 (SLH-DSA | Feature | Description | |---|---| -| **Misconfiguration Scanner** | Runs 95 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, post-quantum cryptography, backup, serverless, private endpoint, and supply chain posture | +| **Misconfiguration Scanner** | Runs 105 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, post-quantum cryptography, backup, serverless, private endpoint, supply chain, security operations, and governance posture | | **Compliance Mapper** | Maps findings to CIS Benchmarks, NIST CSF, ISO 27001, and SOC 2 framework JSON files | | **Scan History API** | Stores scans and findings in PostgreSQL and exposes findings, score, scan history, compliance posture, drift, and resource inventory over REST | -| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (95 playbooks) | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (105 playbooks) | | **Security Dashboard** | Full React dashboard deployed on Vercel - live monitoring, findings, compliance, drift, prioritization, and AI-layer views | | **Project Website** | Documentation and reference site at [openshield-website.vercel.app](https://openshield-website.vercel.app) - blog, rules gallery, docs, roadmap, releases, and interactive playground | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | @@ -104,7 +104,7 @@ Project policies and assurance evidence: flowchart TD A["React Dashboard\nVercel · Live"] B["Flask REST API\nJWT · CORS · Blueprints"] - C["Scanner Engine\n95 Python rules"] + C["Scanner Engine\n105 Python rules"] D["Azure Subscription\nScanned via Azure SDK + Graph"] E["Compliance Framework JSON\nCIS · NIST · ISO 27001 · SOC 2"] F["PostgreSQL Database\nFindings · Scans"] diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index be50f996..98288d0d 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -422,6 +422,56 @@ "control_id": "2.1.20", "control_name": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'", "description": "No enabled Azure Monitor action group with a notification receiver exists, and no Sentinel automation rule routes incidents onward. CIS 2.1.20 requires Defender security alerts to notify a monitored destination; this rule generalizes that requirement to the concrete Azure notification primitive (action groups) and the Sentinel-native incident routing mechanism (automation rules)." + }, + "AZ-GOV-001": { + "control_id": "N/A-GOV-001", + "control_name": "Subscription Outside Approved Management Group Hierarchy", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-002": { + "control_id": "N/A-GOV-002", + "control_name": "Required Security Policy Initiative Missing", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-003": { + "control_id": "N/A-GOV-003", + "control_name": "Preventive Policy Uses Non-Enforcing Effect", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-004": { + "control_id": "N/A-GOV-004", + "control_name": "Policy Exemption Missing Governance Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-005": { + "control_id": "N/A-GOV-005", + "control_name": "Critical Production Resource Missing Deletion Lock", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-006": { + "control_id": "N/A-GOV-006", + "control_name": "Excessive Subscription Owner Assignments", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-007": { + "control_id": "N/A-GOV-007", + "control_name": "Privileged Access Assigned at Broad Scope", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-008": { + "control_id": "N/A-GOV-008", + "control_name": "Undocumented Resource Provider Registered", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-009": { + "control_id": "N/A-GOV-009", + "control_name": "Production Resource Missing Ownership Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-010": { + "control_id": "N/A-GOV-010", + "control_name": "Security Configuration Drift Exceeds SLA", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 16462fc4..83ae5205 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -482,6 +482,56 @@ "control_id": "A.16.1.2", "control_name": "Reporting information security events", "description": "No monitored destination exists for security alerts or Sentinel incidents. A.16.1.2 requires information security events to be reported through appropriate management channels as quickly as possible; an alert with no notified recipient cannot be reported or acted on." + }, + "AZ-GOV-001": { + "control_id": "A.5.15", + "control_name": "Subscription Outside Approved Management Group Hierarchy", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-002": { + "control_id": "A.5.1", + "control_name": "Required Security Policy Initiative Missing", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-003": { + "control_id": "A.5.1", + "control_name": "Preventive Policy Uses Non-Enforcing Effect", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-004": { + "control_id": "A.5.1", + "control_name": "Policy Exemption Missing Governance Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-005": { + "control_id": "A.8.9", + "control_name": "Critical Production Resource Missing Deletion Lock", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-006": { + "control_id": "A.5.18", + "control_name": "Excessive Subscription Owner Assignments", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-007": { + "control_id": "A.5.18", + "control_name": "Privileged Access Assigned at Broad Scope", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-008": { + "control_id": "A.5.9", + "control_name": "Undocumented Resource Provider Registered", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-009": { + "control_id": "A.5.9", + "control_name": "Production Resource Missing Ownership Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-010": { + "control_id": "A.8.8", + "control_name": "Security Configuration Drift Exceeds SLA", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 5dd6df70..9358d8ab 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -482,6 +482,56 @@ "control_id": "RS.CO-2", "control_name": "Incidents are reported consistent with established criteria", "description": "No monitored destination exists for security alerts or Sentinel incidents. RS.CO-2 requires incidents to be reported consistent with established criteria; an alert with no notified recipient is never reported to anyone who can act on it." + }, + "AZ-GOV-001": { + "control_id": "PR.AC-4", + "control_name": "Subscription Outside Approved Management Group Hierarchy", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-002": { + "control_id": "PR.IP-1", + "control_name": "Required Security Policy Initiative Missing", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-003": { + "control_id": "PR.IP-1", + "control_name": "Preventive Policy Uses Non-Enforcing Effect", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-004": { + "control_id": "PR.IP-1", + "control_name": "Policy Exemption Missing Governance Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-005": { + "control_id": "PR.IP-3", + "control_name": "Critical Production Resource Missing Deletion Lock", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-006": { + "control_id": "PR.AC-4", + "control_name": "Excessive Subscription Owner Assignments", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-007": { + "control_id": "PR.AC-4", + "control_name": "Privileged Access Assigned at Broad Scope", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-008": { + "control_id": "PR.IP-1", + "control_name": "Undocumented Resource Provider Registered", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-009": { + "control_id": "ID.AM-1", + "control_name": "Production Resource Missing Ownership Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-010": { + "control_id": "DE.CM-8", + "control_name": "Security Configuration Drift Exceeds SLA", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index ef259f71..9a6f5269 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -482,6 +482,56 @@ "control_id": "CC7.4", "control_name": "Incident Response", "description": "No monitored destination exists for security alerts or Sentinel incidents. CC7.4 requires the entity to respond to identified security incidents; an alert nobody is notified of cannot trigger the incident-response process CC7.4 requires." + }, + "AZ-GOV-001": { + "control_id": "CC6.3", + "control_name": "Subscription Outside Approved Management Group Hierarchy", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-002": { + "control_id": "CC5.2", + "control_name": "Required Security Policy Initiative Missing", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-003": { + "control_id": "CC5.2", + "control_name": "Preventive Policy Uses Non-Enforcing Effect", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-004": { + "control_id": "CC5.3", + "control_name": "Policy Exemption Missing Governance Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-005": { + "control_id": "CC6.5", + "control_name": "Critical Production Resource Missing Deletion Lock", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-006": { + "control_id": "CC6.3", + "control_name": "Excessive Subscription Owner Assignments", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-007": { + "control_id": "CC6.3", + "control_name": "Privileged Access Assigned at Broad Scope", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-008": { + "control_id": "CC6.6", + "control_name": "Undocumented Resource Provider Registered", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-009": { + "control_id": "CC2.2", + "control_name": "Production Resource Missing Ownership Metadata", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." + }, + "AZ-GOV-010": { + "control_id": "CC7.2", + "control_name": "Security Configuration Drift Exceeds SLA", + "description": "OpenShield evaluates this governance control from read-only Azure control-plane evidence and organisation-approved policy inputs." } } } diff --git a/config/governance-policy.example.json b/config/governance-policy.example.json new file mode 100644 index 00000000..84d8f622 --- /dev/null +++ b/config/governance-policy.example.json @@ -0,0 +1,42 @@ +{ + "approved_management_group_ids": [ + "/providers/Microsoft.Management/managementGroups/production" + ], + "required_policy_initiatives": [ + { + "definition_id": "/providers/Microsoft.Authorization/policySetDefinitions/security-baseline", + "scope": "/subscriptions/00000000-0000-0000-0000-000000000000" + } + ], + "preventive_policy_definition_ids": [ + "/providers/Microsoft.Authorization/policyDefinitions/deny-public-resources" + ], + "allowed_preventive_effects": ["deny", "deployIfNotExists", "modify"], + "production_resource_types": [ + "Microsoft.KeyVault/vaults", + "Microsoft.Sql/servers", + "Microsoft.Storage/storageAccounts" + ], + "production_tag": "environment", + "production_tag_values": ["production", "prod"], + "maximum_subscription_owners": 3, + "privileged_role_definition_ids": [ + "8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + "b24988ac-6180-42a0-ab88-20f7382dd24c" + ], + "approved_privileged_scopes": [ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/platform-security" + ], + "approved_provider_namespaces": [ + "Microsoft.Authorization", + "Microsoft.Compute", + "Microsoft.KeyVault", + "Microsoft.Network", + "Microsoft.PolicyInsights", + "Microsoft.Resources", + "Microsoft.Storage" + ], + "ownership_tags": ["owner", "service-owner"], + "drift_sla_days": 30, + "excluded_resource_ids": [] +} diff --git a/docs/architecture.md b/docs/architecture.md index 85dc4ef4..d3274174 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Overview -OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 80 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. +OpenShield is a modular, open source Cloud Security Posture Management (CSPM) platform for Azure. It scans your Azure subscription against 105 security rules, maps findings to compliance frameworks (CIS, NIST CSF, ISO 27001, SOC 2), stores results in PostgreSQL, and exposes posture data through a Flask REST API consumed by a live React dashboard. --- @@ -43,7 +43,7 @@ OpenShield is a modular, open source Cloud Security Posture Management (CSPM) pl ┌───────────▼──────────────────────────────────────────────────────┐ │ Rule Modules (scanner/rules/) │ │ │ -│ 80 rule files across Storage, Network, Identity, Database, │ +│ 105 rule files across Storage, Network, Identity, Database, │ │ Compute, Key Vault, AKS, post-quantum cryptography, Backup, │ │ Serverless, Private Endpoint posture, and Supply Chain │ └───────────┬───────────────────────────────────────────────────────┘ @@ -111,22 +111,25 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 80 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. +There are 105 rule files in `scanner/rules/`. See `docs/rules-reference.md` for the full table. | Category | Count | Rules | |---|---|---| | Storage | 5 | AZ-STOR-001 to 005 | -| Network | 15 | AZ-NET-001 to 015 | +| Network | 17 | AZ-NET-001 to 017 | | Identity | 15 | AZ-IDN-001 to 015 | | Database | 4 | AZ-DB-001 to 004 | | Compute | 4 | AZ-CMP-001 to 004 | -| Key Vault | 5 | AZ-KV-001 to 005 | +| Key Vault | 6 | AZ-KV-001 to 006 | | Kubernetes | 6 | AZ-AKS-001 to 006 | | Post-quantum | 3 | AZ-PQC-001 to 003 | +| Governance | 10 | AZ-GOV-001 to 010 | | Backup | 4 | AZ-BAK-001, 002, 004, 006 | | Serverless | 5 | AZ-FUNC-001 to 005 | | Private Endpoint | 6 | AZ-PE-001 to 006 | | Supply Chain | 8 | AZ-SC-001 to 008 | +| Security Operations | 10 | AZ-SECOPS-001 to 010 | +| Data Link | 2 | AZ-DL-001 to 002 | Every rule has a matching Azure CLI playbook in `playbooks/cli/`. diff --git a/docs/governance-rules.md b/docs/governance-rules.md new file mode 100644 index 00000000..de771f6c --- /dev/null +++ b/docs/governance-rules.md @@ -0,0 +1,27 @@ +# Enterprise Governance Rules + +`AZ-GOV-001` through `AZ-GOV-010` evaluate Azure management hierarchy, policy assignments and exemptions, RBAC, resource locks, provider registration, ownership metadata, and unresolved policy drift. + +## Configuration + +Copy `config/governance-policy.example.json` to an organisation-controlled location, replace every example value, and set: + +```bash +export OPENSHIELD_GOVERNANCE_POLICY=/secure/path/governance-policy.json +``` + +The scanner does not invent governance defaults. If the policy is missing or invalid, the rules log an `UNKNOWN` result and do not create a finding. Evidence collections are independently nullable, so a permission failure in one Azure API does not become a false pass or false failure in another control. + +## Evidence and permissions + +The collector uses read-only Azure Resource Manager and Resource Graph requests. Grant only the read permissions listed in each finding. Policy state collection also needs `Microsoft.PolicyInsights/policyStates/queryResults/action`. + +Resource scope is determined by the configured production resource types and production tag. Valid exclusions are exact, case-insensitive resource ID matches. Policy and RBAC assignment IDs are compared at their effective Azure scopes. + +## Remediation safety + +Every governance playbook requires the literal `--apply` flag. Review inherited access, policy impact, active exceptions, and workload dependencies before running it. The exemption playbook intentionally stops with operator guidance because exemption metadata schemas are organisation-specific. + +## Current scanner contract + +The scanner currently persists findings only. A confirmed unsafe state produces `FAIL` as a finding. Compliant, inaccessible, and out-of-scope resources do not create findings, while `UNKNOWN` and `NOT_APPLICABLE` are recorded in logs. Issue #263 tracks persistent per-resource evaluation states and compliance-score correction. diff --git a/docs/learn/index.html b/docs/learn/index.html index 891f19ef..2acd0890 100644 --- a/docs/learn/index.html +++ b/docs/learn/index.html @@ -841,9 +841,9 @@

Production-shaped, MVP-friendly architecture

Rule coverage

-

95 Azure security rules

+

105 Azure security rules

- OpenShield currently has 95 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, + OpenShield currently has 105 dynamic rules. The strongest contributor work improves rule accuracy, reduces false positives, strengthens validation, or improves remediation quality.

diff --git a/docs/rules-reference.md b/docs/rules-reference.md index c9824b96..9777865a 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 80 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 105 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -84,6 +84,31 @@ OpenShield currently ships 80 Azure scan rules. This table is generated from the | AZ-SC-006 | Terraform State Storage Account Missing Versioning or Soft Delete | HIGH | Supply Chain | N/A-SC-006 | PR.IP-4 | A.12.3.1 | | AZ-SC-007 | Pipeline Service Connection Scoped to Subscription | HIGH | Supply Chain | N/A-SC-007 | PR.AC-4 | A.9.2.3 | | AZ-SC-008 | Pipeline Service Connection Uses Password Instead of Federated Credential | MEDIUM | Supply Chain | N/A-SC-008 | PR.AC-1 | A.9.4.3 | +| AZ-DL-001 | ExpressRoute Direct Link Does Not Use MACsec | HIGH | Data Link | N/A-DL-001 | PR.DS-2 | A.13.1.1 | +| AZ-DL-002 | High-Speed ExpressRoute Direct Link Uses Non-XPN MACsec | MEDIUM | Data Link | N/A-DL-002 | PR.DS-2 | A.13.1.1 | +| AZ-KV-006 | Key Vault Using Legacy Access Policies Instead of Azure RBAC | MEDIUM | KeyVault | 8.6 | PR.AC-4 | A.9.2.3 | +| AZ-NET-016 | Network Interface Has IP Forwarding Enabled | MEDIUM | Network | N/A-NET-016 | SC-7 | A.13.1.1 | +| AZ-NET-017 | User-Defined Default Route Uses Direct Internet Next Hop | MEDIUM | Network | N/A-NET-017 | SC-7 | A.13.1.1 | +| AZ-SECOPS-001 | Subscription Activity Log Not Exported to an Approved Central Destination | HIGH | Security Operations | 5.1.1 | PR.PT-1 | A.12.4.1 | +| AZ-SECOPS-002 | Required Activity Log Categories Missing From Central Export | HIGH | Security Operations | 5.1.2 | PR.PT-1 | A.12.4.1 | +| AZ-SECOPS-003 | Critical Resource Missing Required Diagnostic Settings | HIGH | Security Operations | 5.4 | DE.AE-3 | A.12.4.1 | +| AZ-SECOPS-004 | Security Logs Have Insufficient Retention | MEDIUM | Security Operations | N/A-SECOPS-004 | PR.PT-1 | A.12.4.1 | +| AZ-SECOPS-005 | Security Logs Stored Only in a Destination the Workload Administrator Can Modify | HIGH | Security Operations | N/A-SECOPS-005 | PR.DS-6 | A.12.4.2 | +| AZ-SECOPS-006 | Required Microsoft Defender for Cloud Plan Not Enabled | HIGH | Security Operations | N/A-SECOPS-006 | DE.CM-8 | A.12.6.1 | +| AZ-SECOPS-007 | High-Risk Defender Recommendation Unresolved Beyond SLA | HIGH | Security Operations | 2.1.13 | RS.MI-3 | A.12.6.1 | +| AZ-SECOPS-008 | Required Microsoft Sentinel Data Connector Disconnected or Unhealthy | HIGH | Security Operations | N/A-SECOPS-008 | DE.AE-3 | A.12.4.1 | +| AZ-SECOPS-009 | Sentinel Missing Required High-Severity Analytics Coverage | HIGH | Security Operations | N/A-SECOPS-009 | DE.CM-1 | A.12.4.1 | +| AZ-SECOPS-010 | Security Alerts Have No Monitored Incident-Response Destination | HIGH | Security Operations | 2.1.20 | RS.CO-2 | A.16.1.2 | +| AZ-GOV-001 | Subscription Outside Approved Management Group Hierarchy | HIGH | Governance | N/A-GOV-001 | PR.AC-4 | A.5.15 | +| AZ-GOV-002 | Required Security Policy Initiative Missing | HIGH | Governance | N/A-GOV-002 | PR.IP-1 | A.5.1 | +| AZ-GOV-003 | Preventive Policy Uses Non-Enforcing Effect | HIGH | Governance | N/A-GOV-003 | PR.IP-1 | A.5.1 | +| AZ-GOV-004 | Policy Exemption Missing Governance Metadata | MEDIUM | Governance | N/A-GOV-004 | PR.IP-1 | A.5.1 | +| AZ-GOV-005 | Critical Production Resource Missing Deletion Lock | HIGH | Governance | N/A-GOV-005 | PR.IP-3 | A.8.9 | +| AZ-GOV-006 | Excessive Subscription Owner Assignments | HIGH | Governance | N/A-GOV-006 | PR.AC-4 | A.5.18 | +| AZ-GOV-007 | Privileged Access Assigned at Broad Scope | HIGH | Governance | N/A-GOV-007 | PR.AC-4 | A.5.18 | +| AZ-GOV-008 | Undocumented Resource Provider Registered | MEDIUM | Governance | N/A-GOV-008 | PR.IP-1 | A.5.9 | +| AZ-GOV-009 | Production Resource Missing Ownership Metadata | MEDIUM | Governance | N/A-GOV-009 | ID.AM-1 | A.5.9 | +| AZ-GOV-010 | Security Configuration Drift Exceeds SLA | HIGH | Governance | N/A-GOV-010 | DE.CM-8 | A.8.8 | SOC 2 mappings are maintained in `compliance/frameworks/soc2.json`. diff --git a/playbooks/cli/fix_az_gov_001.sh b/playbooks/cli/fix_az_gov_001.sh new file mode 100755 index 00000000..83bbba60 --- /dev/null +++ b/playbooks/cli/fix_az_gov_001.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-001" "$@" diff --git a/playbooks/cli/fix_az_gov_002.sh b/playbooks/cli/fix_az_gov_002.sh new file mode 100755 index 00000000..5c7404a8 --- /dev/null +++ b/playbooks/cli/fix_az_gov_002.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-002" "$@" diff --git a/playbooks/cli/fix_az_gov_003.sh b/playbooks/cli/fix_az_gov_003.sh new file mode 100755 index 00000000..82aee67c --- /dev/null +++ b/playbooks/cli/fix_az_gov_003.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-003" "$@" diff --git a/playbooks/cli/fix_az_gov_004.sh b/playbooks/cli/fix_az_gov_004.sh new file mode 100755 index 00000000..1a092900 --- /dev/null +++ b/playbooks/cli/fix_az_gov_004.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-004" "$@" diff --git a/playbooks/cli/fix_az_gov_005.sh b/playbooks/cli/fix_az_gov_005.sh new file mode 100755 index 00000000..4f9a7557 --- /dev/null +++ b/playbooks/cli/fix_az_gov_005.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-005" "$@" diff --git a/playbooks/cli/fix_az_gov_006.sh b/playbooks/cli/fix_az_gov_006.sh new file mode 100755 index 00000000..c34a9c5b --- /dev/null +++ b/playbooks/cli/fix_az_gov_006.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-006" "$@" diff --git a/playbooks/cli/fix_az_gov_007.sh b/playbooks/cli/fix_az_gov_007.sh new file mode 100755 index 00000000..e3f5648a --- /dev/null +++ b/playbooks/cli/fix_az_gov_007.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-007" "$@" diff --git a/playbooks/cli/fix_az_gov_008.sh b/playbooks/cli/fix_az_gov_008.sh new file mode 100755 index 00000000..7dc1e72d --- /dev/null +++ b/playbooks/cli/fix_az_gov_008.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-008" "$@" diff --git a/playbooks/cli/fix_az_gov_009.sh b/playbooks/cli/fix_az_gov_009.sh new file mode 100755 index 00000000..c59b903c --- /dev/null +++ b/playbooks/cli/fix_az_gov_009.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-009" "$@" diff --git a/playbooks/cli/fix_az_gov_010.sh b/playbooks/cli/fix_az_gov_010.sh new file mode 100755 index 00000000..9b7c9675 --- /dev/null +++ b/playbooks/cli/fix_az_gov_010.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$SCRIPT_DIR/fix_az_governance_control.sh" "AZ-GOV-010" "$@" diff --git a/playbooks/cli/fix_az_governance_control.sh b/playbooks/cli/fix_az_governance_control.sh new file mode 100755 index 00000000..6e67bd99 --- /dev/null +++ b/playbooks/cli/fix_az_governance_control.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -euo pipefail + +RULE_ID="${1:-}" +MODE="${2:-}" +shift 2 || true + +if [ "$MODE" != "--apply" ]; then + echo "Usage: fix_az_gov_NNN.sh --apply " + echo "This playbook changes Azure governance state. Review the finding evidence and approved policy first." + exit 1 +fi + +az account show --output none + +case "$RULE_ID" in + AZ-GOV-001) + [ "$#" -eq 2 ] || { echo "Arguments: "; exit 1; } + az account management-group subscription add --subscription "$1" --name "$2" + ;; + AZ-GOV-002) + [ "$#" -eq 3 ] || { echo "Arguments: "; exit 1; } + az policy assignment create --name "$1" --policy-set-definition "$2" --scope "$3" + ;; + AZ-GOV-003) + [ "$#" -eq 3 ] || { echo "Arguments: "; exit 1; } + az policy assignment update --name "$1" --scope "$2" --params "{\"effect\":{\"value\":\"$3\"}}" + ;; + AZ-GOV-004) + echo "Update the exemption owner, justification, and expiration through the approved governance workflow." + echo "Automatic exemption metadata changes are intentionally disabled because metadata schemas are organisation-specific." + exit 2 + ;; + AZ-GOV-005) + [ "$#" -eq 2 ] || { echo "Arguments: "; exit 1; } + az lock create --name "$2" --lock-type CanNotDelete --resource "$1" + ;; + AZ-GOV-006|AZ-GOV-007) + [ "$#" -eq 1 ] || { echo "Arguments: "; exit 1; } + az role assignment delete --ids "$1" + ;; + AZ-GOV-008) + [ "$#" -eq 1 ] || { echo "Arguments: "; exit 1; } + az provider unregister --namespace "$1" + ;; + AZ-GOV-009) + [ "$#" -eq 3 ] || { echo "Arguments: "; exit 1; } + az tag update --resource-id "$1" --operation Merge --tags "$2=$3" + ;; + AZ-GOV-010) + [ "$#" -eq 1 ] || { echo "Arguments: "; exit 1; } + az policy state trigger-scan --subscription "$1" + ;; + *) + echo "Unsupported governance rule: $RULE_ID" + exit 1 + ;; +esac diff --git a/scanner/governance.py b/scanner/governance.py new file mode 100644 index 00000000..ad05ea6f --- /dev/null +++ b/scanner/governance.py @@ -0,0 +1,204 @@ +"""Read-only Azure governance evidence collection and policy loading.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +import requests +from azure.core.exceptions import AzureError + +ARM_ENDPOINT = "https://management.azure.com" + + +@dataclass(frozen=True) +class GovernancePolicy: + approved_management_group_ids: frozenset[str] + required_policy_initiatives: tuple[Mapping[str, str], ...] + preventive_policy_definition_ids: frozenset[str] + allowed_preventive_effects: frozenset[str] + production_resource_types: frozenset[str] + production_tag: str + production_tag_values: frozenset[str] + maximum_subscription_owners: int + privileged_role_definition_ids: frozenset[str] + approved_privileged_scopes: frozenset[str] + approved_provider_namespaces: frozenset[str] + ownership_tags: frozenset[str] + drift_sla_days: int + excluded_resource_ids: frozenset[str] + + +_FIELDS = { + "approved_management_group_ids", + "required_policy_initiatives", + "preventive_policy_definition_ids", + "allowed_preventive_effects", + "production_resource_types", + "production_tag", + "production_tag_values", + "maximum_subscription_owners", + "privileged_role_definition_ids", + "approved_privileged_scopes", + "approved_provider_namespaces", + "ownership_tags", + "drift_sla_days", + "excluded_resource_ids", +} + + +def _strings(raw: Mapping[str, Any], field: str) -> frozenset[str]: + values = raw[field] + if not isinstance(values, list) or any(not isinstance(item, str) or not item.strip() for item in values): + raise ValueError(f"{field} must be a list of non-empty strings") + return frozenset(item.strip().lower() for item in values) + + +def load_governance_policy(path: str | Path) -> GovernancePolicy: + """Load strict organisation-owned governance expectations.""" + with Path(path).open(encoding="utf-8") as handle: + raw = json.load(handle) + if not isinstance(raw, dict) or set(raw) != _FIELDS: + raise ValueError("governance policy has missing or unsupported fields") + initiatives = raw["required_policy_initiatives"] + if not isinstance(initiatives, list): + raise ValueError("required_policy_initiatives must be a list") + normalised_initiatives = [] + for item in initiatives: + if not isinstance(item, dict) or set(item) != {"definition_id", "scope"}: + raise ValueError("each required policy initiative needs definition_id and scope") + if any(not isinstance(item[key], str) or not item[key].strip() for key in item): + raise ValueError("initiative definition_id and scope must be non-empty strings") + normalised_initiatives.append({key: value.strip().lower() for key, value in item.items()}) + for field in ("maximum_subscription_owners", "drift_sla_days"): + if not isinstance(raw[field], int) or isinstance(raw[field], bool) or raw[field] <= 0: + raise ValueError(f"{field} must be a positive integer") + if not isinstance(raw["production_tag"], str) or not raw["production_tag"].strip(): + raise ValueError("production_tag must be a non-empty string") + return GovernancePolicy( + approved_management_group_ids=_strings(raw, "approved_management_group_ids"), + required_policy_initiatives=tuple(normalised_initiatives), + preventive_policy_definition_ids=_strings(raw, "preventive_policy_definition_ids"), + allowed_preventive_effects=_strings(raw, "allowed_preventive_effects"), + production_resource_types=_strings(raw, "production_resource_types"), + production_tag=raw["production_tag"].strip().lower(), + production_tag_values=_strings(raw, "production_tag_values"), + maximum_subscription_owners=raw["maximum_subscription_owners"], + privileged_role_definition_ids=_strings(raw, "privileged_role_definition_ids"), + approved_privileged_scopes=_strings(raw, "approved_privileged_scopes"), + approved_provider_namespaces=_strings(raw, "approved_provider_namespaces"), + ownership_tags=_strings(raw, "ownership_tags"), + drift_sla_days=raw["drift_sla_days"], + excluded_resource_ids=_strings(raw, "excluded_resource_ids"), + ) + + +class GovernanceCollector: + """Collect governance control-plane metadata without changing Azure state.""" + + def __init__(self, credential: Any, subscription_id: str, *, session: Any = requests) -> None: + self.credential = credential + self.subscription_id = subscription_id + self.session = session + self.scope = f"/subscriptions/{subscription_id}" + + def _headers(self) -> dict[str, str]: + token = self.credential.get_token("https://management.azure.com/.default") + return {"Authorization": f"Bearer {token.token}", "Content-Type": "application/json"} + + def _get_all(self, path: str) -> list[dict[str, Any]] | None: + url = path if path.startswith("https://") else f"{ARM_ENDPOINT}{path}" + items: list[dict[str, Any]] = [] + try: + while url: + response = self.session.get(url, headers=self._headers(), timeout=30) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + return None + values = payload.get("value", []) + if not isinstance(values, list): + return None + items.extend(item for item in values if isinstance(item, dict)) + next_link = payload.get("nextLink") + url = next_link if isinstance(next_link, str) and next_link.startswith(ARM_ENDPOINT) else "" + return items + except (requests.RequestException, AzureError, ValueError, TypeError): + return None + + def _post_graph(self, query: str) -> list[dict[str, Any]] | None: + items: list[dict[str, Any]] = [] + options: dict[str, Any] = {"resultFormat": "objectArray"} + try: + while True: + response = self.session.post( + f"{ARM_ENDPOINT}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01", + headers=self._headers(), + json={"subscriptions": [self.subscription_id], "query": query, "options": options}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + data = payload.get("data") + if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): + return None + items.extend(data) + skip_token = payload.get("$skipToken") + if not isinstance(skip_token, str) or not skip_token: + return items + options = {"resultFormat": "objectArray", "$skipToken": skip_token} + except (requests.RequestException, AzureError, ValueError, TypeError, AttributeError): + return None + + def _post_values(self, path: str) -> list[dict[str, Any]] | None: + url = f"{ARM_ENDPOINT}{path}" + items: list[dict[str, Any]] = [] + try: + while url: + response = self.session.post(url, headers=self._headers(), json={}, timeout=30) + response.raise_for_status() + payload = response.json() + values = payload.get("value") + if not isinstance(values, list) or not all(isinstance(item, dict) for item in values): + return None + items.extend(values) + next_link = payload.get("@odata.nextLink", payload.get("nextLink")) + url = next_link if isinstance(next_link, str) and next_link.startswith(ARM_ENDPOINT) else "" + return items + except (requests.RequestException, AzureError, ValueError, TypeError, AttributeError): + return None + + def collect(self) -> dict[str, Any]: + """Return independently nullable evidence sets so partial access stays UNKNOWN.""" + policy_api = "2023-04-01" + authorization_api = "2022-04-01" + resources = self._post_graph("Resources | project id, name, type, resourceGroup, tags, subscriptionId") + hierarchy_rows = self._post_graph( + "ResourceContainers | where type =~ 'microsoft.resources/subscriptions' " + "| project properties.managementGroupAncestorsChain" + ) + return { + "scope": self.scope, + "hierarchy": hierarchy_rows, + "policy_assignments": self._get_all( + f"{self.scope}/providers/Microsoft.Authorization/policyAssignments?api-version={policy_api}&%24filter=atScope()" + ), + "policy_exemptions": self._get_all( + f"{self.scope}/providers/Microsoft.Authorization/policyExemptions" + "?api-version=2022-07-01-preview&%24filter=atScope()" + ), + "role_assignments": self._get_all( + f"{self.scope}/providers/Microsoft.Authorization/roleAssignments?api-version={authorization_api}&%24filter=atScope()" + ), + "policy_definitions": self._get_all( + "/providers/Microsoft.Authorization/policyDefinitions?api-version=2023-04-01" + ), + "locks": self._get_all(f"{self.scope}/providers/Microsoft.Authorization/locks?api-version=2016-09-01"), + "providers": self._get_all(f"{self.scope}/providers?api-version=2021-04-01"), + "resources": resources, + "policy_states": self._post_values( + f"{self.scope}/providers/Microsoft.PolicyInsights/policyStates/latest/queryResults?api-version=2019-10-01" + ), + } diff --git a/scanner/rules/_governance_common.py b/scanner/rules/_governance_common.py new file mode 100644 index 00000000..fd008135 --- /dev/null +++ b/scanner/rules/_governance_common.py @@ -0,0 +1,412 @@ +"""Shared evaluation helpers for AZ-GOV-001 through AZ-GOV-010.""" + +from __future__ import annotations + +import logging +import os +import re +from datetime import datetime, timezone +from pathlib import PurePosixPath +from typing import Any, Mapping + +from scanner.governance import GovernanceCollector, GovernancePolicy, load_governance_policy + +logger = logging.getLogger(__name__) +POLICY_ENV = "OPENSHIELD_GOVERNANCE_POLICY" +OWNER_ROLE_ID = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + + +def value(item: Any, field: str, default: Any = None) -> Any: + if isinstance(item, Mapping): + return item.get(field, default) + return getattr(item, field, default) + + +def normal(value_: Any) -> str: + return str(value_ or "").strip().lower().rstrip("/") + + +def properties(item: Any) -> Mapping[str, Any]: + candidate = value(item, "properties", {}) + return candidate if isinstance(candidate, Mapping) else {} + + +def load_context(azure_client: Any, subscription_id: str, rule_id: str) -> tuple[GovernancePolicy, dict] | None: + path = os.environ.get(POLICY_ENV) + if not path: + logger.warning("%s: %s is unset; result is UNKNOWN", rule_id, POLICY_ENV) + return None + try: + policy = load_governance_policy(path) + except (OSError, ValueError) as exc: + logger.warning("%s: governance policy is invalid or unavailable: %s", rule_id, exc) + return None + cache = getattr(azure_client, "_governance_snapshot_cache", None) + if cache is None: + cache = GovernanceCollector(azure_client.credential, subscription_id).collect() + setattr(azure_client, "_governance_snapshot_cache", cache) + return policy, cache + + +def _resource_name(resource_id: str) -> str: + return PurePosixPath(resource_id).name or "subscription" + + +def finding( + spec: Mapping[str, Any], subscription_id: str, resource_id: str, resource_type: str, evidence: dict +) -> dict: + return { + "rule_id": spec["id"], + "rule_name": spec["name"], + "severity": spec["severity"], + "category": "Governance", + "resource_id": resource_id, + "resource_name": _resource_name(resource_id), + "resource_type": resource_type, + "description": spec["description"], + "remediation": spec["remediation"], + "playbook": spec["playbook"], + "frameworks": spec["frameworks"], + "metadata": { + "scope": resource_id or f"/subscriptions/{subscription_id}", + "effective_scope": evidence.get("assignment_scope", resource_id), + "inherited_assignment": evidence.get("inherited_assignment", False), + "exception_state": evidence.get("exception_state", "not_exempt"), + "unknown_reason": None, + "evidence": evidence, + "confidence": "HIGH", + "permissions_required": spec["permissions"], + "evidence_collected_at": datetime.now(timezone.utc).isoformat(), + }, + } + + +def _unknown(rule_id: str, source: str) -> list[dict]: + logger.warning("%s: %s evidence is unavailable; result is UNKNOWN", rule_id, source) + return [] + + +def _production(resource: Mapping[str, Any], policy: GovernancePolicy) -> bool: + if normal(value(resource, "type")) not in policy.production_resource_types: + return False + tags = value(resource, "tags", {}) + if not isinstance(tags, Mapping): + return False + lowered = {normal(key): normal(item) for key, item in tags.items()} + return lowered.get(policy.production_tag) in policy.production_tag_values + + +def _assignment_scope(assignment: Mapping[str, Any]) -> str: + resource_id = normal(value(assignment, "id")) + marker = "/providers/microsoft.authorization/" + return resource_id.split(marker, 1)[0] if marker in resource_id else resource_id + + +def _effective_policy_effect(definition: Mapping[str, Any], assignment: Mapping[str, Any]) -> str: + policy_rule = definition.get("policyRule", {}) + then = policy_rule.get("then", {}) if isinstance(policy_rule, Mapping) else {} + raw_effect = value(then, "effect") + if not isinstance(raw_effect, str): + return "" + parameter_match = re.fullmatch(r"\[parameters\(['\"]([^'\"]+)['\"]\)\]", raw_effect.strip(), re.IGNORECASE) + if not parameter_match: + return normal(raw_effect) + parameter_name = normal(parameter_match.group(1)) + assigned = assignment.get("parameters", {}) + if isinstance(assigned, Mapping): + for key, parameter in assigned.items(): + if normal(key) == parameter_name: + return normal(value(parameter, "value", parameter)) + declared = definition.get("parameters", {}) + if isinstance(declared, Mapping): + for key, parameter in declared.items(): + if normal(key) == parameter_name: + return normal(value(parameter, "defaultValue")) + return "" + + +def evaluate(spec: Mapping[str, Any], azure_client: Any, subscription_id: str) -> list[dict]: + context = load_context(azure_client, subscription_id, spec["id"]) + if context is None: + return [] + policy, snapshot = context + rule_id = spec["id"] + subscription_scope = f"/subscriptions/{subscription_id}" + + if rule_id == "AZ-GOV-001": + rows = snapshot.get("hierarchy") + if rows is None: + return _unknown(rule_id, "management-group hierarchy") + if not rows: + return _unknown(rule_id, "subscription hierarchy row") + actual: set[str] = set() + for row in rows: + chain = value( + row, "managementGroupAncestorsChain", value(row, "properties_managementGroupAncestorsChain", []) + ) + if not chain: + chain = properties(row).get("managementGroupAncestorsChain", []) + for ancestor in chain if isinstance(chain, list) else []: + actual.add(normal(value(ancestor, "id", value(ancestor, "name")))) + if actual & policy.approved_management_group_ids: + return [] + return [ + finding( + spec, + subscription_id, + subscription_scope, + "Microsoft.Resources/subscriptions", + {"observed_management_groups": sorted(actual)}, + ) + ] + + if rule_id in {"AZ-GOV-002", "AZ-GOV-003"}: + assignments = snapshot.get("policy_assignments") + if assignments is None: + return _unknown(rule_id, "policy assignment") + if rule_id == "AZ-GOV-002": + found = { + (normal(properties(item).get("policyDefinitionId")), _assignment_scope(item)) for item in assignments + } + missing = [ + dict(item) + for item in policy.required_policy_initiatives + if (item["definition_id"], item["scope"]) not in found + ] + return ( + [] + if not missing + else [ + finding( + spec, + subscription_id, + subscription_scope, + "Microsoft.Authorization/policyAssignments", + {"missing_assignments": missing}, + ) + ] + ) + definitions = snapshot.get("policy_definitions") + if definitions is None: + return _unknown(rule_id, "policy definition") + definitions_by_id = {} + for definition in definitions: + props = properties(definition) + definitions_by_id[normal(value(definition, "id"))] = props + violations = [] + for item in assignments: + props = properties(item) + definition_id = normal(props.get("policyDefinitionId")) + if definition_id not in policy.preventive_policy_definition_ids: + continue + definition = definitions_by_id.get(definition_id) + if definition is None: + logger.warning("%s: policy definition is unavailable for %s; result is UNKNOWN", rule_id, definition_id) + continue + effect = _effective_policy_effect(definition, props) + enforcement_mode = normal(props.get("enforcementMode", "Default")) + if not effect: + logger.warning( + "%s: effective policy effect is unavailable for %s; result is UNKNOWN", rule_id, definition_id + ) + continue + if effect not in policy.allowed_preventive_effects or enforcement_mode == "donotenforce": + assignment_scope = _assignment_scope(item) + violations.append( + { + "assignment_id": value(item, "id"), + "assignment_scope": assignment_scope, + "inherited_assignment": assignment_scope != normal(subscription_scope), + "effect": effect, + "enforcement_mode": enforcement_mode, + } + ) + return [ + finding(spec, subscription_id, item["assignment_id"], "Microsoft.Authorization/policyAssignments", item) + for item in violations + ] + + if rule_id == "AZ-GOV-004": + exemptions = snapshot.get("policy_exemptions") + if exemptions is None: + return _unknown(rule_id, "policy exemption") + if not exemptions: + logger.info("%s: no policy exemptions exist; result is NOT_APPLICABLE", rule_id) + return [] + results = [] + now = datetime.now(timezone.utc) + for item in exemptions: + props = properties(item) + expires = props.get("expiresOn") + expired = False + if isinstance(expires, str): + try: + parsed_expiration = datetime.fromisoformat(expires.replace("Z", "+00:00")) + expired = parsed_expiration.tzinfo is None or parsed_expiration <= now + except (TypeError, ValueError): + expired = True + metadata = props.get("metadata", {}) if isinstance(props.get("metadata", {}), Mapping) else {} + missing = [] + if not normal(metadata.get("owner")): + missing.append("owner") + if not normal(metadata.get("justification", props.get("description"))): + missing.append("justification") + if not expires or expired: + missing.append("valid expiration") + if missing: + resource_id = str(value(item, "id", subscription_scope)) + results.append( + finding( + spec, + subscription_id, + resource_id, + "Microsoft.Authorization/policyExemptions", + {"missing_or_invalid": missing}, + ) + ) + return results + + if rule_id in {"AZ-GOV-005", "AZ-GOV-009"}: + resources = snapshot.get("resources") + if resources is None: + return _unknown(rule_id, "resource inventory") + production = [ + item + for item in resources + if _production(item, policy) and normal(value(item, "id")) not in policy.excluded_resource_ids + ] + if not production: + logger.info("%s: no in-scope production resources exist; result is NOT_APPLICABLE", rule_id) + return [] + if rule_id == "AZ-GOV-009": + results = [] + for item in production: + tags = value(item, "tags", {}) + tag_keys = {normal(key) for key in tags} if isinstance(tags, Mapping) else set() + if not tag_keys & policy.ownership_tags: + resource_id = str(value(item, "id")) + results.append( + finding( + spec, + subscription_id, + resource_id, + str(value(item, "type")), + {"required_ownership_tags": sorted(policy.ownership_tags)}, + ) + ) + return results + locks = snapshot.get("locks") + if locks is None: + return _unknown(rule_id, "resource lock") + protected_scopes = set() + for lock in locks: + if normal(properties(lock).get("level")) in {"cannotdelete", "readonly"}: + lock_id = normal(value(lock, "id")) + protected_scopes.add(lock_id.split("/providers/microsoft.authorization/locks/", 1)[0]) + results = [] + for item in production: + resource_id = str(value(item, "id")) + normal_id = normal(resource_id) + if not any(normal_id == scope or normal_id.startswith(f"{scope}/") for scope in protected_scopes): + results.append( + finding( + spec, subscription_id, resource_id, str(value(item, "type")), {"approved_lock_found": False} + ) + ) + return results + + if rule_id in {"AZ-GOV-006", "AZ-GOV-007"}: + assignments = snapshot.get("role_assignments") + if assignments is None: + return _unknown(rule_id, "role assignment") + if rule_id == "AZ-GOV-006": + owners = [ + item + for item in assignments + if normal(properties(item).get("roleDefinitionId")).endswith(OWNER_ROLE_ID) + and _assignment_scope(item) == normal(subscription_scope) + ] + if len(owners) <= policy.maximum_subscription_owners: + return [] + return [ + finding( + spec, + subscription_id, + subscription_scope, + "Microsoft.Authorization/roleAssignments", + {"owner_count": len(owners), "maximum": policy.maximum_subscription_owners}, + ) + ] + results = [] + for item in assignments: + role_id = normal(properties(item).get("roleDefinitionId")) + scope = _assignment_scope(item) + if ( + any(role_id.endswith(identifier) for identifier in policy.privileged_role_definition_ids) + and scope not in policy.approved_privileged_scopes + ): + resource_id = str(value(item, "id", subscription_scope)) + results.append( + finding( + spec, + subscription_id, + resource_id, + "Microsoft.Authorization/roleAssignments", + { + "assignment_scope": scope, + "inherited_assignment": scope != normal(subscription_scope), + "role_definition_id": role_id, + }, + ) + ) + return results + + if rule_id == "AZ-GOV-008": + providers = snapshot.get("providers") + if providers is None: + return _unknown(rule_id, "resource provider") + return [ + finding( + spec, + subscription_id, + f"{subscription_scope}/providers/{value(item, 'namespace')}", + "Microsoft.Resources/providers", + {"registration_state": value(item, "registrationState")}, + ) + for item in providers + if normal(value(item, "registrationState")) == "registered" + and normal(value(item, "namespace")) not in policy.approved_provider_namespaces + ] + + states = snapshot.get("policy_states") + if states is None: + return _unknown(rule_id, "policy state") + results = [] + now = datetime.now(timezone.utc) + for item in states: + props = properties(item) or item + if normal(props.get("complianceState")) != "noncompliant": + continue + timestamp = props.get("timestamp") + try: + observed = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")) + except (TypeError, ValueError): + logger.warning("%s: malformed policy-state timestamp; result is UNKNOWN for one resource", rule_id) + continue + age_days = (now.date() - observed.date()).days + if age_days > policy.drift_sla_days: + resource_id = str(props.get("resourceId") or subscription_scope) + results.append( + finding( + spec, + subscription_id, + resource_id, + str(props.get("resourceType") or "Microsoft.PolicyInsights/policyStates"), + { + "age_days": age_days, + "sla_days": policy.drift_sla_days, + "policy_assignment_id": props.get("policyAssignmentId"), + }, + ) + ) + return results diff --git a/scanner/rules/az_gov_001.py b/scanner/rules/az_gov_001.py new file mode 100644 index 00000000..b9563ec3 --- /dev/null +++ b/scanner/rules/az_gov_001.py @@ -0,0 +1,33 @@ +"""AZ-GOV-001: Subscription Outside Approved Management Group Hierarchy.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-001" +RULE_NAME = "Subscription Outside Approved Management Group Hierarchy" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-001", "NIST": "PR.AC-4", "ISO27001": "A.5.15", "SOC2": "CC6.3"} +DESCRIPTION = "An Azure subscription is outside the organisation's approved management-group hierarchy." +REMEDIATION = ( + "Move the subscription into an approved management group after validating inherited policy and access effects." +) +PLAYBOOK = "playbooks/cli/fix_az_gov_001.sh" +PERMISSIONS = "Microsoft.Management/managementGroups/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-001 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_002.py b/scanner/rules/az_gov_002.py new file mode 100644 index 00000000..95c9a08a --- /dev/null +++ b/scanner/rules/az_gov_002.py @@ -0,0 +1,31 @@ +"""AZ-GOV-002: Required Security Policy Initiative Missing.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-002" +RULE_NAME = "Required Security Policy Initiative Missing" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-002", "NIST": "PR.IP-1", "ISO27001": "A.5.1", "SOC2": "CC5.2"} +DESCRIPTION = "A required security policy initiative is not assigned at its approved scope." +REMEDIATION = "Assign the required initiative at the approved scope and validate its effective state." +PLAYBOOK = "playbooks/cli/fix_az_gov_002.sh" +PERMISSIONS = "Microsoft.Authorization/policyAssignments/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-002 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_003.py b/scanner/rules/az_gov_003.py new file mode 100644 index 00000000..4a66ce38 --- /dev/null +++ b/scanner/rules/az_gov_003.py @@ -0,0 +1,31 @@ +"""AZ-GOV-003: Preventive Policy Uses Non-Enforcing Effect.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-003" +RULE_NAME = "Preventive Policy Uses Non-Enforcing Effect" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-003", "NIST": "PR.IP-1", "ISO27001": "A.5.1", "SOC2": "CC5.2"} +DESCRIPTION = "A mandatory preventive policy assignment uses an effect that does not enforce the approved control." +REMEDIATION = "Update the assignment effect to an organisation-approved preventive effect after testing impact." +PLAYBOOK = "playbooks/cli/fix_az_gov_003.sh" +PERMISSIONS = "Microsoft.Authorization/policyAssignments/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-003 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_004.py b/scanner/rules/az_gov_004.py new file mode 100644 index 00000000..c93f1bf0 --- /dev/null +++ b/scanner/rules/az_gov_004.py @@ -0,0 +1,31 @@ +"""AZ-GOV-004: Policy Exemption Missing Governance Metadata.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-004" +RULE_NAME = "Policy Exemption Missing Governance Metadata" +SEVERITY = "MEDIUM" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-004", "NIST": "PR.IP-1", "ISO27001": "A.5.1", "SOC2": "CC5.3"} +DESCRIPTION = "A policy exemption lacks an accountable owner, justification, or valid expiration date." +REMEDIATION = "Add the required exemption metadata or remove the exemption if it is no longer approved." +PLAYBOOK = "playbooks/cli/fix_az_gov_004.sh" +PERMISSIONS = "Microsoft.Authorization/policyExemptions/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-004 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_005.py b/scanner/rules/az_gov_005.py new file mode 100644 index 00000000..998b971d --- /dev/null +++ b/scanner/rules/az_gov_005.py @@ -0,0 +1,31 @@ +"""AZ-GOV-005: Critical Production Resource Missing Deletion Lock.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-005" +RULE_NAME = "Critical Production Resource Missing Deletion Lock" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-005", "NIST": "PR.IP-3", "ISO27001": "A.8.9", "SOC2": "CC6.5"} +DESCRIPTION = "A production resource in the protected scope has no effective deletion lock." +REMEDIATION = "Apply a CanNotDelete lock at the resource or an approved parent scope after validating operations." +PLAYBOOK = "playbooks/cli/fix_az_gov_005.sh" +PERMISSIONS = "Microsoft.Authorization/locks/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-005 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_006.py b/scanner/rules/az_gov_006.py new file mode 100644 index 00000000..fe10c2bc --- /dev/null +++ b/scanner/rules/az_gov_006.py @@ -0,0 +1,33 @@ +"""AZ-GOV-006: Excessive Subscription Owner Assignments.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-006" +RULE_NAME = "Excessive Subscription Owner Assignments" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-006", "NIST": "PR.AC-4", "ISO27001": "A.5.18", "SOC2": "CC6.3"} +DESCRIPTION = "The subscription has more Owner role assignments than the organisation permits." +REMEDIATION = ( + "Remove unnecessary Owner assignments and use least-privilege roles with eligible activation where appropriate." +) +PLAYBOOK = "playbooks/cli/fix_az_gov_006.sh" +PERMISSIONS = "Microsoft.Authorization/roleAssignments/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-006 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_007.py b/scanner/rules/az_gov_007.py new file mode 100644 index 00000000..760ce12d --- /dev/null +++ b/scanner/rules/az_gov_007.py @@ -0,0 +1,31 @@ +"""AZ-GOV-007: Privileged Access Assigned at Broad Scope.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-007" +RULE_NAME = "Privileged Access Assigned at Broad Scope" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-007", "NIST": "PR.AC-4", "ISO27001": "A.5.18", "SOC2": "CC6.3"} +DESCRIPTION = "A privileged role is assigned outside the organisation's approved scopes." +REMEDIATION = "Reduce the assignment scope and use eligible time-bound access where supported." +PLAYBOOK = "playbooks/cli/fix_az_gov_007.sh" +PERMISSIONS = "Microsoft.Authorization/roleAssignments/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-007 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_008.py b/scanner/rules/az_gov_008.py new file mode 100644 index 00000000..af107d4a --- /dev/null +++ b/scanner/rules/az_gov_008.py @@ -0,0 +1,33 @@ +"""AZ-GOV-008: Undocumented Resource Provider Registered.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-008" +RULE_NAME = "Undocumented Resource Provider Registered" +SEVERITY = "MEDIUM" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-008", "NIST": "PR.IP-1", "ISO27001": "A.5.9", "SOC2": "CC6.6"} +DESCRIPTION = "A resource provider is registered without an approved operational requirement." +REMEDIATION = ( + "Confirm the business requirement, record approval, or unregister the unused provider after impact review." +) +PLAYBOOK = "playbooks/cli/fix_az_gov_008.sh" +PERMISSIONS = "Microsoft.Resources/subscriptions/providers/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-008 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_009.py b/scanner/rules/az_gov_009.py new file mode 100644 index 00000000..7ee83a21 --- /dev/null +++ b/scanner/rules/az_gov_009.py @@ -0,0 +1,31 @@ +"""AZ-GOV-009: Production Resource Missing Ownership Metadata.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-009" +RULE_NAME = "Production Resource Missing Ownership Metadata" +SEVERITY = "MEDIUM" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-009", "NIST": "ID.AM-1", "ISO27001": "A.5.9", "SOC2": "CC2.2"} +DESCRIPTION = "A production resource has no recognised accountable ownership tag." +REMEDIATION = "Add an approved ownership tag with a current team or service owner." +PLAYBOOK = "playbooks/cli/fix_az_gov_009.sh" +PERMISSIONS = "Microsoft.Resources/subscriptions/resources/read" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-009 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_010.py b/scanner/rules/az_gov_010.py new file mode 100644 index 00000000..f509d761 --- /dev/null +++ b/scanner/rules/az_gov_010.py @@ -0,0 +1,31 @@ +"""AZ-GOV-010: Security Configuration Drift Exceeds SLA.""" + +from typing import Any, Dict, List + +from scanner.rules._governance_common import evaluate + +RULE_ID = "AZ-GOV-010" +RULE_NAME = "Security Configuration Drift Exceeds SLA" +SEVERITY = "HIGH" +CATEGORY = "Governance" +FRAMEWORKS = {"CIS": "N/A-GOV-010", "NIST": "DE.CM-8", "ISO27001": "A.8.8", "SOC2": "CC7.2"} +DESCRIPTION = "Confirmed non-compliant policy state has remained unresolved beyond the approved remediation SLA." +REMEDIATION = "Investigate the policy state, remediate the drift, and confirm compliance with a new evaluation." +PLAYBOOK = "playbooks/cli/fix_az_gov_010.sh" +PERMISSIONS = "Microsoft.PolicyInsights/policyStates/queryResults/action" + +_SPEC = { + "id": RULE_ID, + "name": RULE_NAME, + "severity": SEVERITY, + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "permissions": PERMISSIONS, +} + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Evaluate AZ-GOV-010 from read-only governance evidence.""" + return evaluate(_SPEC, azure_client, subscription_id) diff --git a/tests/test_governance_foundation.py b/tests/test_governance_foundation.py new file mode 100644 index 00000000..c946f420 --- /dev/null +++ b/tests/test_governance_foundation.py @@ -0,0 +1,101 @@ +import json +from types import SimpleNamespace + +import pytest + +from scanner.governance import GovernanceCollector, load_governance_policy + + +def _valid_policy(): + return { + "approved_management_group_ids": ["/providers/Microsoft.Management/managementGroups/prod"], + "required_policy_initiatives": [{"definition_id": "/definitions/base", "scope": "/subscriptions/sub"}], + "preventive_policy_definition_ids": ["/definitions/deny-public"], + "allowed_preventive_effects": ["deny"], + "production_resource_types": ["Microsoft.Storage/storageAccounts"], + "production_tag": "environment", + "production_tag_values": ["prod"], + "maximum_subscription_owners": 2, + "privileged_role_definition_ids": ["owner-role"], + "approved_privileged_scopes": ["/subscriptions/sub/resourcegroups/security"], + "approved_provider_namespaces": ["Microsoft.Storage"], + "ownership_tags": ["owner"], + "drift_sla_days": 30, + "excluded_resource_ids": [], + } + + +def test_policy_loader_is_strict_and_normalises(tmp_path): + path = tmp_path / "policy.json" + path.write_text(json.dumps(_valid_policy()), encoding="utf-8") + policy = load_governance_policy(path) + assert policy.production_resource_types == frozenset({"microsoft.storage/storageaccounts"}) + assert policy.maximum_subscription_owners == 2 + + invalid = _valid_policy() + invalid["unexpected"] = True + path.write_text(json.dumps(invalid), encoding="utf-8") + with pytest.raises(ValueError, match="missing or unsupported"): + load_governance_policy(path) + + +class _Response: + def __init__(self, payload): + self.payload = payload + + def raise_for_status(self): + return None + + def json(self): + return self.payload + + +class _Session: + def __init__(self): + self.get_responses = [ + _Response({"value": [{"id": "one"}], "nextLink": "https://management.azure.com/next"}), + _Response({"value": [{"id": "two"}]}), + ] + + def get(self, *_args, **_kwargs): + return self.get_responses.pop(0) + + def post(self, _url, **kwargs): + if "query" in kwargs.get("json", {}): + return _Response({"data": []}) + return _Response({"value": []}) + + +def test_collector_follows_arm_next_link(): + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=_Session()) + assert collector._get_all("/first") == [{"id": "one"}, {"id": "two"}] + + +def test_collector_returns_none_for_transport_failure(): + class BrokenSession: + @staticmethod + def get(*_args, **_kwargs): + raise TypeError("bad transport") + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=BrokenSession()) + assert collector._get_all("/first") is None + + +def test_resource_graph_collector_follows_skip_token(): + class GraphSession: + def __init__(self): + self.requests = [] + + def post(self, _url, **kwargs): + self.requests.append(kwargs["json"]) + if len(self.requests) == 1: + return _Response({"data": [{"id": "one"}], "$skipToken": "next-page"}) + return _Response({"data": [{"id": "two"}]}) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + session = GraphSession() + collector = GovernanceCollector(credential, "sub", session=session) + assert collector._post_graph("Resources | project id") == [{"id": "one"}, {"id": "two"}] + assert session.requests[1]["options"]["$skipToken"] == "next-page" diff --git a/tests/test_rules_governance.py b/tests/test_rules_governance.py new file mode 100644 index 00000000..2930e6f4 --- /dev/null +++ b/tests/test_rules_governance.py @@ -0,0 +1,193 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from scanner.governance import GovernancePolicy +from scanner.rules import ( + az_gov_001, + az_gov_002, + az_gov_003, + az_gov_004, + az_gov_005, + az_gov_006, + az_gov_007, + az_gov_008, + az_gov_009, + az_gov_010, +) +from scanner.rules import _governance_common as common + +SUB = "sub" +SCOPE = "/subscriptions/sub" +RESOURCE = f"{SCOPE}/resourceGroups/prod/providers/Microsoft.Storage/storageAccounts/data" +OWNER = "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" + + +@pytest.fixture +def policy(): + return GovernancePolicy( + approved_management_group_ids=frozenset({"/providers/microsoft.management/managementgroups/prod"}), + required_policy_initiatives=({"definition_id": "/definitions/base", "scope": SCOPE},), + preventive_policy_definition_ids=frozenset({"/definitions/deny-public"}), + allowed_preventive_effects=frozenset({"deny"}), + production_resource_types=frozenset({"microsoft.storage/storageaccounts"}), + production_tag="environment", + production_tag_values=frozenset({"prod"}), + maximum_subscription_owners=1, + privileged_role_definition_ids=frozenset({OWNER}), + approved_privileged_scopes=frozenset({f"{SCOPE}/resourcegroups/security"}), + approved_provider_namespaces=frozenset({"microsoft.storage"}), + ownership_tags=frozenset({"owner"}), + drift_sla_days=30, + excluded_resource_ids=frozenset(), + ) + + +@pytest.fixture +def run(monkeypatch, policy): + def _run(module, snapshot): + monkeypatch.setattr(common, "load_context", lambda *_args: (policy, snapshot)) + return module.scan(object(), SUB) + + return _run + + +def test_gov_001_hierarchy_compliant_and_noncompliant(run): + good = { + "hierarchy": [ + {"managementGroupAncestorsChain": [{"id": "/providers/Microsoft.Management/managementGroups/prod"}]} + ] + } + assert run(az_gov_001, good) == [] + findings = run(az_gov_001, {"hierarchy": [{"managementGroupAncestorsChain": []}]}) + assert findings[0]["rule_id"] == "AZ-GOV-001" + + +def test_gov_002_required_initiative_scope(run): + good = { + "policy_assignments": [ + { + "id": f"{SCOPE}/providers/Microsoft.Authorization/policyAssignments/base", + "properties": {"policyDefinitionId": "/definitions/base"}, + } + ] + } + assert run(az_gov_002, good) == [] + assert run(az_gov_002, {"policy_assignments": []})[0]["metadata"]["evidence"]["missing_assignments"] + + +def test_gov_003_only_confirmed_bad_effect_fails(run): + definition = { + "id": "/definitions/deny-public", + "properties": { + "parameters": {"effect": {"defaultValue": "Audit"}}, + "policyRule": {"then": {"effect": "[parameters('effect')]"}}, + }, + } + assignment = { + "id": f"{SCOPE}/providers/Microsoft.Authorization/policyAssignments/deny", + "properties": {"policyDefinitionId": "/definitions/deny-public", "parameters": {}}, + } + snapshot = {"policy_assignments": [assignment], "policy_definitions": [definition]} + assert run(az_gov_003, snapshot)[0]["rule_id"] == "AZ-GOV-003" + assignment["properties"]["parameters"]["effect"] = {"value": "Deny"} + assert run(az_gov_003, snapshot) == [] + + +def test_gov_004_exemption_metadata_and_expiration(run): + future = (datetime.now(timezone.utc) + timedelta(days=20)).isoformat() + good = { + "policy_exemptions": [ + { + "id": f"{SCOPE}/providers/Microsoft.Authorization/policyExemptions/approved", + "properties": {"expiresOn": future, "metadata": {"owner": "team", "justification": "migration"}}, + } + ] + } + assert run(az_gov_004, good) == [] + bad = {"policy_exemptions": [{"id": "exemption", "properties": {"metadata": {}}}]} + assert set(run(az_gov_004, bad)[0]["metadata"]["evidence"]["missing_or_invalid"]) == { + "owner", + "justification", + "valid expiration", + } + + +def test_gov_005_honours_parent_lock(run): + resource = {"id": RESOURCE, "type": "Microsoft.Storage/storageAccounts", "tags": {"Environment": "Prod"}} + parent = f"{SCOPE}/resourceGroups/prod" + lock = {"id": f"{parent}/providers/Microsoft.Authorization/locks/protect", "properties": {"level": "CanNotDelete"}} + assert run(az_gov_005, {"resources": [resource], "locks": [lock]}) == [] + assert run(az_gov_005, {"resources": [resource], "locks": []})[0]["resource_id"] == RESOURCE + + +def test_gov_006_owner_threshold(run): + def owner(name): + return { + "id": f"{SCOPE}/providers/Microsoft.Authorization/roleAssignments/{name}", + "properties": {"roleDefinitionId": f"{SCOPE}/providers/Microsoft.Authorization/roleDefinitions/{OWNER}"}, + } + + assert run(az_gov_006, {"role_assignments": [owner("one")]}) == [] + assert ( + run(az_gov_006, {"role_assignments": [owner("one"), owner("two")]})[0]["metadata"]["evidence"]["owner_count"] + == 2 + ) + + +def test_gov_007_privileged_scope(run): + broad = { + "id": f"{SCOPE}/providers/Microsoft.Authorization/roleAssignments/one", + "properties": {"roleDefinitionId": f"/roleDefinitions/{OWNER}"}, + } + finding = run(az_gov_007, {"role_assignments": [broad]})[0] + assert finding["rule_id"] == "AZ-GOV-007" + assert finding["metadata"]["effective_scope"] == SCOPE + assert finding["metadata"]["permissions_required"] + assert finding["metadata"]["unknown_reason"] is None + + +def test_gov_008_registered_provider_allowlist(run): + providers = [ + {"namespace": "Microsoft.Storage", "registrationState": "Registered"}, + {"namespace": "Microsoft.Unapproved", "registrationState": "Registered"}, + ] + findings = run(az_gov_008, {"providers": providers}) + assert len(findings) == 1 + assert "Microsoft.Unapproved" in findings[0]["resource_id"] + + +def test_gov_009_ownership_tag(run): + resource = {"id": RESOURCE, "type": "Microsoft.Storage/storageAccounts", "tags": {"environment": "prod"}} + assert run(az_gov_009, {"resources": [resource]})[0]["rule_id"] == "AZ-GOV-009" + resource["tags"]["owner"] = "payments" + assert run(az_gov_009, {"resources": [resource]}) == [] + + +def test_gov_010_drift_sla_boundary(run): + old = (datetime.now(timezone.utc) - timedelta(days=31)).isoformat() + boundary = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + recent = (datetime.now(timezone.utc) - timedelta(days=29)).isoformat() + + def state(timestamp): + return {"complianceState": "NonCompliant", "timestamp": timestamp, "resourceId": RESOURCE} + + assert run(az_gov_010, {"policy_states": [state(old)]})[0]["rule_id"] == "AZ-GOV-010" + assert run(az_gov_010, {"policy_states": [state(boundary)]}) == [] + assert run(az_gov_010, {"policy_states": [state(recent)]}) == [] + + +@pytest.mark.parametrize( + "module,source", + [ + (az_gov_001, "hierarchy"), + (az_gov_002, "policy_assignments"), + (az_gov_004, "policy_exemptions"), + (az_gov_005, "resources"), + (az_gov_006, "role_assignments"), + (az_gov_008, "providers"), + (az_gov_010, "policy_states"), + ], +) +def test_missing_evidence_never_becomes_failure(run, module, source): + assert run(module, {source: None}) == [] From 82a001b78d0540b1bb02fcc01025c45f23372194 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 24 Aug 2026 23:29:03 +0100 Subject: [PATCH 2/4] test(governance): cover secondary evidence guards and all UNKNOWN paths Add tests for GOV-003 policy_definitions None branch, GOV-005 locks None branch, and extend the parametrize table to include GOV-003, GOV-007, and GOV-009 so every rule None-evidence guard is explicitly verified. Signed-off-by: Tanvir Farhad --- tests/test_rules_governance.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_rules_governance.py b/tests/test_rules_governance.py index 2930e6f4..ec45caae 100644 --- a/tests/test_rules_governance.py +++ b/tests/test_rules_governance.py @@ -177,15 +177,31 @@ def state(timestamp): assert run(az_gov_010, {"policy_states": [state(recent)]}) == [] +def test_gov_003_missing_definitions_evidence_is_unknown(run): + assignment = { + "id": f"{SCOPE}/providers/Microsoft.Authorization/policyAssignments/deny", + "properties": {"policyDefinitionId": "/definitions/deny-public", "parameters": {}}, + } + assert run(az_gov_003, {"policy_assignments": [assignment], "policy_definitions": None}) == [] + + +def test_gov_005_missing_locks_evidence_is_unknown(run): + resource = {"id": RESOURCE, "type": "Microsoft.Storage/storageAccounts", "tags": {"Environment": "Prod"}} + assert run(az_gov_005, {"resources": [resource], "locks": None}) == [] + + @pytest.mark.parametrize( "module,source", [ (az_gov_001, "hierarchy"), (az_gov_002, "policy_assignments"), + (az_gov_003, "policy_assignments"), (az_gov_004, "policy_exemptions"), (az_gov_005, "resources"), (az_gov_006, "role_assignments"), + (az_gov_007, "role_assignments"), (az_gov_008, "providers"), + (az_gov_009, "resources"), (az_gov_010, "policy_states"), ], ) From 714e7c5901522afc01dc0ff35d76376ac6a84651 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Tue, 25 Aug 2026 17:05:27 +0100 Subject: [PATCH 3/4] ci: ignore chromadb CVE-2026-45830 and CVE-2026-45833 in pip-audit No patched chromadb version exists. PR #317 removes chromadb from core requirements entirely; this ignore is a short-term unblock until that lands and the branch rebases. Signed-off-by: Tanvir Farhad --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 950bc21d..7dbb55c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -472,12 +472,15 @@ jobs: run: pip install pip-audit # transformers (transitive via sentence-transformers) has no non-breaking fix yet; tracked separately + # chromadb CVE-2026-45830/45833: no patched version; removal tracked in PR #317 - name: Run pip-audit run: | pip-audit -r requirements.txt \ --ignore-vuln PYSEC-2025-217 \ --ignore-vuln CVE-2026-1839 \ - --ignore-vuln CVE-2026-4372 + --ignore-vuln CVE-2026-4372 \ + --ignore-vuln CVE-2026-45830 \ + --ignore-vuln CVE-2026-45833 # ── Software Bill of Materials (Syft, CycloneDX) ────────────────────────── sbom: From 14f9d83c0b3e56de4e6bc1a49f72e601174b14ce Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 29 Aug 2026 01:51:35 +0100 Subject: [PATCH 4/4] fix(scanner): count inherited management-group Owners in AZ-GOV-006 The atScope() ARM filter returns both direct subscription-level and inherited management-group Owner assignments. The previous filter `_assignment_scope(item) == subscription_scope` discarded all MG-inherited grants, making it possible to exceed the Owner threshold with zero findings. - Remove the subscription-scope filter so all effective Owner assignments are counted toward the threshold - Add a regression test using an MG-scoped fixture to pin this behaviour - Remove now-obsolete chromadb CVE-2026-45830/45833 pip-audit exclusions (chromadb was removed from requirements in PR #317) Signed-off-by: Tanvir Farhad --- .github/workflows/ci.yml | 5 +---- scanner/rules/_governance_common.py | 9 +++++---- tests/test_rules_governance.py | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dbb55c6..950bc21d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -472,15 +472,12 @@ jobs: run: pip install pip-audit # transformers (transitive via sentence-transformers) has no non-breaking fix yet; tracked separately - # chromadb CVE-2026-45830/45833: no patched version; removal tracked in PR #317 - name: Run pip-audit run: | pip-audit -r requirements.txt \ --ignore-vuln PYSEC-2025-217 \ --ignore-vuln CVE-2026-1839 \ - --ignore-vuln CVE-2026-4372 \ - --ignore-vuln CVE-2026-45830 \ - --ignore-vuln CVE-2026-45833 + --ignore-vuln CVE-2026-4372 # ── Software Bill of Materials (Syft, CycloneDX) ────────────────────────── sbom: diff --git a/scanner/rules/_governance_common.py b/scanner/rules/_governance_common.py index fd008135..1c776c62 100644 --- a/scanner/rules/_governance_common.py +++ b/scanner/rules/_governance_common.py @@ -320,11 +320,12 @@ def evaluate(spec: Mapping[str, Any], azure_client: Any, subscription_id: str) - if assignments is None: return _unknown(rule_id, "role assignment") if rule_id == "AZ-GOV-006": + # The collector uses atScope() which already returns both direct and + # inherited (management-group) assignments effective at this subscription. + # Count all of them; filtering to subscription scope would miss the most + # common enterprise pattern of an Owner granted at a parent MG. owners = [ - item - for item in assignments - if normal(properties(item).get("roleDefinitionId")).endswith(OWNER_ROLE_ID) - and _assignment_scope(item) == normal(subscription_scope) + item for item in assignments if normal(properties(item).get("roleDefinitionId")).endswith(OWNER_ROLE_ID) ] if len(owners) <= policy.maximum_subscription_owners: return [] diff --git a/tests/test_rules_governance.py b/tests/test_rules_governance.py index ec45caae..638bbb0a 100644 --- a/tests/test_rules_governance.py +++ b/tests/test_rules_governance.py @@ -128,11 +128,27 @@ def owner(name): "properties": {"roleDefinitionId": f"{SCOPE}/providers/Microsoft.Authorization/roleDefinitions/{OWNER}"}, } + def mg_owner(name): + mg_scope = "/providers/Microsoft.Management/managementGroups/root" + return { + "id": f"{mg_scope}/providers/Microsoft.Authorization/roleAssignments/{name}", + "properties": {"roleDefinitionId": f"{mg_scope}/providers/Microsoft.Authorization/roleDefinitions/{OWNER}"}, + } + assert run(az_gov_006, {"role_assignments": [owner("one")]}) == [] assert ( run(az_gov_006, {"role_assignments": [owner("one"), owner("two")]})[0]["metadata"]["evidence"]["owner_count"] == 2 ) + # Inherited management-group Owner must count toward the threshold. + # The collector's atScope() returns both direct and MG-inherited assignments; + # filtering to subscription scope alone silently misses this common pattern. + assert ( + run(az_gov_006, {"role_assignments": [owner("direct"), mg_owner("inherited")]})[0]["metadata"]["evidence"][ + "owner_count" + ] + == 2 + ) def test_gov_007_privileged_scope(run):