scripts/Generate-ToolCatalog.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Manifest-driven generator for the azure-analyzer tool catalogs. .DESCRIPTION Reads tools/tool-manifest.json (single source of truth) and emits two Markdown catalog pages: docs/consumer/tool-catalog.md consumer view (name, displayName, scope, provider, status, what-it-does, link to per-tool consumer doc when one exists) docs/contributor/tool-catalog.md contributor view (full manifest fields: provider, scope, normalizer, invokeMethod, requiredPermissionTier, platforms, install kind / command, report color/phase, upstream pin) Both files include a clear GENERATED header that warns against hand-edits and points back to this script and the manifest. The generator is idempotent. Running it twice on a clean tree produces no diff. CI uses -CheckOnly mode to fail when the committed catalogs are stale relative to the manifest. .PARAMETER ManifestPath Path to tools/tool-manifest.json. Defaults to the repo-relative location. .PARAMETER ConsumerOutPath Path for the consumer-facing catalog. Defaults to docs/reference/tool-catalog.md. .PARAMETER ContributorOutPath Path for the contributor-facing catalog. Defaults to docs/reference/tool-catalog-contributor.md. .PARAMETER CheckOnly Do not write files. Compare the generated content with what is on disk. Exits 0 when in sync, exits 1 when stale (and prints the offending paths). .EXAMPLE pwsh -File scripts/Generate-ToolCatalog.ps1 Regenerate both catalog pages. .EXAMPLE pwsh -File scripts/Generate-ToolCatalog.ps1 -CheckOnly Used by CI: fail if the committed catalog is stale. #> [CmdletBinding()] param( [string]$ManifestPath, [string]$ConsumerOutPath, [string]$ContributorOutPath, [switch]$CheckOnly ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-RepoRoot { $candidate = Split-Path -Parent $PSScriptRoot if (-not $candidate) { $candidate = (Get-Location).Path } return $candidate } $repoRoot = Get-RepoRoot if (-not $ManifestPath) { $ManifestPath = Join-Path $repoRoot 'tools/tool-manifest.json' } if (-not $ConsumerOutPath) { $ConsumerOutPath = Join-Path $repoRoot 'docs/reference/tool-catalog.md' } if (-not $ContributorOutPath) { $ContributorOutPath = Join-Path $repoRoot 'docs/reference/tool-catalog-contributor.md' } if (-not (Test-Path -LiteralPath $ManifestPath)) { throw "Manifest not found at: $ManifestPath" } $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json if (-not $manifest.tools) { throw "Manifest at $ManifestPath has no 'tools' array." } $schemaVersion = if ($manifest.PSObject.Properties.Name -contains 'schemaVersion') { $manifest.schemaVersion } else { 'unknown' } # Map of tool name -> consumer doc relative path (within docs/consumer). Add # entries here as per-tool consumer pages are written. Missing entries fall # back to the central scenarios/README index. $consumerDocLinks = @{ 'maester' = 'ai-triage.md' 'gitleaks' = 'gitleaks-pattern-tuning.md' 'ado-repos-secrets' = 'gitleaks-pattern-tuning.md' 'gh-actions-billing' = 'permissions/gh-actions-billing.md' 'ado-consumption' = 'permissions/ado-consumption.md' } # Short, consumer-friendly description per tool. Falls back to displayName when # absent so the catalog never ships an empty cell. $consumerBlurb = @{ 'azqr' = 'Azure best-practice review across reliability, security, cost, performance and operational excellence.' 'appinsights' = 'Application Insights telemetry signals: slow requests, dependency failures, and exception clusters via KQL.' 'kubescape' = 'Runtime posture for AKS clusters: misconfigurations, RBAC, network policies, vulnerabilities.' 'kube-bench' = 'CIS Kubernetes benchmark for AKS node hardening.' 'defender-for-cloud' = 'Pulls Microsoft Defender for Cloud Secure Score and active recommendations per subscription.' 'falco' = 'AKS runtime anomaly detection (syscall-level threat detection).' 'azure-cost' = 'Per-subscription monthly Azure spend pulled from the Consumption API.' 'finops' = 'FinOps signals: idle / orphaned resources that drive avoidable spend.' 'loadtesting' = 'Azure Load Testing reliability signals: failed runs, cancelled runs, and metric regressions.' 'psrule' = 'Microsoft PSRule for Azure: Well-Architected and best-practice rule baseline.' 'powerpipe' = 'Powerpipe control-pack benchmark results with framework-aware compliance metadata.' 'azgovviz' = 'Azure Governance Visualizer: management-group / subscription / RBAC / policy posture.' 'alz-queries' = 'ALZ Resource Graph queries: landing-zone compliance and drift detection.' 'wara' = 'Well-Architected Reliability Assessment workflow for production workloads.' 'maester' = 'Microsoft Entra (Identity) security baseline: conditional access, MFA, privileged roles.' 'scorecard' = 'OpenSSF Scorecard for repository supply-chain hygiene.' 'gh-actions-billing' = 'GitHub Actions billing and runner-minute telemetry for CI/CD cost optimization.' 'ado-connections' = 'Azure DevOps service-connection security: identity, scope, federation.' 'ado-pipelines' = 'Azure DevOps pipeline-security posture (variable groups, environments, approvals).' 'ado-consumption' = 'Azure DevOps pipeline consumption telemetry: runner share, duration regression, and failure waste.' 'ado-repos-secrets' = 'Secret scanning across Azure DevOps repositories via gitleaks.' 'ado-pipeline-correlator' = 'Correlates ADO pipeline runs with downstream Azure resource changes.' 'identity-correlator' = 'Correlates Entra identities, role assignments, and resource ownership.' 'identity-graph-expansion' = 'Expands the identity graph: cross-tenant B2B + service-principal-to-resource edges.' 'zizmor' = 'Static analysis for GitHub Actions workflow security risks.' 'gitleaks' = 'Secret scanning across local or remote git repositories.' 'trivy' = 'Vulnerability and IaC misconfiguration scanner for repos and container images.' 'bicep-iac' = 'Bicep IaC validation: lint, build, and best-practice checks.' 'terraform-iac' = 'Terraform IaC validation: tflint / tfsec / checkov-style checks.' 'infracost' = 'Pre-deploy cost estimate for Terraform and Bicep resources.' 'sentinel-incidents' = 'Pulls active Microsoft Sentinel incidents from a Log Analytics workspace.' 'sentinel-coverage' = 'Sentinel detection posture: analytic rules, watchlists, data connectors, hunting queries.' 'copilot-triage' = 'Optional Copilot-powered AI triage for finding prioritization (disabled by default).' } function Format-Status { param($enabled) if ($enabled) { 'Enabled' } else { 'Disabled' } } function Format-InstallKind { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'install' -or -not $tool.install) { return 'n/a' } $kind = $tool.install.kind $extra = '' switch ($kind) { 'cli' { if ($tool.install.PSObject.Properties.Name -contains 'command' -and $tool.install.command) { $extra = " (`"$($tool.install.command)`")" } } 'psmodule' { if ($tool.install.PSObject.Properties.Name -contains 'module' -and $tool.install.module) { $extra = " (`"$($tool.install.module)`")" } } 'gitclone' { if ($tool.install.PSObject.Properties.Name -contains 'repo' -and $tool.install.repo) { $extra = " (`"$($tool.install.repo)`")" } } default { } } return "$kind$extra" } function Format-Upstream { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'upstream' -or -not $tool.upstream) { return 'n/a' } $repo = if ($tool.upstream.PSObject.Properties.Name -contains 'repo') { $tool.upstream.repo } else { '' } $pin = if ($tool.upstream.PSObject.Properties.Name -contains 'currentPin') { $tool.upstream.currentPin } else { '' } if ($repo -and $pin) { return "$repo @ $pin" } if ($repo) { return $repo } if ($pin) { return $pin } return 'n/a' } function Format-Platforms { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'platforms' -or -not $tool.platforms) { return 'n/a' } return ($tool.platforms -join ', ') } function Format-Frameworks { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'frameworks' -or -not $tool.frameworks) { return '-' } return ($tool.frameworks -join ', ') } function Format-Color { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'report' -or -not $tool.report) { return '' } if ($tool.report.PSObject.Properties.Name -contains 'color') { return $tool.report.color } return '' } function Format-Phase { param($tool) if ($tool.PSObject.Properties.Name -notcontains 'report' -or -not $tool.report) { return '' } if ($tool.report.PSObject.Properties.Name -contains 'phase') { return [string]$tool.report.phase } return '' } function Get-ConsumerDocLink { param([string]$name) if ($consumerDocLinks.ContainsKey($name)) { return "[docs](./$($consumerDocLinks[$name]))" } return '-' } function Get-ConsumerBlurb { param($tool) if ($consumerBlurb.ContainsKey($tool.name)) { return $consumerBlurb[$tool.name] } return $tool.displayName } function New-ConsumerCatalog { param($tools, [string]$schema) $enabledTools = $tools | Where-Object { $_.enabled } | Sort-Object name $disabledTools = $tools | Where-Object { -not $_.enabled } | Sort-Object name $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Tool catalog (consumer view)') [void]$sb.AppendLine() [void]$sb.AppendLine('> GENERATED FROM tools/tool-manifest.json - do not edit by hand.') [void]$sb.AppendLine('> Regenerate with `pwsh -File scripts/Generate-ToolCatalog.ps1`.') [void]$sb.AppendLine('> Stale catalogs are blocked by the `tool-catalog-fresh` CI check.') [void]$sb.AppendLine() [void]$sb.AppendLine("Manifest schema version: ``$schema``") [void]$sb.AppendLine() [void]$sb.AppendLine("This page lists every analyzer tool azure-analyzer can run, what it covers, what scope it targets, and where to find consumer-focused setup notes when one exists. For the full manifest fields (normalizer, install kind, upstream pin, report color/phase) see [docs/contributor/tool-catalog.md](../contributor/tool-catalog.md).") [void]$sb.AppendLine() [void]$sb.AppendLine("**Total enabled:** $($enabledTools.Count). **Disabled / opt-in:** $($disabledTools.Count).") [void]$sb.AppendLine() [void]$sb.AppendLine('## Enabled by default') [void]$sb.AppendLine() [void]$sb.AppendLine('| Name | Display name | Scope | Provider | Frameworks | What it does | Docs |') [void]$sb.AppendLine('|---|---|---|---|---|---|---|') foreach ($t in $enabledTools) { $row = '| `{0}` | {1} | {2} | {3} | {4} | {5} | {6} |' -f ` $t.name, $t.displayName, $t.scope, $t.provider, (Format-Frameworks $t), (Get-ConsumerBlurb $t), (Get-ConsumerDocLink $t.name) [void]$sb.AppendLine($row) } if ($disabledTools.Count -gt 0) { [void]$sb.AppendLine() [void]$sb.AppendLine('## Disabled / opt-in') [void]$sb.AppendLine() [void]$sb.AppendLine('These tools are wired but turned off in the manifest. Enable them by setting `enabled: true` in `tools/tool-manifest.json` or via `tools/install-config.json`.') [void]$sb.AppendLine() [void]$sb.AppendLine('| Name | Display name | Scope | Provider | Frameworks | What it does |') [void]$sb.AppendLine('|---|---|---|---|---|---|') foreach ($t in $disabledTools) { $row = '| `{0}` | {1} | {2} | {3} | {4} | {5} |' -f ` $t.name, $t.displayName, $t.scope, $t.provider, (Format-Frameworks $t), (Get-ConsumerBlurb $t) [void]$sb.AppendLine($row) } } [void]$sb.AppendLine() [void]$sb.AppendLine('## Scope reference') [void]$sb.AppendLine() [void]$sb.AppendLine('| Scope | Targets |') [void]$sb.AppendLine('|---|---|') [void]$sb.AppendLine('| `subscription` | Single Azure subscription (`-SubscriptionId`). |') [void]$sb.AppendLine('| `managementGroup` | Azure Management Group (`-ManagementGroupId`). |') [void]$sb.AppendLine('| `tenant` | Entra ID tenant (`-TenantId`, requires `Connect-MgGraph`). |') [void]$sb.AppendLine('| `repository` | GitHub or ADO repo (`-Repository` or `-RepoPath`). |') [void]$sb.AppendLine('| `ado` | Azure DevOps organization (`-AdoOrg`). |') [void]$sb.AppendLine('| `workspace` | Log Analytics / Sentinel workspace (`-SentinelWorkspaceId`). |') [void]$sb.AppendLine() return $sb.ToString() } function New-ContributorCatalog { param($tools, [string]$schema) $sortedTools = $tools | Sort-Object name $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# Tool catalog (contributor view)') [void]$sb.AppendLine() [void]$sb.AppendLine('> GENERATED FROM tools/tool-manifest.json - do not edit by hand.') [void]$sb.AppendLine('> Regenerate with `pwsh -File scripts/Generate-ToolCatalog.ps1`.') [void]$sb.AppendLine('> Stale catalogs are blocked by the `tool-catalog-fresh` CI check.') [void]$sb.AppendLine() [void]$sb.AppendLine("Manifest schema version: ``$schema``") [void]$sb.AppendLine() [void]$sb.AppendLine('Full manifest projection: every wired tool with normalizer, invocation, install, report, and upstream metadata. For the consumer-friendly subset see [docs/consumer/tool-catalog.md](../consumer/tool-catalog.md). To onboard a new tool follow [adding-a-tool.md](./adding-a-tool.md).') [void]$sb.AppendLine() [void]$sb.AppendLine("**Total tools registered:** $($sortedTools.Count).") [void]$sb.AppendLine() [void]$sb.AppendLine('## Registration matrix') [void]$sb.AppendLine() [void]$sb.AppendLine('| Name | Display name | Type | Provider | Scope | Status | Tier | Platforms | Frameworks |') [void]$sb.AppendLine('|---|---|---|---|---|---|---|---|---|') foreach ($t in $sortedTools) { $type = if ($t.PSObject.Properties.Name -contains 'type') { $t.type } else { '' } $tier = if ($t.PSObject.Properties.Name -contains 'requiredPermissionTier') { [string]$t.requiredPermissionTier } else { '' } $row = '| `{0}` | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} |' -f ` $t.name, $t.displayName, $type, $t.provider, $t.scope, (Format-Status $t.enabled), $tier, (Format-Platforms $t), (Format-Frameworks $t) [void]$sb.AppendLine($row) } [void]$sb.AppendLine() [void]$sb.AppendLine('## Invocation') [void]$sb.AppendLine() [void]$sb.AppendLine('| Name | Normalizer | Invoke | Script / module | Required params |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($t in $sortedTools) { $normalizer = if ($t.PSObject.Properties.Name -contains 'normalizer') { $t.normalizer } else { '' } $invoke = if ($t.PSObject.Properties.Name -contains 'invokeMethod') { $t.invokeMethod } else { '' } $script = if ($t.PSObject.Properties.Name -contains 'script') { $t.script } else { '' } $required = if ($t.PSObject.Properties.Name -contains 'requiredParams' -and $t.requiredParams) { ($t.requiredParams -join ', ') } else { '-' } $row = '| `{0}` | `{1}` | {2} | `{3}` | {4} |' -f $t.name, $normalizer, $invoke, $script, $required [void]$sb.AppendLine($row) } [void]$sb.AppendLine() [void]$sb.AppendLine('## Install + upstream') [void]$sb.AppendLine() [void]$sb.AppendLine('| Name | Install kind | Upstream pin | Report color | Phase |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($t in $sortedTools) { $row = '| `{0}` | {1} | {2} | `{3}` | {4} |' -f ` $t.name, (Format-InstallKind $t), (Format-Upstream $t), (Format-Color $t), (Format-Phase $t) [void]$sb.AppendLine($row) } [void]$sb.AppendLine() [void]$sb.AppendLine('## Notes') [void]$sb.AppendLine() [void]$sb.AppendLine('- `tier` is `requiredPermissionTier` (0..6, see [docs/contributor/ARCHITECTURE.md](./ARCHITECTURE.md#permission-tiers-tier-06) for the tier breakdown).') [void]$sb.AppendLine('- `phase` is the report grouping hint used by `New-HtmlReport.ps1` and `New-MdReport.ps1`.') [void]$sb.AppendLine('- `report.color` is consumed by the per-source bar chart in the HTML report.') [void]$sb.AppendLine('- `install.kind` is one of `psmodule`, `cli`, `gitclone`, `none` and is enforced by `modules/shared/Installer.ps1`.') [void]$sb.AppendLine('- `upstream` drives the weekly auto-update loop; `pinType` and `currentPin` are managed by `tools/Update-ToolPins.ps1`.') [void]$sb.AppendLine() return $sb.ToString() } function Convert-ToLfText { param([string]$Text) return ($Text -replace "`r`n", "`n") } function Write-OrCheck { param( [string]$Path, [string]$Content, [switch]$CheckOnly ) $normalized = Convert-ToLfText $Content if ($CheckOnly) { if (-not (Test-Path -LiteralPath $Path)) { Write-Host "[stale] missing: $Path" return $false } $current = Convert-ToLfText (Get-Content -LiteralPath $Path -Raw) if ($current -ne $normalized) { Write-Host "[stale] $Path differs from manifest projection" return $false } Write-Host "[ok] $Path" return $true } $dir = Split-Path -Parent $Path if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Write LF-only, no BOM (consistent diff on Windows + Linux CI). [System.IO.File]::WriteAllText($Path, $normalized, [System.Text.UTF8Encoding]::new($false)) Write-Host "[wrote] $Path" return $true } $consumerContent = New-ConsumerCatalog $manifest.tools $schemaVersion $contributorContent = New-ContributorCatalog $manifest.tools $schemaVersion $okConsumer = Write-OrCheck -Path $ConsumerOutPath -Content $consumerContent -CheckOnly:$CheckOnly $okContributor = Write-OrCheck -Path $ContributorOutPath -Content $contributorContent -CheckOnly:$CheckOnly if ($CheckOnly -and (-not ($okConsumer -and $okContributor))) { Write-Host '' Write-Host 'Tool catalog is stale relative to tools/tool-manifest.json.' Write-Host 'Run: pwsh -File scripts/Generate-ToolCatalog.ps1' exit 1 } exit 0 |