From 4a06906224cfcff772d40db4213230e31f299def Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 20 Aug 2026 15:02:49 +0100 Subject: [PATCH 01/13] 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 | 11 +- docs/governance-rules.md | 27 ++ docs/learn/index.html | 4 +- docs/rules-reference.md | 25 ++ 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, 1630 insertions(+), 9 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 48ece101..7cfaadec 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,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, Kubernetes workloads, post-quantum cryptography, backup, serverless, private endpoint, and supply chain posture | +| **Misconfiguration Scanner** | Runs 95 Azure security rules across storage, network, identity, database, compute, Key Vault, AKS, post-quantum cryptography, backup, serverless, private endpoint, supply chain, 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 documented rule ships with a matching review-gated remediation script (95 playbooks) | +| **Remediation Playbooks** | Every rule ships with a matching Azure CLI remediation script (95 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 [owasp.github.io/openshield](https://owasp.github.io/openshield/) - blog, rules gallery, architecture, evidence guides, roadmap, and releases | | **Sentinel Integration** | Normalises findings and pushes them into Microsoft Sentinel via a Log Analytics custom table and KQL analytics rules | @@ -119,7 +119,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 ce9545ac..202b4a25 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -644,6 +644,56 @@ "control_id": "N/A-NET-027", "control_name": "Internet-facing application rate limiting baseline (no direct CIS Azure Foundations 2.0.0 control)", "description": "Rate limiting protects public applications from abusive request volume; CIS Azure Foundations 2.0.0 has no direct Application Gateway rate-rule recommendation." + }, + "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 ef3f5d5e..685971b3 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -704,6 +704,56 @@ "control_id": "A.13.1.1", "control_name": "Network controls", "description": "Rate limiting protects internet-facing application entry points." + }, + "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 22e278eb..b6efaafa 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -704,6 +704,56 @@ "control_id": "PR.PT-4", "control_name": "Communications and control networks are protected", "description": "Rate-limit rules protect internet-facing applications from abusive request volume." + }, + "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 839ddb73..8a952751 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -704,6 +704,56 @@ "control_id": "CC6.6", "control_name": "Logical Access Security Measures", "description": "Rate-limit rules protect public application access from abusive request volume." + }, + "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 95db51f9..186b4726 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 Azure and connected Kubernetes evidence against 95 documented 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 Azure and connected Kubernetes evidence against documented 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/) │ │ │ -│ 95 documented rules across Storage, Network, Identity, Database, │ +│ Rule files across Storage, Network, Identity, Database, │ │ Compute, Key Vault, AKS, post-quantum cryptography, Backup, │ │ Serverless, Private Endpoint posture, and Supply Chain │ └───────────┬───────────────────────────────────────────────────────┘ @@ -111,12 +111,12 @@ result = engine.run_scan() ### 4. Current Rule Modules -There are 95 documented controls in `docs/rules-reference.md`, including 21 AKS and Kubernetes controls. +See `docs/rules-reference.md` for the full table of documented controls. | Category | Count | Rules | |---|---|---| | Storage | 9 | AZ-STOR-001 to 009 | -| Network | 15 | AZ-NET-001 to 015 | +| Network | 17 | AZ-NET-001 to 017 | | Identity | 25 | AZ-IDN-001 to 025 | | Database | 7 | AZ-DB-001 to 007 | | Cosmos DB | 2 | AZ-COSMOS-001 to 002 | @@ -125,10 +125,13 @@ There are 95 documented controls in `docs/rules-reference.md`, including 21 AKS | Key Vault | 5 | AZ-KV-001 to 005 | | Kubernetes | 21 | AZ-AKS-001 to 021 | | 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 bbba690b..d8fbf7c6 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -129,6 +129,31 @@ OpenShield currently ships 95 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 48e2f6927d80792d12c5855683701e7657e9c9c1 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Mon, 24 Aug 2026 23:29:03 +0100 Subject: [PATCH 02/13] 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 b8b3fee6d08dd5ac6b1a2141f2bd3501a93d8a53 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Tue, 25 Aug 2026 17:05:27 +0100 Subject: [PATCH 03/13] 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 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c49af220..aa845e69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -480,6 +480,7 @@ 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 \ From f44826d7a139bd21d8f717504b70fb9a5dfe5bfc Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 29 Aug 2026 01:51:35 +0100 Subject: [PATCH 04/13] 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 | 1 - scanner/rules/_governance_common.py | 9 +++++---- tests/test_rules_governance.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa845e69..c49af220 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -480,7 +480,6 @@ 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 \ 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): From 4a7223f94634b20ea3f7e322fd4d7e55e24c636a Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 19 Sep 2026 12:58:30 +0100 Subject: [PATCH 05/13] fix(scanner): validate ARM pagination URL origin before following continuation Replace startswith(ARM_ENDPOINT) checks in _get_all() and _post_values() with _is_valid_arm_continuation(), which uses urllib.parse to verify exact scheme (https), exact host (management.azure.com), no userinfo, and no non-standard port. A lookalike host such as management.azure.com.attacker.invalid passes the old prefix check but is rejected by the new one. Additional hardening: - Pass allow_redirects=False on all paginated GET and POST requests so a redirect cannot bypass the origin validation on a subsequent hop. - Return None (UNKNOWN) when a continuation URL is rejected rather than silently returning the partial page-one results as if collection were complete. Regression tests added: - Parametrised rejects for lookalike host, userinfo, non-standard port, wrong scheme, and empty string in both _get_all and _post_values. - Asserts allow_redirects=False is passed on GET requests. Signed-off-by: Tanvir Farhad --- scanner/governance.py | 37 ++++++++++++++++++++--- tests/test_governance_foundation.py | 47 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/scanner/governance.py b/scanner/governance.py index ad05ea6f..ddc0358b 100644 --- a/scanner/governance.py +++ b/scanner/governance.py @@ -7,10 +7,31 @@ from pathlib import Path from typing import Any, Mapping +import urllib.parse + import requests from azure.core.exceptions import AzureError ARM_ENDPOINT = "https://management.azure.com" +_ARM_HOST = "management.azure.com" + + +def _is_valid_arm_continuation(url: str) -> bool: + """Return True only if url is a safe ARM pagination URL. + + Accepts exactly scheme=https, host=management.azure.com, no userinfo, + no non-standard port. Rejects lookalike hosts and redirect targets. + """ + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return False + return ( + parsed.scheme == "https" + and parsed.netloc == _ARM_HOST + and not parsed.username + and not parsed.password + ) @dataclass(frozen=True) @@ -113,7 +134,7 @@ def _get_all(self, path: str) -> list[dict[str, Any]] | None: items: list[dict[str, Any]] = [] try: while url: - response = self.session.get(url, headers=self._headers(), timeout=30) + response = self.session.get(url, headers=self._headers(), timeout=30, allow_redirects=False) response.raise_for_status() payload = response.json() if not isinstance(payload, dict): @@ -123,7 +144,11 @@ def _get_all(self, path: str) -> list[dict[str, Any]] | None: 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 "" + if next_link is None: + break + if not isinstance(next_link, str) or not _is_valid_arm_continuation(next_link): + return None + url = next_link return items except (requests.RequestException, AzureError, ValueError, TypeError): return None @@ -157,7 +182,7 @@ def _post_values(self, path: str) -> list[dict[str, Any]] | None: items: list[dict[str, Any]] = [] try: while url: - response = self.session.post(url, headers=self._headers(), json={}, timeout=30) + response = self.session.post(url, headers=self._headers(), json={}, timeout=30, allow_redirects=False) response.raise_for_status() payload = response.json() values = payload.get("value") @@ -165,7 +190,11 @@ def _post_values(self, path: str) -> list[dict[str, Any]] | None: 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 "" + if next_link is None: + break + if not isinstance(next_link, str) or not _is_valid_arm_continuation(next_link): + return None + url = next_link return items except (requests.RequestException, AzureError, ValueError, TypeError, AttributeError): return None diff --git a/tests/test_governance_foundation.py b/tests/test_governance_foundation.py index c946f420..d4215af8 100644 --- a/tests/test_governance_foundation.py +++ b/tests/test_governance_foundation.py @@ -72,6 +72,53 @@ def test_collector_follows_arm_next_link(): assert collector._get_all("/first") == [{"id": "one"}, {"id": "two"}] +@pytest.mark.parametrize("bad_url", [ + "https://management.azure.com.attacker.invalid/next", + "https://user@management.azure.com/next", + "https://management.azure.com:8443/next", + "http://management.azure.com/next", + "https://evil.com/next", + "", +]) +def test_get_all_rejects_unsafe_continuation_and_returns_none(bad_url): + class BadNextSession: + def get(self, *_args, **_kwargs): + return _Response({"value": [{"id": "one"}], "nextLink": bad_url}) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=BadNextSession()) + assert collector._get_all("/first") is None + + +def test_get_all_does_not_follow_redirects(): + called_with = {} + + class RedirectSession: + def get(self, url, **kwargs): + called_with["allow_redirects"] = kwargs.get("allow_redirects") + return _Response({"value": []}) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=RedirectSession()) + collector._get_all("/first") + assert called_with["allow_redirects"] is False + + +@pytest.mark.parametrize("bad_url", [ + "https://management.azure.com.attacker.invalid/next", + "https://user@management.azure.com/next", + "http://management.azure.com/next", +]) +def test_post_values_rejects_unsafe_continuation_and_returns_none(bad_url): + class BadODataSession: + def post(self, *_args, **_kwargs): + return _Response({"value": [{"id": "one"}], "@odata.nextLink": bad_url}) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=BadODataSession()) + assert collector._post_values("/path") is None + + def test_collector_returns_none_for_transport_failure(): class BrokenSession: @staticmethod From 7998a6bc1ad5b651c55117775ae5945242988ab6 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 19 Sep 2026 13:18:37 +0100 Subject: [PATCH 06/13] style: collapse _is_valid_arm_continuation return to pass ruff format check Signed-off-by: Tanvir Farhad --- scanner/governance.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/scanner/governance.py b/scanner/governance.py index ddc0358b..c8f49d51 100644 --- a/scanner/governance.py +++ b/scanner/governance.py @@ -26,12 +26,7 @@ def _is_valid_arm_continuation(url: str) -> bool: parsed = urllib.parse.urlparse(url) except Exception: return False - return ( - parsed.scheme == "https" - and parsed.netloc == _ARM_HOST - and not parsed.username - and not parsed.password - ) + return parsed.scheme == "https" and parsed.netloc == _ARM_HOST and not parsed.username and not parsed.password @dataclass(frozen=True) From 732361e4e656175ae494e732e2269cdc8c5749de Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 19 Sep 2026 14:11:43 +0100 Subject: [PATCH 07/13] style: reformat parametrize decorators in test file to pass ruff format check Signed-off-by: Tanvir Farhad --- tests/test_governance_foundation.py | 32 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test_governance_foundation.py b/tests/test_governance_foundation.py index d4215af8..cb509950 100644 --- a/tests/test_governance_foundation.py +++ b/tests/test_governance_foundation.py @@ -72,14 +72,17 @@ def test_collector_follows_arm_next_link(): assert collector._get_all("/first") == [{"id": "one"}, {"id": "two"}] -@pytest.mark.parametrize("bad_url", [ - "https://management.azure.com.attacker.invalid/next", - "https://user@management.azure.com/next", - "https://management.azure.com:8443/next", - "http://management.azure.com/next", - "https://evil.com/next", - "", -]) +@pytest.mark.parametrize( + "bad_url", + [ + "https://management.azure.com.attacker.invalid/next", + "https://user@management.azure.com/next", + "https://management.azure.com:8443/next", + "http://management.azure.com/next", + "https://evil.com/next", + "", + ], +) def test_get_all_rejects_unsafe_continuation_and_returns_none(bad_url): class BadNextSession: def get(self, *_args, **_kwargs): @@ -104,11 +107,14 @@ def get(self, url, **kwargs): assert called_with["allow_redirects"] is False -@pytest.mark.parametrize("bad_url", [ - "https://management.azure.com.attacker.invalid/next", - "https://user@management.azure.com/next", - "http://management.azure.com/next", -]) +@pytest.mark.parametrize( + "bad_url", + [ + "https://management.azure.com.attacker.invalid/next", + "https://user@management.azure.com/next", + "http://management.azure.com/next", + ], +) def test_post_values_rejects_unsafe_continuation_and_returns_none(bad_url): class BadODataSession: def post(self, *_args, **_kwargs): From 45a3c396faecef30f0c2447ab0acf09606eb0ab7 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sun, 4 Oct 2026 02:41:59 +0100 Subject: [PATCH 08/13] fix(governance): validate pagination URLs against exact ARM origin to prevent SSRF Signed-off-by: Tanvir Farhad --- scanner/governance.py | 13 +++++++++ tests/test_governance_foundation.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/scanner/governance.py b/scanner/governance.py index c8f49d51..992a3304 100644 --- a/scanner/governance.py +++ b/scanner/governance.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping @@ -12,6 +13,8 @@ import requests from azure.core.exceptions import AzureError +logger = logging.getLogger(__name__) + ARM_ENDPOINT = "https://management.azure.com" _ARM_HOST = "management.azure.com" @@ -142,6 +145,11 @@ def _get_all(self, path: str) -> list[dict[str, Any]] | None: if next_link is None: break if not isinstance(next_link, str) or not _is_valid_arm_continuation(next_link): + logger.warning( + "ARM pagination aborted: nextLink failed origin validation (%r); " + "partial evidence is NOT treated as complete.", + next_link, + ) return None url = next_link return items @@ -188,6 +196,11 @@ def _post_values(self, path: str) -> list[dict[str, Any]] | None: if next_link is None: break if not isinstance(next_link, str) or not _is_valid_arm_continuation(next_link): + logger.warning( + "ARM pagination aborted: odata/nextLink failed origin validation (%r); " + "partial evidence is NOT treated as complete.", + next_link, + ) return None url = next_link return items diff --git a/tests/test_governance_foundation.py b/tests/test_governance_foundation.py index cb509950..312dacd2 100644 --- a/tests/test_governance_foundation.py +++ b/tests/test_governance_foundation.py @@ -112,6 +112,7 @@ def get(self, url, **kwargs): [ "https://management.azure.com.attacker.invalid/next", "https://user@management.azure.com/next", + "https://management.azure.com:8080/next", "http://management.azure.com/next", ], ) @@ -125,6 +126,48 @@ def post(self, *_args, **_kwargs): assert collector._post_values("/path") is None +def test_get_all_logs_warning_and_does_not_return_partial_evidence_on_bad_next_link(caplog): + """Partial results from page 1 must not be silently returned as complete when + the continuation URL fails origin validation.""" + import logging + + class BadNextSession: + def get(self, *_args, **_kwargs): + return _Response( + {"value": [{"id": "partial"}], "nextLink": "https://management.azure.com.attacker.invalid/page2"} + ) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=BadNextSession()) + with caplog.at_level(logging.WARNING, logger="scanner.governance"): + result = collector._get_all("/first") + assert result is None, "partial evidence must not be silently returned as complete" + assert any("pagination aborted" in record.message.lower() for record in caplog.records), ( + "a warning must be logged when a nextLink fails origin validation" + ) + + +def test_post_values_logs_warning_and_does_not_return_partial_evidence_on_bad_next_link(caplog): + """Partial results from page 1 must not be silently returned as complete when + the odata continuation URL fails origin validation.""" + import logging + + class BadODataSession: + def post(self, *_args, **_kwargs): + return _Response( + {"value": [{"id": "partial"}], "@odata.nextLink": "https://management.azure.com.attacker.invalid/page2"} + ) + + credential = SimpleNamespace(get_token=lambda _scope: SimpleNamespace(token="secret")) + collector = GovernanceCollector(credential, "sub", session=BadODataSession()) + with caplog.at_level(logging.WARNING, logger="scanner.governance"): + result = collector._post_values("/path") + assert result is None, "partial evidence must not be silently returned as complete" + assert any("pagination aborted" in record.message.lower() for record in caplog.records), ( + "a warning must be logged when an odata nextLink fails origin validation" + ) + + def test_collector_returns_none_for_transport_failure(): class BrokenSession: @staticmethod From b7203c41c2c57c413b9b6992bd4a21625b2ef592 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sun, 4 Oct 2026 17:46:04 +0100 Subject: [PATCH 09/13] fix: rstrip trailing slash in _strings, add allow_redirects to _post_graph, exc_info on collection failures, exact GUID match via rsplit Signed-off-by: Tanvir Farhad --- scanner/governance.py | 6 +++++- scanner/rules/_governance_common.py | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scanner/governance.py b/scanner/governance.py index 992a3304..d11fef65 100644 --- a/scanner/governance.py +++ b/scanner/governance.py @@ -72,7 +72,7 @@ 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) + return frozenset(item.strip().lower().rstrip("/") for item in values) def load_governance_policy(path: str | Path) -> GovernancePolicy: @@ -154,6 +154,7 @@ def _get_all(self, path: str) -> list[dict[str, Any]] | None: url = next_link return items except (requests.RequestException, AzureError, ValueError, TypeError): + logger.warning("_get_all collection failed", exc_info=True) return None def _post_graph(self, query: str) -> list[dict[str, Any]] | None: @@ -166,6 +167,7 @@ def _post_graph(self, query: str) -> list[dict[str, Any]] | None: headers=self._headers(), json={"subscriptions": [self.subscription_id], "query": query, "options": options}, timeout=30, + allow_redirects=False, ) response.raise_for_status() payload = response.json() @@ -178,6 +180,7 @@ def _post_graph(self, query: str) -> list[dict[str, Any]] | None: return items options = {"resultFormat": "objectArray", "$skipToken": skip_token} except (requests.RequestException, AzureError, ValueError, TypeError, AttributeError): + logger.warning("_post_graph collection failed", exc_info=True) return None def _post_values(self, path: str) -> list[dict[str, Any]] | None: @@ -205,6 +208,7 @@ def _post_values(self, path: str) -> list[dict[str, Any]] | None: url = next_link return items except (requests.RequestException, AzureError, ValueError, TypeError, AttributeError): + logger.warning("_post_values collection failed", exc_info=True) return None def collect(self) -> dict[str, Any]: diff --git a/scanner/rules/_governance_common.py b/scanner/rules/_governance_common.py index 1c776c62..fa8e420e 100644 --- a/scanner/rules/_governance_common.py +++ b/scanner/rules/_governance_common.py @@ -325,7 +325,8 @@ def evaluate(spec: Mapping[str, Any], azure_client: Any, subscription_id: str) - # 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) + item for item in assignments + if normal(properties(item).get("roleDefinitionId", "")).rsplit("/", 1)[-1] == OWNER_ROLE_ID ] if len(owners) <= policy.maximum_subscription_owners: return [] @@ -343,7 +344,7 @@ def evaluate(spec: Mapping[str, Any], azure_client: Any, subscription_id: str) - 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) + any(role_id.rsplit("/", 1)[-1] == 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)) From 303ed7a1dc4b7a624f7ecebad968dd0b712291e9 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 8 Oct 2026 14:15:37 +0100 Subject: [PATCH 10/13] fix(governance): keep findings helpers out of the coverage evaluator interface Signed-off-by: Tanvir Farhad --- docs/governance-rules.md | 2 +- scanner/rules/az_gov_001.py | 4 ++-- scanner/rules/az_gov_002.py | 4 ++-- scanner/rules/az_gov_003.py | 4 ++-- scanner/rules/az_gov_004.py | 4 ++-- scanner/rules/az_gov_005.py | 4 ++-- scanner/rules/az_gov_006.py | 4 ++-- scanner/rules/az_gov_007.py | 4 ++-- scanner/rules/az_gov_008.py | 4 ++-- scanner/rules/az_gov_009.py | 4 ++-- scanner/rules/az_gov_010.py | 4 ++-- tests/test_rules_governance.py | 27 +++++++++++++++++++++++++++ 12 files changed, 48 insertions(+), 21 deletions(-) diff --git a/docs/governance-rules.md b/docs/governance-rules.md index de771f6c..61cd550e 100644 --- a/docs/governance-rules.md +++ b/docs/governance-rules.md @@ -24,4 +24,4 @@ Every governance playbook requires the literal `--apply` flag. Review inherited ## 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. +The scanner persists findings and per-resource rule evaluations. Governance rules currently emit findings through the legacy scan interface, so the engine records UNKNOWN coverage with LEGACY_RULE_NOT_MIGRATED rather than inferring PASS from an empty finding list. Dedicated governance coverage evaluators are still required before these controls can contribute affirmative compliance or resolve lifecycle findings. diff --git a/scanner/rules/az_gov_001.py b/scanner/rules/az_gov_001.py index b9563ec3..0dc66331 100644 --- a/scanner/rules/az_gov_001.py +++ b/scanner/rules/az_gov_001.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-001" RULE_NAME = "Subscription Outside Approved Management Group Hierarchy" @@ -30,4 +30,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_002.py b/scanner/rules/az_gov_002.py index 95c9a08a..b2bd2d4e 100644 --- a/scanner/rules/az_gov_002.py +++ b/scanner/rules/az_gov_002.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-002" RULE_NAME = "Required Security Policy Initiative Missing" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_003.py b/scanner/rules/az_gov_003.py index 4a66ce38..e0ea000e 100644 --- a/scanner/rules/az_gov_003.py +++ b/scanner/rules/az_gov_003.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-003" RULE_NAME = "Preventive Policy Uses Non-Enforcing Effect" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_004.py b/scanner/rules/az_gov_004.py index c93f1bf0..9af8faad 100644 --- a/scanner/rules/az_gov_004.py +++ b/scanner/rules/az_gov_004.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-004" RULE_NAME = "Policy Exemption Missing Governance Metadata" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_005.py b/scanner/rules/az_gov_005.py index 998b971d..090b6bc0 100644 --- a/scanner/rules/az_gov_005.py +++ b/scanner/rules/az_gov_005.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-005" RULE_NAME = "Critical Production Resource Missing Deletion Lock" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_006.py b/scanner/rules/az_gov_006.py index fe10c2bc..3932f115 100644 --- a/scanner/rules/az_gov_006.py +++ b/scanner/rules/az_gov_006.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-006" RULE_NAME = "Excessive Subscription Owner Assignments" @@ -30,4 +30,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_007.py b/scanner/rules/az_gov_007.py index 760ce12d..a7c2653f 100644 --- a/scanner/rules/az_gov_007.py +++ b/scanner/rules/az_gov_007.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-007" RULE_NAME = "Privileged Access Assigned at Broad Scope" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_008.py b/scanner/rules/az_gov_008.py index af107d4a..3c5fa1fa 100644 --- a/scanner/rules/az_gov_008.py +++ b/scanner/rules/az_gov_008.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-008" RULE_NAME = "Undocumented Resource Provider Registered" @@ -30,4 +30,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_009.py b/scanner/rules/az_gov_009.py index 7ee83a21..855c645c 100644 --- a/scanner/rules/az_gov_009.py +++ b/scanner/rules/az_gov_009.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-009" RULE_NAME = "Production Resource Missing Ownership Metadata" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/scanner/rules/az_gov_010.py b/scanner/rules/az_gov_010.py index f509d761..3a322198 100644 --- a/scanner/rules/az_gov_010.py +++ b/scanner/rules/az_gov_010.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List -from scanner.rules._governance_common import evaluate +from scanner.rules._governance_common import evaluate as _evaluate RULE_ID = "AZ-GOV-010" RULE_NAME = "Security Configuration Drift Exceeds SLA" @@ -28,4 +28,4 @@ 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) + return _evaluate(_SPEC, azure_client, subscription_id) diff --git a/tests/test_rules_governance.py b/tests/test_rules_governance.py index 638bbb0a..f52dda66 100644 --- a/tests/test_rules_governance.py +++ b/tests/test_rules_governance.py @@ -17,6 +17,33 @@ ) from scanner.rules import _governance_common as common + +@pytest.mark.parametrize( + "module", + [ + 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, + ], +) +def test_engine_does_not_call_internal_findings_helper_as_coverage_evaluator(module): + from scanner.engine import ScanEngine + + engine = ScanEngine.__new__(ScanEngine) + engine.subscription_id = "sub" + engine.client = object() + evaluations = engine._evaluate_rule(module, module.RULE_ID) + assert evaluations[0].status == "UNKNOWN" + assert evaluations[0].reason_code == "LEGACY_RULE_NOT_MIGRATED" + + SUB = "sub" SCOPE = "/subscriptions/sub" RESOURCE = f"{SCOPE}/resourceGroups/prod/providers/Microsoft.Storage/storageAccounts/data" From c56bd81fba88c52f2a155f4509cf4043ef7003cd Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Thu, 8 Oct 2026 14:17:22 +0100 Subject: [PATCH 11/13] fix(deps): require patched Werkzeug and multidict releases Signed-off-by: Tanvir Farhad --- requirements-dev.txt | 346 +++++++++++++++++++++---------------------- requirements.in | 4 + requirements.txt | 346 +++++++++++++++++++++---------------------- 3 files changed, 350 insertions(+), 346 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 32cdba87..1bd3066c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -956,177 +956,177 @@ msal-extensions==1.3.1 \ --hash=sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca msrest==0.7.1 \ --hash=sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32 -multidict==6.8.0 \ - --hash=sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b \ - --hash=sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d \ - --hash=sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e \ - --hash=sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd \ - --hash=sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e \ - --hash=sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40 \ - --hash=sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20 \ - --hash=sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368 \ - --hash=sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad \ - --hash=sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e \ - --hash=sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4 \ - --hash=sha256:0eca15d627e942ce186a935061f1568cc46c02e97c419c8da802df2be9f917d8 \ - --hash=sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b \ - --hash=sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7 \ - --hash=sha256:11d71490bf4bbff1141b14b93af419ad68c56b60bea9277fcb3f94dcca4796eb \ - --hash=sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384 \ - --hash=sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0 \ - --hash=sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e \ - --hash=sha256:15db8e6cab5f4cc9241bc56e69fdf3452cf49c10ee3c7977c742e68a275b3786 \ - --hash=sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c \ - --hash=sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04 \ - --hash=sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794 \ - --hash=sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff \ - --hash=sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab \ - --hash=sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af \ - --hash=sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33 \ - --hash=sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee \ - --hash=sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836 \ - --hash=sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc \ - --hash=sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6 \ - --hash=sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712 \ - --hash=sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916 \ - --hash=sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f \ - --hash=sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec \ - --hash=sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1 \ - --hash=sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15 \ - --hash=sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d \ - --hash=sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f \ - --hash=sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78 \ - --hash=sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb \ - --hash=sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3 \ - --hash=sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc \ - --hash=sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39 \ - --hash=sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b \ - --hash=sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4 \ - --hash=sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4 \ - --hash=sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c \ - --hash=sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8 \ - --hash=sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8 \ - --hash=sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5 \ - --hash=sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9 \ - --hash=sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b \ - --hash=sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a \ - --hash=sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a \ - --hash=sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2 \ - --hash=sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0 \ - --hash=sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035 \ - --hash=sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8 \ - --hash=sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4 \ - --hash=sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110 \ - --hash=sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15 \ - --hash=sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891 \ - --hash=sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5 \ - --hash=sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f \ - --hash=sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8 \ - --hash=sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb \ - --hash=sha256:6300d5176647145ba1e22991c924fb29743e54b4d7b8bc85a0d3ec0e55e189cb \ - --hash=sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef \ - --hash=sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5 \ - --hash=sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c \ - --hash=sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31 \ - --hash=sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2 \ - --hash=sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a \ - --hash=sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3 \ - --hash=sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891 \ - --hash=sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943 \ - --hash=sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735 \ - --hash=sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e \ - --hash=sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62 \ - --hash=sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac \ - --hash=sha256:7a2573d0fd34f361a4a14e54d8cda3a91ac4e55fbf0d719698024f3b09c5b147 \ - --hash=sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5 \ - --hash=sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90 \ - --hash=sha256:7bc7003991ebd368a20d05228137a37b3d3066751f3ea1e4f7b8efe8e752f2f5 \ - --hash=sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630 \ - --hash=sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18 \ - --hash=sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab \ - --hash=sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b \ - --hash=sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7 \ - --hash=sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238 \ - --hash=sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364 \ - --hash=sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435 \ - --hash=sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08 \ - --hash=sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f \ - --hash=sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52 \ - --hash=sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da \ - --hash=sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4 \ - --hash=sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20 \ - --hash=sha256:8dc2d9c3a924ed14166e63650b2cf9f59e7821743bdd50b23802bd97ca09bde5 \ - --hash=sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a \ - --hash=sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84 \ - --hash=sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e \ - --hash=sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14 \ - --hash=sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c \ - --hash=sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7 \ - --hash=sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882 \ - --hash=sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d \ - --hash=sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2 \ - --hash=sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f \ - --hash=sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874 \ - --hash=sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6 \ - --hash=sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca \ - --hash=sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602 \ - --hash=sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb \ - --hash=sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463 \ - --hash=sha256:b1cd4d66ce894a45482e1ac2837c31d0bd447df35065e542b60055aa2d00404b \ - --hash=sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25 \ - --hash=sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d \ - --hash=sha256:b7e62b8fc7bd6cad007b9f2e0ad9c8d4854c06350d5f51e1a439dd18b510ecac \ - --hash=sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83 \ - --hash=sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316 \ - --hash=sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b \ - --hash=sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e \ - --hash=sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee \ - --hash=sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385 \ - --hash=sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d \ - --hash=sha256:c0fe437a6d2f36aac2b49517057776575b5bf359df314cca20d230a6e139c089 \ - --hash=sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11 \ - --hash=sha256:c2c5fd0fd39574ccd58e1a52565b341aff522c5c836f1b3eb7605c371e61f52c \ - --hash=sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e \ - --hash=sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742 \ - --hash=sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101 \ - --hash=sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0 \ - --hash=sha256:c9c98d2f0126ba84cb45601eed97ff67ff767e19ae6eb3c31b02827b54d700e5 \ - --hash=sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944 \ - --hash=sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d \ - --hash=sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f \ - --hash=sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26 \ - --hash=sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd \ - --hash=sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba \ - --hash=sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc \ - --hash=sha256:d6dc7804c50fabd28644d4d18a4b20aad3681b3e64f3acd3182b330ca73f7a32 \ - --hash=sha256:d7e5ba0a0153e35fbce9c51df530c8b4cb0c3012b46a04ff9a048441a269c2ed \ - --hash=sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556 \ - --hash=sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58 \ - --hash=sha256:dc911ae6152e455b16a2a1a626aa6cd612fa01efb9d0a4ab3f5cf328b911483d \ - --hash=sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9 \ - --hash=sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3 \ - --hash=sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38 \ - --hash=sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc \ - --hash=sha256:e418ec99574ca24365ca96546af285c2b021a1a072478a79f0e3cc3b08837154 \ - --hash=sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395 \ - --hash=sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f \ - --hash=sha256:e8e1e895e23818d343e4ae7dd95a0a556fdeaf8b471acf1c0a39b93c6f54d478 \ - --hash=sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081 \ - --hash=sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f \ - --hash=sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23 \ - --hash=sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f \ - --hash=sha256:edda19aff836ec515caafc09ea53d2ab144a041f09ee9a7cefcbd3ae4e976256 \ - --hash=sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39 \ - --hash=sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706 \ - --hash=sha256:f2fa3d3b1c933d4bcb8fd2018700d5e7235c52f2ab8c88d22286965c5c0f00f8 \ - --hash=sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b \ - --hash=sha256:f3a0a31189acf6703307397c6139ddabd734c20c5ef92649fc93e473df6615a3 \ - --hash=sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91 \ - --hash=sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc \ - --hash=sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6 \ - --hash=sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c \ - --hash=sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea \ - --hash=sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c +multidict==6.9.1 \ + --hash=sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c \ + --hash=sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9 \ + --hash=sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa \ + --hash=sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188 \ + --hash=sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201 \ + --hash=sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382 \ + --hash=sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f \ + --hash=sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919 \ + --hash=sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418 \ + --hash=sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771 \ + --hash=sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c \ + --hash=sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76 \ + --hash=sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345 \ + --hash=sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580 \ + --hash=sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9 \ + --hash=sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1 \ + --hash=sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1 \ + --hash=sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64 \ + --hash=sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5 \ + --hash=sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006 \ + --hash=sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8 \ + --hash=sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02 \ + --hash=sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132 \ + --hash=sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c \ + --hash=sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19 \ + --hash=sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b \ + --hash=sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba \ + --hash=sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b \ + --hash=sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08 \ + --hash=sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe \ + --hash=sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f \ + --hash=sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd \ + --hash=sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879 \ + --hash=sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f \ + --hash=sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9 \ + --hash=sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987 \ + --hash=sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd \ + --hash=sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea \ + --hash=sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc \ + --hash=sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c \ + --hash=sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b \ + --hash=sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709 \ + --hash=sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4 \ + --hash=sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce \ + --hash=sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94 \ + --hash=sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a \ + --hash=sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e \ + --hash=sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b \ + --hash=sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96 \ + --hash=sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1 \ + --hash=sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e \ + --hash=sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455 \ + --hash=sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3 \ + --hash=sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471 \ + --hash=sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d \ + --hash=sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b \ + --hash=sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d \ + --hash=sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f \ + --hash=sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004 \ + --hash=sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4 \ + --hash=sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e \ + --hash=sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5 \ + --hash=sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60 \ + --hash=sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14 \ + --hash=sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f \ + --hash=sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9 \ + --hash=sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3 \ + --hash=sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194 \ + --hash=sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a \ + --hash=sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3 \ + --hash=sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42 \ + --hash=sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c \ + --hash=sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828 \ + --hash=sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290 \ + --hash=sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485 \ + --hash=sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e \ + --hash=sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8 \ + --hash=sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c \ + --hash=sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b \ + --hash=sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659 \ + --hash=sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b \ + --hash=sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9 \ + --hash=sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953 \ + --hash=sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c \ + --hash=sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1 \ + --hash=sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299 \ + --hash=sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94 \ + --hash=sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983 \ + --hash=sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d \ + --hash=sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac \ + --hash=sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a \ + --hash=sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7 \ + --hash=sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e \ + --hash=sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45 \ + --hash=sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410 \ + --hash=sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40 \ + --hash=sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4 \ + --hash=sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805 \ + --hash=sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d \ + --hash=sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa \ + --hash=sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d \ + --hash=sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701 \ + --hash=sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80 \ + --hash=sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6 \ + --hash=sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40 \ + --hash=sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20 \ + --hash=sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694 \ + --hash=sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a \ + --hash=sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203 \ + --hash=sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519 \ + --hash=sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125 \ + --hash=sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0 \ + --hash=sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d \ + --hash=sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a \ + --hash=sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea \ + --hash=sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2 \ + --hash=sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c \ + --hash=sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3 \ + --hash=sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d \ + --hash=sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3 \ + --hash=sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a \ + --hash=sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68 \ + --hash=sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8 \ + --hash=sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca \ + --hash=sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8 \ + --hash=sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840 \ + --hash=sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82 \ + --hash=sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610 \ + --hash=sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca \ + --hash=sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a \ + --hash=sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1 \ + --hash=sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5 \ + --hash=sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a \ + --hash=sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13 \ + --hash=sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07 \ + --hash=sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05 \ + --hash=sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1 \ + --hash=sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4 \ + --hash=sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2 \ + --hash=sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82 \ + --hash=sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6 \ + --hash=sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d \ + --hash=sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88 \ + --hash=sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f \ + --hash=sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126 \ + --hash=sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24 \ + --hash=sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab \ + --hash=sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67 \ + --hash=sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3 \ + --hash=sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1 \ + --hash=sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800 \ + --hash=sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3 \ + --hash=sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8 \ + --hash=sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5 \ + --hash=sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24 \ + --hash=sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c \ + --hash=sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a \ + --hash=sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841 \ + --hash=sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387 \ + --hash=sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec \ + --hash=sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721 \ + --hash=sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d \ + --hash=sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390 \ + --hash=sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1 \ + --hash=sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d \ + --hash=sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e \ + --hash=sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df \ + --hash=sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb \ + --hash=sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5 \ + --hash=sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9 oauthlib==4.0.0 \ --hash=sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd packaging==26.3 \ @@ -1483,8 +1483,8 @@ urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 websocket-client==1.9.2 \ --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce -werkzeug==3.1.8 \ - --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 +werkzeug==3.1.9 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab yarl==1.25.1 \ --hash=sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3 \ --hash=sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de \ diff --git a/requirements.in b/requirements.in index 1c8abe85..03e1419e 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,9 @@ # Direct runtime dependencies. Regenerate locks with scripts/lock_dependencies.py. flask==3.1.3 +# Security floors for transitive runtime dependencies. +# GHSA-g6x2-hccm-hh4m and GHSA-54p9-h82j-f925. +werkzeug>=3.1.9 +multidict>=6.9.1 flask-cors==6.0.5 alembic==1.18.5 azure-identity==1.25.3 diff --git a/requirements.txt b/requirements.txt index 45eb9a8a..6897a858 100644 --- a/requirements.txt +++ b/requirements.txt @@ -833,177 +833,177 @@ msal-extensions==1.3.1 \ --hash=sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca msrest==0.7.1 \ --hash=sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32 -multidict==6.8.0 \ - --hash=sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b \ - --hash=sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d \ - --hash=sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e \ - --hash=sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd \ - --hash=sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e \ - --hash=sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40 \ - --hash=sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20 \ - --hash=sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368 \ - --hash=sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad \ - --hash=sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e \ - --hash=sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4 \ - --hash=sha256:0eca15d627e942ce186a935061f1568cc46c02e97c419c8da802df2be9f917d8 \ - --hash=sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b \ - --hash=sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7 \ - --hash=sha256:11d71490bf4bbff1141b14b93af419ad68c56b60bea9277fcb3f94dcca4796eb \ - --hash=sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384 \ - --hash=sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0 \ - --hash=sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e \ - --hash=sha256:15db8e6cab5f4cc9241bc56e69fdf3452cf49c10ee3c7977c742e68a275b3786 \ - --hash=sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c \ - --hash=sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04 \ - --hash=sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794 \ - --hash=sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff \ - --hash=sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab \ - --hash=sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af \ - --hash=sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33 \ - --hash=sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee \ - --hash=sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836 \ - --hash=sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc \ - --hash=sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6 \ - --hash=sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712 \ - --hash=sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916 \ - --hash=sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f \ - --hash=sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec \ - --hash=sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1 \ - --hash=sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15 \ - --hash=sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d \ - --hash=sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f \ - --hash=sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78 \ - --hash=sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb \ - --hash=sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3 \ - --hash=sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc \ - --hash=sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39 \ - --hash=sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b \ - --hash=sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4 \ - --hash=sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4 \ - --hash=sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c \ - --hash=sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8 \ - --hash=sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8 \ - --hash=sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5 \ - --hash=sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9 \ - --hash=sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b \ - --hash=sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a \ - --hash=sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a \ - --hash=sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2 \ - --hash=sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0 \ - --hash=sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035 \ - --hash=sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8 \ - --hash=sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4 \ - --hash=sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110 \ - --hash=sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15 \ - --hash=sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891 \ - --hash=sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5 \ - --hash=sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f \ - --hash=sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8 \ - --hash=sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb \ - --hash=sha256:6300d5176647145ba1e22991c924fb29743e54b4d7b8bc85a0d3ec0e55e189cb \ - --hash=sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef \ - --hash=sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5 \ - --hash=sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c \ - --hash=sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31 \ - --hash=sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2 \ - --hash=sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a \ - --hash=sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3 \ - --hash=sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891 \ - --hash=sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943 \ - --hash=sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735 \ - --hash=sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e \ - --hash=sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62 \ - --hash=sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac \ - --hash=sha256:7a2573d0fd34f361a4a14e54d8cda3a91ac4e55fbf0d719698024f3b09c5b147 \ - --hash=sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5 \ - --hash=sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90 \ - --hash=sha256:7bc7003991ebd368a20d05228137a37b3d3066751f3ea1e4f7b8efe8e752f2f5 \ - --hash=sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630 \ - --hash=sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18 \ - --hash=sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab \ - --hash=sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b \ - --hash=sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7 \ - --hash=sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238 \ - --hash=sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364 \ - --hash=sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435 \ - --hash=sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08 \ - --hash=sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f \ - --hash=sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52 \ - --hash=sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da \ - --hash=sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4 \ - --hash=sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20 \ - --hash=sha256:8dc2d9c3a924ed14166e63650b2cf9f59e7821743bdd50b23802bd97ca09bde5 \ - --hash=sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a \ - --hash=sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84 \ - --hash=sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e \ - --hash=sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14 \ - --hash=sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c \ - --hash=sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7 \ - --hash=sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882 \ - --hash=sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d \ - --hash=sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2 \ - --hash=sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f \ - --hash=sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874 \ - --hash=sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6 \ - --hash=sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca \ - --hash=sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602 \ - --hash=sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb \ - --hash=sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463 \ - --hash=sha256:b1cd4d66ce894a45482e1ac2837c31d0bd447df35065e542b60055aa2d00404b \ - --hash=sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25 \ - --hash=sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d \ - --hash=sha256:b7e62b8fc7bd6cad007b9f2e0ad9c8d4854c06350d5f51e1a439dd18b510ecac \ - --hash=sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83 \ - --hash=sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316 \ - --hash=sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b \ - --hash=sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e \ - --hash=sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee \ - --hash=sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385 \ - --hash=sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d \ - --hash=sha256:c0fe437a6d2f36aac2b49517057776575b5bf359df314cca20d230a6e139c089 \ - --hash=sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11 \ - --hash=sha256:c2c5fd0fd39574ccd58e1a52565b341aff522c5c836f1b3eb7605c371e61f52c \ - --hash=sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e \ - --hash=sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742 \ - --hash=sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101 \ - --hash=sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0 \ - --hash=sha256:c9c98d2f0126ba84cb45601eed97ff67ff767e19ae6eb3c31b02827b54d700e5 \ - --hash=sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944 \ - --hash=sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d \ - --hash=sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f \ - --hash=sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26 \ - --hash=sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd \ - --hash=sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba \ - --hash=sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc \ - --hash=sha256:d6dc7804c50fabd28644d4d18a4b20aad3681b3e64f3acd3182b330ca73f7a32 \ - --hash=sha256:d7e5ba0a0153e35fbce9c51df530c8b4cb0c3012b46a04ff9a048441a269c2ed \ - --hash=sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556 \ - --hash=sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58 \ - --hash=sha256:dc911ae6152e455b16a2a1a626aa6cd612fa01efb9d0a4ab3f5cf328b911483d \ - --hash=sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9 \ - --hash=sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3 \ - --hash=sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38 \ - --hash=sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc \ - --hash=sha256:e418ec99574ca24365ca96546af285c2b021a1a072478a79f0e3cc3b08837154 \ - --hash=sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395 \ - --hash=sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f \ - --hash=sha256:e8e1e895e23818d343e4ae7dd95a0a556fdeaf8b471acf1c0a39b93c6f54d478 \ - --hash=sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081 \ - --hash=sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f \ - --hash=sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23 \ - --hash=sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f \ - --hash=sha256:edda19aff836ec515caafc09ea53d2ab144a041f09ee9a7cefcbd3ae4e976256 \ - --hash=sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39 \ - --hash=sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706 \ - --hash=sha256:f2fa3d3b1c933d4bcb8fd2018700d5e7235c52f2ab8c88d22286965c5c0f00f8 \ - --hash=sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b \ - --hash=sha256:f3a0a31189acf6703307397c6139ddabd734c20c5ef92649fc93e473df6615a3 \ - --hash=sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91 \ - --hash=sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc \ - --hash=sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6 \ - --hash=sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c \ - --hash=sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea \ - --hash=sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c +multidict==6.9.1 \ + --hash=sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c \ + --hash=sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9 \ + --hash=sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa \ + --hash=sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188 \ + --hash=sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201 \ + --hash=sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382 \ + --hash=sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f \ + --hash=sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919 \ + --hash=sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418 \ + --hash=sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771 \ + --hash=sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c \ + --hash=sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76 \ + --hash=sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345 \ + --hash=sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580 \ + --hash=sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9 \ + --hash=sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1 \ + --hash=sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1 \ + --hash=sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64 \ + --hash=sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5 \ + --hash=sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006 \ + --hash=sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8 \ + --hash=sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02 \ + --hash=sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132 \ + --hash=sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c \ + --hash=sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19 \ + --hash=sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b \ + --hash=sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba \ + --hash=sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b \ + --hash=sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08 \ + --hash=sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe \ + --hash=sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f \ + --hash=sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd \ + --hash=sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879 \ + --hash=sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f \ + --hash=sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9 \ + --hash=sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987 \ + --hash=sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd \ + --hash=sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea \ + --hash=sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc \ + --hash=sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c \ + --hash=sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b \ + --hash=sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709 \ + --hash=sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4 \ + --hash=sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce \ + --hash=sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94 \ + --hash=sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a \ + --hash=sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e \ + --hash=sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b \ + --hash=sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96 \ + --hash=sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1 \ + --hash=sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e \ + --hash=sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455 \ + --hash=sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3 \ + --hash=sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471 \ + --hash=sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d \ + --hash=sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b \ + --hash=sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d \ + --hash=sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f \ + --hash=sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004 \ + --hash=sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4 \ + --hash=sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e \ + --hash=sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5 \ + --hash=sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60 \ + --hash=sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14 \ + --hash=sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f \ + --hash=sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9 \ + --hash=sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3 \ + --hash=sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194 \ + --hash=sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a \ + --hash=sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3 \ + --hash=sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42 \ + --hash=sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c \ + --hash=sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828 \ + --hash=sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290 \ + --hash=sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485 \ + --hash=sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e \ + --hash=sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8 \ + --hash=sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c \ + --hash=sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b \ + --hash=sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659 \ + --hash=sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b \ + --hash=sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9 \ + --hash=sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953 \ + --hash=sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c \ + --hash=sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1 \ + --hash=sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299 \ + --hash=sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94 \ + --hash=sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983 \ + --hash=sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d \ + --hash=sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac \ + --hash=sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a \ + --hash=sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7 \ + --hash=sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e \ + --hash=sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45 \ + --hash=sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410 \ + --hash=sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40 \ + --hash=sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4 \ + --hash=sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805 \ + --hash=sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d \ + --hash=sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa \ + --hash=sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d \ + --hash=sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701 \ + --hash=sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80 \ + --hash=sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6 \ + --hash=sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40 \ + --hash=sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20 \ + --hash=sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694 \ + --hash=sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a \ + --hash=sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203 \ + --hash=sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519 \ + --hash=sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125 \ + --hash=sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0 \ + --hash=sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d \ + --hash=sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a \ + --hash=sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea \ + --hash=sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2 \ + --hash=sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c \ + --hash=sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3 \ + --hash=sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d \ + --hash=sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3 \ + --hash=sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a \ + --hash=sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68 \ + --hash=sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8 \ + --hash=sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca \ + --hash=sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8 \ + --hash=sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840 \ + --hash=sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82 \ + --hash=sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610 \ + --hash=sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca \ + --hash=sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a \ + --hash=sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1 \ + --hash=sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5 \ + --hash=sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a \ + --hash=sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13 \ + --hash=sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07 \ + --hash=sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05 \ + --hash=sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1 \ + --hash=sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4 \ + --hash=sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2 \ + --hash=sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82 \ + --hash=sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6 \ + --hash=sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d \ + --hash=sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88 \ + --hash=sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f \ + --hash=sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126 \ + --hash=sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24 \ + --hash=sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab \ + --hash=sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67 \ + --hash=sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3 \ + --hash=sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1 \ + --hash=sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800 \ + --hash=sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3 \ + --hash=sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8 \ + --hash=sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5 \ + --hash=sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24 \ + --hash=sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c \ + --hash=sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a \ + --hash=sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841 \ + --hash=sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387 \ + --hash=sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec \ + --hash=sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721 \ + --hash=sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d \ + --hash=sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390 \ + --hash=sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1 \ + --hash=sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d \ + --hash=sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e \ + --hash=sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df \ + --hash=sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb \ + --hash=sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5 \ + --hash=sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9 oauthlib==4.0.0 \ --hash=sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd packaging==26.3 \ @@ -1324,8 +1324,8 @@ urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 websocket-client==1.9.2 \ --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce -werkzeug==3.1.8 \ - --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 +werkzeug==3.1.9 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab yarl==1.25.1 \ --hash=sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3 \ --hash=sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de \ From 339a7185070ba353892eda8507397b197ff59459 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 17 Oct 2026 02:42:18 +0100 Subject: [PATCH 12/13] fix(governance): key snapshot cache by subscription_id to prevent stale replays load_context() stored the governance snapshot on the azure_client object without associating it with a subscription ID. When a scan evaluated rules across multiple subscriptions in one process, all subscriptions after the first would read the first subscription's snapshot instead of collecting their own. Added a _governance_snapshot_cache_sub sentinel alongside the cache. If the stored subscription does not match the current call, the cache is invalidated and a fresh GovernanceCollector.collect() is run for the new subscription. Signed-off-by: Tanvir Farhad --- scanner/rules/_governance_common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scanner/rules/_governance_common.py b/scanner/rules/_governance_common.py index dbbda4b2..9c06be88 100644 --- a/scanner/rules/_governance_common.py +++ b/scanner/rules/_governance_common.py @@ -42,9 +42,11 @@ def load_context(azure_client: Any, subscription_id: str, rule_id: str) -> tuple 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_sub = getattr(azure_client, "_governance_snapshot_cache_sub", None) + if cache is None or cache_sub != subscription_id: cache = GovernanceCollector(azure_client.credential, subscription_id).collect() setattr(azure_client, "_governance_snapshot_cache", cache) + setattr(azure_client, "_governance_snapshot_cache_sub", subscription_id) return policy, cache From 50557a1039c1b2e84f27922bdfef2b3743bc3f29 Mon Sep 17 00:00:00 2001 From: Tanvir Farhad Date: Sat, 17 Oct 2026 02:43:17 +0100 Subject: [PATCH 13/13] test(governance): cover load_context subscription-ID cache keying Added TestLoadContextCaching with three cases: - cache populated on first call - cache reused for the same subscription (collector called only once) - cache invalidated when subscription changes (stale-replay guard) Signed-off-by: Tanvir Farhad --- tests/test_rules_governance.py | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_rules_governance.py b/tests/test_rules_governance.py index f52dda66..620503e8 100644 --- a/tests/test_rules_governance.py +++ b/tests/test_rules_governance.py @@ -250,3 +250,60 @@ def test_gov_005_missing_locks_evidence_is_unknown(run): ) def test_missing_evidence_never_becomes_failure(run, module, source): assert run(module, {source: None}) == [] + + +class TestLoadContextCaching: + """load_context must cache by subscription_id to prevent cross-subscription stale replays.""" + + def _make_client(self): + class _FakeCollector: + def __init__(self, credential, subscription_id): + self.subscription_id = subscription_id + + def collect(self): + return {"sub": self.subscription_id} + + class _FakeClient: + credential = object() + + return _FakeClient(), _FakeCollector + + def test_cache_is_populated_on_first_call(self, monkeypatch, tmp_path): + client, FakeCollector = self._make_client() + monkeypatch.setattr(common, "GovernanceCollector", FakeCollector) + policy_file = tmp_path / "policy.json" + policy_file.write_text('{"approved_management_group_ids": []}') + monkeypatch.setenv(common.POLICY_ENV, str(policy_file)) + monkeypatch.setattr(common, "load_governance_policy", lambda _path: object()) + common.load_context(client, "sub-A", "AZ-GOV-001") + assert getattr(client, "_governance_snapshot_cache_sub") == "sub-A" + assert getattr(client, "_governance_snapshot_cache") == {"sub": "sub-A"} + + def test_cache_is_reused_for_same_subscription(self, monkeypatch, tmp_path): + client, FakeCollector = self._make_client() + call_count = 0 + + class _CountingCollector(FakeCollector): + def __init__(self, credential, subscription_id): + nonlocal call_count + call_count += 1 + super().__init__(credential, subscription_id) + + monkeypatch.setattr(common, "GovernanceCollector", _CountingCollector) + monkeypatch.setenv(common.POLICY_ENV, str(tmp_path / "p.json")) + (tmp_path / "p.json").write_text("{}") + monkeypatch.setattr(common, "load_governance_policy", lambda _path: object()) + common.load_context(client, "sub-A", "AZ-GOV-001") + common.load_context(client, "sub-A", "AZ-GOV-002") + assert call_count == 1, "Second call with the same subscription must reuse the cache" + + def test_cache_is_invalidated_on_new_subscription(self, monkeypatch, tmp_path): + client, FakeCollector = self._make_client() + monkeypatch.setattr(common, "GovernanceCollector", FakeCollector) + monkeypatch.setenv(common.POLICY_ENV, str(tmp_path / "p.json")) + (tmp_path / "p.json").write_text("{}") + monkeypatch.setattr(common, "load_governance_policy", lambda _path: object()) + common.load_context(client, "sub-A", "AZ-GOV-001") + common.load_context(client, "sub-B", "AZ-GOV-001") + assert getattr(client, "_governance_snapshot_cache") == {"sub": "sub-B"} + assert getattr(client, "_governance_snapshot_cache_sub") == "sub-B"