@apexops/cli 0.10.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/capability-packs/azure-governance-discovery/source/scripts/discover.py +1189 -0
- package/assets/capability-packs/azure-governance-discovery/source/scripts/render_governance.py +676 -0
- package/assets/capability-packs/registry.v1.json +73 -0
- package/assets/client-projection-metadata/copilot-cli-agent-tools.json +92 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-architect.agent.md +78 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-operator.agent.md +71 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-planner.agent.md +79 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex-requirements.agent.md +69 -0
- package/assets/client-projections/github-copilot-cli/.github/agents/apex.agent.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/copilot-instructions.md +13 -0
- package/assets/client-projections/github-copilot-cli/.github/mcp.json +54 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-architecture/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/SKILL.md +65 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/references/presentation-conventions.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/references/reference-only-outlines.md +78 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/architecture-assessment.md +35 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/cost-estimate.md +47 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/deployment-summary.md +35 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/governance-constraints.md +35 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/implementation-plan.md +43 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/operations-runbook.md +35 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/requirements.md +42 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-artifacts/templates/resource-inventory.md +29 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-adr/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-adr/references/decision-examples.md +38 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-adr/references/decision-guardrails.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-adr/references/decision-quality.md +21 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-adr/references/decision-record-fields.md +57 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cloud-migrate/SKILL.md +55 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cloud-migrate/references/lambda-to-functions-assessment.md +29 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cloud-migrate/references/migration-readiness.md +27 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cloud-migrate/references/staged-validation-handoff.md +26 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cloud-migrate/references/workload-mapping.md +27 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compliance/SKILL.md +58 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compliance/references/compliance-finding-criteria.md +63 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compliance/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compute/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compute/references/compute-selection.md +26 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compute/references/pricing-evidence.md +11 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-compute/references/recommendation-and-scale-rules.md +44 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cost-optimization/SKILL.md +61 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cost-optimization/references/cost-assessment-criteria.md +61 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-cost-optimization/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/SKILL.md +59 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/avm-binding-guidance.md +29 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/baseline-fallbacks.md +53 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/cost-monitoring.md +47 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/decision-boundaries.md +19 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/governance-effects.md +28 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/naming.md +47 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/network-planning.md +47 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/security-baseline.md +40 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/service-selection.md +51 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-defaults/references/tag-precedence.md +31 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-deploy/SKILL.md +50 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-deploy/references/operational-checklist.md +26 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-deploy/references/preview-recovery-verification.md +48 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-diagnostics/SKILL.md +60 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-diagnostics/references/diagnostic-interpretation.md +42 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-diagnostics/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-governance/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-governance/references/evidence-interpretation.md +58 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-governance/references/operational-checklist.md +25 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-kusto/SKILL.md +58 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-kusto/references/kql-evidence-interpretation.md +41 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-kusto/references/operational-checklist.md +25 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-prepare/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-prepare/references/functions-recipe-pack.md +27 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-prepare/references/preparation-lineage.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-quotas/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-quotas/references/capacity-decision-rules.md +48 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-quotas/references/operational-checklist.md +23 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-rbac/SKILL.md +45 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-rbac/references/assignment-intent.md +39 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-rbac/references/least-privilege-selection.md +50 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-resources/SKILL.md +54 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-resources/references/inventory-query-patterns.md +49 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-resources/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-storage/SKILL.md +35 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-storage/references/security-and-governance.md +12 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-storage/references/service-auth-and-sdk-boundary.md +33 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-storage/references/storage-selection.md +12 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-validate/SKILL.md +49 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-validate/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-azure-validate/references/preflight-evidence.md +43 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/SKILL.md +41 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/references/avm-and-codegen-acceptance.md +11 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/references/codegen-acceptance-checklist.md +46 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/references/compiler-and-provider-gotchas.md +74 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/references/module-interfaces-and-parameters.md +56 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-bicep-patterns/references/network-and-observability.md +52 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-codegen/SKILL.md +32 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-entra-app-registration/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-entra-app-registration/references/capability-receipts.md +21 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-entra-app-registration/references/design-and-diagnostics.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-entra-app-registration/references/oauth-registration-model.md +26 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-entra-app-registration/references/permissions-and-credentials.md +27 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-mermaid/SKILL.md +44 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-mermaid/references/styling.md +33 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-mermaid/references/syntax.md +99 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-microsoft-docs/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-microsoft-docs/references/research-method.md +91 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-operations/SKILL.md +34 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-planning/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-requirements/SKILL.md +43 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-import/SKILL.md +47 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-import/references/adoption-attestation.md +10 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-import/references/import-assessment.md +14 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-import/references/import-mapping.md +27 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-import/references/mapping-and-adoption-attestation.md +12 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/SKILL.md +49 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/references/module-composition-and-refactor.md +20 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/references/module-composition-and-state-boundaries.md +11 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/references/module-locks-and-codegen-acceptance.md +14 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/references/network-and-observability.md +12 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-patterns/references/plan-and-change-assessment.md +24 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-test/SKILL.md +45 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-test/references/evidence-acceptance.md +10 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-test/references/plan-mode-and-mock-design.md +12 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-test/references/plan-mode-test-design.md +23 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-terraform-test/references/test-design.md +13 -0
- package/assets/client-projections/github-copilot-cli/.github/skills/apex-workflow/SKILL.md +37 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-architect.agent.md +87 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-codegen.agent.md +54 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-operator.agent.md +75 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-planner.agent.md +89 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-requirements.agent.md +78 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-reviewer.agent.md +45 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex-validator.agent.md +50 -0
- package/assets/client-projections/github-copilot-vscode/.github/agents/apex.agent.md +71 -0
- package/assets/client-projections/github-copilot-vscode/.github/copilot-instructions.md +13 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-architecture/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/SKILL.md +65 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/references/presentation-conventions.md +52 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/references/reference-only-outlines.md +78 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/architecture-assessment.md +35 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/cost-estimate.md +47 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/deployment-summary.md +35 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/governance-constraints.md +35 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/implementation-plan.md +43 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/operations-runbook.md +35 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/requirements.md +42 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-artifacts/templates/resource-inventory.md +29 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-adr/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-adr/references/decision-examples.md +38 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-adr/references/decision-guardrails.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-adr/references/decision-quality.md +21 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-adr/references/decision-record-fields.md +57 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cloud-migrate/SKILL.md +55 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cloud-migrate/references/lambda-to-functions-assessment.md +29 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cloud-migrate/references/migration-readiness.md +27 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cloud-migrate/references/staged-validation-handoff.md +26 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cloud-migrate/references/workload-mapping.md +27 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compliance/SKILL.md +58 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compliance/references/compliance-finding-criteria.md +63 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compliance/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compute/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compute/references/compute-selection.md +26 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compute/references/pricing-evidence.md +11 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-compute/references/recommendation-and-scale-rules.md +44 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cost-optimization/SKILL.md +61 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cost-optimization/references/cost-assessment-criteria.md +61 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-cost-optimization/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/SKILL.md +59 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/avm-binding-guidance.md +29 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/baseline-fallbacks.md +53 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/cost-monitoring.md +47 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/decision-boundaries.md +19 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/governance-effects.md +28 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/naming.md +47 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/network-planning.md +47 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/security-baseline.md +40 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/service-selection.md +51 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-defaults/references/tag-precedence.md +31 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-deploy/SKILL.md +50 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-deploy/references/operational-checklist.md +26 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-deploy/references/preview-recovery-verification.md +48 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-diagnostics/SKILL.md +60 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-diagnostics/references/diagnostic-interpretation.md +42 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-diagnostics/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-governance/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-governance/references/evidence-interpretation.md +58 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-governance/references/operational-checklist.md +25 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-kusto/SKILL.md +58 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-kusto/references/kql-evidence-interpretation.md +41 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-kusto/references/operational-checklist.md +25 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-prepare/SKILL.md +52 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-prepare/references/functions-recipe-pack.md +27 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-prepare/references/preparation-lineage.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-quotas/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-quotas/references/capacity-decision-rules.md +48 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-quotas/references/operational-checklist.md +23 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-rbac/SKILL.md +45 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-rbac/references/assignment-intent.md +39 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-rbac/references/least-privilege-selection.md +50 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-resources/SKILL.md +54 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-resources/references/inventory-query-patterns.md +49 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-resources/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-storage/SKILL.md +35 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-storage/references/security-and-governance.md +12 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-storage/references/service-auth-and-sdk-boundary.md +33 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-storage/references/storage-selection.md +12 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-validate/SKILL.md +49 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-validate/references/operational-checklist.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-azure-validate/references/preflight-evidence.md +43 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/SKILL.md +41 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/references/avm-and-codegen-acceptance.md +11 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/references/codegen-acceptance-checklist.md +46 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/references/compiler-and-provider-gotchas.md +74 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/references/module-interfaces-and-parameters.md +56 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-bicep-patterns/references/network-and-observability.md +52 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-codegen/SKILL.md +32 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-entra-app-registration/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-entra-app-registration/references/capability-receipts.md +21 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-entra-app-registration/references/design-and-diagnostics.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-entra-app-registration/references/oauth-registration-model.md +26 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-entra-app-registration/references/permissions-and-credentials.md +27 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-mermaid/SKILL.md +44 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-mermaid/references/styling.md +33 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-mermaid/references/syntax.md +99 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-microsoft-docs/SKILL.md +53 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-microsoft-docs/references/research-method.md +91 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-operations/SKILL.md +34 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-planning/SKILL.md +36 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-requirements/SKILL.md +43 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-import/SKILL.md +47 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-import/references/adoption-attestation.md +10 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-import/references/import-assessment.md +14 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-import/references/import-mapping.md +27 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-import/references/mapping-and-adoption-attestation.md +12 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/SKILL.md +49 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/references/module-composition-and-refactor.md +20 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/references/module-composition-and-state-boundaries.md +11 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/references/module-locks-and-codegen-acceptance.md +14 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/references/network-and-observability.md +12 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-patterns/references/plan-and-change-assessment.md +24 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-test/SKILL.md +45 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-test/references/evidence-acceptance.md +10 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-test/references/plan-mode-and-mock-design.md +12 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-test/references/plan-mode-test-design.md +23 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-terraform-test/references/test-design.md +13 -0
- package/assets/client-projections/github-copilot-vscode/.github/skills/apex-workflow/SKILL.md +37 -0
- package/assets/client-projections/github-copilot-vscode/.vscode/mcp.json +21 -0
- package/assets/config/capability-packs.v1.json +43 -0
- package/assets/config/defaults.v1.json +98 -0
- package/assets/config/improvement-policy.v1.json +37 -0
- package/assets/config/quality-scorecard.v1.json +105 -0
- package/assets/config/recipe-packs.v1.json +29 -0
- package/assets/config/runtime-bundle.v1.json +84 -0
- package/assets/config/toolchain.v1.json +134 -0
- package/assets/config/workflow.v1.json +876 -0
- package/assets/customizations/.github/agents/apex-architect.agent.md +80 -0
- package/assets/customizations/.github/agents/apex-codegen.agent.md +51 -0
- package/assets/customizations/.github/agents/apex-operator.agent.md +68 -0
- package/assets/customizations/.github/agents/apex-planner.agent.md +82 -0
- package/assets/customizations/.github/agents/apex-requirements.agent.md +71 -0
- package/assets/customizations/.github/agents/apex-reviewer.agent.md +42 -0
- package/assets/customizations/.github/agents/apex-validator.agent.md +47 -0
- package/assets/customizations/.github/agents/apex.agent.md +64 -0
- package/assets/customizations/.github/copilot-instructions.md +13 -0
- package/assets/customizations/.github/mcp.json +54 -0
- package/assets/customizations/.github/skills/apex-architecture/SKILL.md +36 -0
- package/assets/customizations/.github/skills/apex-artifacts/SKILL.md +65 -0
- package/assets/customizations/.github/skills/apex-artifacts/references/presentation-conventions.md +52 -0
- package/assets/customizations/.github/skills/apex-artifacts/references/reference-only-outlines.md +78 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/architecture-assessment.md +35 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/cost-estimate.md +47 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/deployment-summary.md +35 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/governance-constraints.md +35 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/implementation-plan.md +43 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/operations-runbook.md +35 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/requirements.md +42 -0
- package/assets/customizations/.github/skills/apex-artifacts/templates/resource-inventory.md +29 -0
- package/assets/customizations/.github/skills/apex-azure-adr/SKILL.md +52 -0
- package/assets/customizations/.github/skills/apex-azure-adr/references/decision-examples.md +38 -0
- package/assets/customizations/.github/skills/apex-azure-adr/references/decision-guardrails.md +36 -0
- package/assets/customizations/.github/skills/apex-azure-adr/references/decision-quality.md +21 -0
- package/assets/customizations/.github/skills/apex-azure-adr/references/decision-record-fields.md +57 -0
- package/assets/customizations/.github/skills/apex-azure-cloud-migrate/SKILL.md +55 -0
- package/assets/customizations/.github/skills/apex-azure-cloud-migrate/references/lambda-to-functions-assessment.md +29 -0
- package/assets/customizations/.github/skills/apex-azure-cloud-migrate/references/migration-readiness.md +27 -0
- package/assets/customizations/.github/skills/apex-azure-cloud-migrate/references/staged-validation-handoff.md +26 -0
- package/assets/customizations/.github/skills/apex-azure-cloud-migrate/references/workload-mapping.md +27 -0
- package/assets/customizations/.github/skills/apex-azure-compliance/SKILL.md +58 -0
- package/assets/customizations/.github/skills/apex-azure-compliance/references/compliance-finding-criteria.md +63 -0
- package/assets/customizations/.github/skills/apex-azure-compliance/references/operational-checklist.md +24 -0
- package/assets/customizations/.github/skills/apex-azure-compute/SKILL.md +36 -0
- package/assets/customizations/.github/skills/apex-azure-compute/references/compute-selection.md +26 -0
- package/assets/customizations/.github/skills/apex-azure-compute/references/pricing-evidence.md +11 -0
- package/assets/customizations/.github/skills/apex-azure-compute/references/recommendation-and-scale-rules.md +44 -0
- package/assets/customizations/.github/skills/apex-azure-cost-optimization/SKILL.md +61 -0
- package/assets/customizations/.github/skills/apex-azure-cost-optimization/references/cost-assessment-criteria.md +61 -0
- package/assets/customizations/.github/skills/apex-azure-cost-optimization/references/operational-checklist.md +24 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/SKILL.md +59 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/avm-binding-guidance.md +29 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/baseline-fallbacks.md +53 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/cost-monitoring.md +47 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/decision-boundaries.md +19 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/governance-effects.md +28 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/naming.md +47 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/network-planning.md +47 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/security-baseline.md +40 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/service-selection.md +51 -0
- package/assets/customizations/.github/skills/apex-azure-defaults/references/tag-precedence.md +31 -0
- package/assets/customizations/.github/skills/apex-azure-deploy/SKILL.md +50 -0
- package/assets/customizations/.github/skills/apex-azure-deploy/references/operational-checklist.md +26 -0
- package/assets/customizations/.github/skills/apex-azure-deploy/references/preview-recovery-verification.md +48 -0
- package/assets/customizations/.github/skills/apex-azure-diagnostics/SKILL.md +60 -0
- package/assets/customizations/.github/skills/apex-azure-diagnostics/references/diagnostic-interpretation.md +42 -0
- package/assets/customizations/.github/skills/apex-azure-diagnostics/references/operational-checklist.md +24 -0
- package/assets/customizations/.github/skills/apex-azure-governance/SKILL.md +52 -0
- package/assets/customizations/.github/skills/apex-azure-governance/references/evidence-interpretation.md +58 -0
- package/assets/customizations/.github/skills/apex-azure-governance/references/operational-checklist.md +25 -0
- package/assets/customizations/.github/skills/apex-azure-kusto/SKILL.md +58 -0
- package/assets/customizations/.github/skills/apex-azure-kusto/references/kql-evidence-interpretation.md +41 -0
- package/assets/customizations/.github/skills/apex-azure-kusto/references/operational-checklist.md +25 -0
- package/assets/customizations/.github/skills/apex-azure-prepare/SKILL.md +52 -0
- package/assets/customizations/.github/skills/apex-azure-prepare/references/functions-recipe-pack.md +27 -0
- package/assets/customizations/.github/skills/apex-azure-prepare/references/preparation-lineage.md +36 -0
- package/assets/customizations/.github/skills/apex-azure-quotas/SKILL.md +53 -0
- package/assets/customizations/.github/skills/apex-azure-quotas/references/capacity-decision-rules.md +48 -0
- package/assets/customizations/.github/skills/apex-azure-quotas/references/operational-checklist.md +23 -0
- package/assets/customizations/.github/skills/apex-azure-rbac/SKILL.md +45 -0
- package/assets/customizations/.github/skills/apex-azure-rbac/references/assignment-intent.md +39 -0
- package/assets/customizations/.github/skills/apex-azure-rbac/references/least-privilege-selection.md +50 -0
- package/assets/customizations/.github/skills/apex-azure-resources/SKILL.md +54 -0
- package/assets/customizations/.github/skills/apex-azure-resources/references/inventory-query-patterns.md +49 -0
- package/assets/customizations/.github/skills/apex-azure-resources/references/operational-checklist.md +24 -0
- package/assets/customizations/.github/skills/apex-azure-storage/SKILL.md +35 -0
- package/assets/customizations/.github/skills/apex-azure-storage/references/security-and-governance.md +12 -0
- package/assets/customizations/.github/skills/apex-azure-storage/references/service-auth-and-sdk-boundary.md +33 -0
- package/assets/customizations/.github/skills/apex-azure-storage/references/storage-selection.md +12 -0
- package/assets/customizations/.github/skills/apex-azure-validate/SKILL.md +49 -0
- package/assets/customizations/.github/skills/apex-azure-validate/references/operational-checklist.md +24 -0
- package/assets/customizations/.github/skills/apex-azure-validate/references/preflight-evidence.md +43 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/SKILL.md +41 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/references/avm-and-codegen-acceptance.md +11 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/references/codegen-acceptance-checklist.md +46 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/references/compiler-and-provider-gotchas.md +74 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/references/module-interfaces-and-parameters.md +56 -0
- package/assets/customizations/.github/skills/apex-bicep-patterns/references/network-and-observability.md +52 -0
- package/assets/customizations/.github/skills/apex-codegen/SKILL.md +32 -0
- package/assets/customizations/.github/skills/apex-entra-app-registration/SKILL.md +53 -0
- package/assets/customizations/.github/skills/apex-entra-app-registration/references/capability-receipts.md +21 -0
- package/assets/customizations/.github/skills/apex-entra-app-registration/references/design-and-diagnostics.md +36 -0
- package/assets/customizations/.github/skills/apex-entra-app-registration/references/oauth-registration-model.md +26 -0
- package/assets/customizations/.github/skills/apex-entra-app-registration/references/permissions-and-credentials.md +27 -0
- package/assets/customizations/.github/skills/apex-mermaid/SKILL.md +44 -0
- package/assets/customizations/.github/skills/apex-mermaid/references/styling.md +33 -0
- package/assets/customizations/.github/skills/apex-mermaid/references/syntax.md +99 -0
- package/assets/customizations/.github/skills/apex-microsoft-docs/SKILL.md +53 -0
- package/assets/customizations/.github/skills/apex-microsoft-docs/references/research-method.md +91 -0
- package/assets/customizations/.github/skills/apex-operations/SKILL.md +34 -0
- package/assets/customizations/.github/skills/apex-planning/SKILL.md +36 -0
- package/assets/customizations/.github/skills/apex-requirements/SKILL.md +43 -0
- package/assets/customizations/.github/skills/apex-terraform-import/SKILL.md +47 -0
- package/assets/customizations/.github/skills/apex-terraform-import/references/adoption-attestation.md +10 -0
- package/assets/customizations/.github/skills/apex-terraform-import/references/import-assessment.md +14 -0
- package/assets/customizations/.github/skills/apex-terraform-import/references/import-mapping.md +27 -0
- package/assets/customizations/.github/skills/apex-terraform-import/references/mapping-and-adoption-attestation.md +12 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/SKILL.md +49 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/references/module-composition-and-refactor.md +20 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/references/module-composition-and-state-boundaries.md +11 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/references/module-locks-and-codegen-acceptance.md +14 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/references/network-and-observability.md +12 -0
- package/assets/customizations/.github/skills/apex-terraform-patterns/references/plan-and-change-assessment.md +24 -0
- package/assets/customizations/.github/skills/apex-terraform-test/SKILL.md +45 -0
- package/assets/customizations/.github/skills/apex-terraform-test/references/evidence-acceptance.md +10 -0
- package/assets/customizations/.github/skills/apex-terraform-test/references/plan-mode-and-mock-design.md +12 -0
- package/assets/customizations/.github/skills/apex-terraform-test/references/plan-mode-test-design.md +23 -0
- package/assets/customizations/.github/skills/apex-terraform-test/references/test-design.md +13 -0
- package/assets/customizations/.github/skills/apex-workflow/SKILL.md +37 -0
- package/assets/customizations/.vscode/mcp.json +21 -0
- package/assets/customizations/README.md +22 -0
- package/assets/customizations/manifest.json +251 -0
- package/assets/manifest.json +5027 -0
- package/dist/assets.d.ts +69 -0
- package/dist/assets.js +336 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +643 -0
- package/dist/dependency-revision.d.ts +5 -0
- package/dist/dependency-revision.js +21 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +34 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.js +210 -0
- package/dist/provider-runtime.d.ts +27 -0
- package/dist/provider-runtime.js +229 -0
- package/dist/provider-transfer.d.ts +52 -0
- package/dist/provider-transfer.js +533 -0
- package/dist/service.d.ts +806 -0
- package/dist/service.js +3817 -0
- package/dist/state-transfer.d.ts +51 -0
- package/dist/state-transfer.js +559 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/workflow-validators.d.ts +80 -0
- package/dist/workflow-validators.js +843 -0
- package/package.json +57 -0
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ruff: noqa: E501
|
|
3
|
+
"""Deterministic Azure Policy discovery for 04g-Governance.
|
|
4
|
+
|
|
5
|
+
Replaces the legacy governance-discovery-subagent. Lists effective policy
|
|
6
|
+
assignments at subscription scope (including MG-inherited), pulls definitions,
|
|
7
|
+
set-definitions, and exemptions, classifies effects, filters Defender
|
|
8
|
+
auto-assignments, and writes a governance-constraints-v1 envelope.
|
|
9
|
+
|
|
10
|
+
Stdout line 1 is always a one-line JSON status object. Optional Markdown
|
|
11
|
+
preview follows on later lines. Stderr carries warnings and filter notes.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Constants #
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
API_ASSIGNMENTS = "2022-06-01"
|
|
34
|
+
API_DEFINITIONS = "2021-06-01"
|
|
35
|
+
API_EXEMPTIONS = "2022-07-01-preview"
|
|
36
|
+
ARM = "https://management.azure.com"
|
|
37
|
+
|
|
38
|
+
# L0 envelope: staleness threshold (days). Downstream consumers (Planner,
|
|
39
|
+
# CodeGen, Deploy) treat envelopes older than this as STALE and refuse to
|
|
40
|
+
# proceed without a fresh `--refresh` discovery.
|
|
41
|
+
DEFAULT_TTL_DAYS = 7
|
|
42
|
+
|
|
43
|
+
# Only these effects are plan-blocking. Everything else lives in the summary.
|
|
44
|
+
BLOCKER_EFFECTS = {"Deny"}
|
|
45
|
+
AUTO_REMEDIATE_EFFECTS = {"DeployIfNotExists", "Modify"}
|
|
46
|
+
RELEVANT_EFFECTS = BLOCKER_EFFECTS | AUTO_REMEDIATE_EFFECTS
|
|
47
|
+
|
|
48
|
+
# Defender-for-Cloud auto-assignment marker. See
|
|
49
|
+
# references/effect-classification.md for background.
|
|
50
|
+
DEFENDER_ASSIGNED_BY_VALUES = {
|
|
51
|
+
"Security Center",
|
|
52
|
+
"Microsoft Defender for Cloud",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Resource-type to Bicep singular-lowercase mapping. Extend as needed; the
|
|
56
|
+
# fallback derives from the full ARM type string.
|
|
57
|
+
BICEP_TYPE_OVERRIDES = {
|
|
58
|
+
"Microsoft.Storage/storageAccounts": "storageAccounts",
|
|
59
|
+
"Microsoft.Sql/servers": "sqlServers",
|
|
60
|
+
"Microsoft.Sql/servers/databases": "sqlServers/databases",
|
|
61
|
+
"Microsoft.KeyVault/vaults": "keyVaults",
|
|
62
|
+
"Microsoft.Web/sites": "sites",
|
|
63
|
+
"Microsoft.Network/virtualNetworks": "virtualNetworks",
|
|
64
|
+
"Microsoft.Compute/virtualMachines": "virtualMachines",
|
|
65
|
+
"Microsoft.Resources/subscriptions/resourceGroups": "resourceGroups",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
# Azure CLI shim (injectable for tests) #
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
|
|
73
|
+
# Parallelism cap for concurrent ARM calls. ARM throttles aggressively above
|
|
74
|
+
# ~16 concurrent; 8 is a safe default that still gives ~4-6x speedup over
|
|
75
|
+
# sequential `az rest` subprocess invocations.
|
|
76
|
+
_PARALLEL_WORKERS = 8
|
|
77
|
+
|
|
78
|
+
# Process-wide ARM token cache. `az account get-access-token` takes ~0.5-1s
|
|
79
|
+
# per invocation; caching saves that overhead on every subsequent REST call.
|
|
80
|
+
_TOKEN_CACHE: dict[str, Any] = {"token": None, "expires_at": 0.0}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_arm_token() -> str:
|
|
84
|
+
"""Return a cached ARM bearer token, refreshing when near expiry."""
|
|
85
|
+
now = time.time()
|
|
86
|
+
if _TOKEN_CACHE["token"] and _TOKEN_CACHE["expires_at"] > now + 60:
|
|
87
|
+
return _TOKEN_CACHE["token"]
|
|
88
|
+
out = subprocess.check_output( # noqa: S603 — trusted Azure CLI
|
|
89
|
+
[
|
|
90
|
+
"az",
|
|
91
|
+
"account",
|
|
92
|
+
"get-access-token",
|
|
93
|
+
"--resource",
|
|
94
|
+
f"{ARM}/",
|
|
95
|
+
"-o",
|
|
96
|
+
"json",
|
|
97
|
+
],
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=30,
|
|
100
|
+
)
|
|
101
|
+
data = json.loads(out)
|
|
102
|
+
_TOKEN_CACHE["token"] = data["accessToken"]
|
|
103
|
+
# Tokens are valid for ~60 min. Use 50-min safe window to avoid mid-run
|
|
104
|
+
# expiry. `expiresOn` format varies across az versions, so we compute
|
|
105
|
+
# relative expiry from wall-clock instead of parsing it.
|
|
106
|
+
_TOKEN_CACHE["expires_at"] = now + 50 * 60
|
|
107
|
+
return data["accessToken"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _default_az_rest(url: str) -> dict[str, Any]:
|
|
111
|
+
"""GET `url` with the cached ARM bearer token via stdlib urllib.
|
|
112
|
+
|
|
113
|
+
Replaces per-call `az rest` subprocess (saves ~0.5-1s × N calls). Returns
|
|
114
|
+
`{}` for 404 (missing policy definition) so callers can filter silently.
|
|
115
|
+
Raises on auth failures and other HTTP errors.
|
|
116
|
+
"""
|
|
117
|
+
token = _get_arm_token()
|
|
118
|
+
req = urllib.request.Request(
|
|
119
|
+
url,
|
|
120
|
+
headers={
|
|
121
|
+
"Authorization": f"Bearer {token}",
|
|
122
|
+
"Content-Type": "application/json",
|
|
123
|
+
},
|
|
124
|
+
method="GET",
|
|
125
|
+
)
|
|
126
|
+
try:
|
|
127
|
+
with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 — ARM https
|
|
128
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
129
|
+
except urllib.error.HTTPError as e:
|
|
130
|
+
if e.code == 404:
|
|
131
|
+
return {}
|
|
132
|
+
detail = ""
|
|
133
|
+
try:
|
|
134
|
+
detail = e.read().decode("utf-8", errors="replace")[:500]
|
|
135
|
+
except Exception: # pragma: no cover
|
|
136
|
+
pass
|
|
137
|
+
raise RuntimeError(
|
|
138
|
+
f"ARM GET {url} failed: HTTP {e.code} {e.reason}; {detail}"
|
|
139
|
+
) from e
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _default_get_subscription() -> str:
|
|
143
|
+
out = subprocess.check_output( # noqa: S603
|
|
144
|
+
["az", "account", "show", "--query", "id", "-o", "tsv"],
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=30,
|
|
147
|
+
)
|
|
148
|
+
return out.strip()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _default_check_auth() -> None:
|
|
152
|
+
"""Raise if the ARM token cannot be obtained."""
|
|
153
|
+
# Warms the token cache as a side effect so subsequent REST calls are fast.
|
|
154
|
+
_get_arm_token()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
# Discovery #
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _list_all(az_rest: Callable[[str], dict[str, Any]], url: str) -> list[dict[str, Any]]:
|
|
163
|
+
"""Follow `nextLink` pagination and return the union of `value` arrays."""
|
|
164
|
+
items: list[dict[str, Any]] = []
|
|
165
|
+
next_url: str | None = url
|
|
166
|
+
while next_url:
|
|
167
|
+
page = az_rest(next_url)
|
|
168
|
+
items.extend(page.get("value", []))
|
|
169
|
+
next_url = page.get("nextLink")
|
|
170
|
+
return items
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parallel_list(
|
|
174
|
+
az_rest: Callable[[str], dict[str, Any]],
|
|
175
|
+
urls: dict[str, str],
|
|
176
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
177
|
+
"""Run multiple paginated list calls concurrently. Preserves key mapping.
|
|
178
|
+
|
|
179
|
+
Uses a thread pool because `az_rest` is a blocking callable (either
|
|
180
|
+
urllib GET or subprocess `az rest`). Thread-per-URL is fine at this
|
|
181
|
+
concurrency level.
|
|
182
|
+
"""
|
|
183
|
+
if not urls:
|
|
184
|
+
return {}
|
|
185
|
+
with ThreadPoolExecutor(max_workers=min(_PARALLEL_WORKERS, len(urls))) as pool:
|
|
186
|
+
futures = {key: pool.submit(_list_all, az_rest, url) for key, url in urls.items()}
|
|
187
|
+
return {key: fut.result() for key, fut in futures.items()}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parallel_fetch_items(
|
|
191
|
+
az_rest: Callable[[str], dict[str, Any]],
|
|
192
|
+
urls: list[str],
|
|
193
|
+
expected_ids: list[str] | None = None,
|
|
194
|
+
) -> list[dict[str, Any] | None]:
|
|
195
|
+
"""Fetch individual items concurrently; tolerate both response shapes.
|
|
196
|
+
|
|
197
|
+
Azure returns a single object for GET `.../policyDefinitions/{name}`, but
|
|
198
|
+
injected test fakes commonly match on a URL substring and return the
|
|
199
|
+
wrapped list shape `{"value": [...]}`. This helper accepts both:
|
|
200
|
+
|
|
201
|
+
- dict with no `value` key → single-item payload (return as-is, unless empty)
|
|
202
|
+
- dict with `value` list → look for the item whose id matches the expected
|
|
203
|
+
id (preserved positionally via `expected_ids`); fall back to first
|
|
204
|
+
"""
|
|
205
|
+
if not urls:
|
|
206
|
+
return []
|
|
207
|
+
if expected_ids is None:
|
|
208
|
+
expected_ids = [""] * len(urls)
|
|
209
|
+
|
|
210
|
+
def _fetch(url: str, expected_id: str) -> dict[str, Any] | None:
|
|
211
|
+
resp = az_rest(url)
|
|
212
|
+
if not isinstance(resp, dict) or not resp:
|
|
213
|
+
return None
|
|
214
|
+
if "value" in resp and isinstance(resp["value"], list):
|
|
215
|
+
if expected_id:
|
|
216
|
+
for item in resp["value"]:
|
|
217
|
+
if (item.get("id") or "").lower() == expected_id.lower():
|
|
218
|
+
return item
|
|
219
|
+
return resp["value"][0] if resp["value"] else None
|
|
220
|
+
if resp.get("id"):
|
|
221
|
+
return resp
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
with ThreadPoolExecutor(max_workers=min(_PARALLEL_WORKERS, len(urls))) as pool:
|
|
225
|
+
return list(pool.map(_fetch, urls, expected_ids))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_PARAMETER_EXPRESSION_RE = re.compile(r"^\[parameters\('([^']+)'\)\]$", re.IGNORECASE)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_parameter_value(value: Any, assignment_params: dict[str, Any]) -> Any:
|
|
232
|
+
if isinstance(value, str):
|
|
233
|
+
match = _PARAMETER_EXPRESSION_RE.match(value)
|
|
234
|
+
if match:
|
|
235
|
+
return assignment_params.get(match.group(1))
|
|
236
|
+
return value
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _effect_of(defn: dict[str, Any], assignment_params: dict[str, Any] | None = None) -> str | None:
|
|
240
|
+
rule = (defn.get("properties") or {}).get("policyRule") or {}
|
|
241
|
+
then = rule.get("then") or {}
|
|
242
|
+
eff = then.get("effect")
|
|
243
|
+
assignment_params = assignment_params or {}
|
|
244
|
+
eff = _resolve_parameter_value(eff, assignment_params)
|
|
245
|
+
if eff is None:
|
|
246
|
+
expression = then.get("effect")
|
|
247
|
+
if isinstance(expression, str):
|
|
248
|
+
match = _PARAMETER_EXPRESSION_RE.match(expression)
|
|
249
|
+
if match:
|
|
250
|
+
eff = (((defn.get("properties") or {}).get("parameters") or {}).get(match.group(1)) or {}).get(
|
|
251
|
+
"defaultValue"
|
|
252
|
+
)
|
|
253
|
+
if not isinstance(eff, str):
|
|
254
|
+
return None
|
|
255
|
+
# Canonicalise case (some definitions use lowercase).
|
|
256
|
+
mapping = {
|
|
257
|
+
"deny": "Deny",
|
|
258
|
+
"audit": "Audit",
|
|
259
|
+
"auditifnotexists": "AuditIfNotExists",
|
|
260
|
+
"deployifnotexists": "DeployIfNotExists",
|
|
261
|
+
"modify": "Modify",
|
|
262
|
+
"append": "Append",
|
|
263
|
+
"disabled": "Disabled",
|
|
264
|
+
}
|
|
265
|
+
return mapping.get(eff.lower(), eff)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _resource_types(defn: dict[str, Any]) -> list[str]:
|
|
269
|
+
rule = (defn.get("properties") or {}).get("policyRule") or {}
|
|
270
|
+
types: set[str] = set()
|
|
271
|
+
stack = [rule.get("if")]
|
|
272
|
+
while stack:
|
|
273
|
+
node = stack.pop()
|
|
274
|
+
if isinstance(node, dict):
|
|
275
|
+
if node.get("field") == "type" and isinstance(node.get("equals"), str):
|
|
276
|
+
types.add(node["equals"])
|
|
277
|
+
stack.extend(node.values())
|
|
278
|
+
elif isinstance(node, list):
|
|
279
|
+
stack.extend(node)
|
|
280
|
+
return sorted(types)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _required_value(defn: dict[str, Any]) -> Any:
|
|
284
|
+
"""Extract the value a Deny/Modify policy requires."""
|
|
285
|
+
props = defn.get("properties") or {}
|
|
286
|
+
then = (props.get("policyRule") or {}).get("then") or {}
|
|
287
|
+
details = then.get("details") or {}
|
|
288
|
+
if not isinstance(details, dict):
|
|
289
|
+
details = {}
|
|
290
|
+
|
|
291
|
+
if "value" in details:
|
|
292
|
+
return details["value"]
|
|
293
|
+
|
|
294
|
+
ops = details.get("operations") or []
|
|
295
|
+
if isinstance(ops, list) and len(ops) == 1:
|
|
296
|
+
op = ops[0] or {}
|
|
297
|
+
if "value" in op:
|
|
298
|
+
return op["value"]
|
|
299
|
+
|
|
300
|
+
params = props.get("parameters") or {}
|
|
301
|
+
for pname, pval in params.items():
|
|
302
|
+
dv = (pval or {}).get("defaultValue")
|
|
303
|
+
key = pname.lower()
|
|
304
|
+
if dv is not None and key in {
|
|
305
|
+
"allowedlocations",
|
|
306
|
+
"listofallowedlocations",
|
|
307
|
+
"allowedskus",
|
|
308
|
+
"listofallowedskus",
|
|
309
|
+
"tagname",
|
|
310
|
+
"tagvalue",
|
|
311
|
+
"minimumtlsversion",
|
|
312
|
+
"requiredretentiondays",
|
|
313
|
+
}:
|
|
314
|
+
return dv
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _property_paths(defn: dict[str, Any], resource_types: list[str]) -> dict[str, str | None]:
|
|
319
|
+
"""Best-effort extraction of azurePropertyPath / bicepPropertyPath.
|
|
320
|
+
|
|
321
|
+
Walks the policyRule looking for `field: "<Type>/<path>"` patterns, which
|
|
322
|
+
is how Azure Policy addresses resource properties. Falls back to the
|
|
323
|
+
`Modify` / `DeployIfNotExists` operation field when the top-level rule
|
|
324
|
+
does not name a field.
|
|
325
|
+
"""
|
|
326
|
+
rule = (defn.get("properties") or {}).get("policyRule") or {}
|
|
327
|
+
|
|
328
|
+
# Tag policies address `tags['<name>']`, often without any `field: type`.
|
|
329
|
+
# Check before the resource-type short-circuit below.
|
|
330
|
+
category = ((((defn.get("properties") or {}).get("metadata") or {}).get("category")) or "").lower()
|
|
331
|
+
if _looks_like_tag_policy(rule) and category == "tags":
|
|
332
|
+
return {
|
|
333
|
+
"azurePropertyPath": "resourceGroup.tags",
|
|
334
|
+
"bicepPropertyPath": "resourceGroups::tags",
|
|
335
|
+
"pathSemantics": "tag-policy-non-property",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if not resource_types:
|
|
339
|
+
return {"azurePropertyPath": "", "bicepPropertyPath": ""}
|
|
340
|
+
|
|
341
|
+
primary_type = resource_types[0]
|
|
342
|
+
|
|
343
|
+
candidate_fields: list[str] = []
|
|
344
|
+
stack = [rule.get("if"), rule.get("then")]
|
|
345
|
+
while stack:
|
|
346
|
+
node = stack.pop()
|
|
347
|
+
if isinstance(node, dict):
|
|
348
|
+
f = node.get("field")
|
|
349
|
+
if isinstance(f, str) and "/" in f and f.startswith(primary_type):
|
|
350
|
+
candidate_fields.append(f)
|
|
351
|
+
stack.extend(node.values())
|
|
352
|
+
elif isinstance(node, list):
|
|
353
|
+
stack.extend(node)
|
|
354
|
+
|
|
355
|
+
# Also check modify operations for their `field` property.
|
|
356
|
+
then = rule.get("then") or {}
|
|
357
|
+
ops = (then.get("details") or {}).get("operations") or []
|
|
358
|
+
if isinstance(ops, list):
|
|
359
|
+
for op in ops:
|
|
360
|
+
f = (op or {}).get("field")
|
|
361
|
+
if isinstance(f, str) and f.startswith(primary_type):
|
|
362
|
+
candidate_fields.append(f)
|
|
363
|
+
|
|
364
|
+
if not candidate_fields:
|
|
365
|
+
return {"azurePropertyPath": "", "bicepPropertyPath": ""}
|
|
366
|
+
|
|
367
|
+
# Prefer the field that names the deepest property path (most slashes
|
|
368
|
+
# after the type prefix); otherwise take the first candidate.
|
|
369
|
+
prefix = primary_type + "/"
|
|
370
|
+
scored = [
|
|
371
|
+
(f[len(prefix):] if f.startswith(prefix) else f.rsplit("/", 1)[-1], f)
|
|
372
|
+
for f in candidate_fields
|
|
373
|
+
]
|
|
374
|
+
# Drop empty tails and sort by descending depth.
|
|
375
|
+
scored = [(tail, orig) for tail, orig in scored if tail]
|
|
376
|
+
if not scored:
|
|
377
|
+
return {"azurePropertyPath": "", "bicepPropertyPath": ""}
|
|
378
|
+
scored.sort(key=lambda pair: pair[0].count("/"), reverse=True)
|
|
379
|
+
path = scored[0][0]
|
|
380
|
+
# Azure path: lowerCamel resource type + dot + property path.
|
|
381
|
+
provider_type = primary_type.split("/", 1)[-1]
|
|
382
|
+
camel_type = provider_type[:1].lower() + provider_type[1:]
|
|
383
|
+
azure_path = f"{camel_type}.{path}"
|
|
384
|
+
bicep_segment = BICEP_TYPE_OVERRIDES.get(primary_type, camel_type)
|
|
385
|
+
bicep_path = f"{bicep_segment}::{path}"
|
|
386
|
+
return {"azurePropertyPath": azure_path, "bicepPropertyPath": bicep_path}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _looks_like_tag_policy(rule: dict[str, Any]) -> bool:
|
|
390
|
+
operations = (((rule.get("then") or {}).get("details") or {}).get("operations") or [])
|
|
391
|
+
if isinstance(operations, list) and operations:
|
|
392
|
+
operation_fields = [
|
|
393
|
+
(operation or {}).get("field")
|
|
394
|
+
for operation in operations
|
|
395
|
+
if isinstance((operation or {}).get("field"), str)
|
|
396
|
+
]
|
|
397
|
+
if operation_fields and not all(field.lower().startswith("tags[") for field in operation_fields):
|
|
398
|
+
return False
|
|
399
|
+
stack = [rule.get("if")]
|
|
400
|
+
while stack:
|
|
401
|
+
node = stack.pop()
|
|
402
|
+
if isinstance(node, dict):
|
|
403
|
+
f = node.get("field")
|
|
404
|
+
if isinstance(f, str) and f.lower().startswith("tags["):
|
|
405
|
+
return True
|
|
406
|
+
stack.extend(node.values())
|
|
407
|
+
elif isinstance(node, list):
|
|
408
|
+
stack.extend(node)
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
_TAG_FIELD_RE = re.compile(r"^tags\[(?:'|\")?([^'\"\]]+)(?:'|\")?\]$", re.IGNORECASE)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _extract_policy_rule_tag_keys(defn: dict[str, Any]) -> list[str]:
|
|
416
|
+
"""Walk policyRule looking for hard-coded `tags['<key>']` field references.
|
|
417
|
+
|
|
418
|
+
Many custom deny-style tag policies (e.g. `JV-Enforce Resource Group Tags`)
|
|
419
|
+
hard-code the enforced tag list in `policyRule.if.allOf[*].anyOf[*].field`
|
|
420
|
+
instead of exposing it as `tagName*` assignment parameters. Without this
|
|
421
|
+
extraction the downstream `tag_contract.required_tag_keys` silently falls
|
|
422
|
+
back to a sibling Modify policy's keys, producing transcription drift
|
|
423
|
+
(e.g. `tech-contact` vs `technical-contact`). See
|
|
424
|
+
`tools/scripts/diagnose-governance-tag-drift.md` for the full root-cause.
|
|
425
|
+
"""
|
|
426
|
+
rule = (defn.get("properties") or {}).get("policyRule") or {}
|
|
427
|
+
keys: list[str] = []
|
|
428
|
+
seen: set[str] = set()
|
|
429
|
+
stack: list[Any] = [rule.get("if")]
|
|
430
|
+
while stack:
|
|
431
|
+
node = stack.pop()
|
|
432
|
+
if isinstance(node, dict):
|
|
433
|
+
field = node.get("field")
|
|
434
|
+
if isinstance(field, str):
|
|
435
|
+
m = _TAG_FIELD_RE.match(field.strip())
|
|
436
|
+
if m:
|
|
437
|
+
key = m.group(1).strip()
|
|
438
|
+
if key and key not in seen:
|
|
439
|
+
seen.add(key)
|
|
440
|
+
keys.append(key)
|
|
441
|
+
stack.extend(node.values())
|
|
442
|
+
elif isinstance(node, list):
|
|
443
|
+
stack.extend(node)
|
|
444
|
+
return keys
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _is_defender_auto(assignment: dict[str, Any]) -> bool:
|
|
448
|
+
metadata = (assignment.get("properties") or {}).get("metadata") or {}
|
|
449
|
+
assigned_by = metadata.get("assignedBy")
|
|
450
|
+
if isinstance(assigned_by, str) and assigned_by.strip() in DEFENDER_ASSIGNED_BY_VALUES:
|
|
451
|
+
return True
|
|
452
|
+
created_by = metadata.get("createdBy")
|
|
453
|
+
if isinstance(created_by, dict):
|
|
454
|
+
name = (created_by.get("displayName") or "").strip()
|
|
455
|
+
if name in DEFENDER_ASSIGNED_BY_VALUES:
|
|
456
|
+
return True
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _build_exemption_map(exemptions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
461
|
+
"""Map lowercase policyAssignmentId → exemption summary."""
|
|
462
|
+
out: dict[str, dict[str, Any]] = {}
|
|
463
|
+
for ex in exemptions:
|
|
464
|
+
props = ex.get("properties") or {}
|
|
465
|
+
aid = (props.get("policyAssignmentId") or "").lower()
|
|
466
|
+
if not aid:
|
|
467
|
+
continue
|
|
468
|
+
out[aid] = {
|
|
469
|
+
"exemptionCategory": props.get("exemptionCategory"),
|
|
470
|
+
"expiresOn": props.get("expiresOn"),
|
|
471
|
+
"description": props.get("description"),
|
|
472
|
+
"policyDefinitionReferenceIds": props.get("policyDefinitionReferenceIds") or [],
|
|
473
|
+
}
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# --------------------------------------------------------------------------- #
|
|
478
|
+
# Envelope enrichment helpers (Fix E) #
|
|
479
|
+
# --------------------------------------------------------------------------- #
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _extract_tags_required(findings: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
483
|
+
"""Extract tag-enforcement findings into a flat tags_required list.
|
|
484
|
+
|
|
485
|
+
Looks for actual tag key names in assignment parameters (tagName, tagNames)
|
|
486
|
+
and in required_value. Falls back to category-based matching for tag policies
|
|
487
|
+
that don't use pathSemantics.
|
|
488
|
+
"""
|
|
489
|
+
seen: set[str] = set()
|
|
490
|
+
tags: list[dict[str, str]] = []
|
|
491
|
+
|
|
492
|
+
for f in findings:
|
|
493
|
+
is_tag = (
|
|
494
|
+
f.get("pathSemantics") == "tag-policy-non-property"
|
|
495
|
+
or (f.get("category") or "").lower() == "tags"
|
|
496
|
+
)
|
|
497
|
+
if not is_tag:
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
# Try to extract actual tag keys from assignment parameters.
|
|
501
|
+
params = f.get("assignment_parameters") or {}
|
|
502
|
+
tag_keys: list[str] = []
|
|
503
|
+
|
|
504
|
+
# Deny-style tag policies often hard-code keys in policyRule rather
|
|
505
|
+
# than exposing them via tagName* parameters. Prefer those — they are
|
|
506
|
+
# the authoritative enforced list.
|
|
507
|
+
for key in (f.get("extracted_tag_keys") or []):
|
|
508
|
+
if isinstance(key, str) and key:
|
|
509
|
+
tag_keys.append(key)
|
|
510
|
+
|
|
511
|
+
# Common parameter names for tag policies (Modify/Append style).
|
|
512
|
+
for pname in ("tagName", "tagname", "tag_name"):
|
|
513
|
+
val = params.get(pname)
|
|
514
|
+
if isinstance(val, str) and val:
|
|
515
|
+
tag_keys.append(val)
|
|
516
|
+
elif isinstance(val, list):
|
|
517
|
+
tag_keys.extend(str(v) for v in val if v)
|
|
518
|
+
|
|
519
|
+
for pname, val in params.items():
|
|
520
|
+
if re.fullmatch(r"tagName\d+", pname, re.IGNORECASE) and isinstance(val, str) and val:
|
|
521
|
+
tag_keys.append(val)
|
|
522
|
+
|
|
523
|
+
# Some policies use tagNames (plural) or listOfTagNames
|
|
524
|
+
for pname in ("tagNames", "listOfTagNames", "tagnames"):
|
|
525
|
+
val = params.get(pname)
|
|
526
|
+
if isinstance(val, list):
|
|
527
|
+
tag_keys.extend(str(v) for v in val if v)
|
|
528
|
+
|
|
529
|
+
source = f.get("policy_id", "")
|
|
530
|
+
policy_name = (f.get("display_name") or "").strip()
|
|
531
|
+
|
|
532
|
+
if tag_keys:
|
|
533
|
+
for key in tag_keys:
|
|
534
|
+
key = key.strip()
|
|
535
|
+
if key and key not in seen:
|
|
536
|
+
seen.add(key)
|
|
537
|
+
tags.append({
|
|
538
|
+
"name": key,
|
|
539
|
+
"source_policy": source,
|
|
540
|
+
"source_assignment": f.get("assignment_display_name", ""),
|
|
541
|
+
})
|
|
542
|
+
elif policy_name and policy_name not in seen:
|
|
543
|
+
# No specific tag key found — record the policy as a tag requirement
|
|
544
|
+
# so the agent knows to investigate further.
|
|
545
|
+
seen.add(policy_name)
|
|
546
|
+
tags.append({
|
|
547
|
+
"name": f"[unresolved: {policy_name}]",
|
|
548
|
+
"source_policy": source,
|
|
549
|
+
"source_assignment": f.get("assignment_display_name", ""),
|
|
550
|
+
"unresolved": "true",
|
|
551
|
+
})
|
|
552
|
+
return tags
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _is_location_constraint(defn: dict[str, Any]) -> bool:
|
|
556
|
+
props = defn.get("properties") or {}
|
|
557
|
+
if any(name.lower() in {"listofallowedlocations", "allowedlocations"} for name in (props.get("parameters") or {})):
|
|
558
|
+
return True
|
|
559
|
+
display_name = props.get("displayName") or ""
|
|
560
|
+
if not re.search(r"\b(?:locations?|regions?)\b", display_name, re.IGNORECASE):
|
|
561
|
+
return False
|
|
562
|
+
stack = [(props.get("policyRule") or {}).get("if")]
|
|
563
|
+
while stack:
|
|
564
|
+
node = stack.pop()
|
|
565
|
+
if isinstance(node, dict):
|
|
566
|
+
field = node.get("field")
|
|
567
|
+
if isinstance(field, str) and (field.lower() == "location" or field.lower().endswith("/location")):
|
|
568
|
+
return True
|
|
569
|
+
stack.extend(node.values())
|
|
570
|
+
elif isinstance(node, list):
|
|
571
|
+
stack.extend(node)
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _location_values(parameters: dict[str, Any], required_value: Any = None) -> list[str]:
|
|
576
|
+
locations: set[str] = set()
|
|
577
|
+
if isinstance(required_value, list):
|
|
578
|
+
locations.update(str(value) for value in required_value if value)
|
|
579
|
+
elif isinstance(required_value, str) and required_value:
|
|
580
|
+
locations.add(required_value)
|
|
581
|
+
for name, value in parameters.items():
|
|
582
|
+
if name.lower() in {"listofallowedlocations", "allowedlocations"} and isinstance(value, list):
|
|
583
|
+
locations.update(str(item) for item in value if item)
|
|
584
|
+
return sorted(locations)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _extract_allowed_locations(
|
|
588
|
+
findings: list[dict[str, Any]], location_constraints: list[dict[str, Any]] | None = None
|
|
589
|
+
) -> list[str]:
|
|
590
|
+
"""Extract allowed-location values from findings with location constraints.
|
|
591
|
+
|
|
592
|
+
Checks both required_value (from definition defaults) and assignment_parameters
|
|
593
|
+
(which carry the actual configured location list).
|
|
594
|
+
"""
|
|
595
|
+
locations: set[str] = set()
|
|
596
|
+
for f in findings:
|
|
597
|
+
dn = (f.get("display_name") or "").lower()
|
|
598
|
+
cat = (f.get("category") or "").lower()
|
|
599
|
+
is_location = "location" in dn or "region" in dn or "location" in cat
|
|
600
|
+
|
|
601
|
+
# Check required_value from definition
|
|
602
|
+
rv = f.get("required_value")
|
|
603
|
+
if is_location:
|
|
604
|
+
if isinstance(rv, list):
|
|
605
|
+
locations.update(str(v) for v in rv)
|
|
606
|
+
elif isinstance(rv, str) and rv:
|
|
607
|
+
locations.add(rv)
|
|
608
|
+
|
|
609
|
+
# Check assignment parameters for location lists
|
|
610
|
+
params = f.get("assignment_parameters") or {}
|
|
611
|
+
for pname in (
|
|
612
|
+
"listOfAllowedLocations", "allowedLocations",
|
|
613
|
+
"listofallowedlocations", "allowedlocations",
|
|
614
|
+
):
|
|
615
|
+
val = params.get(pname)
|
|
616
|
+
if isinstance(val, list):
|
|
617
|
+
locations.update(str(v) for v in val if v)
|
|
618
|
+
for constraint in location_constraints or []:
|
|
619
|
+
locations.update(str(value) for value in constraint.get("allowed_locations") or [] if value)
|
|
620
|
+
return sorted(locations)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# --------------------------------------------------------------------------- #
|
|
624
|
+
# L0 envelope helpers #
|
|
625
|
+
# #
|
|
626
|
+
# The canonical implementations live in render_governance.py so the cached #
|
|
627
|
+
# baseline path (render_cached_governance.py) and the live discovery path #
|
|
628
|
+
# (this module) share one signature algorithm. Re-export under the legacy #
|
|
629
|
+
# private names so existing call sites and tests keep working unchanged. #
|
|
630
|
+
# --------------------------------------------------------------------------- #
|
|
631
|
+
|
|
632
|
+
from render_governance import _build_discovery_metadata # noqa: E402,F401 — shared L0 helper
|
|
633
|
+
from render_governance import _completeness_signature # noqa: E402,F401 — shared L0 helper
|
|
634
|
+
from render_governance import _extract_management_groups # noqa: E402,F401 — shared L0 helper
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _self_check_assignments(
|
|
638
|
+
az_rest: Callable[[str], dict[str, Any]],
|
|
639
|
+
subscription_id: str,
|
|
640
|
+
expected_count: int,
|
|
641
|
+
) -> tuple[bool, int]:
|
|
642
|
+
"""Re-fetch the first page of policyAssignments and verify count.
|
|
643
|
+
|
|
644
|
+
Returns (ok, actual_count). On any network/parse failure, returns
|
|
645
|
+
(False, -1) so the caller can downgrade `discovery_status` to
|
|
646
|
+
`PARTIAL` without crashing.
|
|
647
|
+
"""
|
|
648
|
+
try:
|
|
649
|
+
url = (
|
|
650
|
+
f"{ARM}/subscriptions/{subscription_id}/providers/Microsoft.Authorization/"
|
|
651
|
+
f"policyAssignments?$filter=atScope()&api-version={API_ASSIGNMENTS}"
|
|
652
|
+
)
|
|
653
|
+
# We deliberately re-use _default_az_rest here via the caller's
|
|
654
|
+
# az_rest, which is the same surface that just produced the original
|
|
655
|
+
# list — any drift now is real (filter change, RBAC drop, paging bug).
|
|
656
|
+
first = az_rest(url) or {}
|
|
657
|
+
items = first.get("value") or []
|
|
658
|
+
return len(items) == expected_count, len(items)
|
|
659
|
+
except Exception: # noqa: BLE001 — self-check must never crash discovery
|
|
660
|
+
return False, -1
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# --------------------------------------------------------------------------- #
|
|
664
|
+
# Core #
|
|
665
|
+
# --------------------------------------------------------------------------- #
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _classify(effect: str) -> str:
|
|
669
|
+
if effect in BLOCKER_EFFECTS:
|
|
670
|
+
return "blocker"
|
|
671
|
+
if effect in AUTO_REMEDIATE_EFFECTS:
|
|
672
|
+
return "auto-remediate"
|
|
673
|
+
return "informational"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def discover(
|
|
677
|
+
subscription_id: str,
|
|
678
|
+
*,
|
|
679
|
+
project: str,
|
|
680
|
+
include_defender_auto: bool = False,
|
|
681
|
+
az_rest: Callable[[str], dict[str, Any]] | None = None,
|
|
682
|
+
verbose: bool = False,
|
|
683
|
+
) -> dict[str, Any]:
|
|
684
|
+
"""Run discovery and return the full envelope (not written to disk).
|
|
685
|
+
|
|
686
|
+
``verbose`` gates noisy informational stderr (per-assignment Defender
|
|
687
|
+
filter announcements). The count is always recorded in
|
|
688
|
+
``discovery_summary.defender_auto_filtered`` regardless.
|
|
689
|
+
"""
|
|
690
|
+
if az_rest is None:
|
|
691
|
+
# Resolve at call time so tests can monkeypatch `_default_az_rest`.
|
|
692
|
+
az_rest = _default_az_rest
|
|
693
|
+
base = f"{ARM}/subscriptions/{subscription_id}/providers/Microsoft.Authorization"
|
|
694
|
+
|
|
695
|
+
# Phase 1 — Parallel list calls for subscription-scope data + exemptions.
|
|
696
|
+
# The tenant-wide built-in definition list (~1500 items) is deliberately
|
|
697
|
+
# NOT fetched here; we fetch only the tenant definitions an assignment
|
|
698
|
+
# actually references (Phase 2). Saves a large, mostly-unused payload.
|
|
699
|
+
primary = _parallel_list(
|
|
700
|
+
az_rest,
|
|
701
|
+
{
|
|
702
|
+
"assignments": f"{base}/policyAssignments?$filter=atScope()&api-version={API_ASSIGNMENTS}",
|
|
703
|
+
"sub_defs": f"{base}/policyDefinitions?api-version={API_DEFINITIONS}",
|
|
704
|
+
"sub_sets": f"{base}/policySetDefinitions?api-version={API_DEFINITIONS}",
|
|
705
|
+
"exemptions": f"{base}/policyExemptions?$filter=atScope()&api-version={API_EXEMPTIONS}",
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
assignments = primary["assignments"]
|
|
709
|
+
defs: dict[str, dict[str, Any]] = {d["id"].lower(): d for d in primary["sub_defs"]}
|
|
710
|
+
sets: dict[str, dict[str, Any]] = {s["id"].lower(): s for s in primary["sub_sets"]}
|
|
711
|
+
exemptions = primary["exemptions"]
|
|
712
|
+
|
|
713
|
+
# Phase 2 — Fetch only the tenant-scoped initiatives and definitions that
|
|
714
|
+
# assignments actually reference. Initiatives come first because their
|
|
715
|
+
# members may themselves be tenant-scope definitions. We preserve the
|
|
716
|
+
# original-case ID for URL construction (ARM is case-insensitive but
|
|
717
|
+
# downstream consumers and test routers match by substring).
|
|
718
|
+
tenant_set_ids: dict[str, str] = {} # lowercase → original-case
|
|
719
|
+
tenant_def_ids: dict[str, str] = {}
|
|
720
|
+
for a in assignments:
|
|
721
|
+
pid_original = (a.get("properties") or {}).get("policyDefinitionId") or ""
|
|
722
|
+
pid = pid_original.lower()
|
|
723
|
+
if not pid or not pid.startswith("/providers/microsoft.authorization/"):
|
|
724
|
+
continue
|
|
725
|
+
if "/policysetdefinitions/" in pid and pid not in sets:
|
|
726
|
+
tenant_set_ids.setdefault(pid, pid_original)
|
|
727
|
+
elif "/policydefinitions/" in pid and pid not in defs:
|
|
728
|
+
tenant_def_ids.setdefault(pid, pid_original)
|
|
729
|
+
|
|
730
|
+
if tenant_set_ids:
|
|
731
|
+
ordered = sorted(tenant_set_ids.items())
|
|
732
|
+
fetched_sets = _parallel_fetch_items(
|
|
733
|
+
az_rest,
|
|
734
|
+
[f"{ARM}{orig}?api-version={API_DEFINITIONS}" for _, orig in ordered],
|
|
735
|
+
expected_ids=[orig for _, orig in ordered],
|
|
736
|
+
)
|
|
737
|
+
for s in fetched_sets:
|
|
738
|
+
if s:
|
|
739
|
+
sets.setdefault((s.get("id") or "").lower(), s)
|
|
740
|
+
for m in (s.get("properties") or {}).get("policyDefinitions") or []:
|
|
741
|
+
mid_original = (m.get("policyDefinitionId") or "")
|
|
742
|
+
mid = mid_original.lower()
|
|
743
|
+
if (
|
|
744
|
+
mid
|
|
745
|
+
and mid.startswith("/providers/microsoft.authorization/")
|
|
746
|
+
and mid not in defs
|
|
747
|
+
):
|
|
748
|
+
tenant_def_ids.setdefault(mid, mid_original)
|
|
749
|
+
|
|
750
|
+
if tenant_def_ids:
|
|
751
|
+
ordered = sorted(tenant_def_ids.items())
|
|
752
|
+
fetched_defs = _parallel_fetch_items(
|
|
753
|
+
az_rest,
|
|
754
|
+
[f"{ARM}{orig}?api-version={API_DEFINITIONS}" for _, orig in ordered],
|
|
755
|
+
expected_ids=[orig for _, orig in ordered],
|
|
756
|
+
)
|
|
757
|
+
for d in fetched_defs:
|
|
758
|
+
if d:
|
|
759
|
+
defs.setdefault((d.get("id") or "").lower(), d)
|
|
760
|
+
|
|
761
|
+
exemption_map = _build_exemption_map(exemptions)
|
|
762
|
+
|
|
763
|
+
# Assignment filtering (Defender auto-assignments).
|
|
764
|
+
filtered_defender: list[str] = []
|
|
765
|
+
kept_assignments: list[dict[str, Any]] = []
|
|
766
|
+
for a in assignments:
|
|
767
|
+
if _is_defender_auto(a) and not include_defender_auto:
|
|
768
|
+
filtered_defender.append(
|
|
769
|
+
(a.get("properties") or {}).get("displayName") or a.get("name") or a.get("id") or "<unknown>"
|
|
770
|
+
)
|
|
771
|
+
continue
|
|
772
|
+
kept_assignments.append(a)
|
|
773
|
+
|
|
774
|
+
for name in filtered_defender:
|
|
775
|
+
# Per-assignment filter announcements are noise by default — the
|
|
776
|
+
# aggregate count lives in discovery_summary.defender_auto_filtered.
|
|
777
|
+
# Surface the names only when --verbose is passed (Phase 6 fix).
|
|
778
|
+
if verbose:
|
|
779
|
+
print(f"filter: skipping Defender auto-assignment: {name}", file=sys.stderr)
|
|
780
|
+
|
|
781
|
+
assignment_inventory: list[dict[str, Any]] = []
|
|
782
|
+
findings: list[dict[str, Any]] = []
|
|
783
|
+
location_constraints: list[dict[str, Any]] = []
|
|
784
|
+
audit_count = 0
|
|
785
|
+
disabled_count = 0
|
|
786
|
+
|
|
787
|
+
for a in kept_assignments:
|
|
788
|
+
props = a.get("properties") or {}
|
|
789
|
+
display = props.get("displayName") or a.get("name") or a.get("id") or "<unknown>"
|
|
790
|
+
scope = props.get("scope") or ""
|
|
791
|
+
pid = (props.get("policyDefinitionId") or "").lower()
|
|
792
|
+
assignment_id = (a.get("id") or "").lower()
|
|
793
|
+
assignment_type = (
|
|
794
|
+
"management-group" if "/providers/microsoft.management/managementgroups/" in scope.lower() else "subscription"
|
|
795
|
+
)
|
|
796
|
+
assignment_params = {
|
|
797
|
+
key: (value or {}).get("value")
|
|
798
|
+
for key, value in (props.get("parameters") or {}).items()
|
|
799
|
+
if (value or {}).get("value") is not None
|
|
800
|
+
}
|
|
801
|
+
assignment_inventory.append(
|
|
802
|
+
{
|
|
803
|
+
"displayName": display,
|
|
804
|
+
"scope": scope,
|
|
805
|
+
"assignmentType": assignment_type,
|
|
806
|
+
"policyDefinitionId": pid,
|
|
807
|
+
"enforcementMode": props.get("enforcementMode") or "Default",
|
|
808
|
+
"parameters": assignment_params,
|
|
809
|
+
}
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
if not pid:
|
|
813
|
+
continue
|
|
814
|
+
|
|
815
|
+
# Resolve to member definitions (initiative → its members; policy → itself).
|
|
816
|
+
members: list[tuple[dict[str, Any], str | None, dict[str, Any]]] = []
|
|
817
|
+
if "/policysetdefinitions/" in pid and pid in sets:
|
|
818
|
+
initiative_defaults = {
|
|
819
|
+
key: (value or {}).get("defaultValue")
|
|
820
|
+
for key, value in ((sets[pid].get("properties") or {}).get("parameters") or {}).items()
|
|
821
|
+
if (value or {}).get("defaultValue") is not None
|
|
822
|
+
}
|
|
823
|
+
initiative_params = {**initiative_defaults, **assignment_params}
|
|
824
|
+
for m in (sets[pid].get("properties") or {}).get("policyDefinitions") or []:
|
|
825
|
+
mid = (m.get("policyDefinitionId") or "").lower()
|
|
826
|
+
if mid in defs:
|
|
827
|
+
member_params = {
|
|
828
|
+
key: _resolve_parameter_value((value or {}).get("value"), initiative_params)
|
|
829
|
+
for key, value in (m.get("parameters") or {}).items()
|
|
830
|
+
if _resolve_parameter_value((value or {}).get("value"), initiative_params) is not None
|
|
831
|
+
}
|
|
832
|
+
members.append((defs[mid], m.get("policyDefinitionReferenceId"), member_params))
|
|
833
|
+
elif pid in defs:
|
|
834
|
+
members.append((defs[pid], None, {}))
|
|
835
|
+
# Unknown definition id — nothing to emit; still counted in inventory.
|
|
836
|
+
|
|
837
|
+
for defn, member_ref_id, member_params in members:
|
|
838
|
+
effective_params = {**assignment_params, **member_params}
|
|
839
|
+
eff = _effect_of(defn, effective_params)
|
|
840
|
+
if eff is None:
|
|
841
|
+
continue
|
|
842
|
+
if eff == "Disabled":
|
|
843
|
+
disabled_count += 1
|
|
844
|
+
continue
|
|
845
|
+
if _is_location_constraint(defn):
|
|
846
|
+
location_constraints.append(
|
|
847
|
+
{
|
|
848
|
+
"policy_id": defn.get("id"),
|
|
849
|
+
"display_name": (defn.get("properties") or {}).get("displayName")
|
|
850
|
+
or defn.get("name")
|
|
851
|
+
or defn.get("id"),
|
|
852
|
+
"assignment_display_name": display,
|
|
853
|
+
"assignment_id": a.get("id"),
|
|
854
|
+
"scope": scope,
|
|
855
|
+
"effect": eff[:1].lower() + eff[1:],
|
|
856
|
+
"enforcement_mode": props.get("enforcementMode") or "Default",
|
|
857
|
+
"allowed_locations": _location_values(effective_params, _required_value(defn)),
|
|
858
|
+
}
|
|
859
|
+
)
|
|
860
|
+
if eff in {"Audit", "AuditIfNotExists"}:
|
|
861
|
+
audit_count += 1
|
|
862
|
+
continue
|
|
863
|
+
if eff not in RELEVANT_EFFECTS:
|
|
864
|
+
continue
|
|
865
|
+
|
|
866
|
+
rtypes = _resource_types(defn)
|
|
867
|
+
paths = _property_paths(defn, rtypes)
|
|
868
|
+
category = ((defn.get("properties") or {}).get("metadata") or {}).get(
|
|
869
|
+
"category"
|
|
870
|
+
) or "Uncategorized"
|
|
871
|
+
|
|
872
|
+
exemption = None
|
|
873
|
+
if assignment_id in exemption_map:
|
|
874
|
+
candidate = exemption_map[assignment_id]
|
|
875
|
+
ref_ids = candidate.get("policyDefinitionReferenceIds") or []
|
|
876
|
+
if not ref_ids or member_ref_id in ref_ids:
|
|
877
|
+
exemption = candidate
|
|
878
|
+
|
|
879
|
+
classification = _classify(eff)
|
|
880
|
+
if exemption is not None and classification == "blocker":
|
|
881
|
+
classification = "informational"
|
|
882
|
+
|
|
883
|
+
finding = {
|
|
884
|
+
"policy_id": defn.get("id"),
|
|
885
|
+
"display_name": (defn.get("properties") or {}).get("displayName")
|
|
886
|
+
or defn.get("name")
|
|
887
|
+
or defn.get("id"),
|
|
888
|
+
"effect": eff[:1].lower() + eff[1:], # lower-camel for schema enum
|
|
889
|
+
"scope": scope,
|
|
890
|
+
"assignment_display_name": display,
|
|
891
|
+
"assignment_id": a.get("id"),
|
|
892
|
+
"classification": classification,
|
|
893
|
+
"category": category,
|
|
894
|
+
"resource_types": rtypes,
|
|
895
|
+
"required_value": _required_value(defn),
|
|
896
|
+
"azurePropertyPath": paths.get("azurePropertyPath"),
|
|
897
|
+
"bicepPropertyPath": paths.get("bicepPropertyPath"),
|
|
898
|
+
"exemption": exemption,
|
|
899
|
+
"override": None,
|
|
900
|
+
}
|
|
901
|
+
# Carry assignment-level parameter values (tag keys, location lists).
|
|
902
|
+
if effective_params:
|
|
903
|
+
finding["assignment_parameters"] = effective_params
|
|
904
|
+
if paths.get("pathSemantics"):
|
|
905
|
+
finding["pathSemantics"] = paths["pathSemantics"]
|
|
906
|
+
# For Tags-category policies, extract enforced tag keys from the
|
|
907
|
+
# policyRule body. Deny-style tag policies often hard-code keys
|
|
908
|
+
# in `policyRule.if.allOf[*].anyOf[*].field` rather than exposing
|
|
909
|
+
# them as `tagName*` assignment parameters; without this we get
|
|
910
|
+
# silent drift to sibling Modify-policy keys.
|
|
911
|
+
if (
|
|
912
|
+
finding.get("pathSemantics") == "tag-policy-non-property"
|
|
913
|
+
or (finding.get("category") or "").lower() == "tags"
|
|
914
|
+
):
|
|
915
|
+
rule_tag_keys = _extract_policy_rule_tag_keys(defn)
|
|
916
|
+
if rule_tag_keys:
|
|
917
|
+
finding["extracted_tag_keys"] = rule_tag_keys
|
|
918
|
+
findings.append(finding)
|
|
919
|
+
|
|
920
|
+
blockers = sum(1 for f in findings if f["classification"] == "blocker")
|
|
921
|
+
auto_remediate = sum(1 for f in findings if f["classification"] == "auto-remediate")
|
|
922
|
+
exempted = sum(1 for f in findings if f["exemption"] is not None)
|
|
923
|
+
|
|
924
|
+
# L0 envelope: signature, scope, and self-check.
|
|
925
|
+
management_groups = _extract_management_groups(kept_assignments)
|
|
926
|
+
discovery_status = "COMPLETE"
|
|
927
|
+
# Re-fetch page 1 of assignments and verify the count matches the
|
|
928
|
+
# initial pull. Drift here indicates RBAC change, filter mutation, or
|
|
929
|
+
# a paginated REST surface change between the two calls.
|
|
930
|
+
self_check_ok, self_check_count = _self_check_assignments(az_rest, subscription_id, len(assignments))
|
|
931
|
+
if not self_check_ok:
|
|
932
|
+
discovery_status = "PARTIAL"
|
|
933
|
+
print(
|
|
934
|
+
f"self-check: policyAssignments count drift "
|
|
935
|
+
f"(expected={len(assignments)}, observed={self_check_count}); "
|
|
936
|
+
f"marking discovery_status=PARTIAL",
|
|
937
|
+
file=sys.stderr,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
discovered_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
941
|
+
discovery_metadata = _build_discovery_metadata(
|
|
942
|
+
findings=findings,
|
|
943
|
+
subscription_id=subscription_id,
|
|
944
|
+
management_groups=management_groups,
|
|
945
|
+
page_counts={
|
|
946
|
+
# `_parallel_list` collapses pagination, so we record the
|
|
947
|
+
# final item counts per REST surface. The self-check above
|
|
948
|
+
# validates these against a fresh page-1 fetch.
|
|
949
|
+
"policyAssignments": len(assignments),
|
|
950
|
+
"policyDefinitions": len(defs),
|
|
951
|
+
"policyExemptions": len(exemptions),
|
|
952
|
+
},
|
|
953
|
+
discovered_at=discovered_at,
|
|
954
|
+
discovery_status=discovery_status,
|
|
955
|
+
source="live",
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
envelope = {
|
|
959
|
+
"schema_version": "governance-constraints-v1",
|
|
960
|
+
"project": project,
|
|
961
|
+
"subscription_id": subscription_id,
|
|
962
|
+
"discovered_at": discovered_at,
|
|
963
|
+
"source": "azure-policy-rest-api",
|
|
964
|
+
"discovery_status": discovery_status,
|
|
965
|
+
"discovery_metadata": discovery_metadata,
|
|
966
|
+
"discovery_summary": {
|
|
967
|
+
"assignment_total": len(assignments),
|
|
968
|
+
"assignment_kept": len(kept_assignments),
|
|
969
|
+
"defender_auto_filtered": len(filtered_defender),
|
|
970
|
+
"subscription_scope_count": sum(
|
|
971
|
+
1 for a in kept_assignments if "/providers/microsoft.management/" not in (a.get("properties", {}).get("scope") or "").lower()
|
|
972
|
+
),
|
|
973
|
+
"management_group_inherited_count": sum(
|
|
974
|
+
1 for a in kept_assignments if "/providers/microsoft.management/" in (a.get("properties", {}).get("scope") or "").lower()
|
|
975
|
+
),
|
|
976
|
+
"blocker_count": blockers,
|
|
977
|
+
"auto_remediate_count": auto_remediate,
|
|
978
|
+
"informational_count": sum(
|
|
979
|
+
1 for f in findings if f["classification"] == "informational"
|
|
980
|
+
),
|
|
981
|
+
"audit_count": audit_count,
|
|
982
|
+
"disabled_count": disabled_count,
|
|
983
|
+
"exempted_count": exempted,
|
|
984
|
+
},
|
|
985
|
+
"assignment_inventory": assignment_inventory,
|
|
986
|
+
"findings": findings,
|
|
987
|
+
# Fix E: canonical aliases so the agent never needs to reshape JSON.
|
|
988
|
+
# `policies` is a reference alias of `findings` (not a copy).
|
|
989
|
+
"policies": findings,
|
|
990
|
+
# Authoritative reference set for the L3 policy-precheck subagent.
|
|
991
|
+
# Contains every policy definition ID we resolved during this run
|
|
992
|
+
# (subscription-scope + tenant-scope, both initiative members and
|
|
993
|
+
# standalone assignments), regardless of effect. The L3 subagent
|
|
994
|
+
# uses this to suppress false-positive "live policies missing from
|
|
995
|
+
# constraints" noise caused by initiative child policies that the
|
|
996
|
+
# findings[] filter (Deny + DeployIfNotExists + Modify only) drops.
|
|
997
|
+
# Values are lowercase resource IDs to match `az policy state list`
|
|
998
|
+
# output. See iac-common/references/policy-precheck-contract.md
|
|
999
|
+
# Phase 3 step 1 for the comparison contract.
|
|
1000
|
+
"member_policy_index": sorted(defs.keys()),
|
|
1001
|
+
"tags_required": _extract_tags_required(findings),
|
|
1002
|
+
"location_constraints": location_constraints,
|
|
1003
|
+
"allowed_locations": _extract_allowed_locations(findings, location_constraints),
|
|
1004
|
+
}
|
|
1005
|
+
return envelope
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# --------------------------------------------------------------------------- #
|
|
1009
|
+
# CLI #
|
|
1010
|
+
# --------------------------------------------------------------------------- #
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _emit_status(status: dict[str, Any]) -> None:
|
|
1014
|
+
"""Emit single-line JSON status as the first stdout line."""
|
|
1015
|
+
sys.stdout.write(json.dumps(status, separators=(",", ":")) + "\n")
|
|
1016
|
+
sys.stdout.flush()
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _emit_preview(envelope: dict[str, Any], limit: int = 20) -> None:
|
|
1020
|
+
"""Human-readable Markdown preview. NEVER re-ingest into LLM context."""
|
|
1021
|
+
summary = envelope["discovery_summary"]
|
|
1022
|
+
print(
|
|
1023
|
+
f"\n{envelope['subscription_id']}: {summary['assignment_kept']} kept "
|
|
1024
|
+
f"assignments (of {summary['assignment_total']}; "
|
|
1025
|
+
f"{summary['defender_auto_filtered']} Defender auto filtered); "
|
|
1026
|
+
f"{summary['blocker_count']} blockers, "
|
|
1027
|
+
f"{summary['auto_remediate_count']} auto-remediate, "
|
|
1028
|
+
f"{summary['exempted_count']} exempted."
|
|
1029
|
+
)
|
|
1030
|
+
print()
|
|
1031
|
+
print("| Effect | Classification | Category | Policy | Assignment |")
|
|
1032
|
+
print("|---|---|---|---|---|")
|
|
1033
|
+
for f in envelope["findings"][:limit]:
|
|
1034
|
+
print(
|
|
1035
|
+
f"| {f['effect']} | {f['classification']} | {f['category']} | "
|
|
1036
|
+
f"{f['display_name']} | {f['assignment_display_name']} |"
|
|
1037
|
+
)
|
|
1038
|
+
if len(envelope["findings"]) > limit:
|
|
1039
|
+
print(f"\n… {len(envelope['findings']) - limit} more in {envelope.get('_out_path')}")
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
# Renderer functions extracted to render_governance.py — import and re-export
|
|
1043
|
+
# as module-level aliases so existing tests (test_discover.py) that call
|
|
1044
|
+
# discover._extract_arch_resources(...) continue to pass unchanged.
|
|
1045
|
+
from render_governance import extract_arch_resources as _extract_arch_resources # noqa: F401
|
|
1046
|
+
from render_governance import emit_preview_md as _emit_preview_md # noqa: F401
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1050
|
+
parser = argparse.ArgumentParser(
|
|
1051
|
+
prog="discover",
|
|
1052
|
+
description="Deterministic Azure Policy discovery for 04g-Governance.",
|
|
1053
|
+
)
|
|
1054
|
+
parser.add_argument("--project", required=True)
|
|
1055
|
+
parser.add_argument("--out", required=True)
|
|
1056
|
+
parser.add_argument("--subscription", default="default")
|
|
1057
|
+
parser.add_argument("--refresh", action="store_true")
|
|
1058
|
+
parser.add_argument(
|
|
1059
|
+
"--arch",
|
|
1060
|
+
default=None,
|
|
1061
|
+
help="Path to 02-architecture-assessment.md. Enables policy→resource mapping in preview.md.",
|
|
1062
|
+
)
|
|
1063
|
+
parser.add_argument(
|
|
1064
|
+
"--include-defender-auto",
|
|
1065
|
+
action="store_true",
|
|
1066
|
+
help="Retain Defender-for-Cloud auto-assignments (filtered by default).",
|
|
1067
|
+
)
|
|
1068
|
+
parser.add_argument(
|
|
1069
|
+
"--verbose",
|
|
1070
|
+
action="store_true",
|
|
1071
|
+
help="Emit informational stderr (per-assignment Defender filter announcements). "
|
|
1072
|
+
"Aggregate counts are always recorded in discovery_summary regardless.",
|
|
1073
|
+
)
|
|
1074
|
+
args = parser.parse_args(argv)
|
|
1075
|
+
|
|
1076
|
+
out_path = Path(args.out)
|
|
1077
|
+
existing: dict[str, Any] | None = None
|
|
1078
|
+
if out_path.exists():
|
|
1079
|
+
try:
|
|
1080
|
+
candidate = json.loads(out_path.read_text())
|
|
1081
|
+
existing = candidate if isinstance(candidate, dict) else None
|
|
1082
|
+
except json.JSONDecodeError:
|
|
1083
|
+
existing = None
|
|
1084
|
+
preserved_security_exceptions = (
|
|
1085
|
+
existing.get("security_exceptions")
|
|
1086
|
+
if isinstance(existing, dict) and isinstance(existing.get("security_exceptions"), list)
|
|
1087
|
+
else []
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
# Cache short-circuit — reuse an existing COMPLETE snapshot unless --refresh.
|
|
1091
|
+
if out_path.exists() and not args.refresh:
|
|
1092
|
+
cached = existing
|
|
1093
|
+
if (
|
|
1094
|
+
isinstance(cached, dict)
|
|
1095
|
+
and cached.get("discovery_status") == "COMPLETE"
|
|
1096
|
+
and isinstance(cached.get("findings"), list)
|
|
1097
|
+
):
|
|
1098
|
+
summary = cached.get("discovery_summary") or {}
|
|
1099
|
+
status = {
|
|
1100
|
+
"status": "COMPLETE",
|
|
1101
|
+
"cache_hit": True,
|
|
1102
|
+
"assignment_total": summary.get("assignment_total", 0),
|
|
1103
|
+
"blockers": summary.get("blocker_count", 0),
|
|
1104
|
+
"auto_remediate": summary.get("auto_remediate_count", 0),
|
|
1105
|
+
"exempted": summary.get("exempted_count", 0),
|
|
1106
|
+
"out_path": str(out_path),
|
|
1107
|
+
}
|
|
1108
|
+
_emit_status(status)
|
|
1109
|
+
print(
|
|
1110
|
+
f"cache hit: reusing {out_path} ({len(cached.get('findings', []))} findings; "
|
|
1111
|
+
f"pass --refresh to re-discover)"
|
|
1112
|
+
)
|
|
1113
|
+
return 0
|
|
1114
|
+
|
|
1115
|
+
# Resolve subscription id.
|
|
1116
|
+
try:
|
|
1117
|
+
sub_id = _default_get_subscription() if args.subscription == "default" else args.subscription
|
|
1118
|
+
except subprocess.CalledProcessError as e:
|
|
1119
|
+
_emit_status({"status": "FAILED", "error": "subscription-resolution", "detail": str(e)})
|
|
1120
|
+
print("ERROR: could not resolve subscription via `az account show`.", file=sys.stderr)
|
|
1121
|
+
return 2
|
|
1122
|
+
|
|
1123
|
+
try:
|
|
1124
|
+
_default_check_auth()
|
|
1125
|
+
except subprocess.CalledProcessError as e:
|
|
1126
|
+
_emit_status(
|
|
1127
|
+
{
|
|
1128
|
+
"status": "FAILED",
|
|
1129
|
+
"error": "auth",
|
|
1130
|
+
"detail": (e.output or "").strip() if hasattr(e, "output") else str(e),
|
|
1131
|
+
}
|
|
1132
|
+
)
|
|
1133
|
+
print(
|
|
1134
|
+
"ERROR: Azure ARM token unavailable. Run `az login --use-device-code`.",
|
|
1135
|
+
file=sys.stderr,
|
|
1136
|
+
)
|
|
1137
|
+
return 2
|
|
1138
|
+
|
|
1139
|
+
try:
|
|
1140
|
+
envelope = discover(
|
|
1141
|
+
sub_id,
|
|
1142
|
+
project=args.project,
|
|
1143
|
+
include_defender_auto=args.include_defender_auto,
|
|
1144
|
+
verbose=args.verbose,
|
|
1145
|
+
)
|
|
1146
|
+
except subprocess.CalledProcessError as e:
|
|
1147
|
+
_emit_status(
|
|
1148
|
+
{
|
|
1149
|
+
"status": "FAILED",
|
|
1150
|
+
"error": "rest",
|
|
1151
|
+
"detail": str(e),
|
|
1152
|
+
}
|
|
1153
|
+
)
|
|
1154
|
+
print(f"ERROR: Azure REST call failed: {e}", file=sys.stderr)
|
|
1155
|
+
return 2
|
|
1156
|
+
except Exception as e: # pragma: no cover — defensive
|
|
1157
|
+
_emit_status({"status": "FAILED", "error": "unexpected", "detail": str(e)})
|
|
1158
|
+
print(f"ERROR: unexpected failure: {e}", file=sys.stderr)
|
|
1159
|
+
return 2
|
|
1160
|
+
|
|
1161
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1162
|
+
if preserved_security_exceptions:
|
|
1163
|
+
envelope["security_exceptions"] = preserved_security_exceptions
|
|
1164
|
+
envelope["_out_path"] = str(out_path)
|
|
1165
|
+
out_path.write_text(json.dumps(envelope, indent=2, sort_keys=False) + "\n")
|
|
1166
|
+
|
|
1167
|
+
# Fix C: emit sibling preview.md for agent annotation
|
|
1168
|
+
arch_resources = _extract_arch_resources(args.arch) if args.arch else None
|
|
1169
|
+
preview_path = _emit_preview_md(envelope, out_path, arch_resources=arch_resources)
|
|
1170
|
+
if preview_path:
|
|
1171
|
+
print(f"preview: wrote {preview_path}", file=sys.stderr)
|
|
1172
|
+
|
|
1173
|
+
summary = envelope["discovery_summary"]
|
|
1174
|
+
status = {
|
|
1175
|
+
"status": envelope["discovery_status"],
|
|
1176
|
+
"cache_hit": False,
|
|
1177
|
+
"assignment_total": summary["assignment_total"],
|
|
1178
|
+
"blockers": summary["blocker_count"],
|
|
1179
|
+
"auto_remediate": summary["auto_remediate_count"],
|
|
1180
|
+
"exempted": summary["exempted_count"],
|
|
1181
|
+
"out_path": str(out_path),
|
|
1182
|
+
}
|
|
1183
|
+
_emit_status(status)
|
|
1184
|
+
_emit_preview(envelope)
|
|
1185
|
+
return 0 if envelope["discovery_status"] == "COMPLETE" else 1
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
if __name__ == "__main__":
|
|
1189
|
+
sys.exit(main())
|