AzPosture.psm1

#Requires -Version 7.2
<#
  AzPosture: measure an Azure and Entra ID tenant against the AzPosture checklist,
  read-only, on your own machine, under your own sign-in. Nothing leaves the tenant;
  the output is a folder of JSON on your disk.

  Two planes:
    identity Microsoft Graph, custom PowerShell checks (Global Reader is enough)
    estate Azure Resource Graph KQL + a few ARM REST checks (Reader on subscriptions)

  The check scripts, the shared helpers and checks.json are assembled by build.py from
  the azure-toolkit source. Do not edit them here; edit the source and rebuild.
#>

Set-StrictMode -Off
$ErrorActionPreference = 'Stop'

$script:Root = $PSScriptRoot
$script:Catalog = $null
$script:Weight = @{ critical = 4; high = 3; medium = 2; low = 1 }

function Get-AzPostureCatalog {
    if (-not $script:Catalog) {
        $script:Catalog = Get-Content (Join-Path $script:Root 'checks.json') -Raw | ConvertFrom-Json
    }
    $script:Catalog
}

function Get-AzPostureCheck {
    <#
    .SYNOPSIS
      List the checks in the AzPosture checklist, optionally filtered.
    .EXAMPLE
      Get-AzPostureCheck -Plane Identity -Severity critical, high
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('Identity', 'Estate')] [string] $Plane,
        [string[]] $Framework,
        [ValidateSet('critical', 'high', 'medium', 'low')] [string[]] $Severity
    )
    $c = @((Get-AzPostureCatalog).checks)
    if ($Plane) { $p = if ($Plane -eq 'Identity') { 'graph' } else { 'arm' }; $c = @($c | Where-Object plane -eq $p) }
    if ($Framework) { $c = @($c | Where-Object { $Framework -contains $_.tab }) }
    if ($Severity) { $c = @($c | Where-Object { $Severity -contains $_.severity }) }
    $c | Select-Object key, label, tab, domain, severity, plane, min_role, licence
}

function Write-Marker {
    param([string] $Marker, [string] $Text = '', [ConsoleColor] $Color = 'Cyan')
    Write-Host ('AZPOSTURE::{0}' -f $Marker) -ForegroundColor $Color -NoNewline
    if ($Text) { Write-Host (' ' + $Text) } else { Write-Host '' }
}

function New-Finding {
    param($Meta, [string] $Title, [string] $Severity, [string] $Status, $Detail, $Fix, $ResourceId)
    [ordered]@{
        check_key = $Meta.key; code = $Meta.key; framework = $Meta.framework; domain = $Meta.domain
        tab = $Meta.tab; title = $Title; severity = $Severity; status = $Status
        detail = $Detail; fix = $Fix; resource_id = $ResourceId
        min_role = $Meta.min_role; licence = $Meta.licence
        impact = $Meta.impact; best_practice = $Meta.best_practice
    }
}

function Add-CheckFindings {
    <# Port of run-checks.ps1's merge: one check result -> zero or more findings. #>
    param($Findings, $Meta, $Res)
    if ($Res -and $Res.Status -eq 'skip') {
        [void]$Findings.Add((New-Finding $Meta "$($Res.Name)" 'info' 'skip' $Res.Detail $null $null)); return
    }
    $ev = if ($Res -and $Res.PSObject.Properties['Evidence'] -and $Res.Evidence) { "$($Res.Evidence)" } else { $null }
    $items = if ($Res) { @($Res.Items) } else { @() }
    if ($items.Count -eq 0) {
        $t = if ($ev) { "$($Meta.label)" } else { "$($Meta.label) (no issues)" }
        [void]$Findings.Add((New-Finding $Meta $t 'info' 'pass' $ev $null $null)); return
    }
    $cfix = if ($Res.Fix) { $Res.Fix } else { $Meta.fix }
    foreach ($it in $items) {
        $sev = if ($it.Severity) { $it.Severity } elseif ($Res.Severity) { $Res.Severity } else { $Meta.severity }
        $idet = if ($ev -and $it.Detail) { "$ev - $($it.Detail)" } elseif ($ev) { $ev } else { $it.Detail }
        $rid = if ($it.PSObject.Properties['ResourceId'] -and $it.ResourceId) { "$($it.ResourceId)" } else { $null }
        [void]$Findings.Add((New-Finding $Meta "$($it.Title)" $sev 'fail' $idet $cfix $rid))
    }
}

function Invoke-AzPosture {
    <#
    .SYNOPSIS
      Run the AzPosture checklist against a tenant, read-only, and write findings.json.
    .DESCRIPTION
      Signs in ONCE as you through Microsoft's own Azure PowerShell app (browser, or
      -UseDeviceCode). That app is pre-authorised in every tenant, so there is no consent
      dialog, and a Global Reader is enough. The same sign-in serves both planes: its
      Microsoft Graph token runs the identity checks, its Azure context runs the estate
      checks. Findings and summary are written to a timestamped folder; nothing is
      uploaded. A check that cannot run is reported as skipped with the reason.

      -GraphConsent uses Microsoft Graph Command Line Tools with explicit scopes instead,
      for tenants where Azure PowerShell sign-in is blocked by policy. That route needs
      an administrator's consent.
    .EXAMPLE
      Invoke-AzPosture
      Signs you in and runs against the tenant you land in.
    .EXAMPLE
      Invoke-AzPosture -TenantId contoso.onmicrosoft.com -Plane Identity -UseDeviceCode
    #>

    [CmdletBinding()]
    param(
        [string] $TenantId,
        [string] $OutputFolder = (Join-Path (Get-Location) 'azposture'),
        [ValidateSet('Both', 'Identity', 'Estate')] [string] $Plane = 'Both',
        [string[]] $Check,
        [switch] $UseDeviceCode,
        [switch] $GraphConsent,
        [switch] $PassThru
    )

    $cat = Get-AzPostureCatalog
    $all = @($cat.checks)
    if ($Check) {
        $unknown = @($Check | Where-Object { $all.key -notcontains $_ })
        if ($unknown.Count) { throw "Unknown check key(s): $($unknown -join ', '). See Get-AzPostureCheck." }
        $all = @($all | Where-Object { $Check -contains $_.key })
    }
    if ($Plane -eq 'Identity') { $all = @($all | Where-Object plane -eq 'graph') }
    elseif ($Plane -eq 'Estate') { $all = @($all | Where-Object plane -eq 'arm') }

    $custom = @($all | Where-Object engine -eq 'custom')
    $armGraph = @($all | Where-Object engine -eq 'arm-graph')
    $armScript = @($all | Where-Object engine -eq 'arm-script')
    $needsGraph = $custom.Count -gt 0
    $needsArm = ($armGraph.Count + $armScript.Count) -gt 0
    $scopes = @($custom | ForEach-Object { $_.scopes } | Where-Object { $_ } | Sort-Object -Unique)

    $started = Get-Date
    $out = Join-Path $OutputFolder $started.ToString('yyyy-MM-dd-HHmm')
    New-Item -ItemType Directory -Force -Path $out | Out-Null

    Write-Host ''
    Write-Host ('AzPosture {0} · checklist {1} · {2} checks selected' -f $cat.module_version, $cat.catalog_version, $all.Count) -ForegroundColor White
    Write-Host 'read-only · nothing is installed in the tenant · nothing is uploaded' -ForegroundColor DarkGray
    Write-Host ''

    $findings = [System.Collections.ArrayList]::new()

    # No -TenantId: take it from a session that already exists, otherwise from the
    # account's home tenant after the first sign-in. Nothing has to be looked up first.
    if (-not $TenantId) {
        $mg = if (Get-Module -ListAvailable Microsoft.Graph.Authentication) { Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue; Get-MgContext } else { $null }
        if ($mg -and $mg.TenantId) { $TenantId = $mg.TenantId }
        elseif ((Get-Module -ListAvailable Az.Accounts) -and (Import-Module Az.Accounts -PassThru -ErrorAction SilentlyContinue) -and (Get-AzContext)) { $TenantId = (Get-AzContext).Tenant.Id }
    }
    $isGuid = $TenantId -match '^[0-9a-fA-F-]{36}$'

    # ── one sign-in for both planes, through Microsoft's own Azure PowerShell app ────
    # That first-party app is pre-authorised in every tenant: no consent dialog, and a
    # Global Reader is enough. Its session yields a Microsoft Graph token carrying what
    # your sign-in can read; a check needing a scope the token lacks loud-skips. This is
    # the route the toolkit itself uses. -GraphConsent is the explicit-scopes alternative.
    $azOk = [bool](Get-Module -ListAvailable Az.Accounts)
    $azReady = $false
    if (($needsGraph -and -not $GraphConsent) -or $needsArm) {
        if (-not $azOk) {
            if ($needsGraph -and -not $GraphConsent) { throw 'Az.Accounts is required for sign-in. Install-Module Az.Accounts -Scope CurrentUser, then run again (or use -GraphConsent).' }
        } else {
            Import-Module Az.Accounts -ErrorAction Stop
            $actx = Get-AzContext
            $sameTenant = $actx -and ((-not $isGuid) -or ($actx.Tenant.Id -eq $TenantId))
            if ($actx -and $sameTenant) {
                Write-Marker 'CONNECTED' ('Azure as {0} (existing session)' -f $actx.Account.Id) 'Green'
            } else {
                Write-Marker 'SIGNIN' 'Azure · Microsoft''s own Azure PowerShell app, your identity, read-only use · no consent prompt'
                $tenantArg = if ($TenantId) { @{ Tenant = $TenantId } } else { @{} }
                if ($UseDeviceCode) { Connect-AzAccount @tenantArg -UseDeviceAuthentication -WarningAction SilentlyContinue | Out-Null }
                else { Connect-AzAccount @tenantArg -WarningAction SilentlyContinue | Out-Null }
                $actx = Get-AzContext
                Write-Marker 'CONNECTED' ('Azure as {0}' -f $actx.Account.Id) 'Green'
            }
            if (-not $TenantId) { $TenantId = $actx.Tenant.Id; $isGuid = $true }
            Write-Host (' tenant {0}' -f $TenantId) -ForegroundColor DarkGray
            $azReady = $true
        }
    }

    if ($needsGraph) {
        Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
        if ($azReady -and -not $GraphConsent) {
            $tok = Get-AzAccessToken -ResourceTypeName MSGraph -ErrorAction Stop
            $secure = if ($tok.Token -is [securestring]) { $tok.Token } else { ConvertTo-SecureString $tok.Token -AsPlainText -Force }
            Connect-MgGraph -AccessToken $secure -NoWelcome
            Write-Marker 'CONNECTED' 'Microsoft Graph · through the same sign-in' 'Green'
        } else {
            $ctx = Get-MgContext
            $have = if ($ctx) { @($ctx.Scopes) } else { @() }
            $missing = @($scopes | Where-Object { $have -notcontains $_ })
            $sameTenant = $ctx -and ((-not $isGuid) -or ($ctx.TenantId -eq $TenantId))
            if ($ctx -and $sameTenant -and $missing.Count -eq 0) {
                Write-Marker 'CONNECTED' ('Microsoft Graph as {0} (existing session)' -f $ctx.Account) 'Green'
            } else {
                Write-Marker 'SIGNIN' 'Microsoft Graph · Microsoft Graph Command Line Tools, explicit read-only scopes · needs admin consent'
                $tenantArg = if ($TenantId) { @{ TenantId = $TenantId } } else { @{} }
                try {
                    if ($UseDeviceCode) { Connect-MgGraph @tenantArg -Scopes $scopes -UseDeviceCode -NoWelcome }
                    else { Connect-MgGraph @tenantArg -Scopes $scopes -NoWelcome }
                } catch {
                    $t = if ($TenantId) { $TenantId } else { 'common' }
                    $consent = 'https://login.microsoftonline.com/{0}/v2.0/adminconsent?client_id=14d82eec-204b-4c2f-b7e8-296a70dab67e&scope={1}' -f $t, [uri]::EscapeDataString(($scopes | ForEach-Object { "https://graph.microsoft.com/$_" }) -join ' ')
                    Write-Marker 'BLOCKED' 'Microsoft Graph sign-in did not complete.' 'Red'
                    Write-Host ''
                    Write-Host 'The default route (no -GraphConsent) signs in through Azure PowerShell and needs no' -ForegroundColor Yellow
                    Write-Host 'consent at all; try that first. If this route is required, an administrator can' -ForegroundColor Yellow
                    Write-Host 'approve Microsoft Graph Command Line Tools once for these read-only scopes:' -ForegroundColor Yellow
                    Write-Host (' ' + $consent) -ForegroundColor Cyan
                    throw
                }
                if (-not $TenantId) { $TenantId = (Get-MgContext).TenantId; $isGuid = $true }
                Write-Marker 'CONNECTED' ('Microsoft Graph as {0}' -f (Get-MgContext).Account) 'Green'
            }
        }
        $got = @((Get-MgContext).Scopes)
        Write-Marker 'SCOPES' ($(if ($got.Count) { $got -join ', ' } else { 'as granted to your sign-in' })) 'DarkGray'
        . (Join-Path $script:Root 'lib' '_lib.ps1')
    }

    # ── estate plane: needs Az.ResourceGraph and a readable subscription, else loud skip ──
    $armReady = $false; $armReason = $null
    if ($needsArm) {
        if (-not $azReady) {
            $armReason = 'Az.Accounts is not installed on this machine. Install-Module Az.Accounts, Az.ResourceGraph -Scope CurrentUser, then run again.'
        } elseif (-not (Get-Module -ListAvailable Az.ResourceGraph)) {
            $armReason = 'Az.ResourceGraph is not installed on this machine. Install-Module Az.ResourceGraph -Scope CurrentUser, then run again.'
        } else {
            Import-Module Az.ResourceGraph -ErrorAction Stop
            . (Join-Path $script:Root 'lib' '_lib-arm.ps1')
            $subs = @(Get-Subs)
            if ($subs.Count -eq 0) {
                $armReason = 'No enabled Azure subscription is readable by this account. Assign the Reader role at subscription or management-group scope, then run again.'
            } else {
                $armReady = $true
                Write-Host (' {0} subscription(s) readable' -f $subs.Count) -ForegroundColor DarkGray
            }
        }
        if ($armReason) { Write-Marker 'SKIPPED' ('Azure estate · ' + $armReason) 'Yellow' }
    }

    # ── dispatch ───────────────────────────────────────────────────────────────
    $checksDir = Join-Path $script:Root 'checks'
    $i = 0
    foreach ($cc in $custom) {
        $i++; Write-Progress -Id 1 -Activity 'identity' -Status $cc.label -PercentComplete (100 * $i / [Math]::Max(1, $custom.Count))
        try { $res = & (Join-Path $checksDir $cc.script) }
        catch { [void]$findings.Add((New-Finding $cc "$($cc.label) (could not run)" $cc.severity 'skip' "$_" $null $null)); continue }
        Add-CheckFindings $findings $cc $res
    }
    Write-Progress -Id 1 -Activity 'identity' -Completed
    if ($custom.Count) { Write-Host ('identity {0} checks' -f $custom.Count) -ForegroundColor White }

    $estate = @($armScript) + @($armGraph)
    if ($estate.Count) {
        if (-not $armReady) {
            foreach ($cc in $estate) { [void]$findings.Add((New-Finding $cc "$($cc.label)" 'info' 'skip' $armReason $null $null)) }
            Write-Host ('estate {0} checks not assessed' -f $estate.Count) -ForegroundColor Yellow
        } else {
            $i = 0
            foreach ($cc in $armScript) {
                $i++; Write-Progress -Id 2 -Activity 'estate' -Status $cc.label -PercentComplete (100 * $i / [Math]::Max(1, $estate.Count))
                try { $res = & (Join-Path $checksDir $cc.script) }
                catch { [void]$findings.Add((New-Finding $cc "$($cc.label) (could not run)" $cc.severity 'skip' "$_" $null $null)); continue }
                Add-CheckFindings $findings $cc $res
            }
            foreach ($cc in $armGraph) {
                $i++; Write-Progress -Id 2 -Activity 'estate' -Status $cc.label -PercentComplete (100 * $i / [Math]::Max(1, $estate.Count))
                try {
                    $rows = Search-Graph $cc.kql
                    $items = @($rows | ForEach-Object {
                        $r = $_; $parts = @()
                        if ($r.PSObject.Properties['detail'] -and $r.detail) { $parts += "$($r.detail)" }
                        if ($r.PSObject.Properties['rtype'] -and $r.rtype) { $parts += (("$($r.rtype)" -split '/')[-1]) }
                        if ($r.PSObject.Properties['rg'] -and $r.rg) { $parts += "$($r.rg)" }
                        if ($r.PSObject.Properties['sub'] -and $r.sub) { $s = "$($r.sub)"; $parts += ('sub ' + $(if ($s.Length -ge 8) { $s.Substring(0, 8) } else { $s })) }
                        [pscustomobject]@{ Title = "$($r.title)"; Detail = ($parts -join ', ')
                            ResourceId = $(if ($r.PSObject.Properties['id'] -and $r.id) { "$($r.id)" } else { $null }) }
                    })
                    $res = [pscustomobject]@{ Name = $cc.label; Severity = $cc.severity; Fix = $cc.fix; Items = $items }
                    Add-CheckFindings $findings $cc $res
                } catch {
                    [void]$findings.Add((New-Finding $cc "$($cc.label) (could not run)" $cc.severity 'skip' "$_" $null $null))
                }
            }
            Write-Progress -Id 2 -Activity 'estate' -Completed
            Write-Host ('estate {0} checks' -f $estate.Count) -ForegroundColor White
        }
    }

    # ── roll up: one control per check, weighted 4/3/2/1 ───────────────────────
    $byKey = @{}
    foreach ($f in $findings) {
        if (-not $byKey.ContainsKey($f.check_key)) { $byKey[$f.check_key] = @{ status = 'pass'; severity = $null } }
        $c = $byKey[$f.check_key]
        if ($f.status -eq 'fail') { $c.status = 'fail' } elseif ($f.status -eq 'skip' -and $c.status -ne 'fail') { $c.status = 'skip' }
    }
    $meta = @{}; foreach ($m in $all) { $meta[$m.key] = $m }
    $wPass = 0; $wFail = 0; $nPass = 0; $nFail = 0; $nSkip = 0
    foreach ($k in $byKey.Keys) {
        $w = $script:Weight[$meta[$k].severity]; if (-not $w) { $w = 1 }
        switch ($byKey[$k].status) {
            'fail' { $wFail += $w; $nFail++ }
            'pass' { $wPass += $w; $nPass++ }
            default { $nSkip++ }
        }
    }
    $score = if (($wPass + $wFail) -gt 0) { [int][Math]::Round(100 * $wPass / ($wPass + $wFail)) } else { $null }
    $failItems = @($findings | Where-Object status -eq 'fail')
    $sev = [ordered]@{}
    foreach ($s in 'critical', 'high', 'medium', 'low') { $sev[$s] = @($failItems | Where-Object severity -eq $s).Count }

    $summary = [ordered]@{
        module_version = $cat.module_version; catalog_version = $cat.catalog_version; catalog_commit = $cat.catalog_commit
        tenant = $TenantId; started = $started.ToString('o'); finished = (Get-Date).ToString('o')
        plane = $Plane; checks_selected = $all.Count
        controls = [ordered]@{ pass = $nPass; fail = $nFail; skip = $nSkip }
        failing_items_by_severity = $sev
        score = $score
        estate_assessed = $armReady; estate_skip_reason = $armReason
    }

    $findings | ConvertTo-Json -Depth 8 | Out-File (Join-Path $out 'findings.json') -Encoding utf8
    $summary | ConvertTo-Json -Depth 6 | Out-File (Join-Path $out 'summary.json') -Encoding utf8

    Write-Host ''
    Write-Host (('{0} critical · {1} high · {2} medium · {3} low' -f $sev.critical, $sev.high, $sev.medium, $sev.low) + (' ({0} controls pass, {1} fail, {2} not assessed)' -f $nPass, $nFail, $nSkip)) -ForegroundColor White
    if ($null -ne $score) { Write-Host ('score {0} / 100 (85+ strong · 60 to 84 fair · under 60 at risk)' -f $score) -ForegroundColor White }
    Write-Marker 'DONE' '' 'Green'
    Write-Host ('wrote {0}' -f (Join-Path $out 'findings.json')) -ForegroundColor Green
    Write-Host (' {0}' -f (Join-Path $out 'summary.json')) -ForegroundColor Green
    Write-Host 'next open the report, then get in touch if you want a second opinion on it' -ForegroundColor DarkGray
    Write-Host ''

    if ($PassThru) { [pscustomobject]$summary }
}

Export-ModuleMember -Function Invoke-AzPosture, Get-AzPostureCheck