packs/zero-trust-assessment/tests/Test.ZTA.ps1

function Get-MtZtaInstance {
    <#
    .SYNOPSIS
    Returns one ZTA instance per result of the Microsoft Zero Trust Assessment.

    .DESCRIPTION
    Instance source of the ZTA family. Reads the assessment's ZeroTrustAssessmentReport.json from the
    ZeroTrustAssessment.ReportPath setting when one is set (import), otherwise runs the assessment in a child
    PowerShell process (collect), then returns one instance per test result. Skips the whole family, with the
    reason, when neither is possible.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    $reportPath = [string](Get-MtSetting -Name 'ZeroTrustAssessment.ReportPath' -Default '')
    $collected = $null
    if ([string]::IsNullOrWhiteSpace($reportPath)) {
        $collected = Invoke-MtZtaCollection
        $reportPath = $collected.ReportPath
    }

    try {
        $report = Read-MtZtaReport -Path $reportPath
        $context = Get-MgContext
        if ($context -and $context.TenantId -and $report.TenantId -and $report.TenantId -ne $context.TenantId) {
            Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason "The Zero Trust Assessment report at $reportPath is for tenant $($report.TenantId), but Maester is connected to tenant $($context.TenantId)."
        }

        $maxAgeDays = [int](Get-MtSetting -Name 'ZeroTrustAssessment.MaxReportAgeDays' -Default 7)
        # ConvertFrom-Json reads the ISO date as a DateTime already; text is parsed invariantly, never in the current culture.
        $executedAt = [datetime]::MinValue
        $hasDate = if ($report.ExecutedAt -is [datetime]) { $executedAt = $report.ExecutedAt; $true }
        elseif ($report.ExecutedAt -is [datetimeoffset]) { $executedAt = $report.ExecutedAt.UtcDateTime; $true }
        else {
            [datetime]::TryParse([string]$report.ExecutedAt, [cultureinfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$executedAt)
        }
        if ($maxAgeDays -gt 0 -and $hasDate -and $executedAt.ToUniversalTime() -lt [datetime]::UtcNow.AddDays(-$maxAgeDays)) {
            Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason "The Zero Trust Assessment report at $reportPath was written on $($executedAt.ToString('yyyy-MM-dd')), more than $maxAgeDays days ago. Run Invoke-ZtAssessment again, or raise the ZeroTrustAssessment.MaxReportAgeDays setting."
        }

        Add-MtZtaReportAttachment -ReportPath $reportPath
        ConvertTo-MtZtaInstance -Report $report
    } finally {
        # A collection into a temporary folder leaves nothing behind: the export holds raw tenant data.
        if ($collected -and $collected.Temporary -and (Test-Path -LiteralPath $collected.OutputPath)) {
            Remove-Item -LiteralPath $collected.OutputPath -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

function Add-MtZtaReportAttachment {
    <#
    .SYNOPSIS
    Embeds the assessment's own HTML report in the Maester report, opened with one click.

    .DESCRIPTION
    The assessment writes ZeroTrustAssessmentReport.html in the folder given to Invoke-ZtAssessment -Path, beside
    its zt-export folder. It is read before a temporary output folder is deleted. Nothing is attached when the
    report is not there (an imported report file copied on its own), when the ZeroTrustAssessment.EmbedReport
    setting is false, or on a Maester version without Add-MtReportAttachment.
    #>

    [CmdletBinding()]
    param(
        # The report file, the assessment's output folder, or its zt-export folder.
        [Parameter(Mandatory)] [string] $ReportPath
    )

    if (-not [bool](Get-MtSetting -Name 'ZeroTrustAssessment.EmbedReport' -Default $true)) { return }
    if (-not (Get-Command -Name Add-MtReportAttachment -ErrorAction Ignore)) { return }

    $folder = if (Test-Path -LiteralPath $ReportPath -PathType Container) { $ReportPath } else { Split-Path -Path $ReportPath -Parent }
    if (-not $folder) { return }
    $html = @(
        (Join-Path $folder 'ZeroTrustAssessmentReport.html'),
        (Join-Path (Split-Path -Path $folder -Parent) 'ZeroTrustAssessmentReport.html')
    ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1
    if ($html) {
        Add-MtReportAttachment -Name 'Zero Trust Assessment' -Path $html
    } else {
        Write-Verbose "No ZeroTrustAssessmentReport.html next to $ReportPath, so the assessment's own report is not embedded."
    }
}

function Read-MtZtaReport {
    <#
    .SYNOPSIS
    Reads ZeroTrustAssessmentReport.json from a file, or from a folder the assessment wrote.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        # The report file, the assessment's output folder, or its zt-export folder.
        [Parameter(Mandatory)] [string] $Path
    )

    $file = $Path
    if (Test-Path -LiteralPath $Path -PathType Container) {
        $file = @(
            (Join-Path $Path 'ZeroTrustAssessmentReport.json'),
            (Join-Path -Path $Path -ChildPath 'zt-export' -AdditionalChildPath 'ZeroTrustAssessmentReport.json')
        ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
    }
    if (-not $file -or -not (Test-Path -LiteralPath $file -PathType Leaf)) {
        Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason "No Zero Trust Assessment report was found at $Path. Point the ZeroTrustAssessment.ReportPath setting at ZeroTrustAssessmentReport.json, or clear it to let Maester run the assessment."
    }

    $report = Get-Content -LiteralPath $file -Raw -Encoding utf8 | ConvertFrom-Json -Depth 20
    if (-not $report.PSObject.Properties['Tests']) {
        throw "$file is not a Zero Trust Assessment report: it has no Tests list."
    }
    $report
}

function ConvertTo-MtZtaInstance {
    <#
    .SYNOPSIS
    Turns the Tests of a Zero Trust Assessment report into ZTA instances.

    .DESCRIPTION
    The instance suffix is the assessment's test ID. A test that writes several rows under one ID (the Defender
    for Cloud recommendations do) gets a suffix made from a hash of each row's title, so a row keeps its ID from
    one run to the next. Rows that share a title as well are numbered in report order. Planned tests, which the assessment lists but has not built, are left out.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        # A report read by Read-MtZtaReport.
        [Parameter(Mandatory)] $Report
    )

    $severity = @{ High = 'High'; Medium = 'Medium'; Low = 'Low' }
    $rows = @($Report.Tests | Where-Object { $_ -and $_.TestStatus -ne 'Planned' -and $_.TestSkipped -ne 'UnderConstruction' })
    $repeated = @($rows | Group-Object -Property { [string]$_.TestId } | Where-Object Count -gt 1 | ForEach-Object Name)
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        foreach ($row in $rows) {
            $id = ([string]$row.TestId -replace '[^A-Za-z0-9._-]', '_') -replace '^[^A-Za-z0-9]+', ''
            if (-not $id) { continue }
            if ([string]$row.TestId -in $repeated) {
                $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes([string]$row.TestTitle))
                $id = '{0}.{1}' -f $id, ([System.Convert]::ToHexString($hash).Substring(0, 10).ToLowerInvariant())
            }
            $unique = $id
            for ($n = 2; -not $seen.Add($unique); $n++) { $unique = "$id.$n" }
            $id = $unique
            $tags = @('ZeroTrust') + @(@($row.TestPillar) | Where-Object { $_ } | ForEach-Object { "ZeroTrust.$_" })
            $instance = [ordered]@{
                Id    = $id
                Title = ([string]$row.TestTitle -replace '\s+', ' ').Trim()
                Tag   = $tags
                Data  = $row
            }
            if ($severity.ContainsKey([string]$row.TestRisk)) { $instance.Severity = $severity[[string]$row.TestRisk] }
            [pscustomobject]$instance
        }
    } finally {
        $sha.Dispose()
    }
}

function Invoke-MtZtaCollection {
    <#
    .SYNOPSIS
    Runs the Microsoft Zero Trust Assessment in a child PowerShell process and returns where it wrote its report.

    .DESCRIPTION
    The assessment loads its own pinned versions of the Microsoft Graph, Exchange Online and Az modules and of
    MSAL, which cannot share a process with the versions Maester has already loaded. So it runs in its own
    pwsh process, signs in there with Connect-ZtAssessment, and Maester reads the report it writes.

    The child's output is read line by line and shown with Write-MtTestOutput: in the dashboard of an
    interactive run it scrolls in a box in the main column, elsewhere it is written to the host. The
    assessment runs with -ShowLog (the ZeroTrustAssessment.ShowLog setting, on by default), so the box shows
    the tests it runs, and its progress bars become one line at the bottom of the box that updates in place.

    The child also reports where the assessment is, read from the progress the module keeps for its own
    progress page and from the progress logs it writes: the stage, the exports or tests done of how many,
    the ones running, and each one that fails. It writes these as ##maester[progress] and ##maester[error]
    lines, which Write-MtTestOutput shows at the top of the box (see Write-MtTestProgress).

    The child signs in to the tenant Maester is connected to. When Maester is connected to Graph app-only with a
    certificate, the child uses the same app and certificate; otherwise it signs in interactively. The
    ZeroTrustAssessment.Connect setting passes any other Connect-ZtAssessment parameters.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    $module = Get-Module -Name ZeroTrustAssessment -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1
    if (-not $module) {
        Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'The ZeroTrustAssessment module is not installed. Run Install-Module ZeroTrustAssessment -Scope CurrentUser, or set ZeroTrustAssessment.ReportPath to a report from an earlier run.'
    }

    $outputPath = [string](Get-MtSetting -Name 'ZeroTrustAssessment.OutputPath' -Default '')
    $temporary = [string]::IsNullOrWhiteSpace($outputPath)
    if ($temporary) {
        $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("maester-zta-{0}" -f [guid]::NewGuid().ToString('n'))
    } elseif ((Test-Path -LiteralPath $outputPath) -and @(Get-ChildItem -LiteralPath $outputPath -Force).Count -gt 0) {
        # Invoke-ZtAssessment deletes a non-empty output folder, so each run gets its own folder under it.
        $outputPath = Join-Path $outputPath (Get-Date -Format 'yyyyMMdd-HHmmss')
    }
    $null = New-Item -ItemType Directory -Path $outputPath -Force

    $connect = [ordered]@{}
    $context = Get-MgContext
    $connectSetting = Get-MtSetting -Name 'ZeroTrustAssessment.Connect' -Default $null
    if ($context) {
        if ($context.TenantId) { $connect.TenantId = $context.TenantId }
        if ([string]$context.AuthType -eq 'AppOnly') {
            # Connected with a certificate object, the context names it in Certificate rather than CertificateThumbprint.
            $thumbprint = [string]$context.CertificateThumbprint
            if (-not $thumbprint -and $context.PSObject.Properties['Certificate'] -and $context.Certificate) { $thumbprint = [string]$context.Certificate.Thumbprint }
            if ($thumbprint) {
                $connect.ClientId = $context.ClientId
                $connect.Certificate = $thumbprint
            } elseif (-not $connectSetting) {
                # Connect-ZtAssessment signs in app-only with a certificate only; without one the child would wait for a person to sign in.
                Remove-Item -LiteralPath $outputPath -Recurse -Force -ErrorAction SilentlyContinue
                Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'Maester is connected app-only without a certificate (a client secret, a managed identity or workload identity federation), and the Zero Trust Assessment signs in app-only only with a certificate. Connect Maester with a certificate in the certificate store, set ZeroTrustAssessment.Connect, or run Invoke-ZtAssessment as its own step and set ZeroTrustAssessment.ReportPath.'
                return
            }
        }
    }
    if ($connectSetting) {
        foreach ($property in $connectSetting.PSObject.Properties) { $connect[$property.Name] = $property.Value }
    }

    $assessment = [ordered]@{
        Path             = $outputPath
        Pillar           = [string](Get-MtSetting -Name 'ZeroTrustAssessment.Pillar' -Default 'All')
        NoBrowser        = $true
        DisableTelemetry = [bool](Get-MtSetting -Name 'ZeroTrustAssessment.DisableTelemetry' -Default $true)
        ShowLog          = [bool](Get-MtSetting -Name 'ZeroTrustAssessment.ShowLog' -Default $true)
    }
    $days = Get-MtSetting -Name 'ZeroTrustAssessment.Days' -Default $null
    if ($days) { $assessment.Days = [int]$days }
    $tests = @(Get-MtSetting -Name 'ZeroTrustAssessment.Tests' -Default @()) | Where-Object { $_ }
    if ($tests.Count -gt 0) { $assessment.Tests = [string[]]$tests }

    # The assessment reads emergency access accounts from its own configuration file, in the same shape as
    # Maester's GlobalSettings.EmergencyAccessAccounts.
    $emergencyAccounts = @(@(Get-MtSetting -Name 'EmergencyAccessAccounts' -Default @()) | Where-Object { $_ })
    $configurationFile = $null
    if ($emergencyAccounts.Count -gt 0) {
        $configurationFile = Join-Path ([System.IO.Path]::GetTempPath()) ("maester-zta-config-{0}.json" -f [guid]::NewGuid().ToString('n'))
        # @() keeps one account a JSON array.
        @{ GlobalSettings = @{ EmergencyAccessAccounts = @($emergencyAccounts) } } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $configurationFile -Encoding utf8
        $assessment.ConfigurationFile = $configurationFile
    }

    $request = Join-Path ([System.IO.Path]::GetTempPath()) ("maester-zta-request-{0}.json" -f [guid]::NewGuid().ToString('n'))
    # The titles and pillars of the assessment's tests, from the pack's catalog, for the tests the progress lists
    # by ID, and how many tests each pillar has, for the dashboard's list of pillars. A test in two pillars counts
    # in its first.
    $titles = @{}
    $pillars = @{}
    $pillarTotals = [ordered]@{}
    $catalogPath = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath 'catalog.json'
    if (Test-Path -LiteralPath $catalogPath) {
        foreach ($entry in @((Get-Content -LiteralPath $catalogPath -Raw | ConvertFrom-Json).Tests)) {
            $titles[[string]$entry.TestId] = [string]$entry.Title
            $pillar = @($entry.Pillar | Where-Object { $_ }) | Select-Object -First 1
            if (-not $pillar -or ($assessment.Pillar -and $assessment.Pillar -ne 'All' -and $pillar -ne $assessment.Pillar)) { continue }
            $pillars[[string]$entry.TestId] = [string]$pillar
            $pillarTotals[[string]$pillar] = 1 + [int]$pillarTotals[[string]$pillar]
        }
    }
    $pillarList = @(foreach ($name in $pillarTotals.Keys) { [ordered]@{ Name = $name; Total = $pillarTotals[$name] } })
    [ordered]@{ ModulePath = $module.Path; Connect = $connect; Assessment = $assessment; Titles = $titles; Pillars = $pillars; PillarTotals = $pillarList } |
        ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $request -Encoding utf8

    # The child reads its parameters from the request file, so nothing from the config is put on a command line.
    # Its output is read by Maester as lines of plain text. Its progress bars cannot be drawn there, so each
    # change of one is written as a line that starts with a marker (the module's own Write-Progress calls find
    # this function before the cmdlet), which Maester shows as one line that updates in place.
    $progressMarker = "[maester-progress-$([guid]::NewGuid().ToString('n').Substring(0, 8))] "
    $script = @'
param([string] $RequestPath, [string] $ProgressMarker)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$PSStyle.OutputRendering = 'PlainText'
function global:Write-Progress {
    # Not an advanced function: a parameter it does not declare goes to $args instead of failing the call.
    param([string] $Activity, [string] $Status, [int] $Id, [int] $PercentComplete = -1, [string] $CurrentOperation, [switch] $Completed)
    if ($Completed) {
        # An empty line of progress takes the line away.
        if ($global:__MtZtaProgress) { [Console]::Out.WriteLine($ProgressMarker) }
        $global:__MtZtaProgress = $null
        return
    }
    $text = @($Activity, $Status, $CurrentOperation) | Where-Object { $_ }
    $text = $text -join ': '
    if ($PercentComplete -ge 0) { $text += " ($PercentComplete%)" }
    if ($text -and $text -ne $global:__MtZtaProgress) {
        $global:__MtZtaProgress = $text
        [Console]::Out.WriteLine($ProgressMarker + $text)
    }
}
$request = Get-Content -LiteralPath $RequestPath -Raw | ConvertFrom-Json -AsHashtable
Import-Module -Name $request.ModulePath
# The assessment keeps where it is (its stage, the items done, what each export or test is doing) in a
# dictionary that its own progress page reads, and writes a line to a progress log for each export, database
# table and test that starts, ends or fails, with its time. A second runspace reads both every second and
# reports them to Maester as ##maester lines, which Maester shows at the top of its output box: the stages, the
# counts, and each export or test with its status and time, the way the dashboard shows Maester's own tests.
# Another version of the module that keeps neither reports nothing.
$progressState = $null
try { $progressState = Get-PSFDynamicContentObject -Name 'ZtAssessment.ProgressState' -ErrorAction Stop } catch { $progressState = $null }
# The result of each test, as the tests finish: the dashboard shows the counts in its Results panel and blocks,
# and by pillar where it lists the products.
$resultState = $null
try { $resultState = Get-PSFDynamicContentObject -Name 'ZtAssessment.TestResultDetails' -ErrorAction Stop } catch { $resultState = $null }
$reporter = [powershell]::Create()
$null = $reporter.AddScript({
    param($State, [string] $OutputPath, $Titles, $ResultState, $Pillars, $PillarTotals)
    $culture = [System.Globalization.CultureInfo]::InvariantCulture
    # The stages of the assessment, short enough to draw all six on one line.
    $stages = @('Export', 'Tests', 'Tenant info', 'Results', 'Report data', 'HTML report')
    $kinds = [ordered]@{ '1-export_progress.log' = 'export'; '2-database_progress.log' = 'database'; '3-test_progress.log' = 'tests' }
    $offsets = @{}
    $items = @{}
    $reported = [System.Collections.Generic.HashSet[string]]::new()
    $lastKind = $null
    $last = $null
    while ($true) {
        try {
            $now = Get-Date
            # The newest folder of logs is this run's: read the lines added to its progress logs since the last time.
            $logs = Get-ChildItem -LiteralPath (Join-Path $OutputPath 'zt-export' -AdditionalChildPath 'logs') -Directory -ErrorAction SilentlyContinue |
                Sort-Object -Property Name | Select-Object -Last 1
            if ($logs) {
                foreach ($file in $kinds.Keys) {
                    $path = Join-Path $logs.FullName $file
                    if (-not [System.IO.File]::Exists($path)) { continue }
                    $stream = [System.IO.FileStream]::new($path, 'Open', 'Read', 'ReadWrite, Delete')
                    try {
                        $at = [long]$offsets[$path]
                        if ($stream.Length -le $at) { continue }
                        $null = $stream.Seek($at, 'Begin')
                        $bytes = [byte[]]::new($stream.Length - $at)
                        $read = $stream.Read($bytes, 0, $bytes.Length)
                        $text = [System.Text.Encoding]::UTF8.GetString($bytes, 0, $read)
                        # Only whole lines: the rest is read again when it has ended.
                        $end = $text.LastIndexOf("`n")
                        if ($end -lt 0) { continue }
                        $offsets[$path] = $at + [System.Text.Encoding]::UTF8.GetByteCount($text.Substring(0, $end + 1))
                        $kind = $kinds[$file]
                        foreach ($line in $text.Substring(0, $end).Split("`n")) {
                            if ($line -notmatch '^(\S+ \S+)\s+(STARTED|WAITING|INPROGRESS|COMPLETED|FAILED|ERROR|TIMEDOUT)\s+(\S+)(?:\s+(\d\d:\d\d:\d\d\.\d+))?\s*(.*)$') { continue }
                            $action = $Matches[2]
                            $name = $Matches[3]
                            $duration = $Matches[4]
                            $message = $Matches[5].Trim() -replace '^Error:\s*', ''
                            $time = [datetime]::MinValue
                            $null = [datetime]::TryParseExact($Matches[1], 'yyyy-MM-dd HH:mm:ss.fff', $culture, 'None', [ref]$time)
                            $lastKind = $kind
                            $key = "$kind|$name"
                            $item = $items[$key]
                            if (-not $item -or $action -eq 'STARTED' -and $item.Status -notin 'Running', 'Waiting') {
                                $item = @{ Kind = $kind; Name = $name; Start = $time; End = $null; Status = 'Running'; Detail = ''; Seconds = -1 }
                                $items[$key] = $item
                                $null = $reported.Remove($key)
                            }
                            switch ($action) {
                                'WAITING' { $item.Status = 'Waiting'; $item.Detail = if ($message -like 'dependency:*') { "waiting for $($message.Substring(11))" } else { $message } }
                                'INPROGRESS' { $item.Status = 'Running'; if ($message) { $item.Detail = $message } }
                                default {
                                    if ($action -eq 'STARTED') { break }
                                    $item.End = $time
                                    $item.Status = switch ($action) { 'COMPLETED' { 'Passed' } 'TIMEDOUT' { 'TimedOut' } default { 'Failed' } }
                                    $item.Detail = if ($action -eq 'COMPLETED') { '' } else { $message }
                                    $span = [timespan]::Zero
                                    $item.Seconds = if ($duration -and [timespan]::TryParse($duration, $culture, [ref]$span)) { $span.TotalSeconds }
                                    elseif ($item.Start -ne [datetime]::MinValue) { ($time - $item.Start).TotalSeconds } else { -1 }
                                    if ($action -ne 'COMPLETED') {
                                        $what = switch ($kind) { 'export' { 'Export' } 'database' { 'Import of' } default { 'Test' } }
                                        $label = if ($kind -eq 'tests' -and $Titles -and $Titles[$name]) { "$name $($Titles[$name])" } else { $name }
                                        $verb = if ($action -eq 'TIMEDOUT') { 'timed out' } else { 'failed' }
                                        [Console]::Out.WriteLine('##maester[error] ' + "$what $label $verb" + $(if ($message) { ": $message" } else { '' }))
                                    }
                                }
                            }
                        }
                    } finally { $stream.Dispose() }
                }
            }

            $value = if ($State) { $State.Value } else { $null }
            $stage = if ($value -and $value.ContainsKey('_stage')) { [string]$value['_stage'] } else { $null }
            $kind = switch ($stage) { 'export' { 'export' } 'database' { 'database' } 'tests' { 'tests' } $null { $lastKind } default { $null } }
            if (-not $stage -and -not $kind) { Start-Sleep -Milliseconds 1000; continue }

            # The exports or tests of the stage: those running, then those that finished, the newest last.
            $mine = @($items.Values | Where-Object { $_.Kind -eq $kind })
            $running = @($mine | Where-Object { $_.Status -in 'Running', 'Waiting' } | Sort-Object -Property { $_.Start })
            $finished = @($mine | Where-Object { $_.Status -notin 'Running', 'Waiting' } | Sort-Object -Property { $_.End })
            $shown = [System.Collections.Generic.List[object]]::new()
            foreach ($item in $running) {
                $detail = $item.Detail
                $worker = $null
                if ($value -and $item.Status -eq 'Running' -and $value.TryGetValue("worker_$($item.Name)", [ref]$worker) -and $worker -and $worker.Detail) { $detail = [string]$worker.Detail }
                $shown.Add(@{ Item = $item; Detail = $detail; Seconds = if ($item.Start -ne [datetime]::MinValue) { [math]::Floor(($now - $item.Start).TotalSeconds) } else { -1 } })
            }
            # The last eight that finished, and every one that finished since the last report, so that none is missed.
            $recent = @($finished | Select-Object -Last 8)
            foreach ($item in $finished) {
                $key = "$($item.Kind)|$($item.Name)"
                if ($reported.Add($key) -or $recent -contains $item) { $shown.Add(@{ Item = $item; Detail = $item.Detail; Seconds = $item.Seconds }) }
            }
            # A step of the stage that writes no log, such as creating the database, is shown from the dictionary.
            if ($running.Count -eq 0 -and $value) {
                foreach ($key in @($value.Keys)) {
                    $worker = $null
                    if ($key -like 'worker_*' -and $value.TryGetValue($key, [ref]$worker) -and $worker -and [string]$worker.Status -eq 'Running') {
                        $started = [datetime]::MinValue
                        $seconds = if ([datetime]::TryParse([string]$worker.StartedAt, $culture, 'RoundtripKind', [ref]$started)) { [math]::Floor(($now - $started.ToLocalTime()).TotalSeconds) } else { -1 }
                        $shown.Add(@{ Item = @{ Kind = ''; Name = [string]$worker.Name; Status = 'Running' }; Detail = [string]$worker.Detail; Seconds = $seconds })
                    }
                }
            }
            $list = foreach ($entry in $shown) {
                $item = $entry.Item
                if ($item.Kind -eq 'tests') { $id = "ZTA.$($item.Name)"; $title = if ($Titles) { [string]$Titles[$item.Name] } else { '' } }
                else { $id = $item.Name; $title = '' }
                [ordered]@{ id = $id; title = $title; status = $item.Status; detail = [string]$entry.Detail; seconds = $entry.Seconds }
            }

            $counted = $value -and $stage -in 'export', 'tests' -and [int]$value['_totalItems'] -gt 0
            $snapshot = [ordered]@{
                stages = $stages
                stage = if ($value) { [string]$value['_stageName'] } else { switch ($kind) { 'export' { 'Exporting Tenant Data' } 'database' { 'Importing Data into Database' } default { 'Running Tests' } } }
                stageNumber = if ($value) { [int]$value['_stageNumber'] } elseif ($kind -eq 'tests') { 2 } else { 1 }
                stageCount = $stages.Count
                completed = if ($counted) { [int]$value['_completedItems'] } else { @($finished | Where-Object { $_.Status -eq 'Passed' }).Count }
                total = if ($counted) { [int]$value['_totalItems'] } else { 0 }
                failed = if ($counted) { [int]$value['_failedItems'] } else { @($finished | Where-Object { $_.Status -ne 'Passed' }).Count }
                running = if ($counted) { [int]$value['_inProgressItems'] } else { $running.Count }
                items = @($list)
            }
            if ($stage -eq 'done') { $snapshot.stageNumber = $stages.Count + 1; $snapshot.items = @(); $snapshot.running = 0 }

            # The results of the tests that finished, in all and by pillar, from the stage of the tests on.
            $details = if ($ResultState) { $ResultState.Value } else { $null }
            if ($details -and ($kind -eq 'tests' -or $details.Count -gt 0)) {
                $groups = [ordered]@{}
                foreach ($entry in @($PillarTotals)) {
                    if ($entry -and $entry.Name) { $groups[[string]$entry.Name] = [ordered]@{ name = [string]$entry.Name; total = [int]$entry.Total; passed = 0; failed = 0; error = 0; investigate = 0; skipped = 0; running = 0 } }
                }
                $all = [ordered]@{ name = 'Zero Trust Assessment'; total = 0; passed = 0; failed = 0; error = 0; investigate = 0; skipped = 0; running = 0 }
                $pillarOf = {
                    param([string] $id, $detail)
                    if ($Pillars -and $Pillars[$id]) { return [string]$Pillars[$id] }
                    $own = @($detail.TestPillar | Where-Object { $_ }) | Select-Object -First 1
                    if ($own) { [string]$own } else { 'Other' }
                }
                foreach ($id in @($details.Keys)) {
                    $detail = $null
                    if (-not $details.TryGetValue($id, [ref]$detail) -or -not $detail) { continue }
                    $result = switch ([string]$detail.TestStatus) { 'Passed' { 'passed' } 'Failed' { 'failed' } 'Investigate' { 'investigate' } 'Skipped' { 'skipped' } default { 'error' } }
                    $all[$result]++
                    $pillar = & $pillarOf ([string]$id) $detail
                    if (-not $groups.Contains($pillar)) { $groups[$pillar] = [ordered]@{ name = $pillar; total = 0; passed = 0; failed = 0; error = 0; investigate = 0; skipped = 0; running = 0 } }
                    $groups[$pillar][$result]++
                }
                if ($kind -eq 'tests') {
                    foreach ($item in $running) {
                        $all.running++
                        $pillar = & $pillarOf ([string]$item.Name) $null
                        if ($groups.Contains($pillar)) { $groups[$pillar].running++ }
                    }
                }
                $all.total = if ($kind -eq 'tests' -and $value -and [int]$value['_totalItems'] -gt 0) { [int]$value['_totalItems'] } else { 0 }
                $snapshot.results = $all
                $snapshot.groupLabel = 'Zero Trust Pillars'
                $snapshot.groups = @($groups.Values | Where-Object { $_.total -gt 0 -or ($_.passed + $_.failed + $_.error + $_.investigate + $_.skipped + $_.running) -gt 0 })
            }
            $json = $snapshot | ConvertTo-Json -Compress -Depth 4
            if ($json -ne $last) {
                $last = $json
                [Console]::Out.WriteLine('##maester[progress] ' + $json)
            }
        } catch {
            # Reporting progress must never stop the assessment.
            $null = $_
        }
        Start-Sleep -Milliseconds 1000
    }
}).AddArgument($progressState).AddArgument([string]$request.Assessment.Path).AddArgument($request.Titles).AddArgument($resultState).AddArgument($request.Pillars).AddArgument(@($request.PillarTotals))
$reporting = $reporter.BeginInvoke()
try {
    $connect = $request.Connect
    Connect-ZtAssessment @connect
    $assessment = $request.Assessment
    Invoke-ZtAssessment @assessment
} finally {
    $reporter.Stop()
    $reporter.Dispose()
}
'@

    $scriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ("maester-zta-run-{0}.ps1" -f [guid]::NewGuid().ToString('n'))
    Set-Content -LiteralPath $scriptPath -Value $script -Encoding utf8

    $startInfo = [System.Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path)
    foreach ($argument in '-NoProfile', '-File', $scriptPath, '-RequestPath', $request, '-ProgressMarker', $progressMarker) { $startInfo.ArgumentList.Add($argument) }
    $startInfo.UseShellExecute = $false
    # The child must not write to the console itself: while Maester's dashboard has the screen, that would
    # draw over it. Maester shows what it writes instead.
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $startInfo.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false)
    $startInfo.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false)
    # The last lines it wrote, for the error when it fails.
    $tail = [System.Collections.Generic.Queue[string]]::new()
    $show = {
        param([string] $Line)
        $progress = $Line.StartsWith($progressMarker)
        if ($progress) {
            $Line = $Line.Substring($progressMarker.Length)
        } elseif ($Line.Trim() -and -not $Line.StartsWith('##maester[progress]')) {
            $tail.Enqueue([Maester.Engine.MtConsoleRenderer]::OutputText($Line))
            if ($tail.Count -gt 15) { $null = $tail.Dequeue() }
        }
        Write-MtTestOutput -InputObject $Line -Progress:$progress
    }
    $process = $null
    $completed = $false
    try {
        Write-MtTestOutput -Title "Microsoft Zero Trust Assessment $($module.Version)" -InputObject 'Running the assessment in a separate PowerShell process. This can take an hour or more on a large tenant.'
        $process = [System.Diagnostics.Process]::Start($startInfo)
        $readers = @($process.StandardOutput, $process.StandardError)
        $reads = [System.Threading.Tasks.Task[]]@($readers[0].ReadLineAsync(), $readers[1].ReadLineAsync())
        $exited = $null
        while ($reads[0] -or $reads[1]) {
            # Wait in short steps so a stop request (a timeout or Ctrl+C) is seen between them.
            $null = [System.Threading.Tasks.Task]::WaitAny([System.Threading.Tasks.Task[]]@($reads | Where-Object { $_ }), 1000)
            for ($i = 0; $i -lt 2; $i++) {
                if (-not $reads[$i] -or -not $reads[$i].IsCompleted) { continue }
                $line = $reads[$i].GetAwaiter().GetResult()
                if ($null -eq $line) { $reads[$i] = $null; continue }
                $reads[$i] = $readers[$i].ReadLineAsync()
                & $show $line
            }
            # A process the child started can keep its output open after it ends: give it a few seconds.
            if ($process.HasExited) {
                if (-not $exited) { $exited = [System.Diagnostics.Stopwatch]::StartNew() }
                elseif ($exited.Elapsed.TotalSeconds -gt 5) { break }
            }
        }
        $process.WaitForExit()
        if ($process.ExitCode -ne 0) {
            $where = if ($temporary) { '' } else { " Its log is in $outputPath." }
            $last = if ($tail.Count -gt 0) { " Its last lines:`n" + ($tail.ToArray() -join "`n") } else { '' }
            throw "The Zero Trust Assessment ended with exit code $($process.ExitCode).$where$last"
        }
        $completed = $true
    } finally {
        # Stopping the Maester run (a timeout or Ctrl+C) stops the assessment too.
        if ($process -and -not $process.HasExited) { $process.Kill($true); $null = $process.WaitForExit(5000) }
        Write-MtTestOutput -Complete
        Remove-Item -LiteralPath $request, $scriptPath -Force -ErrorAction SilentlyContinue
        if ($configurationFile) { Remove-Item -LiteralPath $configurationFile -Force -ErrorAction SilentlyContinue }
        # The export holds tenant data. Get-MtZtaInstance removes it after a run that completed; this removes what a failed or stopped run left.
        if ($temporary -and -not $completed) { Remove-Item -LiteralPath $outputPath -Recurse -Force -ErrorAction SilentlyContinue }
    }

    [pscustomobject]@{
        OutputPath = $outputPath
        ReportPath = Join-Path -Path $outputPath -ChildPath 'zt-export' -AdditionalChildPath 'ZeroTrustAssessmentReport.json'
        Temporary  = $temporary
    }
}

function Test-MtZeroTrustAssessment {
    [MaesterTest(
        Id = 'ZTA',
        Title = 'Microsoft Zero Trust Assessment',
        Product = 'Zero Trust Assessment',
        Service = 'Graph',
        LongRunning = $true,
        InstanceSource = 'Get-MtZtaInstance',
        Tag = 'ZeroTrust',
        Author = 'merill',
        HelpUrl = 'https://aka.ms/zerotrust/assessment'
    )]
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        # One result of the Zero Trust Assessment, supplied by the engine.
        $Instance
    )

    $row = $Instance.Data
    $detail = @{
        Description = [string]$row.TestDescription
        Result      = [string]$row.TestResult
    }

    # Skip reasons the assessment shares with Maester keep Maester's wording; the rest keep the assessment's.
    $sharedSkipReason = @(
        'NotApplicable', 'NotSupported', 'NotDotGovDomain', 'NotConnectedAzure', 'NotConnectedExchange',
        'NotConnectedSecurityCompliance', 'NotLicensedEntraIDP1', 'NotLicensedEntraIDP2',
        'NotLicensedEntraIDGovernance', 'NotLicensedEntraWorkloadID', 'NotLicensedIntune'
    )

    switch ([string]$row.TestStatus) {
        'Passed' {
            Add-MtTestResultDetail @detail
            return $true
        }
        'Failed' {
            Add-MtTestResultDetail @detail
            return $false
        }
        'Investigate' {
            Add-MtTestResultDetail @detail -Investigate
            return $true
        }
        'Skipped' {
            $code = [string]$row.TestSkipped
            if ($code -in $sharedSkipReason) {
                Add-MtTestResultDetail -Description $detail.Description -SkippedBecause $code
            }
            $reason = if ($row.SkippedReason) { [string]$row.SkippedReason } elseif ($code) { $code } else { 'Skipped by the Zero Trust Assessment.' }
            Add-MtTestResultDetail -Description $detail.Description -SkippedBecause Custom -SkippedCustomReason $reason
        }
        default {
            # Error, or a status this version of the pack does not know.
            Add-MtTestResultDetail @detail -SkippedBecause Error
        }
    }
}