modules/Invoke-AlzQueries.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for alz-graph-queries ARG queries.
.DESCRIPTION
    Reads alz_additional_queries.json, runs each queryable item via Search-AzGraph,
    and returns an array of PSObjects with compliance results.
    Requires the Az.ResourceGraph module.
    Never throws: skips items that fail individually, warns on module absence.
 
    Source of truth for the query set is the canonical upstream repo
    https://github.com/martinopedal/alz-graph-queries. That repo owns the query
    schema (every query MUST emit a boolean `compliant` column) and the
    validation tooling. The local queries/alz/alz_additional_queries.json file in
    this repo is a cached snapshot of that upstream JSON; refresh it with
    scripts/Sync-AlzQueries.ps1 (issue #315, in flight) or by copying
    alz_additional_queries.json from a fresh clone of alz-graph-queries.
    See the README.md "ALZ queries" section for the full provenance chain.
.PARAMETER SubscriptionId
    Scope queries to a specific subscription.
.PARAMETER ManagementGroupId
    Scope queries to a management group.
.PARAMETER QueriesFile
    Path to alz_additional_queries.json (cached snapshot of the canonical
    martinopedal/alz-graph-queries upstream).
    Defaults to .\queries\alz\alz_additional_queries.json relative to this script.
#>

[CmdletBinding(DefaultParameterSetName = 'Subscription')]
param (
    [Parameter(Mandatory, ParameterSetName = 'Subscription')]
    [ValidateNotNullOrEmpty()]
    [string] $SubscriptionId,

    [Parameter(Mandatory, ParameterSetName = 'ManagementGroup')]
    [ValidateNotNullOrEmpty()]
    [string] $ManagementGroupId,

    [string] $QueriesFile = (Join-Path $PSScriptRoot '..' 'queries' 'alz' 'alz_additional_queries.json')
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }

$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) {
    function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason; if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }; return $line }
}
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}
if (-not (Get-Command Write-MissingToolNotice -ErrorAction SilentlyContinue)) {
    function Write-MissingToolNotice { param([string]$Tool, [string]$Message) Write-Warning $Message }
}

# Dot-source retry helper so Search-AzGraph calls transparently handle
# Azure Resource Graph throttling (429) and transient service errors.
. (Join-Path $PSScriptRoot 'shared' 'Retry.ps1')

if (-not (Get-Module -Name Az.ResourceGraph -ListAvailable -ErrorAction SilentlyContinue)) {
    Write-MissingToolNotice -Tool 'alz-queries' -Message "Az.ResourceGraph module not installed. Skipping ALZ queries. Run: Install-Module Az.ResourceGraph"
    return [PSCustomObject]@{
        Source   = 'alz-queries'
        SchemaVersion = '1.0'
        Status   = 'Skipped'
        Message  = 'Az.ResourceGraph not installed'
        Findings = @()
        Errors   = @()
    }
}

Import-Module Az.ResourceGraph -ErrorAction SilentlyContinue

if (-not (Test-Path $QueriesFile)) {
    Write-Warning "ALZ queries file not found at: $QueriesFile"
    Write-Warning "Clone https://github.com/martinopedal/alz-graph-queries and run with -QueriesFile path."
    return [PSCustomObject]@{
        Source   = 'alz-queries'
        SchemaVersion = '1.0'
        Status   = 'Skipped'
        Message  = 'Query file not found'
        Findings = @()
        Errors   = @()
    }
}

try {
    $data = Get-Content $QueriesFile -Raw | ConvertFrom-Json -ErrorAction Stop
} catch {
    $parseErr = New-FindingError -Source 'wrapper:alz-queries' -Category 'ParseError' -Reason "Failed to parse query file '$(Remove-Credentials -Text $QueriesFile)': $(Remove-Credentials -Text $_)" -Remediation 'Ensure the queries JSON file is valid JSON.'
    return (New-WrapperEnvelope -Source 'alz-queries' -Status 'Failed' -Message (Format-FindingErrorMessage $parseErr) -FindingErrors @($parseErr))
}
$queryable = $data.queries | Where-Object { $_.queryable -eq $true -and $_.graph }
$toolVersion = if ($data.PSObject.Properties['metadata'] -and $data.metadata -and $data.metadata.PSObject.Properties['version']) {
    [string]$data.metadata.version
} else {
    ''
}
$upstreamQueryFile = 'https://github.com/martinopedal/alz-graph-queries/blob/main/alz_additional_queries.json'

if ($queryable.Count -eq 0) {
    Write-Warning "No queryable items found in $QueriesFile"
    return [PSCustomObject]@{
        Source   = 'alz-queries'
        SchemaVersion = '1.0'
        Status   = 'Skipped'
        Message  = 'No queryable items found'
        Findings = @()
        Errors   = @()
    }
}

$graphParams = @{}
if ($PSCmdlet.ParameterSetName -eq 'ManagementGroup') {
    $graphParams['ManagementGroup'] = $ManagementGroupId
} else {
    $graphParams['Subscription'] = $SubscriptionId
}

$findings = [System.Collections.Generic.List[PSCustomObject]]::new()

foreach ($item in $queryable) {
    try {
        $rows = Invoke-WithRetry -MaxAttempts 3 -BaseDelaySec 2 -MaxDelaySec 30 -ScriptBlock {
            Search-AzGraph -Query $item.graph @graphParams -First 1000 -ErrorAction Stop
        }
        # Queries return a 'compliant' boolean column.
        # No rows means no resources in scope — treat as compliant.
        if ($rows.Count -eq 0) {
            $compliant = $true
        } else {
            $nonCompliantRows = @($rows | Where-Object {
                $p = $_.PSObject.Properties['compliant']
                $p -and ($p.Value -eq $false -or $p.Value -eq 0)
            })
            $compliant = $nonCompliantRows.Count -eq 0
        }

        # Extract resource ID from first non-compliant row if available
        $firstId = ''
        if (-not $compliant -and $nonCompliantRows.Count -gt 0) {
            $idProp = $nonCompliantRows[0].PSObject.Properties['id']
            if ($idProp -and $idProp.Value) { $firstId = [string]$idProp.Value }
        }

        $findings.Add([PSCustomObject]@{
            Id           = $item.guid
            Title        = $item.text
            Category     = $item.category
            Subcategory  = $item.subcategory
            Severity     = $item.severity
            Compliant    = $compliant
            Detail       = if ($compliant) {
                               if ($rows.Count -eq 0) { 'No resources in scope' } else { "All $($rows.Count) resource(s) compliant" }
                           } else {
                               "$($nonCompliantRows.Count) of $($rows.Count) resource(s) non-compliant"
                           }
            ResourceId   = $firstId
            LearnMoreUrl = ''
            QueryIntent  = if ($item.PSObject.Properties['queryIntent']) { [string]$item.queryIntent } else { '' }
            Description  = if ($item.PSObject.Properties['description']) { [string]$item.description } else { '' }
            QuerySource  = $upstreamQueryFile
            ToolVersion  = $toolVersion
        })
    } catch {
        Write-Warning "ALZ query failed for $($item.guid): $(Remove-Credentials -Text ([string]$_))"
        $findings.Add([PSCustomObject]@{
            Id           = $item.guid
            Title        = $item.text
            Category     = $item.category
            Subcategory  = $item.subcategory
            Severity     = $item.severity
            Compliant    = $false
            Detail       = (Remove-Credentials -Text "Query error: $([string]$_)")
            ResourceId   = ''
            LearnMoreUrl = ''
            QueryIntent  = if ($item.PSObject.Properties['queryIntent']) { [string]$item.queryIntent } else { '' }
            Description  = if ($item.PSObject.Properties['description']) { [string]$item.description } else { '' }
            QuerySource  = $upstreamQueryFile
            ToolVersion  = $toolVersion
        })
    }
}

return [PSCustomObject]@{
    Source   = 'alz-queries'
    SchemaVersion = '1.0'
    ToolVersion = $toolVersion
    Status   = 'Success'
    Message  = ''
    Findings = @($findings.ToArray())
    Errors   = @()
}