diff --git a/README.md b/README.md index 5f7142481..d0f49c45b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,40 @@ Allowed values include: Connect-Maester -Environment USGov ``` +## Zero Trust Assessment integration + +Maester can load a [Zero Trust Assessment](https://microsoft.github.io/zerotrustassessment/) (ZTA) result bundle so its tests can focus on the areas ZTA flagged. ZTA runs separately (workstation, another pipeline, another tenant); Maester reads the captured bundle and runs **38 ZTA-aware Pester tests** under `tests/Zta/` plus attaches a `ZtaBundle` analytics object to the result so the HTML report renders a dedicated **ZTA tab**. + +### Quick start + +```powershell +Connect-Maester +Invoke-Maester -ZtaResultsPath ./zta-bundle -Path ./tests +``` + +`-ZtaResultsPath` accepts three source patterns (in priority order): + +1. **Local path** to a folder, `.tar.gz`, or `.zip` +2. **Azure Blob URL** — `https://.blob.core.windows.net/...` (SAS, WIF, or `-Identity`) +3. **Azure Artifacts Universal Package** — `upkg://///@` + +### What it does + +- **Before Pester** — `Import-MtZtaResult` loads bundle metadata, manifests freshness, and exposes `Get-MtZta` to every test. Applies `Update-MtSeverityFromZta` so ZTA evidence can escalate severity on matching Maester core tests. +- **During Pester** — the 38 `MT.Zta.*` tests evaluate pillar posture, run CA What-If simulations against the live tenant, gap-fill compensating-control checks (App Protection, MFA registration, privileged hygiene), and cross-table joins on the ZTA data via the bundle reader. +- **After Pester** — `Build-MtZtaBundle` compiles per-tenant analytics (inventory, auth-method posture, CA coverage, privileged snapshot, sign-in funnel) and attaches as `$results.ZtaBundle` so HTML / JSON / Markdown outputs all carry it. + +### Where the pieces live + +- `powershell/public/*Zta*.ps1` — 8 public cmdlets (`Get-MtZta`, `Import-MtZtaResult`, `Build-MtZtaBundle`, `Get-MtZtaAuthMethodSet`, `Get-MtZtaRecommendedTag`, `Get-MtZtaThreshold`, `Test-MtZtaIsEmergencyAccess`, `Update-MtSeverityFromZta`) +- `powershell/internal/*Zta*.ps1` — 5 internal helpers (Tier 1 / Tier 2 readers, freshness, artifact resolver, bucketing) +- `tests/Zta/*.Tests.ps1` — 11 test files, 38 distinct `MT.Zta.*` tests +- `report/src/pages/ZtaPage.tsx` + `report/src/components/MtZta*.{jsx,tsx}` — ZTA tab UI + +Omitting `-ZtaResultsPath` keeps Maester behaviour byte-identical to upstream — no test changes, no extra dependencies. + +For full documentation see [Zero Trust Assessment](https://maester.dev/docs/zero-trust-assessment). + ## Keeping your Maester tests up to date The Maester team will add new tests over time. To get the latest updates, use the commands below to update this folder with the latest tests. diff --git a/build/Update-MtZtaTestDocs.ps1 b/build/Update-MtZtaTestDocs.ps1 new file mode 100644 index 000000000..34b831881 --- /dev/null +++ b/build/Update-MtZtaTestDocs.ps1 @@ -0,0 +1,214 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Generates one Markdown page per MT.Zta.* test under + `website/docs/tests/maester/MT.Zta.NNNN.md`, derived from the actual test + files' It titles + Description heredocs. + +.DESCRIPTION + Maester convention is one Markdown page per test id (mirrors what + `https://maester.dev/docs/tests/MT.NNNN` resolves to). This script walks + `tests/Zta/*.Tests.ps1`, parses each `It 'MT.Zta.NNNN: ...'` block, + extracts the Description heredoc that the test body passes to + `Add-MtTestResultDetail -Description ...`, and writes a Docusaurus- + compatible page with the same shape Maester core uses for MT.NNNN pages. + + Run this whenever a description is edited or a new test is added — the + pages are deterministic from the source, no hand-editing needed. + +.PARAMETER ForkRoot + Root of the Maester fork repo. Defaults to two levels up from this script. + +.EXAMPLE + ./build/Update-MtZtaTestDocs.ps1 + + Regenerates all per-test pages. + +.LINK + https://maester.dev/docs/zero-trust-assessment +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string] $ForkRoot = (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$testsDir = Join-Path $ForkRoot 'tests/Zta' +$docsDir = Join-Path $ForkRoot 'website/docs/tests/maester' +if (-not (Test-Path $testsDir)) { throw "tests/Zta not found at $testsDir" } +if (-not (Test-Path $docsDir)) { New-Item -Path $docsDir -ItemType Directory -Force | Out-Null } + +# Parse one .Tests.ps1 file, return an array of @{ Id; Title; Severity; Description }. +function Get-MtZtaTestsFromFile { + param([string] $Path) + + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errs) + if ($errs.Count -gt 0) { + throw ("Parse errors in {0}: {1}" -f $Path, $errs[0].Message) + } + + # Find all top-level `It` invocations, regardless of nesting depth. + $itCalls = $ast.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.CommandAst] -and + $n.CommandElements.Count -gt 1 -and + $n.CommandElements[0].Extent.Text -eq 'It' + }, $true) + + $results = New-Object System.Collections.Generic.List[object] + foreach ($it in $itCalls) { + # First positional argument after `It` is the title string. + $titleArg = $it.CommandElements[1] + $title = $titleArg.Extent.Text.Trim("'`"") + if ($title -notmatch '^(MT\.Zta\.\d+):\s*(.+?)(?:\.\s*See\s+https://.*)?$') { continue } + $id = $Matches[1] + $short = $Matches[2].TrimEnd('.') + + # -Tag parameter — look for `Severity:Level` + $severity = 'Medium' + $tagsAst = $it.CommandElements | Where-Object { + $_ -is [System.Management.Automation.Language.CommandParameterAst] -and $_.ParameterName -eq 'Tag' + } + if ($tagsAst) { + $tagIdx = $it.CommandElements.IndexOf($tagsAst[0]) + if ($tagIdx -ge 0 -and $it.CommandElements.Count -gt $tagIdx + 1) { + $tagArg = $it.CommandElements[$tagIdx + 1].Extent.Text + if ($tagArg -match "Severity:(\w+)") { $severity = $Matches[1] } + } + } + + # Description heredoc — locate the ScriptBlockExpression argument of the It + # call (the actual It body), then find heredocs inside it. + $itBody = $null + $sbArg = $it.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.ScriptBlockExpressionAst] } | Select-Object -First 1 + if ($sbArg) { $itBody = $sbArg.ScriptBlock } + + $description = '' + if ($itBody) { + $heredocs = $itBody.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $n.StringConstantType -in @('SingleQuotedHereString','DoubleQuotedHereString') + }, $true) + # Heuristic: the first heredoc whose value starts with "## What this test checks" is the description. + foreach ($hd in $heredocs) { + if ($hd.Value -match '(?ms)^## What this test checks') { + $description = $hd.Value + break + } + } + } + + $results.Add([pscustomobject]@{ + Id = $id + Title = $short + Severity = $severity + Description = $description + SourceFile = (Split-Path -Leaf $Path) + }) | Out-Null + } + return ,$results.ToArray() +} + +$allTests = New-Object System.Collections.Generic.List[object] +foreach ($file in Get-ChildItem $testsDir -Filter 'Test-MtZta.*.Tests.ps1' -File) { + foreach ($t in (Get-MtZtaTestsFromFile -Path $file.FullName)) { + $allTests.Add($t) | Out-Null + } +} + +Write-Host ("Discovered {0} MT.Zta.* tests across {1} files." -f $allTests.Count, (Get-ChildItem $testsDir -Filter '*.Tests.ps1').Count) + +# De-duplicate (data-driven tests may appear multiple times; first occurrence wins). +$seen = @{} +$written = 0 +foreach ($t in $allTests) { + if ($seen.ContainsKey($t.Id)) { continue } + $seen[$t.Id] = $true + + # Split the description into 'Description' + 'How to fix' + 'Related tests' sections. + # The test heredocs use `## What this test checks` and `## How to remediate` and + # `## Related Maester core tests` headings — preserve those, but rewrite as + # Docusaurus-flavored sections matching the MT.NNNN.md format. + $descIntro = $t.Description -replace '(?ms)^## What this test checks\s*\r?\n', '' + # Split on `## ` headings + $sections = [regex]::Split($descIntro, "(?m)^## ") + $whatBody = $sections[0].Trim() + $remediate = '' + $related = '' + foreach ($s in $sections | Select-Object -Skip 1) { + if ($s -match '^How to remediate') { + $remediate = ($s -replace '^How to remediate\s*\r?\n', '').Trim() + } elseif ($s -match '^Related Maester core tests') { + $related = ($s -replace '^Related Maester core tests.*?\r?\n', '').Trim() + } elseif ($s -match '^How to declare break-glass accounts') { + $remediate = ($s -replace '^How to declare break-glass accounts\s*\r?\n', '## Declaring break-glass accounts`n`n').Trim() + "`n`n" + $remediate + } elseif ($s -match '^Why a privileged user') { + # Sub-discussion under "What this test checks" — append to that body + $whatBody += "`n`n## " + $s.Trim() + } + } + + # Frontmatter description: short version of the test title with the description's first paragraph. + $firstPara = ($whatBody -split "(?ms)\r?\n\s*\r?\n" | Select-Object -First 1).Trim() + $shortDesc = ($firstPara -replace '\s+', ' ').Trim() + if ($shortDesc.Length -gt 280) { $shortDesc = $shortDesc.Substring(0, 277) + '...' } + # YAML frontmatter requires escaping double-quotes and backticks inside the quoted string. + $shortDescYaml = $shortDesc -replace '"', '\"' -replace '`', "'" + + $page = @" +--- +title: $($t.Id) - $($t.Title) +description: "$shortDescYaml" +slug: /tests/$($t.Id) +sidebar_class_name: hidden +--- + +# $($t.Title) + +| Severity | Source | +| --- | --- | +| $($t.Severity) | [``$($t.SourceFile)``](https://github.com/maester365/maester/blob/main/tests/Zta/$($t.SourceFile)) | + +## Description + +$whatBody + +"@ + + if ($remediate) { + $page += @" +## How to fix + +$remediate + +"@ + } + + if ($related) { + $page += @" +## Related Maester core tests + +$related + +"@ + } + + $page += @" +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) +"@ + + $outPath = Join-Path $docsDir "$($t.Id).md" + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($outPath, $page, $utf8NoBom) + $written++ +} + +Write-Host ("Wrote {0} per-test pages under {1}" -f $written, $docsDir) diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1 index a8142ff5c..d2e3a3e53 100644 --- a/powershell/Maester.psd1 +++ b/powershell/Maester.psd1 @@ -56,7 +56,7 @@ # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. FunctionsToExport = @( - 'Add-MtMaesterAppFederatedCredential', 'Add-MtTestResultDetail', 'Clear-MtDnsCache', 'Clear-MtExoCache', + 'Add-MtMaesterAppFederatedCredential', 'Add-MtTestResultDetail', 'Build-MtZtaBundle', 'Clear-MtDnsCache', 'Clear-MtExoCache', 'Clear-MtGraphCache', 'Compare-MtJsonObject', 'Compare-MtTestResult', 'Connect-Maester', 'Convert-MtResultsToFlatObject', 'ConvertFrom-MailAuthenticationRecordDkim', 'ConvertFrom-MailAuthenticationRecordDmarc', 'ConvertFrom-MailAuthenticationRecordMx', 'ConvertFrom-MailAuthenticationRecordSpf', 'Disconnect-Maester', @@ -64,7 +64,9 @@ 'Get-MtAzureManagementGroup', 'Get-MtConditionalAccessPolicy', 'Get-MtExo', 'Get-MtExoThreatPolicyMalware', 'Get-MtGraphScope', 'Get-MtGroupMember', 'Get-MtHtmlReport', 'Get-MtLicenseInformation', 'Get-MtMaesterApp', 'Get-MtRole', 'Get-MtRoleMember', 'Get-MtSafeMarkdown', 'Get-MtSession', 'Get-MtTestInventory', 'Get-MtUser', - 'Get-MtUserAuthenticationMethod', 'Get-MtUserAuthenticationMethodInfoByType', 'Import-MtMaesterResult', + 'Get-MtUserAuthenticationMethod', 'Get-MtUserAuthenticationMethodInfoByType', 'Get-MtZta', + 'Get-MtZtaAuthMethodSet', 'Get-MtZtaRecommendedTag', 'Get-MtZtaThreshold', + 'Import-MtMaesterResult', 'Import-MtZtaResult', 'Install-MaesterTests', 'Invoke-Maester', 'Invoke-MtAzureRequest', 'Invoke-MtAzureResourceGraphRequest', 'Invoke-MtGraphRequest', 'Invoke-MtGraphSecurityQuery', 'Merge-MtMaesterResult', 'New-MtMaesterApp', 'Resolve-SPFRecord', 'Send-MtMail', 'Send-MtTeamsMessage', 'Test-AzdoAllowExtensionsLocalNetworkAccess', 'Test-AzdoAllowRequestAccessToken', @@ -165,7 +167,8 @@ 'Test-MtXspmCriticalCredentialsOnNonTpmProtectedDevices', 'Test-MtXspmCriticalCredsOnDevicesWithNonCriticalAccounts', 'Test-MtXspmEnabledPrivilegedUsersLinkedToDisabledIdentity', 'Test-MtXspmExposedCredentialsForPrivilegedUsers', 'Test-MtXspmHybridUsersWithAssignedEntraIdRoles', 'Test-MtXspmPendingApprovalCriticalAssetManagement', - 'Test-MtXspmPrivilegedUsersLinkedToIdentity', 'Test-MtXspmPublicRemotelyExploitableHighExposureDevices', 'Test-ORCA100', + 'Test-MtXspmPrivilegedUsersLinkedToIdentity', 'Test-MtXspmPublicRemotelyExploitableHighExposureDevices', + 'Test-MtZtaIsEmergencyAccess', 'Test-ORCA100', 'Test-ORCA101', 'Test-ORCA102', 'Test-ORCA103', 'Test-ORCA104', 'Test-ORCA105', 'Test-ORCA106', 'Test-ORCA107', 'Test-ORCA108', 'Test-ORCA108_1', 'Test-ORCA109', 'Test-ORCA110', 'Test-ORCA111', 'Test-ORCA112', 'Test-ORCA113', 'Test-ORCA114', 'Test-ORCA115', 'Test-ORCA116', 'Test-ORCA118_1', 'Test-ORCA118_2', 'Test-ORCA118_3', 'Test-ORCA118_4', @@ -175,7 +178,8 @@ 'Test-ORCA221', 'Test-ORCA222', 'Test-ORCA223', 'Test-ORCA224', 'Test-ORCA225', 'Test-ORCA226', 'Test-ORCA227', 'Test-ORCA228', 'Test-ORCA229', 'Test-ORCA230', 'Test-ORCA231', 'Test-ORCA232', 'Test-ORCA233', 'Test-ORCA233_1', 'Test-ORCA234', 'Test-ORCA235', 'Test-ORCA236', 'Test-ORCA237', 'Test-ORCA238', 'Test-ORCA239', 'Test-ORCA240', - 'Test-ORCA241', 'Test-ORCA242', 'Test-ORCA243', 'Test-ORCA244', 'Update-MaesterTests', 'Update-MtMaesterApp' + 'Test-ORCA241', 'Test-ORCA242', 'Test-ORCA243', 'Test-ORCA244', 'Update-MaesterTests', 'Update-MtMaesterApp', + 'Update-MtSeverityFromZta' ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. @@ -188,7 +192,9 @@ AliasesToExport = @( 'Invoke-MtMaester', 'Connect-MtGraph', 'Connect-MtMaester', - 'Disconnect-MtGraph', 'Disconnect-MtMaester' + 'Disconnect-MtGraph', 'Disconnect-MtMaester', + # Back-compat alias for the singular-noun rename of Import-MtZtaResult. + 'Import-MtZtaResults' ) # List of all modules packaged with this module diff --git a/powershell/internal/Get-MtMaesterConfig.ps1 b/powershell/internal/Get-MtMaesterConfig.ps1 index 7270578a6..1847aea18 100644 --- a/powershell/internal/Get-MtMaesterConfig.ps1 +++ b/powershell/internal/Get-MtMaesterConfig.ps1 @@ -120,6 +120,11 @@ function Get-MtMaesterConfig { # Store the source file name so the report can show which config was loaded $configFileName = Split-Path -Path $ConfigFilePath -Leaf Add-Member -InputObject $maesterConfig -MemberType NoteProperty -Name 'ConfigSource' -Value $configFileName + # Resolved absolute path. Invoke-Maester exports this as $env:MAESTER_ZTA_CONFIG_PATH + # so Get-MtZta can re-load ZtaSettings + GlobalSettings inside a Pester sub-runspace + # where $script:MtZtaContext may reset to $null. + $resolvedConfigPath = (Resolve-Path -LiteralPath $ConfigFilePath).Path + Add-Member -InputObject $maesterConfig -MemberType NoteProperty -Name '__ConfigFilePath' -Value $resolvedConfigPath # Add a new property called TestSettingsHash to the config object with Id as the key for faster access Add-Member -InputObject $maesterConfig -MemberType NoteProperty -Name 'TestSettingsHash' -Value @{} diff --git a/powershell/internal/Group-MtZtaFlaggedIdentity.ps1 b/powershell/internal/Group-MtZtaFlaggedIdentity.ps1 new file mode 100644 index 000000000..da3c711b7 --- /dev/null +++ b/powershell/internal/Group-MtZtaFlaggedIdentity.ps1 @@ -0,0 +1,330 @@ +function Group-MtZtaFlaggedIdentity { + <# + .SYNOPSIS + Internal: groups ZTA-failed identities into category buckets so Maester data-driven + tests can iterate per-bucket. + + .DESCRIPTION + Implements a 7-step "same-kind" bucketing algorithm. Consumes the JSON Tests[] + (always present); optionally enriches from DuckDB when `$ContextDatabase` is + supplied (Read-MtZtaDatabase output). + + Buckets are defined by `$CategoryMappings` (typically loaded from + `ZtaSettings.CategoryMappings` in maester-config.json). Each test is matched + against rules in order; first match wins. Unmatched tests fall into 'Other'. + + Algorithm: + 1. failed = Tests[] WHERE TestStatus='Failed' + 2. for each failed t: classify into category by CategoryMappings rules + emit user UPNs / objectIds extracted from TestResult markdown + 3. (optional) DuckDB enrichment unions: + NoMFA <- UserRegistrationDetails WHERE isMfaRegistered=false + StaleSignIn <- SignIn WHERE createdDateTime < 90 days ago (latest per user) + NoCompliantDevice <- Device WHERE isCompliant=false + GuestUnconstrained <- User WHERE userType='Guest' (no CA coverage join — JSON-only proxy) + 4. dedupe by (UserId, Category) + 5. "same-kind" merge: collapse rows sharing Category + overlapping evidence + 6. cap each bucket at MaxUsersPerCategory; deterministic order by UPN + 7. return [{ Category, Pillar, Count, Group=[users] }, ...] + + .PARAMETER Tests + The Tests[] array from a loaded ZTA report (typically `$script:MtZtaContext.Tests`). + Required. + + .PARAMETER CategoryMappings + Array of category-mapping rules (the `ZtaSettings.CategoryMappings` block). + Each rule: { Category, MatchPillar, MatchCategoryAny, MatchTestIds (opt), MaesterTagBoost }. + Empty/missing -> all failures fall into 'Other' (callers warn at >10%). + + .PARAMETER ContextDatabase + Optional DuckDB context object from Read-MtZtaDatabase (`$script:MtZtaContext.Database`). + When supplied, enrichment queries augment the JSON-derived buckets. Errors during + enrichment are non-fatal — buckets still return JSON-only data. + + .PARAMETER MaxUsersPerCategory + Cap per bucket. Default 50 (driven by `DataDrivenSettings.MaxUsersPerCategory`). + + .PARAMETER GroupSimilar + Enable step 5 same-kind merging. Default $true. + + .OUTPUTS + [pscustomobject[]] one entry per bucket: + { Category, Pillar, Count, Group = [pscustomobject[]] } + Each Group entry: { UserId, UserPrincipalName, Pillar, Category, Evidence (string[]) } + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]] $Tests, + + [Parameter(Mandatory = $false)] + [object[]] $CategoryMappings = @(), + + [Parameter(Mandatory = $false)] + [object] $ContextDatabase, + + [Parameter(Mandatory = $false)] + [int] $MaxUsersPerCategory = 50, + + [Parameter(Mandatory = $false)] + [bool] $GroupSimilar = $true + ) + + # Step 1 — failed only. + $failed = @($Tests | Where-Object { $_.TestStatus -eq 'Failed' }) + + # Step 2 — classify + emit JSON-derived users. + $rows = @() # flat: { UserId, UserPrincipalName, Pillar, Category, Evidence } + + foreach ($t in $failed) { + $cat = Get-MtZtaCategoryForTest -Test $t -CategoryMappings $CategoryMappings + + # Extract user identifiers from TestResult markdown. ZTA produces freeform markdown; + # the conservative parser pulls UPNs (email-shaped) and bare GUIDs (objectId). + $users = @() + if ($t.TestResult) { + $resultText = [string]$t.TestResult + $upnMatches = [regex]::Matches($resultText, '\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b') + $guidMatches = [regex]::Matches($resultText, '\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b') + + foreach ($m in $upnMatches) { + $users += [pscustomobject]@{ + UserId = $null + UserPrincipalName = $m.Value + Pillar = $t.TestPillar + Category = $cat + Evidence = @("ZTA test $($t.TestId): $($t.TestTitle)") + } + } + foreach ($m in $guidMatches) { + # Only emit GUIDs that don't co-occur with an UPN already captured. + $users += [pscustomobject]@{ + UserId = $m.Value + UserPrincipalName = $null + Pillar = $t.TestPillar + Category = $cat + Evidence = @("ZTA test $($t.TestId): $($t.TestTitle)") + } + } + } + + # Tests with no extractable identities still count as a category-level signal — + # emit a synthetic placeholder so callers can render "(no users in markdown)". + if (-not $users) { + $users = @([pscustomobject]@{ + UserId = $null + UserPrincipalName = $null + Pillar = $t.TestPillar + Category = $cat + Evidence = @("ZTA test $($t.TestId): $($t.TestTitle) [test-level only]") + }) + } + + $rows += $users + } + + # Step 3 — DuckDB enrichment (best-effort). + if ($ContextDatabase -and $ContextDatabase.Query) { + try { + # NoMFA + $sql = "SELECT id, userPrincipalName FROM UserRegistrationDetails WHERE isMfaRegistered = false LIMIT $($MaxUsersPerCategory * 2)" + $r = & $ContextDatabase.Query $sql + foreach ($u in $r) { + $rows += [pscustomobject]@{ + UserId = $u.id + UserPrincipalName = $u.userPrincipalName + Pillar = 'Identity' + Category = 'IdentityPosture' + Evidence = @('UserRegistrationDetails.isMfaRegistered=false') + } + } + } + catch { Write-Verbose "Group-MtZtaFlaggedIdentity: NoMFA enrichment skipped ($($_.Exception.Message))" } + + try { + # GuestUnconstrained — JSON-derivable proxy; no CA-coverage join. + $sql = "SELECT id, userPrincipalName FROM `"User`" WHERE userType = 'Guest' AND accountEnabled = true LIMIT $($MaxUsersPerCategory * 2)" + $r = & $ContextDatabase.Query $sql + foreach ($u in $r) { + $rows += [pscustomobject]@{ + UserId = $u.id + UserPrincipalName = $u.userPrincipalName + Pillar = 'Identity' + Category = 'GuestUnconstrained' + Evidence = @('User.userType=Guest, accountEnabled=true') + } + } + } + catch { Write-Verbose "Group-MtZtaFlaggedIdentity: GuestUnconstrained enrichment skipped ($($_.Exception.Message))" } + + try { + # NoCompliantDevice — owner is in the Device row's userId field if populated; otherwise + # we still surface the device record under the user-level bucket as a device-class signal. + $sql = "SELECT deviceId, displayName, isCompliant FROM Device WHERE isCompliant = false LIMIT $($MaxUsersPerCategory * 2)" + $r = & $ContextDatabase.Query $sql + foreach ($d in $r) { + $rows += [pscustomobject]@{ + UserId = $d.deviceId + UserPrincipalName = $d.displayName + Pillar = 'Devices' + Category = 'DevicePosture' + Evidence = @("Device.isCompliant=false ($($d.displayName))") + } + } + } + catch { Write-Verbose "Group-MtZtaFlaggedIdentity: NoCompliantDevice enrichment skipped ($($_.Exception.Message))" } + } + + # Step 4 — dedupe by (UserId-or-UPN, Category). Merge Evidence arrays. + $deduped = @{} + foreach ($r in $rows) { + $key = "$($r.Category)|$(if ($r.UserId) { $r.UserId } else { $r.UserPrincipalName })" + if ($deduped.ContainsKey($key)) { + $existing = $deduped[$key] + $existing.Evidence = @($existing.Evidence + $r.Evidence | Select-Object -Unique) + } + else { + $deduped[$key] = $r + } + } + $merged = @($deduped.Values) + + # Step 5 — "same-kind" merge is already a side-effect of step 4 keying on Category. + # The flag exists to allow callers to opt-out (set $GroupSimilar=$false to keep + # one row per (test,user) pair instead). When false, return the pre-dedup rows. + if (-not $GroupSimilar) { $merged = $rows } + + # Step 6 — group by Category, cap per bucket, deterministic order. + $byCategory = $merged | Group-Object Category + + $result = foreach ($g in $byCategory) { + $sorted = $g.Group | Sort-Object @{ Expression = { if ($_.UserPrincipalName) { $_.UserPrincipalName } else { [string]$_.UserId } } } + $capped = $sorted | Select-Object -First $MaxUsersPerCategory + + # Pillar of a bucket = pillar of the first member (consistent within a category). + $pillar = if ($capped) { $capped[0].Pillar } else { $null } + + [pscustomobject]@{ + Category = $g.Name + Pillar = $pillar + Count = @($g.Group).Count + Group = @($capped) + } + } + + # Plain array return so pipeline consumers see individual buckets — a leading + # `,@($x)` would emit the entire array as a single pipeline item, breaking + # `| Where-Object Category -eq 'X'`. Callers that need array-shape preservation + # can wrap with @() at the call site. + return $result +} + +function Get-MtZtaCategoryForTest { + <# + .SYNOPSIS + Internal helper: classifies a single ZTA test against a CategoryMappings rule list. + First match wins. Returns 'Other' on no match. + + .DESCRIPTION + Multi-pass match precedence (highest -> lowest): + Pass 1: explicit MatchTestIds wildcard match against $Test.TestId + Pass 2: explicit category match (MatchCategoryAny non-empty AND intersect $Test.TestCategory) + — applies to both pillar-specific and cross-cut (MatchPillar='*') rules. + Pass 3: pillar-level catch-all (MatchCategoryAny empty AND MatchPillar matches the test pillar). + Cross-cut rules (MatchPillar='*') are NEVER catch-all by design. + + This means a "Privileged access" test under Identity pillar lands in PrivilegedAccess + (the explicit cross-cut), not IdentityPosture (the pillar-level catch-all), regardless + of declaration order. Operators only need order-independent rules. + + Pillar comparison is case-insensitive. Category comparison is case-insensitive + and tolerates ZTA's free-text variants — comma/semicolon split for compound + categories like "Credential management; Privileged access". + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] [object] $Test, + [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $CategoryMappings + ) + + if (-not $CategoryMappings -or $CategoryMappings.Count -eq 0) { return 'Other' } + + $testPillar = if ($Test.TestPillar) { [string]$Test.TestPillar } else { '' } + $testCategory = if ($Test.TestCategory) { [string]$Test.TestCategory } else { '' } + $testId = if ($Test.TestId) { [string]$Test.TestId } else { '' } + + # Split compound categories on ',' and ';' (e.g. "Credential management; Privileged access") + $catParts = $testCategory -split '\s*[;,]\s*' | Where-Object { $_ } + + # Pass 1 — TestId wildcard match (highest priority). + foreach ($rule in $CategoryMappings) { + $matchTestIds = Get-MtZtaRuleMember -Rule $rule -Name 'MatchTestIds' + if ($matchTestIds) { + foreach ($pattern in @($matchTestIds)) { + if ($testId -like $pattern) { return (Get-MtZtaRuleMember -Rule $rule -Name 'Category') } + } + } + } + + # Pass 2 — explicit category match (pillar-gated). Wins over pillar-level catch-alls + # regardless of declaration order. Cross-cut rules (MatchPillar='*') compete here too. + foreach ($rule in $CategoryMappings) { + $matchPillar = Get-MtZtaRuleMember -Rule $rule -Name 'MatchPillar' + $matchCategoryAny = Get-MtZtaRuleMember -Rule $rule -Name 'MatchCategoryAny' + if ($null -eq $matchPillar) { continue } + + $rp = [string]$matchPillar + $pillarMatches = ($rp -eq '*') -or ($rp -ieq $testPillar) + if (-not $pillarMatches) { continue } + + $catList = if ($matchCategoryAny) { @($matchCategoryAny) } else { @() } + if ($catList.Count -eq 0) { continue } # pillar-level catch-all is pass 3 + + foreach ($needle in $catList) { + $needleNorm = ([string]$needle).Trim() + foreach ($part in $catParts) { + if ($part.Trim() -ieq $needleNorm) { return (Get-MtZtaRuleMember -Rule $rule -Name 'Category') } + } + if ($testCategory.Trim() -ieq $needleNorm) { return (Get-MtZtaRuleMember -Rule $rule -Name 'Category') } + } + } + + # Pass 3 — pillar-level catch-all (lowest priority). Cross-cuts cannot be catch-all. + foreach ($rule in $CategoryMappings) { + $matchPillar = Get-MtZtaRuleMember -Rule $rule -Name 'MatchPillar' + $matchCategoryAny = Get-MtZtaRuleMember -Rule $rule -Name 'MatchCategoryAny' + if ($null -eq $matchPillar) { continue } + + $rp = [string]$matchPillar + if ($rp -eq '*') { continue } # cross-cut is never catch-all + if ($rp -ine $testPillar) { continue } + + $catList = if ($matchCategoryAny) { @($matchCategoryAny) } else { @() } + if ($catList.Count -eq 0) { return (Get-MtZtaRuleMember -Rule $rule -Name 'Category') } + } + + return 'Other' +} + +function Get-MtZtaRuleMember { + <# + .SYNOPSIS + Internal: pulls a named member from a CategoryMappings rule whether the rule is a + [hashtable] (created via @{}) or a [pscustomobject] (created via ConvertFrom-Json). + Returns $null when the member is absent. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] [object] $Rule, + [Parameter(Mandatory = $true)] [string] $Name + ) + if ($Rule -is [System.Collections.IDictionary]) { + if ($Rule.Contains($Name)) { return $Rule[$Name] } + return $null + } + if ($Rule.PSObject.Properties[$Name]) { return $Rule.$Name } + return $null +} diff --git a/powershell/internal/Read-MtZtaDatabase.ps1 b/powershell/internal/Read-MtZtaDatabase.ps1 new file mode 100644 index 000000000..1ac90f5bd --- /dev/null +++ b/powershell/internal/Read-MtZtaDatabase.ps1 @@ -0,0 +1,274 @@ +function Read-MtZtaDatabase { + <# + .SYNOPSIS + Internal: opens a ZTA `zt.db` (DuckDB) read-only and returns a query callable plus + schema introspection helpers. + + .DESCRIPTION + DuckDB-first reader for `Import-MtZtaResult`. Opens with read-only access mode so + multiple processes can share the same `zt.db` concurrently. + + Schema baseline — 17 tables present in every observed tenant: + Application, ConfigurationPolicy, Device, + RoleAssignment, RoleAssignmentGroup, + RoleAssignmentScheduleInstance, RoleAssignmentScheduleInstanceGroup, + RoleDefinition, + RoleEligibilityScheduleInstance, RoleEligibilityScheduleInstanceGroup, + RoleManagementPolicyAssignment, + ServicePrincipal, ServicePrincipalSignIn, + SignIn, User, UserRegistrationDetails, + vwRole + + Net-additive per-tenant columns are limited to Microsoft Graph metadata markers + (`@odata.type`, `@odata.context`) and are lazy-probed by callers via `HasColumn`. + + Returns a context object whose surface matches `Read-MtZtaJsonExport` + (Tier 1) so callers can swap interchangeably: + + [pscustomobject] { + Tier = 'Database' + Connection = [System.Data.IDbConnection] + Query = [scriptblock] # { param($sql) ... } -> [array of rows] + Tables = [string[]] # tables observed in 'main' schema + SupportsSql = $true + HasTable = [scriptblock] # ($name) -> [bool] + HasColumn = [scriptblock] # ($table, $column) -> [bool] + GetRows = [scriptblock] # ($table, $pred, $top) -> [rows] + BuildIndex = [scriptblock] # ($table, $keyColumn) -> hashtable + Dispose = [scriptblock] # closes the connection cleanly + } + + Throws on failure (missing native binary, unreadable .db, schema baseline check + finds required tables absent). Caller (Import-MtZtaResult) catches and falls + back to JSON-only mode with a one-line warning. + + .PARAMETER DatabasePath + Path to the zt.db file. Must exist. + + .PARAMETER ModuleRoot + Module root path (defaults to `$PSScriptRoot/..`). Used to locate `lib/DuckDB.NET.Data.dll` + when the assemblies aren't already loaded into the AppDomain. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string] $DatabasePath, + + [Parameter(Mandatory = $false)] + [string] $ModuleRoot + ) + + if (-not (Test-Path $DatabasePath)) { + throw "Read-MtZtaDatabase: database file not found: $DatabasePath" + } + + if (-not $ModuleRoot) { + $ModuleRoot = Resolve-Path (Join-Path $PSScriptRoot '..') | Select-Object -ExpandProperty Path + } + + # Opportunistic: returns $null on miss rather than throwing so callers can + # treat DuckDB as an accelerator. The JSON-shadow reader is the universal floor. + $assembliesReady = Initialize-MtZtaDuckDbAssembly -ModuleRoot $ModuleRoot + if (-not $assembliesReady) { + Write-Verbose "Read-MtZtaDatabase: DuckDB tier unavailable; caller should fall back to Read-MtZtaJsonExport (Tier 1)." + return $null + } + + # READ_ONLY mode lets multiple processes share the same file safely. + $absPath = (Resolve-Path $DatabasePath).Path + $connStr = "Data Source=$absPath;ACCESS_MODE=READ_ONLY" + + $conn = [DuckDB.NET.Data.DuckDBConnection]::new($connStr) + try { + $conn.Open() + } + catch { + $conn.Dispose() + throw "Read-MtZtaDatabase: failed to open DuckDB at '$absPath' read-only ($($_.Exception.Message)). DB version mismatch or file lock?" + } + + # .GetNewClosure() captures $conn from the current scope — $using: is PSRemoting + # syntax and does not apply here. + $queryFn = { + param([string] $sql) + $cmd = $conn.CreateCommand() + $cmd.CommandText = $sql + $reader = $cmd.ExecuteReader() + $rows = @() + try { + while ($reader.Read()) { + $row = [ordered]@{} + for ($i = 0; $i -lt $reader.FieldCount; $i++) { + $row[$reader.GetName($i)] = if ($reader.IsDBNull($i)) { $null } else { $reader.GetValue($i) } + } + $rows += [pscustomobject]$row + } + } + finally { + $reader.Dispose() + $cmd.Dispose() + } + return ,$rows + }.GetNewClosure() + + $tables = @() + try { + $tableRows = & $queryFn "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name" + $tables = @($tableRows | ForEach-Object { $_.table_name }) + } + catch { + $conn.Close(); $conn.Dispose() + throw "Read-MtZtaDatabase: schema probe failed ($($_.Exception.Message)). information_schema.tables is unreadable." + } + + # Baseline assertion — all required tables must exist. + $required = @( + 'Application','ConfigurationPolicy','Device', + 'RoleAssignment','RoleAssignmentGroup', + 'RoleAssignmentScheduleInstance','RoleAssignmentScheduleInstanceGroup', + 'RoleDefinition', + 'RoleEligibilityScheduleInstance','RoleEligibilityScheduleInstanceGroup', + 'RoleManagementPolicyAssignment', + 'ServicePrincipal','ServicePrincipalSignIn', + 'SignIn','User','UserRegistrationDetails','vwRole' + ) + $missing = $required | Where-Object { $_ -notin $tables } + if ($missing) { + $conn.Close(); $conn.Dispose() + throw "Read-MtZtaDatabase: schema baseline mismatch. Missing tables: $($missing -join ', '). Database may be from a ZTA version older than 2.2.0." + } + + $hasTableFn = { + param([string] $name) + return ($tables -contains $name) + }.GetNewClosure() + + $hasColumnFn = { + param([string] $tableName, [string] $columnName) + $rows = & $queryFn "SELECT column_name FROM information_schema.columns WHERE table_schema='main' AND table_name='$tableName' AND column_name='$columnName' LIMIT 1" + return ($rows.Count -gt 0) + }.GetNewClosure() + + # GetRows / BuildIndex match the JsonExport surface so callers can swap readers + # interchangeably. Predicate and top are applied in PowerShell after SELECT *. + # Table names are quoted because `User` is a SQL reserved word in DuckDB. + $getRowsFn = { + param([string] $table, [scriptblock] $Predicate, [int] $Top = 0) + if (-not ($tables -contains $table)) { return @() } + $rows = & $queryFn ('SELECT * FROM "' + $table + '"') + if (-not $Predicate -and $Top -le 0) { return $rows } + $collected = New-Object System.Collections.Generic.List[object] + foreach ($r in $rows) { + if ($Predicate -and -not (& $Predicate $r)) { continue } + $collected.Add($r) + if ($Top -gt 0 -and $collected.Count -ge $Top) { break } + } + return $collected.ToArray() + }.GetNewClosure() + + $buildIndexFn = { + param([string] $table, [string] $KeyColumn = 'id') + if (-not ($tables -contains $table)) { return @{} } + $rows = & $queryFn ('SELECT * FROM "' + $table + '"') + $h = @{} + foreach ($r in $rows) { + $v = $r.$KeyColumn + if ($null -ne $v -and -not [string]::IsNullOrEmpty([string]$v)) { + $h[[string]$v] = $r + } + } + return $h + }.GetNewClosure() + + $disposeFn = { + # DuckDB sometimes throws on Close()/Dispose() when the underlying file handle + # is already gone (e.g. after a forced Pester scope teardown). Swallow silently. + try { $conn.Close() } catch { Write-Verbose "Read-MtZtaDatabase: Close() during dispose: $($_.Exception.Message)" } + try { $conn.Dispose() } catch { Write-Verbose "Read-MtZtaDatabase: Dispose() during dispose: $($_.Exception.Message)" } + }.GetNewClosure() + + return [pscustomobject]@{ + Tier = 'Database' + Connection = $conn + Query = $queryFn + Tables = $tables + SupportsSql = $true + HasTable = $hasTableFn + HasColumn = $hasColumnFn + GetRows = $getRowsFn + BuildIndex = $buildIndexFn + Dispose = $disposeFn + } +} + +function Initialize-MtZtaDuckDbAssembly { + <# + .SYNOPSIS + Internal: opportunistically locates DuckDB.NET.Data (and its native libduckdb) + and loads them into the AppDomain. Returns $true on success, $false on miss. + + .DESCRIPTION + Best-effort: returns $true on success, $false on miss (never throws). + Maester ships zero DuckDB binaries; the JSON-shadow reader covers all callers. + Probes in order: + + 1. AppDomain — already loaded (the ZeroTrustAssessment module declares + DuckDB.NET.Data as a RequiredAssembly, so importing ZTA auto-loads it). + 2. ZeroTrustAssessment module's `lib/` folder — preferred path; ZTA ships + matching versioned binaries. + 3. Maester's own `lib/` folder — backward-compatible path for operators + who manually populated it. + + On any miss returns $false. Caller (Read-MtZtaDatabase) translates that + into a $null return so callers treat DuckDB as an opportunistic accelerator. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] $ModuleRoot + ) + + # Probe 1: already in AppDomain (ZTA already loaded into the session) + if ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'DuckDB.NET.Data' }) { + Write-Verbose "Initialize-MtZtaDuckDbAssembly: DuckDB.NET.Data already loaded into AppDomain." + return $true + } + + # Probe 2: ZeroTrustAssessment module's lib/ — preferred path (versioned with ZTA) + $ztaMod = Get-Module ZeroTrustAssessment -ErrorAction SilentlyContinue + if (-not $ztaMod) { + $ztaMod = Get-Module -ListAvailable ZeroTrustAssessment -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + } + if ($ztaMod) { + $ztaManaged = Join-Path $ztaMod.ModuleBase 'lib/DuckDB.NET.Data.dll' + if (Test-Path $ztaManaged) { + try { + Add-Type -Path $ztaManaged -ErrorAction Stop + Write-Verbose "Initialize-MtZtaDuckDbAssembly: loaded $ztaManaged (from ZeroTrustAssessment v$($ztaMod.Version))." + return $true + } + catch { + Write-Verbose "Initialize-MtZtaDuckDbAssembly: Add-Type failed on ZTA's $ztaManaged ($($_.Exception.Message)). Falling through." + } + } + } + + # Probe 3: Maester's own lib/ — legacy operator-populated path + $managed = Join-Path $ModuleRoot 'lib/DuckDB.NET.Data.dll' + if (Test-Path $managed) { + try { + Add-Type -Path $managed -ErrorAction Stop + Write-Verbose "Initialize-MtZtaDuckDbAssembly: loaded $managed (from Maester's own lib/)." + return $true + } + catch { + Write-Verbose "Initialize-MtZtaDuckDbAssembly: Add-Type failed on Maester's $managed ($($_.Exception.Message))." + } + } + + Write-Verbose 'Initialize-MtZtaDuckDbAssembly: no DuckDB.NET.Data assembly available — Tier 1 (JSON shadow) will carry the load.' + return $false +} diff --git a/powershell/internal/Read-MtZtaJsonExport.ps1 b/powershell/internal/Read-MtZtaJsonExport.ps1 new file mode 100644 index 000000000..7e06228dd --- /dev/null +++ b/powershell/internal/Read-MtZtaJsonExport.ps1 @@ -0,0 +1,258 @@ +function Read-MtZtaJsonExport { + <# + .SYNOPSIS + Internal: opens a ZTA bundle's JSON shadow export (zt-export/<Table>/<Table>-N.json + files) and returns a query context with the same shape as `Read-MtZtaDatabase`. + + .DESCRIPTION + Tier 1 reader — universal, dependency-free path. Reads the per-table JSON shards + ZTA writes alongside `zt.db`. Streaming-bounded by largest shard (~70-100 MB per + ZTA's sharding policy), so memory peak stays the same regardless of total table + size. Hashtable indexes are built lazily on demand. + + Returns a context object whose surface matches `Read-MtZtaDatabase` (DuckDB tier) + so callers can swap interchangeably: + + BundlePath = original bundle root path (string) + ExportRoot = resolved zt-export/ root (string) + Tables = array of table names discovered as zt-export/<Name>/ folders + HasTable = scriptblock(name) -> bool + HasColumn = scriptblock(table, column) -> bool (probes first shard) + GetRows = scriptblock(table[, predicate[, top]]) -> rows (streaming) + BuildIndex = scriptblock(table[, keyColumn]) -> hashtable<keyValue, row> + Query = scriptblock(sql) -> rows (mini SQL adapter) + SupportsSql = $true (limited subset only — see Get-MtZtaSqlPlan) + Tier = 'JsonExport' + Dispose = scriptblock (clears caches) + + Schema baseline (16 tables) is asserted at load time. `vwRole` is excluded + because it is a DuckDB view, not a JSON-shadow folder. + + .PARAMETER BundlePath + Path to the ZTA result bundle. The reader probes + `<BundlePath>/zt-export/<Table>/<Table>-N.json` first, then falls back to + `<BundlePath>/<Table>/...` for compact bundles that flatten the export. + + .PARAMETER LimitToTables + Optional string array. When supplied, only these tables are listed in + `Tables`. Used by callers that want to defer schema-baseline assertions or + narrow the surface for a specific test. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string] $BundlePath, + + [Parameter(Mandatory = $false)] + [string[]] $LimitToTables + ) + + if (-not (Test-Path $BundlePath)) { + throw "Read-MtZtaJsonExport: bundle path not found: $BundlePath" + } + + # Resolve the export root. Two layouts in the wild: + # <BundlePath>/zt-export/<Table>/... (canonical from ZTA) + # <BundlePath>/<Table>/... (rare, when a packager flattens) + $exportRoot = Join-Path $BundlePath 'zt-export' + if (-not (Test-Path $exportRoot)) { + $exportRoot = $BundlePath + } + + # Discover tables = subdirectories under the export root that contain + # at least one <Name>-*.json file (excluding *-model.json). + $tableDirs = Get-ChildItem -Path $exportRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { + Get-ChildItem -Path $_.FullName -Filter "$($_.Name)-*.json" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*-model.json' } | + Select-Object -First 1 + } + $discoveredTables = @($tableDirs | ForEach-Object { $_.Name }) + + # Baseline-required tables absent as folders may be empty in this tenant — + # ZTA omits the folder when the table has zero rows. Include them as + # known-empty so GetRows returns @() and callers don't need to special-case. + # vwRole is a DuckDB view (RoleAssignment join RoleDefinition), not a JSON + # shadow folder, so it is not in the baseline list. + $required = @( + 'Application','ConfigurationPolicy','Device', + 'RoleAssignment','RoleAssignmentGroup', + 'RoleAssignmentScheduleInstance','RoleAssignmentScheduleInstanceGroup', + 'RoleDefinition', + 'RoleEligibilityScheduleInstance','RoleEligibilityScheduleInstanceGroup', + 'RoleManagementPolicyAssignment', + 'ServicePrincipal','ServicePrincipalSignIn', + 'SignIn','User','UserRegistrationDetails' + ) + + # Final table list = baseline-required ∪ discovered (deduped, sorted). + $tables = @(($discoveredTables + $required) | Sort-Object -Unique) + + if ($LimitToTables) { + $tables = @($tables | Where-Object { $_ -in $LimitToTables }) + } + + # Lazy caches, captured by closures below via .GetNewClosure(). + $rowCache = @{} # tableName -> rows (when materialised by BuildIndex or full scan) + $indexCache = @{} # "$table:$column" -> hashtable<value, row> + + # ---- Internal helpers (closure-captured) ---------------------------- + + # Stream-iterate one table's shards into the supplied $collector list. + # Filters via $predicate and stops once $top rows have been collected + # (when $top -gt 0). Top control lives here so $collector.Count is the + # only authoritative counter — sidesteps PowerShell closure-by-value + # semantics on simple [int] variables. + $iterateTable = { + param( + [string] $table, + [scriptblock] $predicate, + [System.Collections.Generic.List[object]] $collector, + [int] $top = 0 + ) + + $shardDir = Join-Path $exportRoot $table + if (-not (Test-Path $shardDir)) { return } + + # Files matching <Table>-N.json (skip <Table>-model.json which is a schema descriptor). + $shards = Get-ChildItem -Path $shardDir -Filter "$table-*.json" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*-model.json' } | + Sort-Object Name + + foreach ($shard in $shards) { + try { + $payload = Get-Content -Path $shard.FullName -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } + catch { + Write-Verbose "Read-MtZtaJsonExport: skipping shard $($shard.Name) (parse error: $($_.Exception.Message))" + continue + } + # Two payload shapes ZTA emits: + # { "@odata.context": "...", "value": [ ...rows... ] } (most tables) + # [ ...rows... ] (rare) + $rows = if ($payload.PSObject.Properties['value'] -and $null -ne $payload.value) { + @($payload.value) + } elseif ($payload -is [System.Array]) { + @($payload) + } else { + @($payload) + } + + foreach ($row in $rows) { + # ZTA emits rows with isZtModelRow=true as schema sentinels; filter them out. + if ($row -and $row.PSObject.Properties['isZtModelRow'] -and $row.isZtModelRow) { continue } + if ($predicate -and -not (& $predicate $row)) { continue } + $collector.Add($row) + if ($top -gt 0 -and $collector.Count -ge $top) { return } + } + } + }.GetNewClosure() + + # ---- Surface scriptblocks (returned to caller) ---------------------- + + $hasTableFn = { + param([string] $name) + return ($tables -contains $name) + }.GetNewClosure() + + $hasColumnFn = { + param([string] $table, [string] $column) + # Cheap probe: read the first row of the first shard, check property bag. + $shardDir = Join-Path $exportRoot $table + $first = Get-ChildItem -Path $shardDir -Filter "$table-*.json" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*-model.json' } | Sort-Object Name | Select-Object -First 1 + if (-not $first) { return $false } + try { + $payload = Get-Content $first.FullName -Raw | ConvertFrom-Json -ErrorAction Stop + $rows = if ($payload.value) { @($payload.value) } else { @($payload) } + $sample = $rows | Where-Object { -not ($_.PSObject.Properties['isZtModelRow'] -and $_.isZtModelRow) } | Select-Object -First 1 + if (-not $sample) { return $false } + return [bool]$sample.PSObject.Properties[$column] + } catch { return $false } + }.GetNewClosure() + + $getRowsFn = { + param([string] $table, [scriptblock] $Predicate, [int] $Top = 0) + if (-not (& $hasTableFn $table)) { return @() } + + $collected = New-Object System.Collections.Generic.List[object] + & $iterateTable $table $Predicate $collected $Top + return $collected.ToArray() + }.GetNewClosure() + + $buildIndexFn = { + param([string] $table, [string] $KeyColumn = 'id') + $cacheKey = "${table}:${KeyColumn}" + if ($indexCache.ContainsKey($cacheKey)) { return $indexCache[$cacheKey] } + + $rows = New-Object System.Collections.Generic.List[object] + & $iterateTable $table $null $rows 0 + $h = @{} + foreach ($r in $rows) { + $v = $r.$KeyColumn + if ($null -ne $v -and -not [string]::IsNullOrEmpty([string]$v)) { + # Last write wins on hash collisions — same behaviour as DuckDB's first-row-by-PK. + $h[[string]$v] = $r + } + } + $indexCache[$cacheKey] = $h + return $h + }.GetNewClosure() + + # Mini SQL adapter — only handles the patterns our tests need. Keeps the API + # surface symmetrical with the DuckDB tier without trying to be a SQL engine. + # Recognised forms: + # SELECT COUNT(*) FROM <table> + # SELECT COUNT(*) FROM <table> WHERE <col> = '<value>' + # SELECT * FROM <table> [LIMIT <n>] + # Anything else throws NotSupportedException with guidance to use GetRows directly. + $queryFn = { + param([string] $sql) + $clean = ($sql -replace '\s+', ' ').Trim() + + # COUNT(*) FROM <t> [WHERE <col> = '<v>'] + if ($clean -match "^\s*SELECT\s+COUNT\s*\(\s*\*\s*\)\s+FROM\s+`"?([A-Za-z_][A-Za-z0-9_]*)`"?(\s+WHERE\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*'([^']*)')?\s*$") { + $tbl = $matches[1] + if ($matches[2]) { + $col = $matches[3]; $val = $matches[4] + $pred = [scriptblock]::Create("param(`$row); `$row.$col -eq '$val'") + $rows = & $getRowsFn $tbl $pred 0 + } + else { + $rows = & $getRowsFn $tbl $null 0 + } + return @([pscustomobject]@{ count_star = $rows.Count }) + } + + # SELECT * FROM <t> [LIMIT N] + if ($clean -match '^\s*SELECT\s+\*\s+FROM\s+"?([A-Za-z_][A-Za-z0-9_]*)"?\s*(LIMIT\s+(\d+))?\s*$') { + $tbl = $matches[1] + $top = if ($matches[3]) { [int]$matches[3] } else { 0 } + return ,@(& $getRowsFn $tbl $null $top) + } + + throw [System.NotSupportedException]::new( + "Read-MtZtaJsonExport: SQL adapter does not support this query — use GetRows / BuildIndex directly. SQL: $sql" + ) + }.GetNewClosure() + + $disposeFn = { + $rowCache.Clear() + $indexCache.Clear() + }.GetNewClosure() + + return [pscustomobject]@{ + Tier = 'JsonExport' + BundlePath = $BundlePath + ExportRoot = $exportRoot + Tables = $tables + SupportsSql = $true + HasTable = $hasTableFn + HasColumn = $hasColumnFn + GetRows = $getRowsFn + BuildIndex = $buildIndexFn + Query = $queryFn + Dispose = $disposeFn + } +} diff --git a/powershell/internal/Resolve-MtZtaArtifact.ps1 b/powershell/internal/Resolve-MtZtaArtifact.ps1 new file mode 100644 index 000000000..f74012aa0 --- /dev/null +++ b/powershell/internal/Resolve-MtZtaArtifact.ps1 @@ -0,0 +1,304 @@ +function Resolve-MtZtaArtifact { + <# + .SYNOPSIS + Internal: detects the source kind of a ZTA result reference and returns a local + directory containing the extracted bundle. + + .DESCRIPTION + Three-source resolver for `Import-MtZtaResult`. Source patterns matched in priority order: + + 1. ^https?://[^/]+\.blob\.core\.windows\.net/ Azure Blob + 2. ^upkg:// OR ^https://pkgs\.dev\.azure\.com/.+/_apis/packaging/ + Azure Artifacts Universal Package + 3. (else) Local path: folder, .tar.gz, or .zip + + Returns the path to a LOCAL DIRECTORY containing manifest.json + + ZeroTrustAssessmentReport.json + db/zt.db at minimum. If the source is already a + local folder, returns it unchanged. If a tarball/zip, extracts to cache and returns + the extracted path. + + Cache root: $env:TEMP/maester/zta-cache/ (override via `MAESTER_ZTA_CACHE`), + keyed by `sha256(source)[:16]`. Retain last 5 OR anything used in last 30 days, + whichever is larger. + + SAS query strings (?sig=...) are masked in any log line. + + .PARAMETER Source + ZTA result source string. See Description for accepted patterns. + + .EXAMPLE + $bundlePath = Resolve-MtZtaArtifact -Source '.\zta-results-2026-05-01.tar.gz' + # -> $env:TEMP/maester/zta-cache/abcdef0123456789/ + + .EXAMPLE + $bundlePath = Resolve-MtZtaArtifact -Source 'https://contoso.blob.core.windows.net/zta/2026-05-01.tar.gz?sig=...' + # -> downloads, extracts, returns local cache path + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string] $Source + ) + + if ([string]::IsNullOrWhiteSpace($Source)) { + throw 'Resolve-MtZtaArtifact: -Source is empty.' + } + + # Mask SAS sig values in any log output + $sourceForLog = $Source -replace '(\?|&)(sig|sv|st|se|sp|spr|sr|ss|srt|sip|tn)=[^&]*', '$1$2=***' + Write-Verbose "Resolve-MtZtaArtifact: source = $sourceForLog" + + # Determine cache root + $cacheRoot = if ($env:MAESTER_ZTA_CACHE) { $env:MAESTER_ZTA_CACHE } else { Join-Path ([System.IO.Path]::GetTempPath()) 'maester/zta-cache' } + if (-not (Test-Path $cacheRoot)) { New-Item -ItemType Directory -Force -Path $cacheRoot | Out-Null } + + # Cache key: sha256(source)[:16] + $sha = [System.Security.Cryptography.SHA256]::Create() + $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Source)) + $sha.Dispose() + $key = ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 16).ToLowerInvariant() + $cacheDir = Join-Path $cacheRoot $key + + # Source kind detection — priority order matters + $kind = $null + if ($Source -match '^https?://[^/]+\.blob\.core\.windows\.net/') { + $kind = 'AzureBlob' + } + elseif ($Source -match '^upkg://' -or $Source -match '^https://pkgs\.dev\.azure\.com/.+/_apis/packaging/') { + $kind = 'UniversalPackage' + } + else { + $kind = 'LocalPath' + } + Write-Verbose "Resolve-MtZtaArtifact: detected kind = $kind" + + switch ($kind) { + 'LocalPath' { + if (-not (Test-Path $Source)) { + throw "Resolve-MtZtaArtifact: local path not found: $Source" + } + $item = Get-Item $Source + if ($item.PSIsContainer) { + # Already a directory — use directly, no caching + return (Resolve-Path $Source).Path + } + # File: must be .tar.gz, .tgz, or .zip + if ($item.Name -match '\.tar\.gz$|\.tgz$') { + Expand-MtZtaTarball -ArchivePath $item.FullName -DestinationPath $cacheDir + return $cacheDir + } + elseif ($item.Name -match '\.zip$') { + if (Test-Path $cacheDir) { Remove-Item -Recurse -Force $cacheDir } + Expand-Archive -Path $item.FullName -DestinationPath $cacheDir -Force + return $cacheDir + } + else { + throw "Resolve-MtZtaArtifact: local path '$Source' is a file but not .tar.gz / .tgz / .zip." + } + } + 'AzureBlob' { + $localFile = Join-Path $cacheDir 'download.tar.gz' + if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null } + Get-MtZtaAzureBlob -BlobUrl $Source -DestinationFile $localFile + Expand-MtZtaTarball -ArchivePath $localFile -DestinationPath $cacheDir + return $cacheDir + } + 'UniversalPackage' { + $localFile = Join-Path $cacheDir 'download.tar.gz' + if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null } + Get-MtZtaUniversalPackage -Reference $Source -DestinationDirectory $cacheDir + # az artifacts universal download extracts the package; expect bundle files + # to be present already. If a tarball is included, extract it. + $tarball = Get-ChildItem -Path $cacheDir -Filter '*.tar.gz' -File | Select-Object -First 1 + if ($tarball) { + Expand-MtZtaTarball -ArchivePath $tarball.FullName -DestinationPath $cacheDir + Remove-Item $tarball.FullName -Force + } + return $cacheDir + } + } +} + +function Expand-MtZtaTarball { + <# + .SYNOPSIS + Internal: extracts a .tar.gz archive to a destination directory using the system tar. + + .DESCRIPTION + Hardened against path-traversal: rejects entries whose normalized path escapes the + destination root. PowerShell 7+ ships tar on every platform; on PS 5.1 falls back + to System.IO.Compression for .zip only (callers using .tar.gz on PS 5.1 will get a + clear error). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] [string] $ArchivePath, + [Parameter(Mandatory = $true)] [string] $DestinationPath + ) + + if (Test-Path $DestinationPath) { Remove-Item -Recurse -Force $DestinationPath } + New-Item -ItemType Directory -Force -Path $DestinationPath | Out-Null + + $tarExe = Get-Command tar -ErrorAction SilentlyContinue + if (-not $tarExe) { + throw "Expand-MtZtaTarball: 'tar' not found on PATH. PowerShell 7+ on Windows/Linux/macOS ships it; install or upgrade." + } + + $absDest = (Resolve-Path $DestinationPath).Path + + # Extract to a quarantine subdir first, validate paths, then move into place. + $quarantine = Join-Path $absDest '.unpack' + New-Item -ItemType Directory -Force -Path $quarantine | Out-Null + + & $tarExe -xzf $ArchivePath -C $quarantine + if ($LASTEXITCODE -ne 0) { + throw "Expand-MtZtaTarball: tar exited with $LASTEXITCODE extracting $ArchivePath." + } + + # Path-traversal check: every file under quarantine must resolve under quarantine root. + Get-ChildItem -Path $quarantine -Recurse -Force | ForEach-Object { + $resolved = (Resolve-Path $_.FullName).Path + if (-not $resolved.StartsWith($quarantine, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Expand-MtZtaTarball: path-traversal detected: $($_.FullName)" + } + } + + # Promote all entries up one level (out of .unpack/) into $DestinationPath. + Get-ChildItem -Path $quarantine -Force | ForEach-Object { + Move-Item -Path $_.FullName -Destination $absDest -Force + } + Remove-Item -Recurse -Force $quarantine -ErrorAction SilentlyContinue +} + +function Get-MtZtaAzureBlob { + <# + .SYNOPSIS + Internal: downloads a blob from Azure Blob Storage to a local file. + + .DESCRIPTION + Auth ladder: + 1. SAS query in the URL — Invoke-WebRequest directly + 2. Current Az session — Get-AzStorageBlobContent -UseConnectedAccount + 3. WIF — Connect-AzAccount -ServicePrincipal -FederatedToken from $env:AZURE_FEDERATED_TOKEN + 4. Managed Identity — Connect-AzAccount -Identity + First successful path wins. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] [string] $BlobUrl, + [Parameter(Mandatory = $true)] [string] $DestinationFile + ) + + $hasSas = $BlobUrl -match '\?(.*&)?sig=' + if ($hasSas) { + Write-Verbose "Get-MtZtaAzureBlob: using SAS in URL" + Invoke-WebRequest -Uri $BlobUrl -OutFile $DestinationFile -UseBasicParsing + return + } + + if (-not (Get-Module Az.Storage -ListAvailable -ErrorAction SilentlyContinue)) { + throw "Get-MtZtaAzureBlob: Azure Blob URL has no SAS and Az.Storage module is not installed. Install Az.Storage or supply a SAS URL." + } + Import-Module Az.Storage -ErrorAction Stop + + # Parse account / container / blob from URL + if ($BlobUrl -match '^https?://([^.]+)\.blob\.core\.windows\.net/([^/]+)/(.+?)(\?|$)') { + $accountName = $Matches[1] + $container = $Matches[2] + $blobName = $Matches[3] + } + else { + throw "Get-MtZtaAzureBlob: unrecognised Azure Blob URL shape: $BlobUrl" + } + + # Ensure we have an Az context — try existing session first, then WIF, then MI. + if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { + if ($env:AZURE_FEDERATED_TOKEN -and $env:AZURE_CLIENT_ID -and $env:AZURE_TENANT_ID) { + Write-Verbose "Get-MtZtaAzureBlob: connecting via WIF" + Connect-AzAccount -ServicePrincipal ` + -ApplicationId $env:AZURE_CLIENT_ID ` + -FederatedToken $env:AZURE_FEDERATED_TOKEN ` + -Tenant $env:AZURE_TENANT_ID ` + -ErrorAction Stop | Out-Null + } + else { + Write-Verbose "Get-MtZtaAzureBlob: trying managed identity" + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + } + } + + $ctx = New-AzStorageContext -StorageAccountName $accountName -UseConnectedAccount -ErrorAction Stop + Get-AzStorageBlobContent -Context $ctx -Container $container -Blob $blobName ` + -Destination $DestinationFile -Force -ErrorAction Stop | Out-Null +} + +function Get-MtZtaUniversalPackage { + <# + .SYNOPSIS + Internal: downloads an Azure Artifacts Universal Package via `az artifacts universal download`. + + .DESCRIPTION + Accepts two reference shapes: + upkg://<org>/<project>/<feed>/<name>@<version> (project-scoped) + upkg://<org>//<feed>/<name>@<version> (org-scoped — note double slash) + + Plus the canonical Azure DevOps URL: + https://pkgs.dev.azure.com/<org>/<project>/_apis/packaging/feeds/<feed>/upack/packages/<name>/versions/<version> + + Requires `az` CLI and an authenticated session (az login OR `SYSTEM_ACCESSTOKEN` + env var on a build agent). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] [string] $Reference, + [Parameter(Mandatory = $true)] [string] $DestinationDirectory + ) + + $az = Get-Command az -ErrorAction SilentlyContinue + if (-not $az) { + throw "Get-MtZtaUniversalPackage: 'az' CLI not found. Install from https://aka.ms/installazurecliwindows." + } + + $org = $null; $project = $null; $feed = $null; $name = $null; $version = $null + + if ($Reference -match '^upkg://([^/]+)/([^/]*)/([^/]+)/([^@]+)@(.+)$') { + $org = $Matches[1] + $project = $Matches[2] # may be empty for org-scoped + $feed = $Matches[3] + $name = $Matches[4] + $version = $Matches[5] + } + elseif ($Reference -match '^https://pkgs\.dev\.azure\.com/([^/]+)/([^/]+)/_apis/packaging/feeds/([^/]+)/upack/packages/([^/]+)/versions/(.+)$') { + $org = $Matches[1] + $project = $Matches[2] + $feed = $Matches[3] + $name = $Matches[4] + $version = $Matches[5] + } + else { + throw "Get-MtZtaUniversalPackage: unrecognised reference shape: $Reference" + } + + if (-not (Test-Path $DestinationDirectory)) { + New-Item -ItemType Directory -Force -Path $DestinationDirectory | Out-Null + } + + # `$args` is a PowerShell automatic variable — avoid assigning to it (PSScriptAnalyzer rule + # PSAvoidAssignmentToAutomaticVariable). Use `$azArgs` instead; same shape, no side-effects. + $azArgs = @( + 'artifacts', 'universal', 'download', + '--organization', "https://dev.azure.com/$org", + '--feed', $feed, + '--name', $name, + '--version', $version, + '--path', $DestinationDirectory + ) + if ($project) { $azArgs += @('--project', $project) } + + Write-Verbose "Get-MtZtaUniversalPackage: invoking az $($azArgs -join ' ')" + & az @azArgs + if ($LASTEXITCODE -ne 0) { + throw "Get-MtZtaUniversalPackage: az exited with $LASTEXITCODE downloading $name@$version from $org/$project/$feed." + } +} diff --git a/powershell/internal/Test-MtZtaFreshness.ps1 b/powershell/internal/Test-MtZtaFreshness.ps1 new file mode 100644 index 000000000..a4d39efad --- /dev/null +++ b/powershell/internal/Test-MtZtaFreshness.ps1 @@ -0,0 +1,109 @@ +function Test-MtZtaFreshness { + <# + .SYNOPSIS + Internal: returns how stale a ZTA artifact is and whether it exceeds the configured + freshness threshold. + + .DESCRIPTION + Timestamp source priority: + 1. ManifestRunStartTime — manifest.json.runStartTime (most authoritative) + 2. JsonExecutedAt — ZeroTrustAssessmentReport.json's ExecutedAt field + 3. DbMtime — zt.db file mtime (least authoritative; warns) + + Returns: + [pscustomobject] { + IsStale = [bool] + AgeDays = [int] + Threshold = [int] + TimestampSource = [string] # one of the three above, or 'None' + Timestamp = [datetime] # the resolved timestamp (UTC) + } + + Caller (Import-MtZtaResult) is responsible for the side effects when stale. + Run still proceeds — warn-but-proceed semantics. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + # The bundle root (folder, .tar.gz / .zip already extracted) containing + # manifest.json + ZeroTrustAssessmentReport.json + db/zt.db. + [Parameter(Mandatory = $true)] + [string] $BundlePath, + + [Parameter(Mandatory = $false)] + [int] $FreshnessDays = 14 + ) + + if (-not (Test-Path $BundlePath)) { + throw "Test-MtZtaFreshness: bundle path not found: $BundlePath" + } + + $manifestFile = Join-Path $BundlePath 'manifest.json' + $jsonFile = Join-Path $BundlePath 'ZeroTrustAssessmentReport.json' + $dbFile = Join-Path $BundlePath 'db/zt.db' + + $timestamp = $null + $source = 'None' + + # Priority 1: manifest.json.runStartTime + if (Test-Path $manifestFile) { + try { + $manifest = Get-Content $manifestFile -Raw | ConvertFrom-Json -ErrorAction Stop + if ($manifest.PSObject.Properties['runStartTime'] -and $manifest.runStartTime) { + $timestamp = [datetime]::Parse($manifest.runStartTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) + $source = 'ManifestRunStartTime' + } + } + catch { + Write-Verbose "Test-MtZtaFreshness: manifest.json.runStartTime unreadable ($($_.Exception.Message)); falling through." + } + } + + # Priority 2: ZeroTrustAssessmentReport.json's ExecutedAt + if (-not $timestamp -and (Test-Path $jsonFile)) { + try { + $report = Get-Content $jsonFile -Raw | ConvertFrom-Json -ErrorAction Stop + if ($report.PSObject.Properties['ExecutedAt'] -and $report.ExecutedAt) { + $timestamp = [datetime]::Parse($report.ExecutedAt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) + $source = 'JsonExecutedAt' + } + } + catch { + Write-Verbose "Test-MtZtaFreshness: ZeroTrustAssessmentReport.json ExecutedAt unreadable ($($_.Exception.Message)); falling through." + } + } + + # Priority 3: zt.db file mtime — least authoritative, warns + if (-not $timestamp -and (Test-Path $dbFile)) { + $timestamp = (Get-Item $dbFile).LastWriteTimeUtc + $source = 'DbMtime' + Write-Warning "Test-MtZtaFreshness: derived from zt.db file mtime ($timestamp); manifest + JSON timestamps unavailable. Result may be unreliable on copied or re-downloaded artifacts." + } + + if (-not $timestamp) { + Write-Warning "Test-MtZtaFreshness: no timestamp source found in $BundlePath. Manifest, JSON, and DB are all unreadable or absent." + return [pscustomobject]@{ + IsStale = $false + AgeDays = -1 + Threshold = $FreshnessDays + TimestampSource = 'None' + Timestamp = $null + } + } + + $now = [datetime]::UtcNow + # Clamp future-dated timestamps to 0 — bundles produced on a host whose clock + # is ahead of the runner produce a negative raw age, which downstream renderers + # turn into a "-N%" chip. Zero is the correct "fresh" sentinel. + $rawAge = ($now - $timestamp).TotalDays + $ageDays = if ($rawAge -lt 0) { 0 } else { [int][math]::Floor($rawAge) } + $isStale = $ageDays -gt $FreshnessDays + + [pscustomobject]@{ + IsStale = $isStale + AgeDays = $ageDays + Threshold = $FreshnessDays + TimestampSource = $source + Timestamp = $timestamp + } +} diff --git a/powershell/public/Build-MtZtaBundle.ps1 b/powershell/public/Build-MtZtaBundle.ps1 new file mode 100644 index 000000000..14a3a6d40 --- /dev/null +++ b/powershell/public/Build-MtZtaBundle.ps1 @@ -0,0 +1,514 @@ +function Build-MtZtaBundle { + <# + .SYNOPSIS + Compiles a single hashtable of ZTA-derived analytics for embedding into + the Maester report's HTML/JSON output (consumed by the ZTA tab in the + React report). + + .DESCRIPTION + The Maester HTML report inlines its result JSON via `Get-MtHtmlReport`. + The ZTA tab needs a few extra fields beyond the standard test rows: + bundle metadata, per-pillar summary, inventory counts, and curated + analytics (auth-method posture, privileged exposure, application + credential hygiene, device trust mix). + + This helper walks `$script:MtZtaContext` (populated by + `Import-MtZtaResult`) and emits one hashtable that the orchestrator + injects into the Maester result object as `ZtaBundle`. When no ZTA + context is loaded the function returns `$null` — the orchestrator + skips augmentation and the ZTA tab degrades gracefully. + + Reuses `Get-MtZtaAuthMethodSet` for method classification and the same + Tier-0 role-template constants used by the ZTA-aware test surface. + + .OUTPUTS + [hashtable] or $null + + .EXAMPLE + # In the orchestrator, right after Invoke-Maester returns: + $bundle = Build-MtZtaBundle + if ($bundle) { + $result | Add-Member -NotePropertyName 'ZtaBundle' -NotePropertyValue $bundle -Force + } + + .LINK + https://maester.dev/docs/commands/Build-MtZtaBundle + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + + if (-not $script:MtZtaContext) { + Write-Verbose 'Build-MtZtaBundle: no MtZtaContext loaded; returning $null.' + return $null + } + $ctx = $script:MtZtaContext + + # Pick the highest-tier reader available: DuckDB if loaded, else JSON shadow. + $reader = $null + if ($ctx.PSObject.Properties['Database'] -and $ctx.Database) { + $reader = $ctx.Database + } elseif ($ctx.PSObject.Properties['JsonExport'] -and $ctx.JsonExport) { + $reader = $ctx.JsonExport + } + + # Curated Tier-0 / critical-impact role-template IDs. + $tier0Roles = [ordered]@{ + '62e90394-69f5-4237-9190-012177145e10' = 'Global Administrator' + 'e8611ab8-bd05-4f8c-9bd9-2ec6c2b4a771' = 'Privileged Role Administrator' + '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' = 'Privileged Authentication Administrator' + 'fe930be7-5e62-47db-91af-98c3a49a38b1' = 'User Administrator' + '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' = 'Application Administrator' + '158c047a-c907-4556-b7ef-446551a6b5f7' = 'Cloud Application Administrator' + '194ae4cb-b126-40b2-bd5b-6091b380977d' = 'Security Administrator' + 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' = 'Conditional Access Administrator' + '8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2' = 'Hybrid Identity Administrator' + '3a2c62db-5318-420d-8d74-23affee5d9d5' = 'Intune Administrator' + '29232cdf-9323-42fd-ade2-1d097af3e4de' = 'Exchange Administrator' + 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' = 'SharePoint Administrator' + '69091246-20e8-4a56-aa4d-066075b2a7a8' = 'Teams Administrator' + } + + # ExecutedAt / ZtaVersion are on the nested Report/Manifest objects — drill in. + $executedAt = $null + if ($ctx.PSObject.Properties['Report'] -and $ctx.Report -and $ctx.Report.PSObject.Properties['ExecutedAt']) { + $executedAt = $ctx.Report.ExecutedAt + } + # Manifest is preferred (packager-stamped), but unpackaged bundles only carry + # the report — fall back to the report's CurrentVersion field. + $ztaVersion = $null + if ($ctx.PSObject.Properties['Manifest'] -and $ctx.Manifest -and $ctx.Manifest.PSObject.Properties['ztaVersion']) { + $ztaVersion = $ctx.Manifest.ztaVersion + } elseif ($ctx.PSObject.Properties['Report'] -and $ctx.Report -and $ctx.Report.PSObject.Properties['CurrentVersion']) { + $ztaVersion = $ctx.Report.CurrentVersion + } + + # ── Top-level metadata + Freshness + Summary ──────────────────────────── + $bundle = @{ + TenantId = $ctx.TenantId + TenantName = $ctx.TenantName + ExecutedAt = $executedAt + ZtaAssessmentVersion = $ztaVersion + BundlePath = if ($ctx.PSObject.Properties['BundlePath']) { $ctx.BundlePath } else { $null } + Source = if ($ctx.PSObject.Properties['Source']) { $ctx.Source } else { $null } + Tier = if ($reader -and $reader.PSObject.Properties['Tier']) { $reader.Tier } else { 'None' } + IsStale = [bool]$ctx.IsStale + Freshness = $null + Summary = $null + Inventory = @{} + Applications = @{} + Devices = @{} + Privileged = @{} + AuthMethodScore = @{} + ConditionalAccess = @{} + } + if ($ctx.PSObject.Properties['Freshness'] -and $ctx.Freshness) { + $bundle.Freshness = @{ + AgeDays = $ctx.Freshness.AgeDays + Threshold = $ctx.Freshness.Threshold + IsStale = [bool]$ctx.Freshness.IsStale + TimestampSource = $ctx.Freshness.TimestampSource + } + } + # Reuse Get-MtZta -Section Summary so the bundle matches what tests see. + # Best-effort — any failure leaves $bundle.Summary as $null and the ZTA tab + # degrades gracefully. + try { + $summary = Get-MtZta -Section Summary -ErrorAction Stop + if ($summary) { $bundle.Summary = $summary } + } catch { + Write-Verbose "Build-MtZtaBundle: Get-MtZta -Section Summary failed ($($_.Exception.Message)); bundle.Summary stays null." + } + + # ── Bail if no reader: bundle still has metadata + summary, just no analytics + if (-not $reader) { + Write-Verbose 'Build-MtZtaBundle: no Tier 1/Tier 2 reader; emitting metadata-only bundle.' + return $bundle + } + + # ── Inventory totals (cheap streaming counts) ──────────────────────────── + $tableTotals = [ordered]@{ + Users = 'User' + Devices = 'Device' + Applications = 'Application' + ServicePrincipals = 'ServicePrincipal' + PermanentRoleAssignments = 'RoleAssignment' + PimEligibleAssignments = 'RoleEligibilityScheduleInstance' + } + foreach ($k in $tableTotals.Keys) { + try { + $rows = & $reader.GetRows $tableTotals[$k] + $bundle.Inventory[$k] = @($rows).Count + } catch { + $bundle.Inventory[$k] = $null + Write-Verbose "Build-MtZtaBundle: failed to count $($tableTotals[$k]): $_" + } + } + + # Member / Guest split of the User table — surfaced for the Tenant scale + # card. These are derived directly from the User table (not from URD) so + # they reflect the full population including users who never signed in. + try { + $allUsers = @(& $reader.GetRows 'User') + $bundle.Inventory['MemberUsers'] = @($allUsers | Where-Object { $_.userType -eq 'member' }).Count + $bundle.Inventory['GuestUsers'] = @($allUsers | Where-Object { $_.userType -eq 'guest' }).Count + } catch { + $bundle.Inventory['MemberUsers'] = $null + $bundle.Inventory['GuestUsers'] = $null + } + + # Privileged principals (unique principalIds across RoleAssignment + + # RoleEligibilityScheduleInstance). Counts every principal with at least + # one permanent OR PIM-eligible directory role — used for the Tenant + # scale "privileged" sub-cell. + try { + $privSet = @{} + try { + foreach ($r in @(& $reader.GetRows 'RoleAssignment')) { + if ($r.principalId) { $privSet[[string]$r.principalId] = $true } + } + } catch { + Write-Verbose "Build-MtZtaBundle: RoleAssignment table read failed ($($_.Exception.Message)); proceeding with PIM-eligible only." + } + try { + foreach ($r in @(& $reader.GetRows 'RoleEligibilityScheduleInstance')) { + if ($r.principalId) { $privSet[[string]$r.principalId] = $true } + } + } catch { + Write-Verbose "Build-MtZtaBundle: RoleEligibilityScheduleInstance read failed ($($_.Exception.Message)); proceeding with permanent only." + } + $bundle.Inventory['PrivilegedPrincipalsTotal'] = $privSet.Count + } catch { + $bundle.Inventory['PrivilegedPrincipalsTotal'] = $null + } + + # ── Applications analytics ─────────────────────────────────────────────── + try { + $appRows = @(& $reader.GetRows 'Application') + $now = [datetime]::UtcNow + $hasExpired = { + param($app) + foreach ($c in @($app.passwordCredentials)) { + if (-not $c.endDateTime) { continue } + try { if ([datetime]::Parse([string]$c.endDateTime).ToUniversalTime() -lt $now) { return $true } } + catch { Write-Verbose "Build-MtZtaBundle: unparseable endDateTime '$($c.endDateTime)' on app — skipping credential." } + } + return $false + } + $withPwd = @($appRows | Where-Object { @($_.passwordCredentials).Count -gt 0 }) + $withKey = @($appRows | Where-Object { @($_.keyCredentials).Count -gt 0 }) + $bundle.Applications = @{ + Total = $appRows.Count + WithPasswordCredentials = $withPwd.Count + WithKeyCredentials = $withKey.Count + CredentialFree = @($appRows | Where-Object { @($_.passwordCredentials).Count -eq 0 -and @($_.keyCredentials).Count -eq 0 }).Count + ExpiredSecretsStillPresent = @($withPwd | Where-Object { & $hasExpired $_ }).Count + MultiTenant = @($appRows | Where-Object { $_.signInAudience -in @('AzureADMultipleOrgs','AzureADandPersonalMicrosoftAccount') }).Count + BySignInAudience = @{} + } + foreach ($g in ($appRows | Group-Object signInAudience)) { + $name = if ($g.Name) { [string]$g.Name } else { '(unset)' } + $bundle.Applications.BySignInAudience[$name] = $g.Count + } + } catch { + Write-Verbose "Build-MtZtaBundle: Application analytics failed: $_" + } + + # ── Devices analytics ──────────────────────────────────────────────────── + try { + $devRows = @(& $reader.GetRows 'Device') + $cutoff = [datetime]::UtcNow.AddDays(-90) + $isStale = { + param($d) + if ($d.accountEnabled -ne $true) { return $false } + if (-not $d.approximateLastSignInDateTime) { return $false } + try { return [datetime]::Parse([string]$d.approximateLastSignInDateTime).ToUniversalTime() -lt $cutoff } catch { return $false } + } + $bundle.Devices = @{ + Total = $devRows.Count + IsCompliantTrue = @($devRows | Where-Object { $_.isCompliant -eq $true }).Count + IsCompliantFalse = @($devRows | Where-Object { $_.isCompliant -eq $false }).Count + IsManagedTrue = @($devRows | Where-Object { $_.isManaged -eq $true }).Count + AccountEnabled = @($devRows | Where-Object { $_.accountEnabled -eq $true }).Count + StaleDevices = @($devRows | Where-Object { & $isStale $_ }).Count + ByTrustType = @{} + ByOs = @{} + } + foreach ($g in ($devRows | Group-Object trustType)) { + $name = if ($g.Name) { [string]$g.Name } else { '(empty)' } + $bundle.Devices.ByTrustType[$name] = $g.Count + } + foreach ($g in ($devRows | Group-Object operatingSystem)) { + $name = if ($g.Name) { [string]$g.Name } else { '(empty)' } + $bundle.Devices.ByOs[$name] = $g.Count + } + } catch { + Write-Verbose "Build-MtZtaBundle: Device analytics failed: $_" + } + + # ── Conditional Access policy posture ─────────────────────────────────── + # Live Graph fetch — Maester is connected at this point in the orchestrator + # so re-using Invoke-MtGraphRequest is the cheapest path. Falls back to + # empty hashtable on any failure so the ZTA tab card degrades gracefully. + try { + $caRows = @() + try { + $caRaw = Invoke-MtGraphRequest -RelativeUri 'identity/conditionalAccess/policies' -ApiVersion v1.0 -ErrorAction Stop + $caRows = @($caRaw) + } catch { + try { + $caRaw = Invoke-MtGraphRequest -RelativeUri 'identity/conditionalAccess/policies' -ApiVersion beta -ErrorAction Stop + $caRows = @($caRaw) + } catch { + Write-Verbose "Build-MtZtaBundle: CA policies fetch failed (both v1.0 and beta): $_" + } + } + + $enabled = @($caRows | Where-Object { $_.state -eq 'enabled' }) + $reportOnly = @($caRows | Where-Object { $_.state -eq 'enabledForReportingButNotEnforced' }) + $disabled = @($caRows | Where-Object { $_.state -eq 'disabled' }) + + # Build a strengthId → isPhishResistant map by inspecting each strength + # policy's `allowedCombinations`. Display-name matching silently misses + # custom strengths whose names don't contain "phish"; inspecting + # allowedCombinations is the only reliable approach. + # Phish-resistant set per Graph authenticationMethodModes: + # fido2, windowsHelloForBusiness, x509CertificateMultiFactor. + $phishStrengths = @{} + $phishResistantSet = @('fido2','windowsHelloForBusiness','x509CertificateMultiFactor') + try { + $strengthPolicies = Invoke-MtGraphRequest -RelativeUri 'identity/conditionalAccess/authenticationStrength/policies' -ApiVersion v1.0 -ErrorAction Stop + foreach ($sp in @($strengthPolicies)) { + if (-not $sp.id) { continue } + $combos = @($sp.allowedCombinations) + if ($combos.Count -eq 0) { continue } + $allPhish = $true + foreach ($c in $combos) { + if ($c -notin $phishResistantSet) { $allPhish = $false; break } + } + $phishStrengths[[string]$sp.id] = $allPhish + } + } catch { + Write-Verbose "Build-MtZtaBundle: authStrength policies fetch failed: $_" + } + + # Categorise enabled policies: + # MfaRequired enforced policy requiring MFA — via builtIn 'mfa' OR + # authenticationStrength (modern tenants use authStrength, + # not builtIn 'mfa') + # PhishResistant subset of MfaRequired where all allowedCombinations + # are phish-resistant + # Block enforced policy with builtIn 'block' + # NoCaApplied enforced policy with neither MFA nor block (e.g. + # compliantDevice-only, passwordChange, session-only) + # NB: `$rNoCaApplied` counts enforced POLICIES with no MFA or block; + # it is surfaced as PolicyNoMfa in the output hashtable to distinguish + # it from the sign-in funnel's NoCaApplied (users not gated by any CA). + $rMfa = 0; $rPhish = 0; $rBlock = 0; $rNoCaApplied = 0 + $rCompliantDevice = 0 + $hasUserExclusion = 0; $hasGroupExclusion = 0 + foreach ($p in $enabled) { + $controls = @($p.grantControls.builtInControls) + $hasMfaBuiltin = $controls -contains 'mfa' + $hasBlock = $controls -contains 'block' + $hasCompliant = $controls -contains 'compliantDevice' + $strengthId = if ($p.grantControls.authenticationStrength) { [string]$p.grantControls.authenticationStrength.id } else { $null } + $hasAuthStr = [bool]$strengthId + $isPhish = $hasAuthStr -and $phishStrengths.ContainsKey($strengthId) -and $phishStrengths[$strengthId] -eq $true + + if ($hasBlock) { + $rBlock++ + } + elseif ($hasMfaBuiltin -or $hasAuthStr) { + $rMfa++ + if ($isPhish) { $rPhish++ } + } + else { + $rNoCaApplied++ + } + if ($hasCompliant) { $rCompliantDevice++ } + + if (@($p.conditions.users.excludeUsers).Count -gt 0) { $hasUserExclusion++ } + if (@($p.conditions.users.excludeGroups).Count -gt 0) { $hasGroupExclusion++ } + } + + # Sign-in funnel — ZTA pre-computes this as a Sankey node list at + # TenantInfo.OverviewCaMfaAllUsers.nodes. We reuse the same numbers so + # the ZTA tab's "No CA applied" matches the standalone ZTA HTML report. + # User sign in → CA applied | No CA applied + # CA applied → MFA | No MFA + $signIn = @{ + Total = 0 + CaApplied = 0 + NoCaApplied = 0 + Mfa = 0 + NoMfa = 0 + MfaProtectedPct = 0 + Description = $null + } + try { + $sankey = $null + if ($ctx.PSObject.Properties['Report'] -and $ctx.Report -and + $ctx.Report.PSObject.Properties['TenantInfo'] -and $ctx.Report.TenantInfo -and + $ctx.Report.TenantInfo.PSObject.Properties['OverviewCaMfaAllUsers']) { + $sankey = $ctx.Report.TenantInfo.OverviewCaMfaAllUsers + } + if ($sankey -and $sankey.PSObject.Properties['nodes']) { + foreach ($n in @($sankey.nodes)) { + if ($n.source -eq 'User sign in' -and $n.target -eq 'No CA applied') { $signIn.NoCaApplied = [int]$n.value } + if ($n.source -eq 'User sign in' -and $n.target -eq 'CA applied') { $signIn.CaApplied = [int]$n.value } + if ($n.source -eq 'CA applied' -and $n.target -eq 'MFA') { $signIn.Mfa = [int]$n.value } + if ($n.source -eq 'CA applied' -and $n.target -eq 'No MFA') { $signIn.NoMfa = [int]$n.value } + } + $signIn.Total = $signIn.CaApplied + $signIn.NoCaApplied + if ($signIn.Total -gt 0) { + $signIn.MfaProtectedPct = [math]::Round(($signIn.Mfa / $signIn.Total) * 100, 1) + } + if ($sankey.PSObject.Properties['description']) { + $signIn.Description = [string]$sankey.description + } + } + } catch { + Write-Verbose "Build-MtZtaBundle: Sign-in funnel extraction failed: $_" + } + + $bundle.ConditionalAccess = @{ + Total = $caRows.Count + Enabled = $enabled.Count + ReportOnly = $reportOnly.Count + Disabled = $disabled.Count + MfaRequired = $rMfa + PhishResistant = $rPhish + Block = $rBlock + # Policy-level "no MFA + no block" renamed to PolicyNoMfa to avoid + # colliding with the sign-in funnel's NoCaApplied. + PolicyNoMfa = $rNoCaApplied + CompliantDevice = $rCompliantDevice + EnabledWithUserExclusion = $hasUserExclusion + EnabledWithGroupExclusion = $hasGroupExclusion + SignIn = $signIn + } + } catch { + Write-Verbose "Build-MtZtaBundle: ConditionalAccess analytics failed: $_" + } + + # ── Privileged-access analytics ────────────────────────────────────────── + # `$raRows` and `$pimRows` are also used by the auth-method-score block below + # to compute the Privileged population, so they're declared before the try. + $raRows = @() + $pimRows = @() + try { + $raRows = @(& $reader.GetRows 'RoleAssignment') + try { $pimRows = @(& $reader.GetRows 'RoleEligibilityScheduleInstance') } + catch { Write-Verbose "Build-MtZtaBundle: PIM-eligible table read failed ($($_.Exception.Message)); proceeding with permanent only." } + $rdef = @() + try { $rdef = @(& $reader.GetRows 'RoleDefinition') } + catch { Write-Verbose "Build-MtZtaBundle: RoleDefinition table read failed ($($_.Exception.Message)); role displayNames will fall back to template GUIDs." } + $rdefIndex = @{} + foreach ($r in $rdef) { if ($r.id) { $rdefIndex[[string]$r.id] = $r } } + + $tier0Perm = 0 + $byRole = @{} + foreach ($r in $raRows) { + $rid = [string]$r.roleDefinitionId + $rname = if ($rdefIndex.ContainsKey($rid)) { $rdefIndex[$rid].displayName } else { $rid } + if (-not $byRole.ContainsKey($rname)) { $byRole[$rname] = 0 } + $byRole[$rname]++ + if ($tier0Roles.Contains($rid)) { $tier0Perm++ } + } + $topRole = $byRole.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 1 + $declared = 0 + try { $declared = @(Get-MtZta -Section EmergencyAccessAccounts).Count } + catch { Write-Verbose "Build-MtZtaBundle: Get-MtZta -Section EmergencyAccessAccounts failed ($($_.Exception.Message)); declared count stays 0." } + + # Service principals + $spRows = @(& $reader.GetRows 'ServicePrincipal') + $spByType = @{} + foreach ($g in ($spRows | Group-Object servicePrincipalType)) { + $name = if ($g.Name) { [string]$g.Name } else { '(empty)' } + $spByType[$name] = $g.Count + } + + $bundle.Privileged = @{ + PermanentTotal = $raRows.Count + PimEligibleTotal = $pimRows.Count + Tier0Permanent = $tier0Perm + TopPermanentRole = if ($topRole) { $topRole.Key } else { $null } + TopPermanentRoleCount = if ($topRole) { $topRole.Value } else { 0 } + BreakGlassDeclared = $declared + ByRole = $byRole + ServicePrincipals = @{ + Total = $spRows.Count + Enabled = @($spRows | Where-Object { $_.accountEnabled -eq $true }).Count + WithPasswordCreds = @($spRows | Where-Object { @($_.passwordCredentials).Count -gt 0 }).Count + WithKeyCreds = @($spRows | Where-Object { @($_.keyCredentials).Count -gt 0 }).Count + ByType = $spByType + } + } + } catch { + Write-Verbose "Build-MtZtaBundle: Privileged analytics failed: $_" + } + + # Auth-method posture (PhishResistant / Mixed / PhishableOnly / NoMfa). + try { + $methods = Get-MtZtaAuthMethodSet + $phishR = $methods.PhishResistant + $phish = $methods.Phishable + $urd = @(& $reader.GetRows 'UserRegistrationDetails') + + $populations = @{ + Members = @($urd | Where-Object { $_.userType -eq 'member' }) + Guests = @($urd | Where-Object { $_.userType -eq 'guest' }) + Privileged = @() + } + # Privileged population = principals with at least one permanent OR + # PIM-eligible role assignment. Restricting to RoleAssignment alone misses + # the typical "everything is in PIM" tenant where most admins are eligible-only. + $privIds = @{} + if ($raRows) { + foreach ($r in $raRows) { if ($r.principalId) { $privIds[[string]$r.principalId] = $true } } + } + if ($pimRows) { + foreach ($r in $pimRows) { if ($r.principalId) { $privIds[[string]$r.principalId] = $true } } + } + if ($privIds.Count -gt 0) { + $populations.Privileged = @($urd | Where-Object { $_.id -and $privIds.ContainsKey([string]$_.id) }) + } + + $score = { + param($pop) + $noMfa = 0; $weakOnly = 0; $strongOnly = 0; $mixed = 0 + foreach ($u in $pop) { + $m = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + if ($m.Count -eq 0) { $noMfa++; continue } + $hasStrong = (@($m | Where-Object { $_ -in $phishR }).Count -gt 0) + $hasWeak = (@($m | Where-Object { $_ -in $phish }).Count -gt 0) + if ($hasStrong -and $hasWeak) { $mixed++ } + elseif ($hasStrong -and -not $hasWeak) { $strongOnly++ } + elseif ($hasWeak -and -not $hasStrong) { $weakOnly++ } + else { $weakOnly++ } # only single-factor / unrecognised → treat as weak + } + return @{ + Total = @($pop).Count + NoMfa = $noMfa + PhishableOnly = $weakOnly + Mixed = $mixed + PhishResistantOnly = $strongOnly + } + } + + $bundle.AuthMethodScore = @{ + All = & $score $urd + Members = & $score $populations.Members + Guests = & $score $populations.Guests + Privileged = & $score $populations.Privileged + } + } catch { + Write-Verbose "Build-MtZtaBundle: AuthMethodScore failed: $_" + } + + return $bundle +} diff --git a/powershell/public/Get-MtZta.ps1 b/powershell/public/Get-MtZta.ps1 new file mode 100644 index 000000000..734c24d7a --- /dev/null +++ b/powershell/public/Get-MtZta.ps1 @@ -0,0 +1,194 @@ +function Get-MtZta { + <# + .SYNOPSIS + Returns the ZTA context loaded by `Import-MtZtaResult`, or `$null` if ZTA was not ingested. + + .DESCRIPTION + Accessor used inside Pester `BeforeDiscovery` and `It` blocks to consume ZTA findings. + Returns `$null` when ZTA was not loaded so tests can `Set-ItResult -Skipped` cleanly + without having to know whether the operator opted into ZTA-focused mode. + + .PARAMETER Section + Which section of the ZTA context to return. When omitted, returns the full + `$script:MtZtaContext` object. + + Tests - raw Tests[] array from ZeroTrustAssessmentReport.json + Manifest - manifest.json contents (tenant, run time, ZTA version, hashes) + Database - DuckDB query context (Tier 2 — when ZTA's loaded assembly available) + JsonExport - JSON-shadow query context (Tier 1 — always populated when bundle has zt-export/) + Reader - highest-tier-available reader (Database if loaded, else JsonExport). + Use this for tests that want to read tables without caring which tier + services the request. + EmergencyAccessAccounts + - normalised break-glass list from GlobalSettings.EmergencyAccessAccounts. + Returns [pscustomobject[]] with { Id, UserPrincipalName, DisplayName }. + Summary - per-pillar fail counts + ratios + tenant id + FlaggedUsers - output of Group-MtZtaFlaggedIdentity + + .EXAMPLE + BeforeDiscovery { $script:zta = Get-MtZta -Section Tests } + It 'Identity pillar has fewer than 30 failures' -Skip:(-not $script:zta) { + ($script:zta | Where-Object { $_.TestPillar -eq 'Identity' -and $_.TestStatus -eq 'Failed' }).Count | + Should -BeLessThan 30 + } + + .EXAMPLE + $summary = Get-MtZta -Section Summary + if ($summary.IdentityFailRatio -ge 0.5) { ... } + + .EXAMPLE + $buckets = Get-MtZta -Section FlaggedUsers + Describe 'Per-user posture' -ForEach $buckets { ... } + + .LINK + https://maester.dev/docs/commands/Get-MtZta + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([object], [object[]])] + param( + [Parameter(Mandatory = $false)] + [ValidateSet('Tests', 'Manifest', 'Database', 'JsonExport', 'Reader', 'EmergencyAccessAccounts', 'Summary', 'FlaggedUsers')] + [string] $Section + ) + + # Self-heal: Pester sometimes spawns a new runspace where $script: resets to null. + # Re-bootstrap from env vars when context is null but the orchestrator left the + # path behind via $env:ZTA_RESULTS_REF. + if (-not $script:MtZtaContext -and $env:ZTA_RESULTS_REF -and (Test-Path $env:ZTA_RESULTS_REF)) { + Write-Verbose "Get-MtZta: context null but ZTA_RESULTS_REF=$($env:ZTA_RESULTS_REF) — bootstrapping." + $bootstrap = @{ ZtaResultsPath = $env:ZTA_RESULTS_REF; ErrorAction = 'SilentlyContinue' } + if ($env:MAESTER_ZTA_CONFIG_PATH -and (Test-Path $env:MAESTER_ZTA_CONFIG_PATH)) { + try { + $cfg = Get-Content $env:MAESTER_ZTA_CONFIG_PATH -Raw | ConvertFrom-Json -ErrorAction Stop + if ($cfg.PSObject.Properties['ZtaSettings'] -and $cfg.ZtaSettings) { + $bootstrap['ZtaSettings'] = $cfg.ZtaSettings + } + # GlobalSettings carries EmergencyAccessAccounts. + if ($cfg.PSObject.Properties['GlobalSettings'] -and $cfg.GlobalSettings) { + $bootstrap['GlobalSettings'] = $cfg.GlobalSettings + } + } catch { + Write-Verbose "Get-MtZta: settings re-read from MAESTER_ZTA_CONFIG_PATH failed ($($_.Exception.Message)) — bootstrapping without settings." + } + } + try { Import-MtZtaResult @bootstrap } catch { + Write-Verbose "Get-MtZta: bootstrap Import-MtZtaResult failed ($($_.Exception.Message))." + } + } + + if (-not $script:MtZtaContext) { + Write-Verbose 'Get-MtZta: $script:MtZtaContext is not set. Run Import-MtZtaResult first.' + return $null + } + + if (-not $Section) { + return $script:MtZtaContext + } + + switch ($Section) { + 'Tests' { return $script:MtZtaContext.Tests } + 'Manifest' { return $script:MtZtaContext.Manifest } + 'Database' { return $script:MtZtaContext.Database } + 'JsonExport' { return $script:MtZtaContext.JsonExport } + 'Reader' { + # Returns the highest-tier-available reader: DuckDB if loaded, else JSON. + # Validates GetRows is a usable scriptblock before returning — Pester child + # runspaces can lose closure context, leaving a truthy object whose .GetRows + # is $null, which produces a cryptic pipeline error at the call site. + # Returning $null here lets callers' `if (-not $reader)` guard Skip cleanly. + $r = $script:MtZtaContext.Database + if (-not $r) { $r = $script:MtZtaContext.JsonExport } + if (-not $r) { return $null } + if (-not ($r.PSObject.Properties['GetRows']) -or -not ($r.GetRows -is [scriptblock])) { + Write-Verbose 'Get-MtZta: reader present but GetRows is not a usable scriptblock (Pester scope context drop?). Returning $null so callers Skip cleanly.' + return $null + } + return $r + } + 'EmergencyAccessAccounts' { + if (-not $script:MtZtaContext.PSObject.Properties['EmergencyAccessAccounts']) { return @() } + return @($script:MtZtaContext.EmergencyAccessAccounts) + } + 'Summary' { + return Get-MtZtaSummary -Tests $script:MtZtaContext.Tests -TenantId $script:MtZtaContext.TenantId + } + 'FlaggedUsers' { + $settings = $script:MtZtaContext.ZtaSettings + $mappings = @() + $maxPerCat = 50 + $groupSimilar = $true + if ($settings) { + if ($settings.PSObject.Properties['CategoryMappings'] -and $settings.CategoryMappings) { + $mappings = @($settings.CategoryMappings) + } + if ($settings.PSObject.Properties['DataDrivenSettings'] -and $settings.DataDrivenSettings) { + if ($settings.DataDrivenSettings.PSObject.Properties['MaxUsersPerCategory']) { + $maxPerCat = [int]$settings.DataDrivenSettings.MaxUsersPerCategory + } + if ($settings.DataDrivenSettings.PSObject.Properties['GroupSimilar']) { + $groupSimilar = [bool]$settings.DataDrivenSettings.GroupSimilar + } + } + } + + return Group-MtZtaFlaggedIdentity ` + -Tests $script:MtZtaContext.Tests ` + -CategoryMappings $mappings ` + -ContextDatabase $script:MtZtaContext.Database ` + -MaxUsersPerCategory $maxPerCat ` + -GroupSimilar $groupSimilar + } + } +} + +function Get-MtZtaSummary { + <# + .SYNOPSIS + Internal helper — derives per-pillar fail counts and ratios from a Tests[] array. + + .DESCRIPTION + Pillar-keyed summary used by Get-MtZta -Section Summary. Returns a flat object so + Pester `-Skip:` expressions can chain .IdentityFailRatio etc. without traversal. + + Counts: Passed, Failed, Skipped, Investigate, Planned, Total. + Ratios: <Pillar>FailRatio = Failed / max(1, Total - Skipped - Planned). + Skipped/Planned excluded from denominator so a fully-licensed pillar with + 10 failures is comparable to one with 10 failures plus 50 skipped tests. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]] $Tests, + + [Parameter(Mandatory = $false)] + [string] $TenantId + ) + + $pillars = 'Identity','Devices','Network','Data' + $out = [ordered]@{ TenantId = $TenantId; TotalTests = @($Tests).Count } + + foreach ($p in $pillars) { + $pillarTests = @($Tests | Where-Object { $_.TestPillar -eq $p }) + $passed = @($pillarTests | Where-Object { $_.TestStatus -eq 'Passed' }).Count + $failed = @($pillarTests | Where-Object { $_.TestStatus -eq 'Failed' }).Count + $skipped = @($pillarTests | Where-Object { $_.TestStatus -eq 'Skipped' }).Count + $investigate = @($pillarTests | Where-Object { $_.TestStatus -eq 'Investigate' }).Count + $planned = @($pillarTests | Where-Object { $_.TestStatus -eq 'Planned' }).Count + $denominator = [math]::Max(1, $pillarTests.Count - $skipped - $planned) + $failRatio = [math]::Round($failed / $denominator, 4) + + $out["${p}Passed"] = $passed + $out["${p}Failed"] = $failed + $out["${p}Skipped"] = $skipped + $out["${p}Investigate"] = $investigate + $out["${p}Planned"] = $planned + $out["${p}FailRatio"] = $failRatio + } + + return [pscustomobject]$out +} diff --git a/powershell/public/Get-MtZtaAuthMethodSet.ps1 b/powershell/public/Get-MtZtaAuthMethodSet.ps1 new file mode 100644 index 000000000..0ad8dfaed --- /dev/null +++ b/powershell/public/Get-MtZtaAuthMethodSet.ps1 @@ -0,0 +1,111 @@ +function Get-MtZtaAuthMethodSet { + <# + .SYNOPSIS + Returns curated classification of Microsoft Graph `methodsRegistered` enum values + into PhishResistant / Phishable / SingleFactor / All buckets. + + .DESCRIPTION + Centralises the classification used by ZTA-aware MFA-uplift tests + (MT.Zta.1140 / 1141 / 1142 / 1143) so a single source of truth feeds every + check. Without this helper, each test inlines a different ad-hoc regex / + array which drifts as Microsoft adds new methods. + + Classification rationale: + + - **PhishResistant** — methods whose protocol prevents an attacker-controlled + relay site from harvesting the credential (FIDO2/WebAuthn binding the + credential to the relying-party origin, X.509 cert with PIN, Windows Hello + for Business, device-bound passkeys). + + - **Phishable** — every interactive method that an AiTM proxy or social-eng + attack can capture or replay: phone-bound (`mobilePhone` / + `alternateMobilePhone` / `officePhone` — these are the URD enum values + for what user-facing tooling calls "SMS"; URD does NOT emit `sms`), + `voice`, email OTP, software & hardware TOTP, Authenticator push (consent + fatigue), and — controversially — `microsoftAuthenticatorPasswordless`. + Microsoft markets the latter as MFA, but it functionally collapses to + "approve push on the same device that owns the session", so under a + stolen-device threat model it behaves as single-factor and is included here. + + Note: `sms` is a value in the CA `authenticationMethodModes` enum (used + by authStrength `allowedCombinations`), NOT the URD `methodsRegistered` + enum this cmdlet models. The CA-side vocabulary is checked inline in + MT.Zta.1131; this cmdlet feeds the user-data-side checks + (MT.Zta.1140 / 1141 / 1142 / 1143) that read URD rows. + + `temporaryAccessPass` is included while active (it's an inline-issued + password substitute used for bootstrapping; phishable like any password). + + `federatedSingleFactor` is included because the trust delegates auth to an + external IdP with no MFA assertion — phishable at the IdP boundary. + + - **SingleFactor** — methods that are explicitly NOT MFA at all + (`x509CertificateSingleFactor` is cert without PIN; `password` is a + password). Listed for completeness; absent tenants typically don't have + these as `methodsRegistered` rows. + + References: + + - Graph schema: https://learn.microsoft.com/graph/api/resources/userregistrationdetails + - Microsoft phish-resistant MFA list: https://learn.microsoft.com/azure/active-directory/authentication/concept-authentication-strengths + + .PARAMETER Bucket + Which classification bucket to return. Default returns the full hashtable. + + .EXAMPLE + $classes = Get-MtZtaAuthMethodSet + $hasOnlyWeak = -not (@($u.methodsRegistered | Where-Object { $_ -in $classes.PhishResistant }).Count -gt 0) + + .EXAMPLE + $phishable = Get-MtZtaAuthMethodSet -Bucket Phishable + if (@($u.methodsRegistered | Where-Object { $_ -in $phishable }).Count -gt 0) { ... } + + .LINK + https://maester.dev/docs/commands/Get-MtZtaAuthMethodSet + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([hashtable], [string[]])] + param( + [Parameter(Mandatory = $false)] + [ValidateSet('PhishResistant', 'Phishable', 'SingleFactor', 'All')] + [string] $Bucket = 'All' + ) + + $classes = @{ + PhishResistant = @( + 'fido2', + 'windowsHelloForBusiness', + 'x509CertificateMultiFactor', + 'passKey', + 'passKeyDeviceBound', + 'passKeyDeviceBoundAuthenticator', + 'passKeyDeviceBoundWindowsHello', + 'passKeyDeviceBoundExternalAuthenticator', + 'federatedMultiFactor' + ) + Phishable = @( + 'mobilePhone', + 'alternateMobilePhone', + 'officePhone', + 'voice', + 'email', + 'softwareOneTimePasscode', + 'hardwareOneTimePasscode', + 'microsoftAuthenticatorPush', + 'microsoftAuthenticatorOath', + 'microsoftAuthenticatorPasswordless', + 'temporaryAccessPass', + 'federatedSingleFactor' + ) + SingleFactor = @( + 'x509CertificateSingleFactor', + 'password' + ) + } + + if ($Bucket -eq 'All') { return $classes } + return $classes[$Bucket] +} diff --git a/powershell/public/Get-MtZtaRecommendedTag.ps1 b/powershell/public/Get-MtZtaRecommendedTag.ps1 new file mode 100644 index 000000000..6c3f7ac50 --- /dev/null +++ b/powershell/public/Get-MtZtaRecommendedTag.ps1 @@ -0,0 +1,117 @@ +function Get-MtZtaRecommendedTag { + <# + .SYNOPSIS + Derives a Pester `-Tag` list from current ZTA findings so Maester runs only the tests + relevant to the areas ZTA flagged. + + .DESCRIPTION + Walks `$script:MtZtaContext.Tests` (failed entries only), classifies each into a bucket + per `ZtaSettings.CategoryMappings`, and emits the union of `MaesterTagBoost` arrays + plus the literal pillar names as a `[string[]]`. + + Defaults when no `ZtaSettings` is present on the context: + - `PillarTagMap` falls back to a vendor-neutral baseline that mirrors the pillar + keywords already used in upstream Maester tests + (Identity, Devices, Network, Data, MFA, ConditionalAccess, PIM, Intune, Compliance, ...). + - `CategoryMappings` empty -> all failures classify as 'Other' (no boost tags emitted). + + Returns an empty array when ZTA was not loaded or has no failures. + + **Coverage warning:** if more than 10 % of failed tests classify as `Other`, the cmdlet + emits a `Write-Warning` so the operator can revisit the CategoryMappings block. + + .EXAMPLE + $tags = Get-MtZtaRecommendedTag + Invoke-Maester -Tag $tags + + .LINK + https://maester.dev/docs/commands/Get-MtZtaRecommendedTag + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([string[]], [object[]])] + param() + + if (-not $script:MtZtaContext) { + Write-Verbose 'Get-MtZtaRecommendedTag: $script:MtZtaContext is not set. Returning empty array.' + return @() + } + + $tests = @($script:MtZtaContext.Tests) + $failed = @($tests | Where-Object { $_.TestStatus -eq 'Failed' }) + if ($failed.Count -eq 0) { + Write-Verbose 'Get-MtZtaRecommendedTag: no failed ZTA tests; returning empty tag list.' + return @() + } + + $settings = $script:MtZtaContext.ZtaSettings + + # CategoryMappings — required for boost tags; empty array gracefully degrades. + $mappings = @() + if ($settings -and $settings.PSObject.Properties['CategoryMappings'] -and $settings.CategoryMappings) { + $mappings = @($settings.CategoryMappings) + } + + # PillarTagMap — pillar literals + their Maester-side aliases. Defaults mirror the + # tag conventions already used in tests/Maester/* so this works without any config block. + $pillarTagMap = @{ + Identity = @('Identity','EID','MFA','ConditionalAccess','PIM') + Devices = @('Intune','Device','Compliance','Defender') + Network = @('Network','GlobalSecureAccess','GSA') + Data = @('Exchange','SharePoint','Purview','Sensitivity') + } + if ($settings -and $settings.PSObject.Properties['PillarTagMap'] -and $settings.PillarTagMap) { + # Merge over defaults — operator overrides win, missing pillars keep defaults. + foreach ($p in $settings.PillarTagMap.PSObject.Properties.Name) { + $pillarTagMap[$p] = @($settings.PillarTagMap.$p) + } + } + + $tags = New-Object System.Collections.Generic.HashSet[string] + $otherCount = 0 + + foreach ($t in $failed) { + # 1. Pillar literal + pillar's Maester-tag aliases + if ($t.TestPillar) { + [void]$tags.Add([string]$t.TestPillar) + if ($pillarTagMap.ContainsKey([string]$t.TestPillar)) { + foreach ($a in $pillarTagMap[[string]$t.TestPillar]) { [void]$tags.Add($a) } + } + } + + # 2. Category mapping -> MaesterTagBoost + $cat = Get-MtZtaCategoryForTest -Test $t -CategoryMappings $mappings + if ($cat -eq 'Other') { + $otherCount++ + continue + } + + $rule = $mappings | Where-Object { + if ($_ -is [System.Collections.IDictionary]) { $_['Category'] -eq $cat } else { $_.Category -eq $cat } + } | Select-Object -First 1 + if ($rule) { + $boostValue = if ($rule -is [System.Collections.IDictionary]) { + $rule['MaesterTagBoost'] + } elseif ($rule.PSObject.Properties['MaesterTagBoost']) { + $rule.MaesterTagBoost + } else { $null } + if ($boostValue) { + foreach ($boost in @($boostValue)) { [void]$tags.Add([string]$boost) } + } + } + } + + if ($failed.Count -gt 0) { + $otherRatio = $otherCount / $failed.Count + if ($otherRatio -gt 0.10) { + Write-Warning ("Get-MtZtaRecommendedTag: {0} of {1} failed tests ({2:P0}) fell into the 'Other' bucket. " + + "Consider adding categories to ZtaSettings.CategoryMappings to improve focus." ` + -f $otherCount, $failed.Count, $otherRatio) + } + } + + # Deterministic ordering for reproducible Invoke-Maester -Tag invocations. + return @($tags | Sort-Object) +} diff --git a/powershell/public/Get-MtZtaThreshold.ps1 b/powershell/public/Get-MtZtaThreshold.ps1 new file mode 100644 index 000000000..88e720c86 --- /dev/null +++ b/powershell/public/Get-MtZtaThreshold.ps1 @@ -0,0 +1,85 @@ +function Get-MtZtaThreshold { + <# + .SYNOPSIS + Returns a per-test threshold value, sourced from + `ZtaSettings.Thresholds.<TestId>` in maester-config.json with a + caller-supplied default fallback. + + .DESCRIPTION + Lets ZTA-aware tests expose their numeric thresholds (warn-band counts, + fail-ratio cutoffs, sample caps) to operator tuning via maester-config + without forking the test code. + + Lookup is two-step: + + 1. `(Get-MtZta).ZtaSettings.Thresholds.<TestId>` if present and non-null + 2. otherwise the `-Default` parameter + + If `ZtaSettings` carries a hashtable / pscustomobject under `Thresholds`, + we accept either shape — JSON-deserialised pscustomobject (default + ConvertFrom-Json behaviour) or hashtable (operator passing + `-AsHashtable`). + + .PARAMETER TestId + The Maester test id (e.g. `MT.Zta.1001`). Conventionally same as the It + block tag — that way the threshold key in maester-config matches what + operators see in the report. + + .PARAMETER Default + The threshold value to return when no operator override exists. Required — + every threshold-bearing test must declare its built-in default. + + .EXAMPLE + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30 + $summary.IdentityFailed | Should -BeLessThan $threshold + + .EXAMPLE + # In maester-config.json: + # "ZtaSettings": { + # "Thresholds": { + # "MT.Zta.1001": 50, + # "MT.Zta.1140": 5 + # } + # } + + .LINK + https://maester.dev/docs/commands/Get-MtZtaThreshold + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [string] $TestId, + + [Parameter(Mandatory = $true)] + [object] $Default + ) + + $ctx = $script:MtZtaContext + if (-not $ctx) { return $Default } + if (-not $ctx.PSObject.Properties['ZtaSettings'] -or -not $ctx.ZtaSettings) { return $Default } + $settings = $ctx.ZtaSettings + + $thresholds = $null + if ($settings -is [hashtable]) { + if ($settings.ContainsKey('Thresholds')) { $thresholds = $settings['Thresholds'] } + } + elseif ($settings.PSObject.Properties['Thresholds']) { + $thresholds = $settings.Thresholds + } + if (-not $thresholds) { return $Default } + + $value = $null + if ($thresholds -is [hashtable]) { + if ($thresholds.ContainsKey($TestId)) { $value = $thresholds[$TestId] } + } + elseif ($thresholds.PSObject.Properties[$TestId]) { + $value = $thresholds.$TestId + } + + if ($null -eq $value) { return $Default } + return $value +} diff --git a/powershell/public/Import-MtZtaResult.ps1 b/powershell/public/Import-MtZtaResult.ps1 new file mode 100644 index 000000000..94e9e8f72 --- /dev/null +++ b/powershell/public/Import-MtZtaResult.ps1 @@ -0,0 +1,250 @@ +function Import-MtZtaResult { + <# + .SYNOPSIS + Loads a Zero Trust Assessment (ZTA) result bundle into Maester so subsequent tests + can focus on the areas ZTA flagged. + + .DESCRIPTION + Resolves a ZTA result source (local path, Azure Blob URI, or Azure Artifacts + Universal Package reference) via `Resolve-MtZtaArtifact`, validates the bundle + contains the expected files (manifest.json + ZeroTrustAssessmentReport.json + db/zt.db), + opens the DuckDB database read-only via `Read-MtZtaDatabase` (with JSON fallback on + any failure), checks freshness via `Test-MtZtaFreshness`, and populates the + module-private `$script:MtZtaContext` with the normalised data. + + Idempotent — subsequent calls with the same source short-circuit. + + No-ops gracefully when -ZtaResultsPath is `$null` or empty (keeps vanilla + `Invoke-Maester` runs byte-identical to upstream). + + .PARAMETER ZtaResultsPath + ZTA result source string. Three patterns recognised, in priority order: + 1. https://<account>.blob.core.windows.net/... - Azure Blob (SAS in URI / WIF / -Identity) + 2. upkg://<org>/<project>/<feed>/<name>@<ver> - Azure Artifacts Universal Package + 3. <local path> - folder, .tar.gz, or .zip + + .PARAMETER FreshnessDays + Override the default 14-day freshness threshold. Stale runs proceed (warn-but-proceed) + but set `$script:MtZtaContext.IsStale = $true`. + + .PARAMETER ForceJsonFallback + Skip DuckDB entirely and use the JSON-only path. Useful on Linux without the + DuckDB.NET native binary or for repro tests. + + .PARAMETER ExpectedTenantId + Optional tenant-id pin. When set, the manifest's tenantId must match exactly or + the load aborts before any test runs. Cross-tenant data leakage guard. + + .PARAMETER ZtaSettings + The `ZtaSettings` block from `maester-config.json` (already deserialised), passed + through to subsequent cmdlets via `$script:MtZtaContext.ZtaSettings`. Drives: + - CategoryMappings (Get-MtZta -Section FlaggedUsers, Get-MtZtaRecommendedTag) + - SeverityEscalationRules (Update-MtSeverityFromZta) + - DataDrivenSettings (Group-MtZtaFlaggedIdentity caps) + - PillarTagMap (Get-MtZtaRecommendedTag pillar-tag union) + Optional — when omitted, callers default to vendor-neutral baselines. + + .PARAMETER GlobalSettings + The `GlobalSettings` block from `maester-config.json` (Maester's standard + section). Today only `EmergencyAccessAccounts` is consumed: it is normalised + and surfaced via `Get-MtZta -Section EmergencyAccessAccounts` and used by + `Test-MtZtaIsEmergencyAccess` to mark legitimate break-glass identities as + compliant-by-design in tests like MT.Zta.1107 (permanent Global Admins). + Each entry can be a string (UPN or GUID) OR an object with `id` / + `userPrincipalName` / `displayName` properties — all three shapes accepted. + + .EXAMPLE + Import-MtZtaResult -ZtaResultsPath .\zta-results-2026-05-01.tar.gz + + .EXAMPLE + Import-MtZtaResult -ZtaResultsPath 'https://contoso-sec.blob.core.windows.net/zta/2026-05-01.tar.gz' + + .EXAMPLE + Import-MtZtaResult -ZtaResultsPath 'upkg://OnTrask-Security/Assessments/zta-results/customer-a-2026-05-01@1.0.0' + + .EXAMPLE + Import-MtZtaResult -ZtaResultsPath .\zta -ForceJsonFallback + + .LINK + https://maester.dev/docs/commands/Import-MtZtaResult + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + # PSScriptAnalyzer wants singular nouns; the plural alias preserves backward compatibility. + [Alias('Import-MtZtaResults')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [AllowNull()] + [string] $ZtaResultsPath, + + [Parameter(Mandatory = $false)] + [int] $FreshnessDays = 14, + + [Parameter(Mandatory = $false)] + [switch] $ForceJsonFallback, + + [Parameter(Mandatory = $false)] + [string] $ExpectedTenantId, + + [Parameter(Mandatory = $false)] + [object] $ZtaSettings, + + [Parameter(Mandatory = $false)] + [object] $GlobalSettings + ) + + # Normalise GlobalSettings.EmergencyAccessAccounts. Three accepted input shapes: + # 1. plain string UPN: "breakglass1@contoso.onmicrosoft.com" + # 2. plain string GUID: "12345678-1234-1234-1234-123456789012" + # 3. object: { id?, userPrincipalName?, displayName? } + function ConvertTo-MtZtaEmergencyAccessNormalized { + param([object] $Settings) + if (-not $Settings) { return @() } + if (-not $Settings.PSObject.Properties['EmergencyAccessAccounts']) { return @() } + $raw = @($Settings.EmergencyAccessAccounts) + if ($raw.Count -eq 0) { return @() } + $normalized = New-Object System.Collections.Generic.List[pscustomobject] + foreach ($entry in $raw) { + if (-not $entry) { continue } + if ($entry -is [string]) { + $s = $entry.Trim() + if (-not $s) { continue } + # GUID heuristic — 36-char xxxx-xxxx-... shape with hyphens. + if ($s -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $normalized.Add([pscustomobject]@{ Id = $s; UserPrincipalName = $null; DisplayName = $null }) + } else { + # UPN-shaped string — anything containing '@' is treated as a UPN. + $normalized.Add([pscustomobject]@{ Id = $null; UserPrincipalName = $s; DisplayName = $null }) + } + } else { + $id = if ($entry.PSObject.Properties['id']) { [string]$entry.id } else { $null } + $upn = if ($entry.PSObject.Properties['userPrincipalName']) { [string]$entry.userPrincipalName } else { $null } + $dn = if ($entry.PSObject.Properties['displayName']) { [string]$entry.displayName } else { $null } + if ($id -or $upn) { + $normalized.Add([pscustomobject]@{ Id = $id; UserPrincipalName = $upn; DisplayName = $dn }) + } + } + } + return ,@($normalized.ToArray()) + } + + if ([string]::IsNullOrWhiteSpace($ZtaResultsPath)) { + Write-Verbose 'Import-MtZtaResult: -ZtaResultsPath empty; ZTA context not loaded.' + $script:MtZtaContext = $null + return + } + + # Idempotent short-circuit: same source, already loaded. + if ($script:MtZtaContext -and $script:MtZtaContext.Source -eq $ZtaResultsPath) { + Write-Verbose "Import-MtZtaResult: source unchanged ('$ZtaResultsPath'); reusing existing context." + return + } + + Write-Verbose "Import-MtZtaResult: resolving $ZtaResultsPath" + $bundlePath = Resolve-MtZtaArtifact -Source $ZtaResultsPath + + $manifestPath = Join-Path $bundlePath 'manifest.json' + $reportPath = Join-Path $bundlePath 'ZeroTrustAssessmentReport.json' + $dbPath = Join-Path $bundlePath 'db/zt.db' + + if (-not (Test-Path $reportPath)) { + throw "Import-MtZtaResult: bundle at '$bundlePath' is missing ZeroTrustAssessmentReport.json. Cannot proceed even with JSON fallback." + } + + $manifest = $null + if (Test-Path $manifestPath) { + try { + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch { + Write-Warning "Import-MtZtaResult: manifest.json present but unreadable ($($_.Exception.Message)). Falling through to report-only metadata." + $manifest = $null + } + } + else { + Write-Verbose "Import-MtZtaResult: no manifest.json at $manifestPath; deriving metadata from ZeroTrustAssessmentReport.json only." + } + + if ($ExpectedTenantId -and $manifest -and $manifest.PSObject.Properties['tenantId']) { + if ($manifest.tenantId -ne $ExpectedTenantId) { + throw "Import-MtZtaResult: tenant mismatch. Manifest tenant '$($manifest.tenantId)' != expected '$ExpectedTenantId'. Refusing to load to prevent cross-tenant data leakage." + } + } + + $report = $null + try { + $report = Get-Content $reportPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Import-MtZtaResult: ZeroTrustAssessmentReport.json unreadable ($($_.Exception.Message)). Bundle at '$bundlePath' is corrupt." + } + + # Tier 1 (JSON shadow) is the universal floor. Always populate. + $jsonExport = $null + try { + $jsonExport = Read-MtZtaJsonExport -BundlePath $bundlePath + Write-Verbose "Import-MtZtaResult: JSON-shadow tier loaded ($($jsonExport.Tables.Count) tables)." + } + catch { + Write-Warning "Import-MtZtaResult: JSON shadow not readable ($($_.Exception.Message)). DuckDB tier may still work." + } + + # Tier 2 (DuckDB) is opportunistic — returns $null on miss rather than throwing, + # so the JSON tier carries the load when DuckDB is unavailable. + $database = $null + $dbStatus = 'NotAttempted' + if (-not $ForceJsonFallback) { + if (Test-Path $dbPath) { + try { + $database = Read-MtZtaDatabase -DatabasePath $dbPath + $dbStatus = if ($database) { 'Loaded' } else { 'JsonOnlyMode' } + if ($database) { + Write-Verbose "Import-MtZtaResult: DuckDB tier loaded ($($database.Tables.Count) tables)." + } else { + Write-Verbose "Import-MtZtaResult: DuckDB tier unavailable; JSON tier is authoritative." + } + } + catch { + Write-Verbose "Import-MtZtaResult: DuckDB tier failed ($($_.Exception.Message)); JSON tier is authoritative." + $dbStatus = "JsonOnlyMode: $($_.Exception.Message)" + $database = $null + } + } + else { + Write-Verbose "Import-MtZtaResult: $dbPath not present; JSON tier is authoritative." + $dbStatus = 'NoDbFile' + } + } + else { + $dbStatus = 'ForcedJsonFallback' + } + + $freshness = Test-MtZtaFreshness -BundlePath $bundlePath -FreshnessDays $FreshnessDays + if ($freshness.IsStale) { + Write-Warning "ZTA artifact is stale: $($freshness.AgeDays) days old (threshold: $($freshness.Threshold) days, source: $($freshness.TimestampSource)). Tests will run but findings may not reflect current tenant state." + } + + $script:MtZtaContext = [pscustomobject]@{ + Source = $ZtaResultsPath + BundlePath = $bundlePath + Manifest = $manifest + Tests = $report.Tests + Report = $report + Database = $database + DatabaseStatus= $dbStatus + Freshness = $freshness + IsStale = $freshness.IsStale + TenantId = if ($manifest -and $manifest.PSObject.Properties['tenantId']) { $manifest.tenantId } elseif ($report.PSObject.Properties['TenantId']) { $report.TenantId } else { $null } + TenantName = if ($manifest -and $manifest.PSObject.Properties['tenantName']) { $manifest.tenantName } elseif ($report.PSObject.Properties['TenantName']) { $report.TenantName } else { $null } + ZtaSettings = $ZtaSettings + GlobalSettings = $GlobalSettings + EmergencyAccessAccounts = (ConvertTo-MtZtaEmergencyAccessNormalized -Settings $GlobalSettings) + JsonExport = $jsonExport + LoadedAt = [datetime]::UtcNow + } + + Write-Verbose "Import-MtZtaResult: context loaded for tenant $($script:MtZtaContext.TenantName) ($($script:MtZtaContext.TenantId)) — $($report.Tests.Count) tests, DB status: $dbStatus, freshness: $($freshness.AgeDays)d via $($freshness.TimestampSource)." +} diff --git a/powershell/public/Invoke-Maester.ps1 b/powershell/public/Invoke-Maester.ps1 index 59d866025..2838f1778 100644 --- a/powershell/public/Invoke-Maester.ps1 +++ b/powershell/public/Invoke-Maester.ps1 @@ -22,6 +22,36 @@ .PARAMETER NonInteractive This will suppress the logo when Maester starts, prevent the test results from being opened in the default browser, and suppress all pretty messages. + .PARAMETER ZtaResultsPath + Path or URL to a Zero Trust Assessment (ZTA) result bundle. When supplied, + Maester pre-loads the bundle via Import-MtZtaResult so ZTA-aware tests + (under Custom/Zta/ in the customer test tree) have data to consume. + After Pester runs, Build-MtZtaBundle attaches a ZtaBundle property to the + returned $maesterResults so the HTML report's ZTA tab and the JSON / + Markdown outputs all carry per-tenant analytics. + + Three source patterns accepted: + - Local path to a folder, .tar.gz, or .zip + - Azure Blob URL: https://<account>.blob.core.windows.net/... + - Azure Artifacts Universal Package: upkg://<org>/<project>/<feed>/<name>@<ver> + + Omitting this parameter preserves stock Maester behaviour byte-for-byte. + + .PARAMETER DisableZta + Opt-out switch. When set, Maester ignores -ZtaResultsPath even if supplied. + + .PARAMETER ZtaForceJsonFallback + Skip the DuckDB reader tier and use the JSON-shadow tier only. Useful on + hosts without the DuckDB.NET native binary or for reproducibility tests. + + .PARAMETER ZtaFreshnessDays + Override the default 14-day artifact freshness threshold. Stale bundles + still load (warn-but-proceed); MtZtaContext.IsStale lets tests react. + + .PARAMETER ExpectedTenantId + Cross-tenant safety pin. When set, the bundle's manifest.tenantId must + match exactly or the load aborts before any test runs. + .EXAMPLE Invoke-Maester @@ -97,6 +127,27 @@ Connect to all tested services and run all tests, including the long-running and preview tests. + .EXAMPLE + ```powershell + Connect-Maester + Invoke-Maester -ZtaResultsPath './zta-results-2026-05-01' -Path './maester-tests' + ``` + + Loads a Zero Trust Assessment bundle (local folder, .tar.gz, .zip, blob URI, or upkg://) + before running tests so ZTA-aware tests under Custom/Zta/ can consume it. + After Pester finishes, attaches a `ZtaBundle` analytics object to the result + so the HTML report renders a ZTA tab and the JSON output carries the data. + + .EXAMPLE + ```powershell + Invoke-Maester -ZtaResultsPath 'https://contoso-sec.blob.core.windows.net/zta/2026-05-01.tar.gz' ` + -ExpectedTenantId '00000000-0000-0000-0000-000000000000' ` + -ZtaFreshnessDays 7 + ``` + + Loads a ZTA bundle from Azure Blob storage with cross-tenant safety pin and a + tighter 7-day freshness threshold. + .LINK https://maester.dev/docs/commands/Invoke-Maester #> @@ -206,7 +257,44 @@ # The root directory for configuration drift tracking. [Parameter(HelpMessage = 'Specify drift root directory, see https://maester.dev/docs/tests/MT.1060')] - [string] $DriftRoot + [string] $DriftRoot, + + # Path to a Zero Trust Assessment (ZTA) result bundle. When supplied, + # `Import-MtZtaResult` runs before Pester discovery so ZTA-aware tests + # under `Custom/Zta/` (and any test calling `Get-MtZta`) have data to + # consume. After the run, `Build-MtZtaBundle` attaches a `ZtaBundle` + # property to the returned results so the HTML report's ZTA tab and the + # JSON/Markdown outputs all carry analytics alongside the test rows. + # + # Three source patterns recognised (in priority order): + # 1. https://<account>.blob.core.windows.net/... — Azure Blob (SAS / WIF / -Identity) + # 2. upkg://<org>/<project>/<feed>/<name>@<ver> — Azure Artifacts Universal Package + # 3. <local path> — folder, .tar.gz, or .zip + # + # Empty / not-passed = current behaviour, byte-identical to upstream. + [Parameter(HelpMessage = 'Path / URL to a ZTA result bundle. Enables ZTA-aware focus mode.')] + [string] $ZtaResultsPath, + + # Opt-out switch — short-circuits all ZTA logic even when -ZtaResultsPath + # is supplied (useful for repro runs or when the bundle is known-stale). + [Parameter(HelpMessage = 'Disable ZTA loading even if -ZtaResultsPath is provided.')] + [switch] $DisableZta, + + # Skip DuckDB entirely and use the JSON-only path. Useful on Linux without + # the DuckDB.NET native binary or for reproducibility tests. + [Parameter(HelpMessage = 'Force JSON-only reader; do not attempt the DuckDB tier.')] + [switch] $ZtaForceJsonFallback, + + # Override the default 14-day ZTA artifact freshness threshold. Stale + # runs still proceed (warn-but-proceed); the IsStale flag rides on the + # context so tests can decide what to do. + [Parameter(HelpMessage = 'Override the default 14-day ZTA freshness threshold.')] + [int] $ZtaFreshnessDays = 14, + + # Cross-tenant safety pin. When set, the bundle's manifest.tenantId must + # match exactly or the load aborts before any test runs. + [Parameter(HelpMessage = 'Pin the ZTA bundle to a specific tenant id.')] + [string] $ExpectedTenantId ) function GetDefaultFileName() { @@ -448,6 +536,67 @@ } $__MtSession.MaesterConfig = Get-MtMaesterConfig -Path $Path -TenantId $configTenantId + # ── ZTA-focus integration ─────────────────────────────────────────────── + # When -ZtaResultsPath is supplied, prime $script:MtZtaContext BEFORE + # Pester discovery so Get-MtZta returns real data inside It blocks and + # Update-MtSeverityFromZta can mutate TestSettings before discovery reads them. + # ZtaSettings and GlobalSettings are pulled from the merged Maester config. + # + # Two env vars are exported for the in-Pester self-heal path: Pester child + # runspaces can reset $script:MtZtaContext; Get-MtZta re-bootstraps from + # $env:ZTA_RESULTS_REF + $env:MAESTER_ZTA_CONFIG_PATH when that happens. + $ztaLoaded = $false + if (-not [string]::IsNullOrWhiteSpace($ZtaResultsPath) -and -not $DisableZta.IsPresent) { + Write-MtProgress -Activity 'Starting Maester' -Status 'Loading Zero Trust Assessment bundle...' -Force + $importArgs = @{ + ZtaResultsPath = $ZtaResultsPath + FreshnessDays = $ZtaFreshnessDays + ErrorAction = 'SilentlyContinue' + } + if ($ZtaForceJsonFallback.IsPresent) { $importArgs['ForceJsonFallback'] = $true } + if (-not [string]::IsNullOrWhiteSpace($ExpectedTenantId)) { $importArgs['ExpectedTenantId'] = $ExpectedTenantId } + + $cfg = $__MtSession.MaesterConfig + if ($cfg) { + if ($cfg.PSObject.Properties['ZtaSettings'] -and $cfg.ZtaSettings) { $importArgs['ZtaSettings'] = $cfg.ZtaSettings } + if ($cfg.PSObject.Properties['GlobalSettings'] -and $cfg.GlobalSettings) { $importArgs['GlobalSettings'] = $cfg.GlobalSettings } + } + + try { + Import-MtZtaResult @importArgs + $ztaLoaded = $null -ne (Get-MtZta -ErrorAction SilentlyContinue) + if ($ztaLoaded) { + $env:ZTA_RESULTS_REF = $ZtaResultsPath + $cfgFile = $__MtSession.MaesterConfig.PSObject.Properties['__ConfigFilePath'] + if ($cfgFile -and $cfgFile.Value) { $env:MAESTER_ZTA_CONFIG_PATH = [string]$cfgFile.Value } + Write-Verbose "ZTA bundle loaded; context available via Get-MtZta." + + # Severity overlay — mutates TestSettings pre-discovery. Re-assign the + # return value because ConvertFrom-Json arrays may not behave reference- + # like across module boundaries. + if ((Get-Command Update-MtSeverityFromZta -ErrorAction SilentlyContinue) -and + $__MtSession.MaesterConfig -and + $__MtSession.MaesterConfig.PSObject.Properties['TestSettings']) { + try { + $currentTestSettings = @($__MtSession.MaesterConfig.TestSettings) + $updated = Update-MtSeverityFromZta -TestSettings $currentTestSettings -ErrorAction SilentlyContinue + if ($null -ne $updated) { + $__MtSession.MaesterConfig.TestSettings = @($updated) + } + } catch { + Write-Verbose "Update-MtSeverityFromZta failed (non-fatal): $($_.Exception.Message)" + } + } + } + else { + Write-Warning "ZTA load returned no context (likely empty/malformed bundle at '$ZtaResultsPath'). Continuing without ZTA awareness." + } + } + catch { + Write-Warning "ZTA bundle load failed: $($_.Exception.Message). Continuing without ZTA awareness." + } + } + Write-MtProgress -Activity 'Starting Maester' -Status 'Discovering tests to run...' -Force $pesterResults = Invoke-Pester -Configuration $pesterConfig @@ -476,8 +625,50 @@ $maesterResults = ConvertTo-MtMaesterResult -PesterResults $PesterResults -OutputFiles $out -InvokeMaesterCommand $invokeMaesterCommand -PesterConfiguration $pesterConfig + # When ZTA was loaded, compile analytics into ZtaBundle and attach to results. + # The HTML/JSON/Markdown writers serialise $maesterResults, so this single + # attachment makes the ZTA tab visible with no further plumbing. Non-fatal. + if ($ztaLoaded) { + Write-MtProgress -Activity 'Processing test results' -Status 'Building ZTA analytics bundle...' -Force + try { + $ztaBundle = Build-MtZtaBundle + if ($ztaBundle) { + $maesterResults | Add-Member -NotePropertyName 'ZtaBundle' -NotePropertyValue $ztaBundle -Force + Write-Verbose 'ZtaBundle attached to results; HTML / JSON / MD outputs will include it.' + } + } catch { + Write-Warning "Build-MtZtaBundle failed: $($_.Exception.Message). Outputs will not carry the ZTA tab." + } + } + if (![string]::IsNullOrEmpty($out.OutputJsonFile)) { + # Serialize at stock depth=5 (upstream byte-identical). Pester's nested + # ErrorRecord chain contains `Exception.Data` as a + # System.Collections.ListDictionaryInternal — a non-string-keyed + # dictionary that ConvertTo-Json refuses. Depth=5 doesn't recurse + # deep enough to hit it, so the write always succeeds. $maesterResults | ConvertTo-Json -Depth 5 -WarningAction SilentlyContinue | Out-File -FilePath $out.OutputJsonFile -Encoding UTF8 + + # ZtaBundle injection — read-modify-write the JUST-WRITTEN on-disk + # JSON to add the bundle at high depth. Once data is round-tripped + # through ConvertFrom-Json, the ListDictionaryInternal instances + # are gone (everything is plain pscustomobject), so writing at + # high depth succeeds. + # + # This mirrors the orchestrator pattern documented in + # Invoke-MaesterAssessment.ps1 and is bug-equivalent: write at the + # safe depth, then re-emit at the bundle-friendly depth so the + # nested Inventory / Applications / Devices / Privileged / + # AuthMethodScore hashtables survive. + if ($maesterResults.PSObject.Properties['ZtaBundle'] -and $maesterResults.ZtaBundle) { + try { + $disk = Get-Content -Path $out.OutputJsonFile -Raw | ConvertFrom-Json -Depth 100 + $disk | Add-Member -NotePropertyName 'ZtaBundle' -NotePropertyValue $maesterResults.ZtaBundle -Force + $disk | ConvertTo-Json -Depth 100 -WarningAction SilentlyContinue | Set-Content -Path $out.OutputJsonFile -Encoding UTF8 + } catch { + Write-Warning "ZtaBundle injection to JSON failed (test rows are intact): $($_.Exception.Message)" + } + } } if (![string]::IsNullOrEmpty($out.OutputMarkdownFile)) { @@ -505,8 +696,12 @@ } if ( ( Get-MtUserInteractive ) -and ( -not $NonInteractive ) ) { - # Open test results in the default browser. - Invoke-Item $out.OutputHtmlFile | Out-Null + # Open test results in the default browser. Some Windows shell + # registrations crash the host with 0xC0000005 from + # ShellExecuteEx — guard so the report stays on disk and the + # PassThru return value reaches the caller even on failure. + try { Invoke-Item $out.OutputHtmlFile | Out-Null } + catch { Write-Verbose "Invoke-Item failed to auto-open the report: $($_.Exception.Message). Open '$($out.OutputHtmlFile)' manually." } } } diff --git a/powershell/public/Test-MtZtaIsEmergencyAccess.ps1 b/powershell/public/Test-MtZtaIsEmergencyAccess.ps1 new file mode 100644 index 000000000..e07f82973 --- /dev/null +++ b/powershell/public/Test-MtZtaIsEmergencyAccess.ps1 @@ -0,0 +1,60 @@ +function Test-MtZtaIsEmergencyAccess { + <# + .SYNOPSIS + Returns $true when the supplied principalId or UPN matches an entry in + `$script:MtZtaContext.EmergencyAccessAccounts` (the operator's break-glass + list, sourced from `GlobalSettings.EmergencyAccessAccounts` in maester-config.json). + + .DESCRIPTION + Use from ZTA gap-fill tests that surface privileged identities (permanent role + grants, stale users, single-factor users, etc.) so legitimate break-glass accounts + are flagged as compliant-by-design rather than reported as findings. + + Match is permissive: either Id or UPN match counts. UPN comparison is + case-insensitive (Entra UPNs are case-insensitive). Returns $false on any + missing context or empty input. + + Companion to `Get-MtZta -Section EmergencyAccessAccounts` which returns the + normalised array. + + .PARAMETER Id + Object ID (GUID) of the principal under inspection. Matched against entries + whose `Id` is populated. + + .PARAMETER UserPrincipalName + UPN of the principal. Matched case-insensitively against entries whose + `UserPrincipalName` is populated. + + .EXAMPLE + if (Test-MtZtaIsEmergencyAccess -Id $r.principalId -UserPrincipalName $u.userPrincipalName) { + # mark as break-glass in the report; don't include in finding count + } + + .LINK + https://maester.dev/docs/commands/Test-MtZtaIsEmergencyAccess + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $false)] + [string] $Id, + + [Parameter(Mandatory = $false)] + [string] $UserPrincipalName + ) + + if (-not $script:MtZtaContext) { return $false } + if (-not $script:MtZtaContext.PSObject.Properties['EmergencyAccessAccounts']) { return $false } + $known = @($script:MtZtaContext.EmergencyAccessAccounts) + if ($known.Count -eq 0) { return $false } + + foreach ($e in $known) { + if (-not $e) { continue } + if ($Id -and $e.Id -and ($e.Id -eq $Id)) { return $true } + if ($UserPrincipalName -and $e.UserPrincipalName -and ($e.UserPrincipalName -ieq $UserPrincipalName)) { return $true } + } + return $false +} diff --git a/powershell/public/Update-MtSeverityFromZta.ps1 b/powershell/public/Update-MtSeverityFromZta.ps1 new file mode 100644 index 000000000..5155a9e19 --- /dev/null +++ b/powershell/public/Update-MtSeverityFromZta.ps1 @@ -0,0 +1,187 @@ +function Update-MtSeverityFromZta { + <# + .SYNOPSIS + Mutates an in-memory `TestSettings[]` array per `ZtaSettings.SeverityEscalationRules` + before Pester discovery, so ZTA findings can escalate severity on matching Maester tests. + + .DESCRIPTION + Reads `$script:MtZtaContext.ZtaSettings.SeverityEscalationRules`, evaluates each rule + against the loaded context, and mutates entries in `-TestSettings` whose `Id` or `Tag` + matches an active rule's selector. + + Rule shape: + { + "WhenPillarFailedAtLeast": <int>, // count of Failed tests in pillar + "WhenCategoryFlaggedUsersAtLeast": <int>, // count of users in CategoryMappings bucket + "Pillar": "<Identity|Devices|Network|Data>", + "Category": "<bucket name from CategoryMappings>", + "EscalateMaesterTagged": ["<tag>", ...], // selector — match TestSetting.Tag + "EscalateMaesterTestId": ["<id>", ...], // selector — match TestSetting.Id (wildcards allowed) + "From": "<severity>", // optional — only escalate if current severity == From + "To": "<severity>" + } + + Idempotent — running twice does not double-escalate (the second run finds severity + already at the target). Safe to call before Pester discovery on each `Invoke-Maester`. + + No-op when `$script:MtZtaContext` is unset, ZtaSettings is missing, or no rules fire. + + .PARAMETER TestSettings + The TestSettings array from `maester-config.json` (already deserialised to PSObject). + Mutated in place AND returned for pipeline-style chaining. + + .EXAMPLE + $cfg = Get-Content maester-config.json -Raw | ConvertFrom-Json + Import-MtZtaResult -ZtaResultsPath .\zta -ZtaSettings $cfg.ZtaSettings + $cfg.TestSettings = Update-MtSeverityFromZta -TestSettings $cfg.TestSettings + + .LINK + https://maester.dev/docs/commands/Update-MtSeverityFromZta + + .LINK + https://maester.dev/docs/zero-trust-assessment + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([object[]])] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]] $TestSettings + ) + + if (-not $script:MtZtaContext) { + Write-Verbose 'Update-MtSeverityFromZta: $script:MtZtaContext is not set. TestSettings unchanged.' + return $TestSettings + } + + $settings = $script:MtZtaContext.ZtaSettings + if (-not $settings -or -not $settings.PSObject.Properties['SeverityEscalationRules'] -or -not $settings.SeverityEscalationRules) { + Write-Verbose 'Update-MtSeverityFromZta: no SeverityEscalationRules configured. TestSettings unchanged.' + return $TestSettings + } + + $rules = @($settings.SeverityEscalationRules) + $tests = @($script:MtZtaContext.Tests) + + # Pre-compute pillar fail counts once per call. + $pillarFailCounts = @{} + foreach ($p in 'Identity','Devices','Network','Data') { + $pillarFailCounts[$p] = @($tests | Where-Object { $_.TestPillar -eq $p -and $_.TestStatus -eq 'Failed' }).Count + } + + # Pre-compute category bucket sizes (cheaper than calling Group-MtZtaFlaggedIdentity per rule). + $mappings = @() + if ($settings.PSObject.Properties['CategoryMappings'] -and $settings.CategoryMappings) { + $mappings = @($settings.CategoryMappings) + } + $categoryUserCounts = @{} + if ($mappings.Count -gt 0) { + $buckets = Get-MtZta -Section FlaggedUsers + foreach ($b in $buckets) { $categoryUserCounts[$b.Category] = [int]$b.Count } + } + + $mutationCount = 0 + foreach ($rule in $rules) { + # Evaluate conditions — rule fires if ALL specified conditions hold. + $fires = $true + + if ($rule.PSObject.Properties['WhenPillarFailedAtLeast'] -and $rule.PSObject.Properties['Pillar']) { + $threshold = [int]$rule.WhenPillarFailedAtLeast + $pillar = [string]$rule.Pillar + $actual = if ($pillarFailCounts.ContainsKey($pillar)) { $pillarFailCounts[$pillar] } else { 0 } + if ($actual -lt $threshold) { $fires = $false } + } + + if ($fires -and $rule.PSObject.Properties['WhenCategoryFlaggedUsersAtLeast'] -and $rule.PSObject.Properties['Category']) { + $threshold = [int]$rule.WhenCategoryFlaggedUsersAtLeast + $category = [string]$rule.Category + $actual = if ($categoryUserCounts.ContainsKey($category)) { $categoryUserCounts[$category] } else { 0 } + if ($actual -lt $threshold) { $fires = $false } + } + + if (-not $fires) { continue } + + # Build the selector predicate. + $tagSelectors = @() + if ($rule.PSObject.Properties['EscalateMaesterTagged'] -and $rule.EscalateMaesterTagged) { + $tagSelectors = @($rule.EscalateMaesterTagged | ForEach-Object { [string]$_ }) + } + $idSelectors = @() + if ($rule.PSObject.Properties['EscalateMaesterTestId'] -and $rule.EscalateMaesterTestId) { + $idSelectors = @($rule.EscalateMaesterTestId | ForEach-Object { [string]$_ }) + } + if (-not $tagSelectors -and -not $idSelectors) { + Write-Verbose "Update-MtSeverityFromZta: rule has no Escalate* selector — skipping." + continue + } + + $fromSeverity = if ($rule.PSObject.Properties['From']) { [string]$rule.From } else { $null } + $toSeverity = [string]$rule.To + if (-not $toSeverity) { + Write-Warning 'Update-MtSeverityFromZta: rule missing To severity; skipping.' + continue + } + + foreach ($ts in $TestSettings) { + if (-not $ts.PSObject.Properties['Id']) { continue } + + # Match by TestId (wildcards) OR by Tag. + $matched = $false + foreach ($pat in $idSelectors) { + if ([string]$ts.Id -like $pat) { $matched = $true; break } + } + if (-not $matched -and $tagSelectors -and $ts.PSObject.Properties['Tag'] -and $ts.Tag) { + $tsTags = @($ts.Tag | ForEach-Object { [string]$_ }) + foreach ($want in $tagSelectors) { + if ($tsTags -contains $want) { $matched = $true; break } + } + } + if (-not $matched) { continue } + + # Apply severity floor: only escalate if current severity == From (when From set) + # AND new severity is strictly higher in the M->H->C ladder. + $currentSeverity = if ($ts.PSObject.Properties['Severity']) { [string]$ts.Severity } else { $null } + if ($fromSeverity -and $currentSeverity -ne $fromSeverity) { continue } + if (-not (Test-MtZtaSeverityHigher -From $currentSeverity -To $toSeverity)) { continue } + + if ($PSCmdlet.ShouldProcess("$($ts.Id) (current: $currentSeverity)", "Escalate severity to $toSeverity")) { + if ($ts.PSObject.Properties['Severity']) { + $ts.Severity = $toSeverity + } + else { + Add-Member -InputObject $ts -MemberType NoteProperty -Name Severity -Value $toSeverity -Force + } + $mutationCount++ + } + } + } + + if ($mutationCount -gt 0) { + Write-Verbose "Update-MtSeverityFromZta: escalated severity on $mutationCount TestSettings entries." + } + + return $TestSettings +} + +function Test-MtZtaSeverityHigher { + <# + .SYNOPSIS + Internal: returns $true when -To is strictly higher than -From in Maester's + severity ladder (Info < Low < Medium < High < Critical). + + .DESCRIPTION + Used by Update-MtSeverityFromZta to enforce monotonic-up escalation — the cmdlet + never lowers severity. Unknown severities (custom strings) collate at 0 so any + recognised target severity will replace them. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [string] $From, + [Parameter(Mandatory = $true)] [string] $To + ) + $rank = @{ 'Info' = 1; 'Low' = 2; 'Medium' = 3; 'High' = 4; 'Critical' = 5 } + $f = if ($From -and $rank.ContainsKey($From)) { $rank[$From] } else { 0 } + $t = if ($rank.ContainsKey($To)) { $rank[$To] } else { 0 } + return ($t -gt $f) +} diff --git a/powershell/tests/functions/Zta/Build-MtZtaBundle.Tests.ps1 b/powershell/tests/functions/Zta/Build-MtZtaBundle.Tests.ps1 new file mode 100644 index 000000000..547a0a2bd --- /dev/null +++ b/powershell/tests/functions/Zta/Build-MtZtaBundle.Tests.ps1 @@ -0,0 +1,68 @@ +# Unit tests for Build-MtZtaBundle. +# These tests do not drive live Graph — they verify the function's contract +# given a populated context: returns $null when no context, otherwise returns +# a hashtable with the documented top-level keys and a stable shape regardless +# of which reader tier is active. + +Describe 'Build-MtZtaBundle' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-bundle-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + Context 'Empty context' { + It 'returns $null when MtZtaContext is not loaded' { + $bundle = Build-MtZtaBundle + $bundle | Should -BeNullOrEmpty + } + } + + Context 'Context loaded from fixtures (no reader tier)' { + It 'returns a hashtable with the documented top-level keys' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $bundle = Build-MtZtaBundle + $bundle | Should -Not -BeNullOrEmpty + $bundle.Keys | Should -Contain 'TenantId' + $bundle.Keys | Should -Contain 'TenantName' + $bundle.Keys | Should -Contain 'ExecutedAt' + $bundle.Keys | Should -Contain 'ZtaAssessmentVersion' + $bundle.Keys | Should -Contain 'IsStale' + $bundle.Keys | Should -Contain 'Freshness' + $bundle.Keys | Should -Contain 'Summary' + $bundle.Keys | Should -Contain 'Inventory' + $bundle.Keys | Should -Contain 'Tier' + } + + It 'propagates TenantId / TenantName from the manifest / report' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $bundle = Build-MtZtaBundle + $bundle.TenantId | Should -Not -BeNullOrEmpty + # TenantName may be null if the sample manifest doesn't carry it. + $bundle.Keys | Should -Contain 'TenantName' + } + + It 'records Tier as "None" when neither Tier 1 nor Tier 2 readers are populated by the fixture-only context' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $bundle = Build-MtZtaBundle + # The sample fixture has no zt-export/ subtree, so Read-MtZtaJsonExport returns + # an empty reader (or none). Tier should be either 'None' or 'JsonExport'. + $bundle.Tier | Should -BeIn @('None', 'JsonExport') + } + } +} diff --git a/powershell/tests/functions/Zta/Get-MtZta.Tests.ps1 b/powershell/tests/functions/Zta/Get-MtZta.Tests.ps1 new file mode 100644 index 000000000..5d78aa92b --- /dev/null +++ b/powershell/tests/functions/Zta/Get-MtZta.Tests.ps1 @@ -0,0 +1,82 @@ +# Unit tests for Get-MtZta -Section Summary derivation + ZtaSettings passthrough. +# Section Tests / Manifest / Database are covered by Import-MtZtaResult.Tests.ps1. + +Describe 'Get-MtZta -Section Summary' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-summary-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + It 'Summary returns per-pillar counts' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $s = Get-MtZta -Section Summary + + $s.TenantId | Should -Be '00000000-0000-0000-0000-000000000000' + $s.TotalTests | Should -Be 5 + + # Fixture: Identity 1P/1F, Devices 0P/1F, Network 0P/1F, Data 0P/0F/1Skipped + $s.IdentityPassed | Should -Be 1 + $s.IdentityFailed | Should -Be 1 + $s.DevicesPassed | Should -Be 0 + $s.DevicesFailed | Should -Be 1 + $s.NetworkFailed | Should -Be 1 + $s.DataFailed | Should -Be 0 + $s.DataSkipped | Should -Be 1 + } + + It 'Summary fail ratio excludes Skipped/Planned from the denominator' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $s = Get-MtZta -Section Summary + + # Identity: 1 Failed of (2 total - 0 skipped - 0 planned) = 0.5 + $s.IdentityFailRatio | Should -Be 0.5 + # Data: 0 Failed of (1 total - 1 skipped - 0 planned) -> denominator=0 -> max(1,0)=1 -> 0/1=0 + $s.DataFailRatio | Should -Be 0 + } + + It 'returns $null when context is unset' { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + Get-MtZta -Section Summary | Should -BeNullOrEmpty + } + + It 'preserves ZtaSettings on the context when passed to Import-MtZtaResult' { + $settings = [pscustomobject]@{ FreshnessDays = 7; CategoryMappings = @() } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ctx = Get-MtZta + $ctx.ZtaSettings | Should -Not -BeNullOrEmpty + $ctx.ZtaSettings.FreshnessDays | Should -Be 7 + } + + It 'Section FlaggedUsers returns buckets when CategoryMappings supplied' { + $settings = [pscustomobject]@{ + CategoryMappings = @( + [pscustomobject]@{ Category='IdentityPosture'; MatchPillar='Identity'; MatchCategoryAny=@(); MaesterTagBoost=@() } + [pscustomobject]@{ Category='DevicePosture'; MatchPillar='Devices'; MatchCategoryAny=@(); MaesterTagBoost=@() } + [pscustomobject]@{ Category='NetworkPosture'; MatchPillar='Network'; MatchCategoryAny=@(); MaesterTagBoost=@() } + ) + DataDrivenSettings = [pscustomobject]@{ MaxUsersPerCategory = 50; GroupSimilar = $true } + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $buckets = Get-MtZta -Section FlaggedUsers + $buckets | Should -Not -BeNullOrEmpty + ($buckets | Where-Object Category -eq 'IdentityPosture').Count | Should -BeGreaterThan 0 + } +} diff --git a/powershell/tests/functions/Zta/Get-MtZtaAuthMethodSet.Tests.ps1 b/powershell/tests/functions/Zta/Get-MtZtaAuthMethodSet.Tests.ps1 new file mode 100644 index 000000000..608850c30 --- /dev/null +++ b/powershell/tests/functions/Zta/Get-MtZtaAuthMethodSet.Tests.ps1 @@ -0,0 +1,63 @@ +# Unit tests for Get-MtZtaAuthMethodSet. +# This cmdlet is the single source of truth for "which Graph +# authenticationMethod enum values are phish-resistant vs phishable" across +# MT.Zta.1140 / 1141 / 1142 / 1143. Drift in this set silently miscategorises +# every user, so the buckets must be locked. + +Describe 'Get-MtZtaAuthMethodSet' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + } + + Context 'Default invocation' { + It 'returns a hashtable / object with PhishResistant + Phishable buckets' { + $set = Get-MtZtaAuthMethodSet + $set | Should -Not -BeNullOrEmpty + $set.PhishResistant | Should -Not -BeNullOrEmpty + $set.Phishable | Should -Not -BeNullOrEmpty + } + + It 'PhishResistant bucket contains the canonical Graph authenticationMethodModes for phish-resistant MFA' { + $set = Get-MtZtaAuthMethodSet + # Per Graph: fido2 / windowsHelloForBusiness / x509CertificateMultiFactor + # are the only authentication-method modes that compose to phish-resistant MFA. + # Device-bound passkeys appear in user-registration data; include them too. + $set.PhishResistant | Should -Contain 'fido2' + $set.PhishResistant | Should -Contain 'windowsHelloForBusiness' + } + + It 'Phishable bucket contains the relayable / phone-bound enum values' { + $set = Get-MtZtaAuthMethodSet + # ZTA's UserRegistrationDetails.methodsRegistered enum emits phone-bound + # methods as `mobilePhone` / `alternateMobilePhone` / `officePhone` + # rather than the Graph CA authStrength enum value `sms`. The + # phishable set tracks the URD vocabulary (that's the column + # MT.Zta.1140-1143 read), not the CA-policy vocabulary. + $set.Phishable | Should -Contain 'mobilePhone' + $set.Phishable | Should -Contain 'voice' + $set.Phishable | Should -Contain 'email' + } + + It 'PhishResistant and Phishable are disjoint' { + $set = Get-MtZtaAuthMethodSet + $overlap = @($set.PhishResistant | Where-Object { $_ -in $set.Phishable }) + $overlap | Should -BeNullOrEmpty + } + } + + Context '-Bucket parameter' { + It "returns the same content for -Bucket 'PhishResistant' as the .PhishResistant property of the default call" { + $full = Get-MtZtaAuthMethodSet + $phish = Get-MtZtaAuthMethodSet -Bucket 'PhishResistant' + (Compare-Object -ReferenceObject $full.PhishResistant -DifferenceObject $phish -SyncWindow 0) | Should -BeNullOrEmpty + } + + It "returns the same content for -Bucket 'Phishable' as the .Phishable property of the default call" { + $full = Get-MtZtaAuthMethodSet + $phish = Get-MtZtaAuthMethodSet -Bucket 'Phishable' + (Compare-Object -ReferenceObject $full.Phishable -DifferenceObject $phish -SyncWindow 0) | Should -BeNullOrEmpty + } + } +} diff --git a/powershell/tests/functions/Zta/Get-MtZtaRecommendedTag.Tests.ps1 b/powershell/tests/functions/Zta/Get-MtZtaRecommendedTag.Tests.ps1 new file mode 100644 index 000000000..2eeea443f --- /dev/null +++ b/powershell/tests/functions/Zta/Get-MtZtaRecommendedTag.Tests.ps1 @@ -0,0 +1,114 @@ +# Unit tests for Get-MtZtaRecommendedTag. +# Verifies CategoryMappings traversal, pillar-tag union, deterministic ordering, and +# the >10%-Other coverage warning. + +Describe 'Get-MtZtaRecommendedTag — tag derivation' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + + $script:settings = [pscustomobject]@{ + FocusMechanisms = @('Tag','Conditional','DataDriven','Severity') + PillarTagMap = [pscustomobject]@{ + Identity = @('Identity','MFA') + Devices = @('Intune') + Network = @('GSA') + Data = @('Purview') + } + CategoryMappings = @( + [pscustomobject]@{ Category='IdentityPosture'; MatchPillar='Identity'; MatchCategoryAny=@(); MaesterTagBoost=@('Identity-Boost') } + [pscustomobject]@{ Category='DevicePosture'; MatchPillar='Devices'; MatchCategoryAny=@(); MaesterTagBoost=@('Device-Boost') } + [pscustomobject]@{ Category='NetworkPosture'; MatchPillar='Network'; MatchCategoryAny=@(); MaesterTagBoost=@('Net-Boost') } + [pscustomobject]@{ Category='DataPosture'; MatchPillar='Data'; MatchCategoryAny=@(); MaesterTagBoost=@('Data-Boost') } + ) + } + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-tag-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + It 'returns empty array when MtZtaContext is unset' { + $tags = @(Get-MtZtaRecommendedTag) + $tags.Count | Should -Be 0 + } + + It 'returns empty array when ZTA loaded but no failed tests' { + # Override fixture to a no-failure version. + $clean = Get-Content (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') -Raw | ConvertFrom-Json + foreach ($t in $clean.Tests) { $t.TestStatus = 'Passed' } + $clean | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $script:settings + $tags = @(Get-MtZtaRecommendedTag) + $tags.Count | Should -Be 0 + } + + It 'emits pillar literals plus PillarTagMap aliases for each failed pillar' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $script:settings + $tags = @(Get-MtZtaRecommendedTag) + # Fixture has Failed tests in Identity, Devices, Network (Data only has a Skipped). + $tags | Should -Contain 'Identity' + $tags | Should -Contain 'Devices' + $tags | Should -Contain 'Network' + $tags | Should -Not -Contain 'Data' + $tags | Should -Contain 'MFA' # PillarTagMap[Identity] + $tags | Should -Contain 'Intune' # PillarTagMap[Devices] + $tags | Should -Contain 'GSA' # PillarTagMap[Network] + } + + It 'emits MaesterTagBoost from CategoryMappings hits' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $script:settings + $tags = @(Get-MtZtaRecommendedTag) + $tags | Should -Contain 'Identity-Boost' + $tags | Should -Contain 'Device-Boost' + $tags | Should -Contain 'Net-Boost' + } + + It 'returns deterministic (sorted) output' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $script:settings + $first = @(Get-MtZtaRecommendedTag) + $second = @(Get-MtZtaRecommendedTag) + ($first -join ',') | Should -Be ($second -join ',') + ($first -join ',') | Should -Be (($first | Sort-Object) -join ',') + } + + It 'uses default PillarTagMap when no ZtaSettings provided' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + # No CategoryMappings on the context → every failed test classifies as + # 'Other' → the cmdlet emits a >10% warning by design. Suppress it here + # because we're explicitly testing the no-settings fallback path, not + # the coverage-warning path (that has its own It below). + $tags = @(Get-MtZtaRecommendedTag -WarningAction SilentlyContinue) + # Defaults include MFA / ConditionalAccess / PIM for Identity etc. + $tags | Should -Contain 'Identity' + $tags | Should -Contain 'MFA' + } + + It 'warns when more than 10% of failed tests classify as Other' { + # Mappings that match nothing in the fixture -> all 4 failures land in Other. + $emptyMappings = [pscustomobject]@{ + CategoryMappings = @( + [pscustomobject]@{ Category='SomethingElse'; MatchPillar='NoSuchPillar'; MatchCategoryAny=@(); MaesterTagBoost=@('x') } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $emptyMappings + + $warningOutput = $null + Get-MtZtaRecommendedTag -WarningAction SilentlyContinue -WarningVariable warningOutput | Out-Null + $warningOutput | Should -Not -BeNullOrEmpty + ($warningOutput -join ' ') | Should -Match 'Other' + } +} diff --git a/powershell/tests/functions/Zta/Get-MtZtaThreshold.Tests.ps1 b/powershell/tests/functions/Zta/Get-MtZtaThreshold.Tests.ps1 new file mode 100644 index 000000000..a61f64cd2 --- /dev/null +++ b/powershell/tests/functions/Zta/Get-MtZtaThreshold.Tests.ps1 @@ -0,0 +1,83 @@ +# Unit tests for Get-MtZtaThreshold. +# Covers: default fallback when context is empty / ZtaSettings missing / key absent, +# and value resolution from both hashtable and pscustomobject ZtaSettings shapes. + +Describe 'Get-MtZtaThreshold' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-thr-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + Context 'Default fallback' { + It 'returns the -Default when no MtZtaContext is loaded' { + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 42) | Should -Be 42 + } + + It 'returns the -Default when ZtaSettings is null' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 99) | Should -Be 99 + } + + It 'returns the -Default when Thresholds is absent from ZtaSettings' { + $settings = [pscustomobject]@{ FreshnessDays = 14 } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 17) | Should -Be 17 + } + + It 'returns the -Default when the key is not present in Thresholds' { + $settings = [pscustomobject]@{ + Thresholds = [pscustomobject]@{ 'MT.Zta.1004' = 5 } + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30) | Should -Be 30 + } + } + + Context 'Value resolution' { + It 'returns the configured value when Thresholds is a pscustomobject (ConvertFrom-Json default)' { + $settings = [pscustomobject]@{ + Thresholds = [pscustomobject]@{ + 'MT.Zta.1001' = 50 + 'MT.Zta.1002' = 0.7 + } + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30) | Should -Be 50 + (Get-MtZtaThreshold -TestId 'MT.Zta.1002' -Default 0.5) | Should -Be 0.7 + } + + It 'returns the configured value when Thresholds is a hashtable (-AsHashtable shape)' { + $settings = @{ Thresholds = @{ 'MT.Zta.1001' = 25 } } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30) | Should -Be 25 + } + + It 'preserves the configured value type (int stays int, double stays double)' { + $settings = [pscustomobject]@{ + Thresholds = [pscustomobject]@{ + 'MT.Zta.1001' = 30 + 'MT.Zta.1002' = 0.5 + } + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + (Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 0) | Should -BeOfType ([int]) + (Get-MtZtaThreshold -TestId 'MT.Zta.1002' -Default 0.0) | Should -BeOfType ([double]) + } + } +} diff --git a/powershell/tests/functions/Zta/Group-MtZtaFlaggedIdentity.Tests.ps1 b/powershell/tests/functions/Zta/Group-MtZtaFlaggedIdentity.Tests.ps1 new file mode 100644 index 000000000..dbf01a30f --- /dev/null +++ b/powershell/tests/functions/Zta/Group-MtZtaFlaggedIdentity.Tests.ps1 @@ -0,0 +1,202 @@ +# Unit tests for Group-MtZtaFlaggedIdentity. +# Tests the 7-step bucketing algorithm against synthetic Tests[] arrays and the +# captured fixture, with and without DuckDB enrichment (stub callable). + +Describe 'Group-MtZtaFlaggedIdentity — bucketing algorithm' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + + $script:standardMappings = @( + @{ Category = 'IdentityPosture'; MatchPillar = 'Identity'; MatchCategoryAny = @(); MaesterTagBoost = @('Identity','MFA') } + @{ Category = 'DevicePosture'; MatchPillar = 'Devices'; MatchCategoryAny = @(); MaesterTagBoost = @('Intune') } + @{ Category = 'NetworkPosture'; MatchPillar = 'Network'; MatchCategoryAny = @(); MaesterTagBoost = @('GSA') } + @{ Category = 'DataPosture'; MatchPillar = 'Data'; MatchCategoryAny = @(); MaesterTagBoost = @('Purview') } + @{ Category = 'PrivilegedAccess'; MatchPillar = '*'; MatchCategoryAny = @('Privileged access','Role management','Credential management') + MaesterTagBoost = @('PIM','PrivilegedAccess') } + @{ Category = 'GuestUnconstrained'; MatchPillar = 'Identity'; MatchCategoryAny = @('External collaboration','External Identities','Guest','Cross-tenant') + MaesterTagBoost = @('Guest','B2B') } + ) + } + + Context 'JSON-only mode (no ContextDatabase)' { + + It 'classifies Privileged-access tests into the cross-cut bucket' { + $tests = @( + [pscustomobject]@{ TestId='1'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Privileged access'; TestTitle='t1'; TestResult='alice@example.com flagged' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + ($buckets | Where-Object Category -eq 'PrivilegedAccess').Count | Should -Be 1 + } + + It 'classifies Identity-pillar non-cross-cut tests into IdentityPosture' { + $tests = @( + [pscustomobject]@{ TestId='2'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Authentication Methods'; TestTitle='t2'; TestResult='bob@example.com' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + ($buckets | Where-Object Category -eq 'IdentityPosture').Count | Should -Be 1 + } + + It 'falls back to Other when no mapping matches and pillar is unknown' { + $tests = @( + [pscustomobject]@{ TestId='3'; TestStatus='Failed'; TestPillar='UnknownPillar'; TestCategory='Whatever'; TestTitle='t3'; TestResult='' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + ($buckets | Where-Object Category -eq 'Other').Count | Should -Be 1 + } + + It 'explicit-category rule wins over pillar-level catch-all regardless of order (GuestUnconstrained beats IdentityPosture)' { + # GuestUnconstrained has explicit MatchCategoryAny including 'External collaboration'. + # IdentityPosture has empty MatchCategoryAny -> it is a pillar-level catch-all. + # The two-pass algorithm: pass-2 (explicit category) beats pass-3 (catch-all), + # so order in the mappings array does NOT affect the outcome. + $tests = @( + [pscustomobject]@{ TestId='4'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='External collaboration'; TestTitle='t4'; TestResult='' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + ($buckets | Where-Object Category -eq 'GuestUnconstrained').Count | Should -Be 1 + ($buckets | Where-Object Category -eq 'IdentityPosture').Count | Should -Be 0 + } + + It 'classification is order-independent — same result with cross-cut rule listed first' { + $crossCutFirst = @( + @{ Category = 'GuestUnconstrained'; MatchPillar = 'Identity'; MatchCategoryAny = @('External collaboration','Guest'); MaesterTagBoost = @() } + @{ Category = 'IdentityPosture'; MatchPillar = 'Identity'; MatchCategoryAny = @(); MaesterTagBoost = @() } + ) + $tests = @( + [pscustomobject]@{ TestId='5'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='External collaboration'; TestTitle='t5'; TestResult='' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $crossCutFirst + ($buckets | Where-Object Category -eq 'GuestUnconstrained').Count | Should -Be 1 + } + + It 'extracts UPNs and GUIDs from TestResult markdown' { + $tests = @( + [pscustomobject]@{ TestId='6'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Authentication Methods'; TestTitle='t6' + TestResult = 'Users without MFA: alice@example.com, bob@example.com, 11111111-2222-3333-4444-555555555555' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + $bucket = $buckets | Where-Object Category -eq 'IdentityPosture' + $bucket.Count | Should -Be 3 + ($bucket.Group | Where-Object UserPrincipalName -eq 'alice@example.com').Count | Should -Be 1 + ($bucket.Group | Where-Object UserId -eq '11111111-2222-3333-4444-555555555555').Count | Should -Be 1 + } + + It 'caps each bucket at MaxUsersPerCategory' { + $manyUsers = (1..25 | ForEach-Object { "user$_@example.com" }) -join ', ' + $tests = @( + [pscustomobject]@{ TestId='7'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Authentication Methods'; TestTitle='t7'; TestResult=$manyUsers } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m -MaxUsersPerCategory 10 + } $tests $script:standardMappings + $bucket = $buckets | Where-Object Category -eq 'IdentityPosture' + $bucket.Count | Should -Be 25 # pre-cap total preserved + @($bucket.Group).Count | Should -Be 10 # post-cap sample is 10 + } + + It 'dedupes per (UserPrincipalName, Category) and merges Evidence' { + $tests = @( + [pscustomobject]@{ TestId='8'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Authentication Methods'; TestTitle='AuthMeth missing'; TestResult='alice@example.com' } + [pscustomobject]@{ TestId='9'; TestStatus='Failed'; TestPillar='Identity'; TestCategory='Authentication Methods'; TestTitle='Other AM gap'; TestResult='alice@example.com' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + $bucket = $buckets | Where-Object Category -eq 'IdentityPosture' + $bucket.Count | Should -Be 1 # one user after dedupe + $alice = $bucket.Group | Select-Object -First 1 + $alice.Evidence.Count | Should -BeGreaterOrEqual 2 # both test titles preserved + } + + It 'ignores tests with TestStatus != Failed' { + $tests = @( + [pscustomobject]@{ TestId='10'; TestStatus='Passed'; TestPillar='Identity'; TestCategory='X'; TestTitle='passed'; TestResult='alice@example.com' } + [pscustomobject]@{ TestId='11'; TestStatus='Skipped'; TestPillar='Identity'; TestCategory='X'; TestTitle='skipped'; TestResult='bob@example.com' } + ) + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $tests $script:standardMappings + @($buckets).Count | Should -Be 0 + } + + It 'returns the captured fixture into known buckets' { + $report = Get-Content (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') -Raw | ConvertFrom-Json + $buckets = & $script:mod { + param($t,$m) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m + } $report.Tests $script:standardMappings + # Fixture has 4 Failed tests across Identity/Devices/Network + 1 Skipped Data test. + ($buckets | Where-Object Category -eq 'IdentityPosture').Count | Should -BeGreaterThan 0 + ($buckets | Where-Object Category -eq 'DevicePosture').Count | Should -BeGreaterThan 0 + ($buckets | Where-Object Category -eq 'NetworkPosture').Count | Should -BeGreaterThan 0 + } + } + + Context 'DuckDB enrichment (stub callable)' { + + BeforeAll { + # Stub a context object that returns canned rows for known queries. + $script:dbStub = [pscustomobject]@{ + Query = { + param($sql) + if ($sql -like '*UserRegistrationDetails*isMfaRegistered = false*') { + return ,@( + [pscustomobject]@{ id='aaa'; userPrincipalName='nomfa1@example.com' } + [pscustomobject]@{ id='bbb'; userPrincipalName='nomfa2@example.com' } + ) + } + if ($sql -like '*FROM "User"*Guest*') { + return ,@( + [pscustomobject]@{ id='ccc'; userPrincipalName='guest1@partner.com' } + ) + } + if ($sql -like '*FROM Device*isCompliant = false*') { + return ,@( + [pscustomobject]@{ deviceId='ddd'; displayName='LAPTOP-01'; isCompliant=$false } + ) + } + return ,@() + }.GetNewClosure() + } + } + + It 'enriches IdentityPosture from UserRegistrationDetails (NoMFA)' { + $tests = @() # no JSON failures — only DB enrichment should produce buckets + $buckets = & $script:mod { + param($t,$m,$db) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m -ContextDatabase $db + } $tests $script:standardMappings $script:dbStub + + $idBucket = $buckets | Where-Object Category -eq 'IdentityPosture' + $idBucket.Count | Should -BeGreaterOrEqual 2 + } + + It 'enriches GuestUnconstrained from User table' { + $buckets = & $script:mod { + param($t,$m,$db) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m -ContextDatabase $db + } @() $script:standardMappings $script:dbStub + ($buckets | Where-Object Category -eq 'GuestUnconstrained').Count | Should -BeGreaterOrEqual 1 + } + + It 'enriches DevicePosture from Device table' { + $buckets = & $script:mod { + param($t,$m,$db) Group-MtZtaFlaggedIdentity -Tests $t -CategoryMappings $m -ContextDatabase $db + } @() $script:standardMappings $script:dbStub + ($buckets | Where-Object Category -eq 'DevicePosture').Count | Should -BeGreaterOrEqual 1 + } + } +} diff --git a/powershell/tests/functions/Zta/Import-MtZtaResults.Tests.ps1 b/powershell/tests/functions/Zta/Import-MtZtaResults.Tests.ps1 new file mode 100644 index 000000000..c9dc8557f --- /dev/null +++ b/powershell/tests/functions/Zta/Import-MtZtaResults.Tests.ps1 @@ -0,0 +1,185 @@ +# Integration tests for Import-MtZtaResult end-to-end + the public surface guarantees. +# All tests use -ForceJsonFallback so they don't depend on DuckDB binaries. +# The DuckDB read path is tested separately in Read-MtZtaDatabase.Tests.ps1. + +Describe 'ZTA module surface + Import-MtZtaResult' -Tag 'Acceptance', 'Zta' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + Context 'Module exports the four new public cmdlets' { + BeforeAll { + $script:exported = Get-Command -Module Maester -CommandType Function | Select-Object -ExpandProperty Name + $script:exportedAliases = Get-Command -Module Maester -CommandType Alias | Select-Object -ExpandProperty Name + } + It 'exports Import-MtZtaResult (singular per PSUseSingularNouns)' { $script:exported | Should -Contain 'Import-MtZtaResult' } + It 'exports the back-compat alias Import-MtZtaResults' { $script:exportedAliases | Should -Contain 'Import-MtZtaResults' } + It 'exports Get-MtZta' { $script:exported | Should -Contain 'Get-MtZta' } + It 'exports Get-MtZtaRecommendedTag' { $script:exported | Should -Contain 'Get-MtZtaRecommendedTag' } + It 'exports Update-MtSeverityFromZta' { $script:exported | Should -Contain 'Update-MtSeverityFromZta' } + } + + Context 'Public-stub guards (no $script:MtZtaContext)' { + BeforeAll { + # Force a clean state — no prior load. + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + It 'Import-MtZtaResult no-ops when -ZtaResultsPath is empty' { + { Import-MtZtaResult -ZtaResultsPath '' -ErrorAction Stop } | Should -Not -Throw + (Get-MtZta) | Should -BeNullOrEmpty + } + It 'Get-MtZta returns $null when context is not set' { + Get-MtZta | Should -BeNullOrEmpty + } + It 'Get-MtZtaRecommendedTag returns empty array when context is not set' { + $tags = @(Get-MtZtaRecommendedTag) + $tags.Count | Should -Be 0 + } + It 'Update-MtSeverityFromZta returns input unchanged when context is not set' { + $tsArray = @( @{ Id = 'X.1'; Severity = 'Medium' } ) + $out = @(Update-MtSeverityFromZta -TestSettings $tsArray -WhatIf:$false) + $out.Count | Should -Be 1 + $out[0].Severity | Should -Be 'Medium' + } + } + + Context 'Sample fixture loads cleanly' { + It 'fixture file exists' { + (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') | Should -Exist + } + It 'parses as JSON with the expected top-level shape' { + $obj = Get-Content (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') -Raw | ConvertFrom-Json + $obj.TenantId | Should -Not -BeNullOrEmpty + $obj.ExecutedAt | Should -Not -BeNullOrEmpty + $obj.Tests | Should -Not -BeNullOrEmpty + $obj.EndOfJson | Should -BeTrue + } + It 'covers all four ZTA pillars' { + $obj = Get-Content (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') -Raw | ConvertFrom-Json + $pillars = $obj.Tests | Select-Object -ExpandProperty TestPillar | Sort-Object -Unique + 'Identity','Devices','Network','Data' | ForEach-Object { $pillars | Should -Contain $_ } + } + } + + Context 'Import-MtZtaResult — local-folder integration (-ForceJsonFallback)' { + + BeforeEach { + # Fresh bundle per test — copy the sanitised fixtures into a temp directory. + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-import-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + + # Reset the module-private context. + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + AfterEach { + if (Test-Path $script:bundle) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + It 'populates MtZtaContext with manifest + tests + freshness' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + + $ctx = Get-MtZta + $ctx | Should -Not -BeNullOrEmpty + $ctx.TenantId | Should -Be '00000000-0000-0000-0000-000000000000' + $ctx.TenantName | Should -Be 'Sample Tenant' + $ctx.Tests.Count | Should -Be 5 + $ctx.Manifest | Should -Not -BeNullOrEmpty + $ctx.Manifest.schemaVersion | Should -Be '1.0' + $ctx.Database | Should -BeNullOrEmpty # ForceJsonFallback skips DuckDB + $ctx.DatabaseStatus | Should -Be 'ForcedJsonFallback' + $ctx.Freshness | Should -Not -BeNullOrEmpty + $ctx.Freshness.TimestampSource | Should -Be 'ManifestRunStartTime' + } + + It 'is idempotent — second call with the same source short-circuits' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $first = Get-MtZta + $firstLoadedAt = $first.LoadedAt + + Start-Sleep -Milliseconds 50 + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $second = Get-MtZta + + $second.LoadedAt | Should -Be $firstLoadedAt + } + + It 'Get-MtZta -Section Tests returns the raw Tests[] array' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $tests = Get-MtZta -Section Tests + $tests.Count | Should -Be 5 + ($tests | Where-Object TestPillar -eq 'Identity').Count | Should -BeGreaterThan 0 + } + + It 'Get-MtZta -Section Manifest returns the manifest object' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $m = Get-MtZta -Section Manifest + $m.tenantId | Should -Be '00000000-0000-0000-0000-000000000000' + $m.ztaVersion | Should -Be '2.2.0' + $m.pillarsCovered.Count | Should -Be 4 + } + + It 'Get-MtZta -Section Database returns $null when ForceJsonFallback was used' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + Get-MtZta -Section Database | Should -BeNullOrEmpty + } + + It 'rejects load when -ExpectedTenantId mismatches the manifest' { + { Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback ` + -ExpectedTenantId 'ffffffff-ffff-ffff-ffff-ffffffffffff' } | + Should -Throw -ExpectedMessage '*tenant mismatch*' + } + + It 'allows load when -ExpectedTenantId matches the manifest' { + { Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback ` + -ExpectedTenantId '00000000-0000-0000-0000-000000000000' } | + Should -Not -Throw + } + + It 'flags the bundle as stale when the manifest timestamp is older than -FreshnessDays' { + # Override the manifest to be 100 days old. + @{ + schemaVersion = '1.0' + tenantId = '00000000-0000-0000-0000-000000000000' + runStartTime = (Get-Date).ToUniversalTime().AddDays(-100).ToString('o') + ztaVersion = '2.2.0' + } | ConvertTo-Json | Set-Content (Join-Path $script:bundle 'manifest.json') + + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -WarningAction SilentlyContinue + + $ctx = Get-MtZta + $ctx.IsStale | Should -BeTrue + $ctx.Freshness.AgeDays | Should -BeGreaterThan 14 + } + + It 'throws when the bundle is missing ZeroTrustAssessmentReport.json' { + Remove-Item (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + { Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback } | + Should -Throw -ExpectedMessage '*missing ZeroTrustAssessmentReport.json*' + } + } + + Context 'Backward-compat: vanilla Maester is unchanged when ZTA is absent' { + + BeforeAll { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + It 'Get-MtZta returns $null with no warnings when no ZTA was loaded' { + (Get-MtZta) | Should -BeNullOrEmpty + } + + It 'Import-MtZtaResult with $null does not populate any context' { + Import-MtZtaResult -ZtaResultsPath $null + (Get-MtZta) | Should -BeNullOrEmpty + } + } +} diff --git a/powershell/tests/functions/Zta/Read-MtZtaDatabase.Tests.ps1 b/powershell/tests/functions/Zta/Read-MtZtaDatabase.Tests.ps1 new file mode 100644 index 000000000..9972cef25 --- /dev/null +++ b/powershell/tests/functions/Zta/Read-MtZtaDatabase.Tests.ps1 @@ -0,0 +1,83 @@ +# Unit tests for Read-MtZtaDatabase. +# DuckDB.NET.Data.dll is NOT bundled with the upstream Maester repo. Tests verify +# parameter validation + the missing-binary error path and schema baseline list. +# Real DuckDB integration tests are gated behind an availability check — they only +# run when DuckDB binaries are accessible on the current machine. + +BeforeDiscovery { + # Pester evaluates -Skip: at Discovery time (before any BeforeAll). + # duckDbAvailable must cover ALL probe paths Initialize-MtZtaDuckDbAssembly uses + # (AppDomain, ZTA module lib/, Maester lib/). Checking only Maester's lib/ would + # mis-classify machines where ZeroTrustAssessment is installed: the "absent" path + # would assume $null but Initialize would actually succeed, causing a real DuckDB + # IO error on the bogus .db file instead of a $null return. + $modulePath = Resolve-Path "$PSScriptRoot/../../../Maester.psd1" | Select-Object -ExpandProperty Path + $moduleRoot = Split-Path $modulePath -Parent + + $duckDbInMaesterLib = Test-Path (Join-Path $moduleRoot 'lib/DuckDB.NET.Data.dll') + + $duckDbInAppDomain = [bool]([System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq 'DuckDB.NET.Data' } | Select-Object -First 1) + + $duckDbInZtaModule = $false + $ztaMod = Get-Module -ListAvailable ZeroTrustAssessment -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | Select-Object -First 1 + if ($ztaMod) { + $duckDbInZtaModule = Test-Path (Join-Path $ztaMod.ModuleBase 'lib/DuckDB.NET.Data.dll') + } + + $script:duckDbAvailable = $duckDbInMaesterLib -or $duckDbInAppDomain -or $duckDbInZtaModule + $script:sampleDbPath = Join-Path $PSScriptRoot 'fixtures/zt.sample.db' + $script:sampleDbExists = (Test-Path $script:sampleDbPath) +} + +Describe 'Read-MtZtaDatabase (PR-C)' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + } + + Context 'Surface and parameter validation' { + + It 'throws when -DatabasePath does not exist' { + $bogus = Join-Path ([System.IO.Path]::GetTempPath()) "no-zt-$([guid]::NewGuid()).db" + { & $script:mod { param($p) Read-MtZtaDatabase -DatabasePath $p } $bogus } | + Should -Throw -ExpectedMessage '*database file not found*' + } + } + + Context 'DuckDB binaries not installed (default state)' -Skip:$duckDbAvailable { + + # Returns $null (not throws) when no DuckDB assembly can be located; + # callers treat DuckDB as opportunistic and fall back to JSON-shadow. + It 'returns $null when no DuckDB.NET.Data assembly can be located' { + $f = New-TemporaryFile + $fakeDb = $f.FullName + '.db' + Move-Item $f.FullName $fakeDb + try { + $result = & $script:mod { param($p) Read-MtZtaDatabase -DatabasePath $p } $fakeDb + $result | Should -BeNullOrEmpty + } + finally { Remove-Item $fakeDb -Force -ErrorAction SilentlyContinue } + } + } + + Context 'DuckDB binaries installed (operator populated lib/)' -Skip:(-not $duckDbAvailable) { + + It 'returns a context object with Connection / Query / Tables / Dispose when the .db opens' -Skip:(-not $sampleDbExists) { + $ctx = & $script:mod { param($p) Read-MtZtaDatabase -DatabasePath $p } $sampleDbPath + try { + $ctx | Should -Not -BeNullOrEmpty + $ctx.Connection | Should -Not -BeNullOrEmpty + $ctx.Query | Should -Not -BeNullOrEmpty + $ctx.Tables | Should -Not -BeNullOrEmpty + $ctx.Tables.Count | Should -BeGreaterThan 0 + } + finally { + if ($ctx -and $ctx.Dispose) { & $ctx.Dispose } + } + } + } +} diff --git a/powershell/tests/functions/Zta/Read-MtZtaJsonExport.Tests.ps1 b/powershell/tests/functions/Zta/Read-MtZtaJsonExport.Tests.ps1 new file mode 100644 index 000000000..d1f8c7767 --- /dev/null +++ b/powershell/tests/functions/Zta/Read-MtZtaJsonExport.Tests.ps1 @@ -0,0 +1,197 @@ +# Unit tests for Read-MtZtaJsonExport (Tier 1 JSON shadow reader). +# Tests that require a real export bundle skip at discovery time when the fixture +# path is not present, so the suite remains portable across machines. + +$global:MtZtaJsonExportFixturePath = 'F:\ALZ\exports\assessments\platform\zta-report\zta-report' +$global:MtZtaJsonExportFixtureExists = Test-Path (Join-Path $global:MtZtaJsonExportFixturePath 'zt-export') + +Describe 'Read-MtZtaJsonExport — Tier 1 JSON shadow reader' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixturePath = $global:MtZtaJsonExportFixturePath + } + + Context 'Bundle resolution + schema baseline' -Skip:(-not $global:MtZtaJsonExportFixtureExists) { + + It 'opens the captured Platform bundle and discovers all 16 baseline tables' { + $ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p + } $script:fixturePath + try { + $ctx | Should -Not -BeNullOrEmpty + $ctx.Tier | Should -Be 'JsonExport' + $ctx.SupportsSql | Should -BeTrue + $ctx.Tables.Count | Should -BeGreaterOrEqual 16 + $ctx.Tables | Should -Contain 'User' + $ctx.Tables | Should -Contain 'SignIn' + $ctx.Tables | Should -Contain 'RoleAssignment' + } + finally { & $ctx.Dispose } + } + + It 'throws when the bundle path does not exist' { + $bogus = Join-Path ([System.IO.Path]::GetTempPath()) "no-zta-$([guid]::NewGuid())" + { & $script:mod { param($p) Read-MtZtaJsonExport -BundlePath $p } $bogus } | + Should -Throw -ExpectedMessage '*bundle path not found*' + } + + It 'on a sparse/empty bundle, baseline tables are present (as known-empty) and GetRows returns @()' { + # ZTA omits JSON folders for tables with 0 rows. Baseline tables must still + # be reachable so callers can call GetRows without special-casing emptiness. + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) "zta-sparse-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path (Join-Path $tmp 'zt-export/User') | Out-Null + try { + $ctx = & $script:mod { param($p) Read-MtZtaJsonExport -BundlePath $p } $tmp + try { + $ctx.Tables | Should -Contain 'RoleAssignment' # baseline-required, no folder + @(& $ctx.GetRows 'RoleAssignment') | Should -BeNullOrEmpty + (& $ctx.HasTable 'RoleAssignment') | Should -BeTrue + } + finally { & $ctx.Dispose } + } + finally { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } + } + + It 'allows narrow opens via -LimitToTables (skips schema baseline)' { + $ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p -LimitToTables 'User','RoleAssignment' + } $script:fixturePath + try { + $ctx.Tables | Should -Contain 'User' + $ctx.Tables | Should -Contain 'RoleAssignment' + $ctx.Tables.Count | Should -Be 2 + } + finally { & $ctx.Dispose } + } + } + + Context 'GetRows streaming + count-parity with zt.db' -Skip:(-not $global:MtZtaJsonExportFixtureExists) { + + BeforeAll { + $script:ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p + } $script:fixturePath + } + AfterAll { if ($script:ctx) { & $script:ctx.Dispose } } + + # Counts are approximate because the captured fixture is refreshed periodically. + # The invariant verified is "table is populated AND streaming through all shards works". + + It 'GetRows User returns at least 10 rows (Platform tenant)' { + $rows = & $script:ctx.GetRows 'User' + @($rows).Count | Should -BeGreaterThan 10 + } + + It 'GetRows SignIn returns hundreds of rows (Platform tenant)' { + $rows = & $script:ctx.GetRows 'SignIn' + @($rows).Count | Should -BeGreaterThan 100 + } + + It 'GetRows ConfigurationPolicy aggregates across many shards (Platform tenant)' { + $rows = & $script:ctx.GetRows 'ConfigurationPolicy' + @($rows).Count | Should -BeGreaterThan 50 + } + + It 'GetRows with Predicate filters in-stream' { + $pred = { param($r) $r.userType -eq 'Member' } + $rows = & $script:ctx.GetRows 'User' $pred + @($rows).Count | Should -BeGreaterThan 0 + ($rows | Where-Object { $_.userType -ne 'Member' }) | Should -BeNullOrEmpty + } + + It 'GetRows with Top stops streaming after N rows' { + $rows = & $script:ctx.GetRows 'SignIn' $null 10 + @($rows).Count | Should -Be 10 + } + } + + Context 'BuildIndex + lookup' -Skip:(-not $global:MtZtaJsonExportFixtureExists) { + + BeforeAll { + $script:ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p + } $script:fixturePath + } + AfterAll { if ($script:ctx) { & $script:ctx.Dispose } } + + It 'BuildIndex User by id returns a hashtable keyed by every user id' { + $idx = & $script:ctx.BuildIndex 'User' 'id' + $idx | Should -BeOfType [hashtable] + $idx.Count | Should -BeGreaterThan 10 + ($idx.Values | Select-Object -First 1).PSObject.Properties['userPrincipalName'] | Should -Not -BeNullOrEmpty + } + + It 'BuildIndex is cached — second call returns the same hashtable instance' { + $idx1 = & $script:ctx.BuildIndex 'RoleAssignment' 'principalId' + $idx2 = & $script:ctx.BuildIndex 'RoleAssignment' 'principalId' + [object]::ReferenceEquals($idx1, $idx2) | Should -BeTrue + } + + It 'BuildIndex on RoleAssignment by principalId enables anti-join lookups' { + $idx = & $script:ctx.BuildIndex 'RoleAssignment' 'principalId' + # For a non-privileged user lookup + $nonPrivUsers = & $script:ctx.GetRows 'User' { param($u) -not $idx.ContainsKey($u.id) } + @($nonPrivUsers).Count | Should -BeGreaterThan 0 + } + } + + Context 'Query (mini SQL adapter)' -Skip:(-not $global:MtZtaJsonExportFixtureExists) { + + BeforeAll { + $script:ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p + } $script:fixturePath + } + AfterAll { if ($script:ctx) { & $script:ctx.Dispose } } + + It 'Query SELECT COUNT(*) returns the row count' { + $r = & $script:ctx.Query 'SELECT COUNT(*) FROM User' + $r[0].count_star | Should -BeGreaterThan 10 + } + + It 'Query SELECT COUNT(*) FROM <t> WHERE <col> = ''<v>'' filters' { + $r = & $script:ctx.Query "SELECT COUNT(*) FROM RoleAssignment WHERE roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'" + $r[0].count_star | Should -BeGreaterOrEqual 0 # depends on tenant; just confirms no throw + } + + It 'Query SELECT * FROM <t> LIMIT 5 returns 5 rows' { + $r = & $script:ctx.Query 'SELECT * FROM SignIn LIMIT 5' + @($r).Count | Should -Be 5 + } + + It 'Query throws NotSupportedException for unsupported SQL' { + { + & $script:ctx.Query 'SELECT u.id FROM User u JOIN SignIn s ON s.userId = u.id' + } | Should -Throw -ExceptionType ([System.NotSupportedException]) + } + } + + Context 'HasTable / HasColumn introspection' -Skip:(-not $global:MtZtaJsonExportFixtureExists) { + + BeforeAll { + $script:ctx = & $script:mod { + param($p) Read-MtZtaJsonExport -BundlePath $p + } $script:fixturePath + } + AfterAll { if ($script:ctx) { & $script:ctx.Dispose } } + + It 'HasTable returns true for known tables and false for unknown' { + (& $script:ctx.HasTable 'User') | Should -BeTrue + (& $script:ctx.HasTable 'NonExistentTable') | Should -BeFalse + } + + It 'HasColumn probes the first shard for the named property' { + (& $script:ctx.HasColumn 'User' 'userPrincipalName') | Should -BeTrue + (& $script:ctx.HasColumn 'User' 'NotARealColumn') | Should -BeFalse + } + } + + Context 'Skip when fixture absent' -Skip:$global:MtZtaJsonExportFixtureExists { + It 'placeholder so this Context emits a row when fixture is missing' { + Set-ItResult -Skipped -Because 'Captured tenant export not present on this checkout.' + } + } +} diff --git a/powershell/tests/functions/Zta/Resolve-MtZtaArtifact.Tests.ps1 b/powershell/tests/functions/Zta/Resolve-MtZtaArtifact.Tests.ps1 new file mode 100644 index 000000000..83d417361 --- /dev/null +++ b/powershell/tests/functions/Zta/Resolve-MtZtaArtifact.Tests.ps1 @@ -0,0 +1,99 @@ +# Unit tests for Resolve-MtZtaArtifact and its sub-helpers. +# Network paths (Azure Blob, Universal Package) are not exercised end-to-end — +# source-pattern detection, extraction, and traversal-guard are covered via local fixtures. + +Describe 'Resolve-MtZtaArtifact (PR-C)' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + Context 'Local-path source' { + + BeforeEach { + $script:bundleDir = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-resolve-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundleDir | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundleDir 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundleDir 'manifest.json') + } + + AfterEach { + if (Test-Path $script:bundleDir) { Remove-Item -Recurse -Force $script:bundleDir } + } + + It 'returns the directory unchanged when -Source is an existing folder' { + $result = & $script:mod { param($p) Resolve-MtZtaArtifact -Source $p } $script:bundleDir + (Resolve-Path $result).Path | Should -Be (Resolve-Path $script:bundleDir).Path + } + + It 'throws on a non-existent local path' { + $bogus = Join-Path ([System.IO.Path]::GetTempPath()) "missing-$([guid]::NewGuid())" + { & $script:mod { param($p) Resolve-MtZtaArtifact -Source $p } $bogus } | + Should -Throw -ExpectedMessage '*local path not found*' + } + + It 'throws when local path is a file but not .tar.gz / .zip' { + $f = New-TemporaryFile + try { + { & $script:mod { param($p) Resolve-MtZtaArtifact -Source $p } $f.FullName } | + Should -Throw -ExpectedMessage '*not .tar.gz*' + } + finally { Remove-Item $f.FullName -Force -ErrorAction SilentlyContinue } + } + } + + Context 'Source-pattern detection' { + + It 'classifies an Azure Blob https URL as the Blob branch' { + # We do not exercise the actual download; we expect the branch to fail at + # network/auth time, NOT at source-classification time. Probing via mock. + $blobUrl = 'https://contoso-sec.blob.core.windows.net/zta/2026-05-01.tar.gz' + $err = $null + try { + & $script:mod { param($u) Resolve-MtZtaArtifact -Source $u -ErrorAction Stop } $blobUrl 2>&1 | Out-Null + } catch { $err = $_ } + + # Expected: error mentions Az.Storage / Invoke-WebRequest / network — NOT source-shape rejection. + ($err.Exception.Message + $err) -match '(Az\.Storage|Invoke-WebRequest|SAS|cannot|connect|DNS|Could not resolve|resolve|host|404|403|network|SSL|fail)' | + Should -BeTrue + } + + It 'classifies upkg:// reference as the Universal Package branch' { + $upkg = 'upkg://OnTrask-Security/Assessments/zta-results/sample@1.0.0' + $err = $null + try { + & $script:mod { param($u) Resolve-MtZtaArtifact -Source $u -ErrorAction Stop } $upkg 2>&1 | Out-Null + } catch { $err = $_ } + + # Either it fails because az CLI rejects (auth/feed not found) OR az isn't installed — + # both are downstream of correct source-shape detection. + $err | Should -Not -BeNullOrEmpty + } + + It 'rejects an empty -Source' { + { & $script:mod { Resolve-MtZtaArtifact -Source '' } } | + Should -Throw -ExpectedMessage '*Source*empty*' + } + } + + Context 'Cache key determinism' { + + It 'maps the same source string to the same cache directory key' { + # Inspect the helper indirectly: same source twice should produce identical cache paths. + # We can't easily reach the internal helper from outside, but we can verify + # the local-folder path is preserved (no caching for folders), which exercises + # the early-return branch. + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-cache-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + try { + $r1 = & $script:mod { param($p) Resolve-MtZtaArtifact -Source $p } $tmp + $r2 = & $script:mod { param($p) Resolve-MtZtaArtifact -Source $p } $tmp + $r1 | Should -Be $r2 + } + finally { Remove-Item -Recurse -Force $tmp } + } + } +} diff --git a/powershell/tests/functions/Zta/Test-MtZtaFreshness.Tests.ps1 b/powershell/tests/functions/Zta/Test-MtZtaFreshness.Tests.ps1 new file mode 100644 index 000000000..71479fa1d --- /dev/null +++ b/powershell/tests/functions/Zta/Test-MtZtaFreshness.Tests.ps1 @@ -0,0 +1,133 @@ +# Unit tests for Test-MtZtaFreshness. +# The function is internal (auto-loaded via Maester.psm1); tests call it inside +# the module scope via `& $module { ... }`. + +Describe 'Test-MtZtaFreshness' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + Context 'Timestamp source priority' { + + BeforeEach { + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-fresh-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + } + + AfterEach { + if (Test-Path $script:bundle) { Remove-Item -Recurse -Force $script:bundle } + } + + It 'prefers manifest.runStartTime over report.ExecutedAt and DB mtime' { + # Three signals point at three different ages; manifest must win. + @{ + schemaVersion = '1.0' + tenantId = '00000000-0000-0000-0000-000000000000' + runStartTime = (Get-Date).ToUniversalTime().AddDays(-3).ToString('o') + } | ConvertTo-Json | Set-Content (Join-Path $script:bundle 'manifest.json') + @{ ExecutedAt = (Get-Date).ToUniversalTime().AddDays(-30).ToString('o'); Tests = @() } | + ConvertTo-Json | Set-Content (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + New-Item -ItemType Directory -Force -Path (Join-Path $script:bundle 'db') | Out-Null + $oldDate = (Get-Date).ToUniversalTime().AddDays(-60) + $dbFile = Join-Path $script:bundle 'db/zt.db' + New-Item -ItemType File -Path $dbFile | Out-Null + (Get-Item $dbFile).LastWriteTimeUtc = $oldDate + + $result = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p } $script:bundle + + $result.TimestampSource | Should -Be 'ManifestRunStartTime' + $result.AgeDays | Should -Be 3 + $result.IsStale | Should -BeFalse # under default 14-day threshold + } + + It 'falls back to report.ExecutedAt when manifest is absent' { + @{ ExecutedAt = (Get-Date).ToUniversalTime().AddDays(-7).ToString('o'); Tests = @() } | + ConvertTo-Json | Set-Content (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + + $result = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p } $script:bundle + + $result.TimestampSource | Should -Be 'JsonExecutedAt' + $result.AgeDays | Should -Be 7 + $result.IsStale | Should -BeFalse + } + + It 'falls back to DB mtime when manifest and report timestamps are unavailable' { + New-Item -ItemType Directory -Force -Path (Join-Path $script:bundle 'db') | Out-Null + $dbFile = Join-Path $script:bundle 'db/zt.db' + New-Item -ItemType File -Path $dbFile | Out-Null + $age = (Get-Date).ToUniversalTime().AddDays(-21) + (Get-Item $dbFile).LastWriteTimeUtc = $age + + # No manifest, no report — only the DB exists. + $result = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p -WarningAction SilentlyContinue } $script:bundle + + $result.TimestampSource | Should -Be 'DbMtime' + $result.AgeDays | Should -Be 21 + $result.IsStale | Should -BeTrue # exceeds default 14-day threshold + } + + It 'returns IsStale=$false with TimestampSource=None when nothing is parseable' { + # Empty bundle: no manifest, no report, no DB. + $result = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p -WarningAction SilentlyContinue } $script:bundle + + $result.TimestampSource | Should -Be 'None' + $result.AgeDays | Should -Be -1 + $result.IsStale | Should -BeFalse + } + } + + Context 'Threshold honored' { + + BeforeEach { + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-fresh-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + } + + AfterEach { + if (Test-Path $script:bundle) { Remove-Item -Recurse -Force $script:bundle } + } + + It 'flips IsStale=$true once age exceeds custom threshold' { + @{ + schemaVersion = '1.0' + runStartTime = (Get-Date).ToUniversalTime().AddDays(-5).ToString('o') + } | ConvertTo-Json | Set-Content (Join-Path $script:bundle 'manifest.json') + + $tight = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p -FreshnessDays 3 } $script:bundle + $loose = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p -FreshnessDays 30 } $script:bundle + + $tight.IsStale | Should -BeTrue + $tight.Threshold | Should -Be 3 + $loose.IsStale | Should -BeFalse + $loose.Threshold | Should -Be 30 + } + + It 'clamps a future-dated timestamp to AgeDays=0 (clock skew / timezone slop)' { + # ZTA bundles produced on a host whose clock is a few minutes / hours + # ahead of the runner produce timestamps "in the future". Floor() of a + # negative TotalDays returns -1, which downstream renderers turn into + # "-1d" and a "-7%" chip. Clamp to 0 so the report shows "fresh". + @{ + schemaVersion = '1.0' + runStartTime = (Get-Date).ToUniversalTime().AddHours(1).ToString('o') + } | ConvertTo-Json | Set-Content (Join-Path $script:bundle 'manifest.json') + + $r = & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p } $script:bundle + + $r.AgeDays | Should -Be 0 + $r.IsStale | Should -BeFalse + $r.TimestampSource | Should -Be 'ManifestRunStartTime' + } + } + + Context 'Error surface' { + It 'throws when -BundlePath does not exist' { + $bogus = Join-Path ([System.IO.Path]::GetTempPath()) "nonexistent-$([guid]::NewGuid())" + { & $script:mod { param($p) Test-MtZtaFreshness -BundlePath $p } $bogus } | Should -Throw -ExpectedMessage '*bundle path not found*' + } + } +} diff --git a/powershell/tests/functions/Zta/Test-MtZtaIsEmergencyAccess.Tests.ps1 b/powershell/tests/functions/Zta/Test-MtZtaIsEmergencyAccess.Tests.ps1 new file mode 100644 index 000000000..b90bd1d52 --- /dev/null +++ b/powershell/tests/functions/Zta/Test-MtZtaIsEmergencyAccess.Tests.ps1 @@ -0,0 +1,111 @@ +# Unit tests for Test-MtZtaIsEmergencyAccess + Get-MtZta -Section EmergencyAccessAccounts. +# Covers all three input shapes (string UPN, string GUID, object) plus negative cases. + +Describe 'Test-MtZtaIsEmergencyAccess' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-bg-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + Context 'No GlobalSettings supplied' { + It 'returns $false on any input when GlobalSettings is absent' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + (Test-MtZtaIsEmergencyAccess -Id 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') | Should -BeFalse + (Test-MtZtaIsEmergencyAccess -UserPrincipalName 'someone@example.com') | Should -BeFalse + } + + It 'Get-MtZta -Section EmergencyAccessAccounts returns empty array' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $list = @(Get-MtZta -Section EmergencyAccessAccounts) + $list.Count | Should -Be 0 + } + } + + Context 'GlobalSettings with mixed input shapes' { + BeforeEach { + $gs = [pscustomobject]@{ + EmergencyAccessAccounts = @( + 'breakglass1@contoso.onmicrosoft.com', + '11111111-2222-3333-4444-555555555555', + [pscustomobject]@{ + userPrincipalName = 'breakglass2@contoso.onmicrosoft.com' + displayName = 'Tier-0 emergency #2' + }, + [pscustomobject]@{ + id = '99999999-aaaa-bbbb-cccc-dddddddddddd' + userPrincipalName = 'breakglass3@contoso.onmicrosoft.com' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -GlobalSettings $gs + } + + It 'normalises UPN-shaped strings into UserPrincipalName entries' { + $list = @(Get-MtZta -Section EmergencyAccessAccounts) + $list.Count | Should -Be 4 + ($list | Where-Object { $_.UserPrincipalName -eq 'breakglass1@contoso.onmicrosoft.com' }).Id | Should -BeNullOrEmpty + } + + It 'normalises GUID-shaped strings into Id entries' { + $list = @(Get-MtZta -Section EmergencyAccessAccounts) + $entry = $list | Where-Object { $_.Id -eq '11111111-2222-3333-4444-555555555555' } + $entry | Should -Not -BeNullOrEmpty + $entry.UserPrincipalName | Should -BeNullOrEmpty + } + + It 'normalises object entries with both id and userPrincipalName' { + $list = @(Get-MtZta -Section EmergencyAccessAccounts) + $entry = $list | Where-Object { $_.Id -eq '99999999-aaaa-bbbb-cccc-dddddddddddd' } + $entry.UserPrincipalName | Should -Be 'breakglass3@contoso.onmicrosoft.com' + } + + It 'matches break-glass by Id (UPN unset on entry)' { + (Test-MtZtaIsEmergencyAccess -Id '11111111-2222-3333-4444-555555555555') | Should -BeTrue + } + + It 'matches break-glass by UPN (Id unset on entry)' { + (Test-MtZtaIsEmergencyAccess -UserPrincipalName 'breakglass1@contoso.onmicrosoft.com') | Should -BeTrue + } + + It 'UPN match is case-insensitive' { + (Test-MtZtaIsEmergencyAccess -UserPrincipalName 'BREAKGLASS1@contoso.onmicrosoft.com') | Should -BeTrue + } + + It 'matches when either Id or UPN matches an object-shaped entry' { + (Test-MtZtaIsEmergencyAccess -Id '99999999-aaaa-bbbb-cccc-dddddddddddd' -UserPrincipalName 'unrelated@example.com') | Should -BeTrue + (Test-MtZtaIsEmergencyAccess -Id 'random' -UserPrincipalName 'breakglass3@contoso.onmicrosoft.com') | Should -BeTrue + } + + It 'returns $false for unknown principals' { + (Test-MtZtaIsEmergencyAccess -Id 'ffffffff-ffff-ffff-ffff-ffffffffffff') | Should -BeFalse + (Test-MtZtaIsEmergencyAccess -UserPrincipalName 'someone-else@example.com') | Should -BeFalse + } + + It 'returns $false when both Id and UPN are empty' { + (Test-MtZtaIsEmergencyAccess) | Should -BeFalse + (Test-MtZtaIsEmergencyAccess -Id '' -UserPrincipalName '') | Should -BeFalse + } + } + + Context 'No context loaded' { + It 'returns $false safely when MtZtaContext is null' { + (Test-MtZtaIsEmergencyAccess -UserPrincipalName 'any@example.com') | Should -BeFalse + } + } +} diff --git a/powershell/tests/functions/Zta/Update-MtSeverityFromZta.Tests.ps1 b/powershell/tests/functions/Zta/Update-MtSeverityFromZta.Tests.ps1 new file mode 100644 index 000000000..298496491 --- /dev/null +++ b/powershell/tests/functions/Zta/Update-MtSeverityFromZta.Tests.ps1 @@ -0,0 +1,193 @@ +# Unit tests for Update-MtSeverityFromZta. +# Verifies rule firing thresholds (WhenPillarFailedAtLeast / WhenCategoryFlaggedUsersAtLeast), +# selector matching (Tagged / TestId wildcards), severity ladder enforcement, and idempotence. + +Describe 'Update-MtSeverityFromZta — severity escalation' -Tag 'Acceptance', 'Zta', 'Unit' { + + BeforeAll { + Remove-Module Maester -ErrorAction Ignore + Import-Module "$PSScriptRoot/../../../Maester.psd1" -Force + $script:mod = Get-Module Maester + $script:fixtureDir = Join-Path $PSScriptRoot 'fixtures' + } + + BeforeEach { + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + $script:bundle = Join-Path ([System.IO.Path]::GetTempPath()) ("mt-zta-sev-$([guid]::NewGuid())") + New-Item -ItemType Directory -Force -Path $script:bundle | Out-Null + Copy-Item (Join-Path $script:fixtureDir 'ZeroTrustAssessmentReport.sample.json') (Join-Path $script:bundle 'ZeroTrustAssessmentReport.json') + Copy-Item (Join-Path $script:fixtureDir 'manifest.sample.json') (Join-Path $script:bundle 'manifest.json') + } + + AfterEach { + if ($script:bundle -and (Test-Path $script:bundle)) { Remove-Item -Recurse -Force $script:bundle } + & $script:mod { Set-Variable -Name 'MtZtaContext' -Value $null -Scope Script -Force } + } + + Context 'No-op paths' { + + It 'returns input unchanged when MtZtaContext is unset' { + $ts = @( [pscustomobject]@{ Id='X.1'; Severity='Medium' } ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts -WhatIf:$false) + $out[0].Severity | Should -Be 'Medium' + } + + It 'returns input unchanged when ZTA loaded without ZtaSettings' { + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback + $ts = @( [pscustomobject]@{ Id='X.1'; Severity='Medium'; Tag=@('MFA') } ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'Medium' + } + + It 'returns input unchanged when SeverityEscalationRules array is empty' { + $settings = [pscustomobject]@{ SeverityEscalationRules = @() } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + $ts = @( [pscustomobject]@{ Id='X.1'; Severity='Medium'; Tag=@('MFA') } ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'Medium' + } + } + + Context 'Pillar-threshold rules' { + + It 'escalates when WhenPillarFailedAtLeast threshold is met' { + # Fixture has exactly 1 Identity failure. Use threshold 1 so the rule fires. + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 1 + Pillar = 'Identity' + EscalateMaesterTagged = @('MFA') + From = 'Medium' + To = 'High' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( + [pscustomobject]@{ Id='M.1'; Severity='Medium'; Tag=@('MFA','Identity') } + [pscustomobject]@{ Id='M.2'; Severity='Medium'; Tag=@('Other') } + ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'High' # tag 'MFA' matched + $out[1].Severity | Should -Be 'Medium' # tag 'Other' did not match + } + + It 'does NOT escalate when WhenPillarFailedAtLeast threshold is not met' { + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 99 + Pillar = 'Identity' + EscalateMaesterTagged = @('MFA') + From = 'Medium' + To = 'High' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( [pscustomobject]@{ Id='M.1'; Severity='Medium'; Tag=@('MFA') } ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'Medium' + } + + It 'honours From-gate (only escalates if current severity matches From)' { + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 1 + Pillar = 'Identity' + EscalateMaesterTagged = @('MFA') + From = 'Medium' + To = 'High' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( + [pscustomobject]@{ Id='L.1'; Severity='Low'; Tag=@('MFA') } # not Medium -> skip + [pscustomobject]@{ Id='C.1'; Severity='Critical'; Tag=@('MFA') } # already > High -> skip + [pscustomobject]@{ Id='M.1'; Severity='Medium'; Tag=@('MFA') } # exact match -> escalate + ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'Low' + $out[1].Severity | Should -Be 'Critical' + $out[2].Severity | Should -Be 'High' + } + } + + Context 'TestId selector with wildcards' { + + It 'matches by EscalateMaesterTestId wildcard pattern' { + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 1 + Pillar = 'Identity' + EscalateMaesterTestId = @('CISA.MS.AAD.8.*') + To = 'High' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( + [pscustomobject]@{ Id='CISA.MS.AAD.8.1'; Severity='Medium' } + [pscustomobject]@{ Id='CISA.MS.AAD.7.1'; Severity='Medium' } + ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'High' + $out[1].Severity | Should -Be 'Medium' + } + } + + Context 'Severity ladder' { + + It 'never lowers severity (Medium -> Low rule is a no-op)' { + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 1 + Pillar = 'Identity' + EscalateMaesterTagged = @('MFA') + To = 'Low' # below default Medium -> no-op + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( [pscustomobject]@{ Id='M.1'; Severity='Medium'; Tag=@('MFA') } ) + $out = @(Update-MtSeverityFromZta -TestSettings $ts) + $out[0].Severity | Should -Be 'Medium' + } + } + + Context 'Idempotence' { + + It 'second invocation does not re-escalate (already at target)' { + $settings = [pscustomobject]@{ + SeverityEscalationRules = @( + [pscustomobject]@{ + WhenPillarFailedAtLeast = 1 + Pillar = 'Identity' + EscalateMaesterTagged = @('MFA') + From = 'Medium' + To = 'High' + } + ) + } + Import-MtZtaResult -ZtaResultsPath $script:bundle -ForceJsonFallback -ZtaSettings $settings + + $ts = @( [pscustomobject]@{ Id='M.1'; Severity='Medium'; Tag=@('MFA') } ) + + $out1 = @(Update-MtSeverityFromZta -TestSettings $ts) + $out1[0].Severity | Should -Be 'High' + + $out2 = @(Update-MtSeverityFromZta -TestSettings $ts) + $out2[0].Severity | Should -Be 'High' # unchanged on second run + } + } +} diff --git a/powershell/tests/functions/Zta/fixtures/ZeroTrustAssessmentReport.sample.json b/powershell/tests/functions/Zta/fixtures/ZeroTrustAssessmentReport.sample.json new file mode 100644 index 000000000..e110e3ca4 --- /dev/null +++ b/powershell/tests/functions/Zta/fixtures/ZeroTrustAssessmentReport.sample.json @@ -0,0 +1,89 @@ +{ + "TenantId": "00000000-0000-0000-0000-000000000000", + "TenantName": "Sample Tenant", + "Domain": "sample.example", + "Account": null, + "ExecutedAt": "2026-05-01T10:00:00.0000000+00:00", + "CurrentVersion": { "Major": 2, "Minor": 2, "Build": 0, "Revision": -1 }, + "LatestVersion": "2.2.0", + "TenantInfo": null, + "TestResultSummary": { + "Identity": { "Passed": 1, "Failed": 1, "Skipped": 0, "Investigate": 0, "Planned": 0 }, + "Devices": { "Passed": 0, "Failed": 1, "Skipped": 0, "Investigate": 0, "Planned": 0 }, + "Network": { "Passed": 0, "Failed": 1, "Skipped": 0, "Investigate": 0, "Planned": 0 }, + "Data": { "Passed": 0, "Failed": 0, "Skipped": 1, "Investigate": 0, "Planned": 0 } + }, + "Tests": [ + { + "TestId": "21892", + "TestTitle": "All sign-in activity comes from managed devices", + "TestStatus": "Failed", + "TestPillar": "Identity", + "TestCategory": "Access control", + "TestRisk": "High", + "TestImpact": "High", + "TestDescription": "Requiring sign-ins from managed devices ensures compliance.", + "TestResult": "Not all sign-in activity comes from managed devices. (sample)", + "TestSkipped": "", + "SkippedReason": null, + "TestTags": null + }, + { + "TestId": "21816", + "TestTitle": "All Microsoft Entra privileged role assignments are managed with PIM", + "TestStatus": "Passed", + "TestPillar": "Identity", + "TestCategory": "Privileged access", + "TestRisk": "High", + "TestImpact": "High", + "TestDescription": "PIM management of privileged roles.", + "TestResult": "All checked privileged role assignments use PIM.", + "TestSkipped": "", + "SkippedReason": null, + "TestTags": null + }, + { + "TestId": "24823", + "TestTitle": "Devices are marked compliant before access", + "TestStatus": "Failed", + "TestPillar": "Devices", + "TestCategory": "Tenant", + "TestRisk": "Medium", + "TestImpact": "Medium", + "TestDescription": "Compliance baseline.", + "TestResult": "Compliance baseline policy is not configured. (sample)", + "TestSkipped": "", + "SkippedReason": null, + "TestTags": null + }, + { + "TestId": "26011", + "TestTitle": "Global Secure Access tunnel is enabled for all tenants", + "TestStatus": "Failed", + "TestPillar": "Network", + "TestCategory": "Global Secure Access", + "TestRisk": "Medium", + "TestImpact": "Medium", + "TestDescription": "GSA identity-aware access.", + "TestResult": "Global Secure Access is not enabled. (sample)", + "TestSkipped": "", + "SkippedReason": null, + "TestTags": null + }, + { + "TestId": "35009", + "TestTitle": "Sensitivity labels are configured and published", + "TestStatus": "Skipped", + "TestPillar": "Data", + "TestCategory": "Sensitivity Labels", + "TestRisk": "Medium", + "TestImpact": "Medium", + "TestDescription": "Sensitivity label classification.", + "TestResult": "", + "TestSkipped": "Skipped — not licensed for AIP in this fixture.", + "SkippedReason": "Not licensed for AIP", + "TestTags": null + } + ], + "EndOfJson": true +} diff --git a/powershell/tests/functions/Zta/fixtures/manifest.sample.json b/powershell/tests/functions/Zta/fixtures/manifest.sample.json new file mode 100644 index 000000000..fe5eb56a4 --- /dev/null +++ b/powershell/tests/functions/Zta/fixtures/manifest.sample.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": "1.0", + "tenantId": "00000000-0000-0000-0000-000000000000", + "tenantName": "Sample Tenant", + "runStartTime": "2026-05-01T10:00:00.0000000+00:00", + "ztaVersion": "2.2.0", + "pillarsCovered": [ "Identity", "Devices", "Network", "Data" ], + "duckdbVersion": "1.5.2", + "contentHashes": { + "ZeroTrustAssessmentReport.json": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "zt.db": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1 b/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1 new file mode 100644 index 000000000..9982027c1 --- /dev/null +++ b/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1 @@ -0,0 +1,596 @@ +# Conditional Access compensation gap-fill — when ZTA flags weak/missing +# authentication enforcement, simulate sign-in scenarios via +# Test-MtConditionalAccessWhatIf (BETA Graph API) to verify the actual policy +# graph blocks/MFAs the right way for representative users. +# +# Why What-If vs reading policy state: +# Reading static CA policies misses how multiple policies COMPOSE. A tenant +# may have 30 CA policies; the operator's intent might be MFA-on-everything +# but a single accidental excludeUsers in one policy can defeat that. +# What-If asks the policy graph "what would actually happen?" — the answer +# is the same answer the user gets at sign-in time. +# +# Tag `Beta` is on every test so an upstream API change can be filtered out +# of a run with -ExcludeTag Beta without breaking the build. +# +# ZTA TestId triggers used in this file: +# +# 21782 Identity / Privileged access +# Privileged accounts have phishing-resistant methods registered +# 21783 Identity / Access control +# Privileged Microsoft Entra built-in roles are targeted with +# Conditional Access requiring phishing-resistant authentication +# 21784 Identity / Access control + Credential management +# All user sign-in activity uses phishing-resistant authentication +# 21801 Identity / Credential management +# Users have strong authentication methods configured +# +# References: +# ZTA project https://microsoft.github.io/zerotrustassessment/ +# Microsoft Learn https://learn.microsoft.com/security/zero-trust/assessment/ +# Auth-strength API https://learn.microsoft.com/graph/api/resources/authenticationstrengthpolicy + +Describe 'ZTA CA What-If compensation' -Tag 'ZTA' { + + It 'MT.Zta.1130: CA What-If: a normal user signing in to Office 365 is required to MFA. See https://maester.dev/docs/tests/MT.Zta.1130' ` + -Tag 'MT.Zta.1130','Severity:High','ConditionalAccess','WhatIf','Beta','MFA' { + + $description = @' +## What this test checks +Triggered when ZTA [`21784`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21784.md) (phish-resistant auth) or [`21801`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21801.md) (strong auth methods configured) Failed. Picks a sample non-privileged Member user and runs `Test-MtConditionalAccessWhatIf` simulating an Office 365 sign-in from a browser. Asserts the returned grant requires MFA — either via `builtInControls -contains 'mfa'` OR via an `authenticationStrength` reference. + +This is the rigorous check for "do we actually require MFA on a normal sign-in?" — independent of how many CA policies exist or how their exclusions compose. + +**Sample selection** — break-glass accounts (per `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (`Sync_*` UPN, members of "Directory Synchronization Accounts" / "On Premises Directory Sync Account" role) are excluded from the typical-user sample pool. Sync accounts intentionally bypass interactive MFA and are protected via a dedicated CA blocking sign-in from outside trusted named locations — that hardening is out of scope for "typical user MFA". + +## How to remediate +1. Conditional Access → New policy → target All users (exclude break-glass) → All cloud apps → Grant: Require MFA OR Require authentication strength. +2. Save as Report-only first; verify via this same What-If; then Enable. +3. Re-run; the simulation should return mfa or authenticationStrength. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21784','21801') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21784/21801 — MFA What-If gap-fill not applicable." + return + } + + # Build sync-account principal-id set from RoleAssignment ⨝ "Directory + # Synchronization Accounts" role (template ID d29b2b05-...). UPN regex + # ^Sync_ kept as a belt-and-suspenders fallback for cases where the + # RoleAssignment table isn't fully populated (e.g. last-good fallback bundle). + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + $reader = Get-MtZta -Section Reader + if ($reader) { + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + } + + # Pick a sample non-privileged Member. Skip break-glass and sync accounts — + # both intentionally bypass typical-user CA enforcement and would invert the + # signal of this test. + $isExcludedSample = { + param($u) + if (-not $u) { return $true } + if ($u.id -and $syncPrincipalIds.ContainsKey([string]$u.id)) { return $true } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { return $true } + return [bool](Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) + } + $sampleUser = $null + if ($zta.Database -and $zta.Database.Query) { + try { + $candidates = & $zta.Database.Query "SELECT id, userPrincipalName FROM `"User`" WHERE userType = 'member' AND accountEnabled = true AND id NOT IN (SELECT principalId FROM RoleAssignment) LIMIT 20" + foreach ($c in @($candidates)) { + if (-not (& $isExcludedSample $c)) { $sampleUser = $c; break } + } + } catch { } + } + if (-not $sampleUser) { + try { + $rows = Invoke-MtGraphRequest -RelativeUri 'users?$filter=accountEnabled eq true and userType eq ''Member''&$top=20&$select=id,userPrincipalName' -ApiVersion beta + foreach ($c in @($rows)) { + if (-not (& $isExcludedSample $c)) { $sampleUser = $c; break } + } + } catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + } + if (-not $sampleUser -or -not $sampleUser.id) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No eligible non-privileged Member user found (after excluding break-glass and Sync_* accounts).' + return + } + + $office365AppId = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + + try { + $whatIf = Test-MtConditionalAccessWhatIf -UserId $sampleUser.id ` + -IncludeApplications $office365AppId ` + -ClientAppType 'browser' ` + -DevicePlatform 'Windows' + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $controls = @($whatIf.grantControls.builtInControls) + $hasAuthStrength = [bool]$whatIf.grantControls.authenticationStrength + $mfaRequired = ($controls -contains 'mfa') -or $hasAuthStrength + + $matchedPolicies = if ($whatIf.policies) { + ($whatIf.policies | Select-Object -First 5 | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { '_no CA policies in scope for this scenario._' } + + $result = @" +| Field | Value | +|---|---| +| Sample user | ``$($sampleUser.userPrincipalName)`` | +| Simulated app | Office 365 | +| Client | browser / Windows | +| Returned builtInControls | $(($controls | Sort-Object -Unique) -join ', ') | +| authenticationStrength present? | $hasAuthStrength | +| **MFA required?** | **$mfaRequired** | + +### Policies in scope (first 5) + +$matchedPolicies +"@ + Add-MtTestResultDetail -Description $description -Result $result + $mfaRequired | Should -BeTrue + } + + It 'MT.Zta.1131: CA What-If: a privileged user is required phish-resistant MFA. See https://maester.dev/docs/tests/MT.Zta.1131' ` + -Tag 'MT.Zta.1131','Severity:High','ConditionalAccess','WhatIf','Beta','PIM','PrivilegedAccess' { + + $description = @' +## What this test checks +Triggered when ZTA [`21782`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21782.md) (privileged accounts have phish-resistant methods registered) or [`21783`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21783.md) (privileged role CA enforces phish-resistant) Failed. Picks a sample privileged user (someone with at least one role assignment) and runs What-If for a sign-in to Office 365. The What-If grant must reference an `authenticationStrength` policy whose `allowedCombinations` are ALL within the phish-resistant set: `fido2`, `windowsHelloForBusiness`, `x509CertificateMultiFactor`. + +**Why allowedCombinations and not displayName**: matching on the policy's display name is fragile — a custom auth strength named "Phishing-resistant MFA" with weak combinations would pass; localised display names would fail; an internally-named "FIDO2-only" strength would fail. Inspecting the actual permitted combinations is the only correct check. `x509CertificateSingleFactor` is single-factor cert auth and is explicitly **not** in the set (not MFA). + +**Sample selection** — break-glass accounts (per `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (members of the "Directory Synchronization Accounts" role, template ID `d29b2b05-8046-44ba-8758-1e26182fcf32`, with `Sync_*` UPN as a fallback heuristic) are excluded from the sample pool. Break-glass should be covered by a dedicated CA policy requiring phish-resistant MFA for that group only (see MT.1005 for break-glass exclusion correctness); sync accounts use cert-based auth + named-location restriction, not interactive MFA. + +The What-If approach is critical here: many tenants have a "Require MFA for admins" policy that uses `builtInControls=mfa`, which accepts SMS/voice — i.e. NOT phish-resistant. Reading the static policy says "MFA required"; What-If reveals the strength is wrong. + +## How to remediate +1. Conditional Access → New policy → target privileged role membership (or admin-targeted group). +2. Grant: **Require authentication strength** → choose **Phishing-resistant MFA** (or a custom strength whose allowed combinations are all phish-resistant). +3. Re-run this test; the simulation should report `All phish-resistant? True`. + +## Related Maester core tests (read together) +This test answers a question the policy-state family does NOT: *"does the actual policy graph enforce phish-resistant MFA for a real privileged user, after all CA policies compose?"*. It uses Graph What-If — the same evaluation Entra runs at sign-in time. + +Policy-state counterparts: + +- `CISA.MS.AAD.3.6` — *Phishing-resistant MFA SHALL be required for highly privileged roles*. Verifies a CA policy with phish-resistant grant exists; does not verify it applies to every priv user after exclusions / scopes compose. +- `CISA.MS.AAD.7.6` / `CISA.MS.AAD.7.8` — *GA role activation SHALL require approval / auth context*. Activation-side controls; orthogonal to live sign-in strength. + +**Joint reading**: + +- ``CISA.MS.AAD.3.6`` Passed + ``MT.Zta.1131`` Passed → policy exists AND it actually enforces at sign-in for the sampled priv user. ✅ +- ``CISA.MS.AAD.3.6`` Passed + ``MT.Zta.1131`` Failed → there is a policy but the sampled priv user falls outside its scope (excludeUsers, excluded group, role-based-target with the wrong role IDs, etc.). **Audit the CA policy's user scope and exclusions** — the policy looks right on paper but doesn't apply where it should. +- ``CISA.MS.AAD.3.6`` Failed + ``MT.Zta.1131`` Passed → unusual; the named CISA-flavored policy isn't present, but some OTHER policy in scope happens to require phish-resistant for this user. Solid by luck, fragile by design. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21782','21783') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21782/21783 — privileged-MFA gap-fill not applicable." + return + } + + # Build sync-account principal-id set from RoleAssignment ⨝ Directory Sync + # role (template ID d29b2b05-...). Sync accounts have a directory role + # so they show up as "privileged" via simple anti-join, but they're not + # interactive admins; use cert-based auth + named-location restriction. + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + $reader = Get-MtZta -Section Reader + if ($reader) { + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + } + + # Pick a sample privileged user — prefer DuckDB (cheap), fall back to Graph + # via /roleManagement/directory/roleAssignments which works without DuckDB. + # Skip break-glass (covered by separate dedicated CA) and sync accounts + # (interactive MFA isn't the right control surface for them). + $isExcludedSample = { + param($u) + if (-not $u) { return $true } + if ($u.id -and $syncPrincipalIds.ContainsKey([string]$u.id)) { return $true } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { return $true } + return [bool](Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) + } + $sampleUser = $null + if ($zta.Database -and $zta.Database.Query) { + try { + $candidates = & $zta.Database.Query "SELECT u.id, u.userPrincipalName FROM `"User`" u JOIN RoleAssignment r ON r.principalId = u.id WHERE u.userType = 'member' AND u.accountEnabled = true LIMIT 20" + foreach ($c in @($candidates)) { + if (-not (& $isExcludedSample $c)) { $sampleUser = $c; break } + } + } catch { } + } + if (-not $sampleUser -or -not $sampleUser.id) { + try { + $assignments = Invoke-MtGraphRequest -RelativeUri 'roleManagement/directory/roleAssignments?$top=50' -ApiVersion beta + foreach ($ra in @($assignments)) { + if (-not $ra.principalId) { continue } + try { + $user = Invoke-MtGraphRequest -RelativeUri "users/$($ra.principalId)?`$select=id,userPrincipalName,userType,accountEnabled" -ApiVersion beta -ErrorAction Stop + } catch { continue } + if ($user -and $user.userType -eq 'Member' -and $user.accountEnabled -and -not (& $isExcludedSample $user)) { + $sampleUser = $user + break + } + } + } catch { } + } + if (-not $sampleUser -or -not $sampleUser.id) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No eligible privileged user found (after excluding break-glass and Sync_* accounts) — cannot pick What-If subject for the privileged-MFA simulation.' + return + } + + $office365AppId = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + + try { + $whatIf = Test-MtConditionalAccessWhatIf -UserId $sampleUser.id ` + -IncludeApplications $office365AppId ` + -ClientAppType 'browser' ` + -DevicePlatform 'Windows' + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + # `Test-MtConditionalAccessWhatIf` returns the ARRAY of in-scope policy + # evaluations (it does `Select-Object -ExpandProperty value` internally). + # Each entry carries its own grantControls.authenticationStrength reference. + # An earlier version treated $whatIf as a single object — PowerShell + # member-access on an array returns arrays, which then stringify to a + # space-joined blob in displayName ("BASE: MFA str - passkeys BASE: MFA + # str - admins Phishing-resistant MFA ...") and produce a malformed URL + # in the per-strength GET, so allowedCombinations were always "unavailable". + # + # Correct semantics: iterate each in-scope policy's authStrength reference, + # de-duplicate by strength id, fetch allowedCombinations once per unique + # strength, and report phish-resistant = ANY in-scope strength whose + # allowedCombinations are all in the phish-resistant set. Because CA + # policies compose with AND, having *any* phish-resistant-only strength + # in scope forces the user's auth into the intersection of allowed + # combinations — i.e. effectively phish-resistant. + # + # Phish-resistant set per Graph authenticationMethodModes: + # fido2, windowsHelloForBusiness, x509CertificateMultiFactor. + # x509CertificateSingleFactor is single-factor — explicitly not MFA. + $phishResistantSet = @('fido2','windowsHelloForBusiness','x509CertificateMultiFactor') + $strengthSummaries = New-Object System.Collections.Generic.List[pscustomobject] + $matchedPolicyLines = New-Object System.Collections.Generic.List[string] + $seenStrengthIds = @{} + $anyPhishResistant = $false + + foreach ($p in @($whatIf)) { + if (-not $p) { continue } + $polName = if ($p.PSObject.Properties['displayName']) { [string]$p.displayName } else { '(no name)' } + $polState = if ($p.PSObject.Properties['state']) { [string]$p.state } else { '' } + $matchedPolicyLines.Add("- $polName ($polState)") | Out-Null + + $strRef = $null + try { $strRef = $p.grantControls.authenticationStrength } catch { } + if (-not $strRef) { continue } + $sId = [string]$strRef.id + $sName = [string]$strRef.displayName + if (-not $sId) { continue } + if ($seenStrengthIds.ContainsKey($sId)) { continue } + $seenStrengthIds[$sId] = $true + + $combos = @() + try { + $policy = Invoke-MtGraphRequest -RelativeUri "identity/conditionalAccess/authenticationStrength/policies/$sId" -ApiVersion beta -ErrorAction Stop + $combos = @($policy.allowedCombinations) + } catch { } + + $isPR = $false + if ($combos.Count -gt 0) { + $isPR = -not (@($combos | Where-Object { $_ -notin $phishResistantSet }).Count -gt 0) + } + if ($isPR) { $anyPhishResistant = $true } + $strengthSummaries.Add([pscustomobject]@{ + Name = $sName + Id = $sId + Combinations = $combos + IsPhishResistant = $isPR + }) | Out-Null + } + + $isPhishResistant = $anyPhishResistant + + $strengthTable = if ($strengthSummaries.Count -gt 0) { + ($strengthSummaries | ForEach-Object { + $combo = if ($_.Combinations.Count -gt 0) { ($_.Combinations | Sort-Object -Unique) -join ', ' } else { '(unavailable)' } + $pr = if ($_.IsPhishResistant) { 'yes' } else { 'no' } + "| ``$($_.Name)`` | $combo | $pr |" + }) -join "`n" + } else { '| _(no authentication strength returned by any in-scope policy)_ | — | — |' } + + $matchedPolicies = if ($matchedPolicyLines.Count -gt 0) { + ($matchedPolicyLines | Select-Object -First 5) -join "`n" + } else { '_no CA policies in scope._' } + + $result = @" +| Field | Value | +|---|---| +| Sample privileged user | ``$($sampleUser.userPrincipalName)`` | +| Simulated app | Office 365 | +| Client | browser / Windows | +| In-scope policies | $(@($whatIf).Count) | +| Distinct auth-strength references | $($strengthSummaries.Count) | +| **Any phish-resistant authStrength in scope?** | **$isPhishResistant** | + +### Authentication strengths in scope + +| Strength | Allowed combinations | Phish-resistant? | +|---|---|---| +$strengthTable + +### Policies in scope (first 5) + +$matchedPolicies +"@ + Add-MtTestResultDetail -Description $description -Result $result + $isPhishResistant | Should -BeTrue + } + + It 'MT.Zta.1132: CA What-If: legacy-auth client is blocked. See https://maester.dev/docs/tests/MT.Zta.1132' ` + -Tag 'MT.Zta.1132','Severity:Medium','ConditionalAccess','WhatIf','Beta','LegacyAuth' { + + $description = @' +## What this test checks +Fires when the Identity pillar Failed count is ≥ 5 (proxy for "tenant Identity posture is in active drift"). Simulates a sign-in via legacy-auth (`exchangeActiveSync`) and asserts the grant is `block`. + +Many tenants have multiple "Block legacy auth" policies that compose oddly with exclusions. What-If is the only reliable way to verify the actual outcome. + +## How to remediate +1. Conditional Access → New / edit policy → target All users → Conditions → Client apps → check Exchange ActiveSync clients + Other clients. +2. Grant: Block access. +3. Re-run; the simulation should return block. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $summary = Get-MtZta -Section Summary + if (-not $summary -or $summary.IdentityFailed -lt 5) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "Identity-pillar Failed count ($(if ($summary) { $summary.IdentityFailed } else { 'n/a' })) below trigger threshold (5) — legacy-auth What-If not applicable." + return + } + + # Sample non-privileged user. + $sampleUser = $null + if ($zta.Database -and $zta.Database.Query) { + try { + $sampleUser = & $zta.Database.Query "SELECT id, userPrincipalName FROM `"User`" WHERE userType = 'member' AND accountEnabled = true LIMIT 1" | Select-Object -First 1 + } catch { } + } + if (-not $sampleUser) { + try { + $rows = Invoke-MtGraphRequest -RelativeUri 'users?$filter=accountEnabled eq true and userType eq ''Member''&$top=1&$select=id,userPrincipalName' -ApiVersion beta + $sampleUser = @($rows)[0] + } catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + } + if (-not $sampleUser -or -not $sampleUser.id) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'Could not pick a sample user.' + return + } + + $office365AppId = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + + try { + $whatIf = Test-MtConditionalAccessWhatIf -UserId $sampleUser.id ` + -IncludeApplications $office365AppId ` + -ClientAppType 'exchangeActiveSync' + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $controls = @($whatIf.grantControls.builtInControls) + $blocked = ($controls -contains 'block') + + $matchedPolicies = if ($whatIf.policies) { + ($whatIf.policies | Select-Object -First 5 | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { '_no CA policies in scope._' } + + $result = @" +| Field | Value | +|---|---| +| Sample user | ``$($sampleUser.userPrincipalName)`` | +| Client | exchangeActiveSync (legacy auth) | +| Returned builtInControls | $(($controls | Sort-Object -Unique) -join ', ') | +| **Blocked?** | **$blocked** | + +### Policies in scope (first 5) + +$matchedPolicies +"@ + Add-MtTestResultDetail -Description $description -Result $result + $blocked | Should -BeTrue + } + + It 'MT.Zta.1133: Sign-ins not covered by Conditional Access stay below threshold. See https://maester.dev/docs/tests/MT.Zta.1133' ` + -Tag 'MT.Zta.1133','Severity:High','ConditionalAccess','SignIn','Coverage' { + + $description = @' +## What this test checks +Streams the ZTA `SignIn` table and counts rows where `conditionalAccessStatus` +is `notApplied` — i.e. the sign-in completed without ANY Conditional Access +policy evaluating it. Asserts the ratio stays below the configured threshold +(default 5%). + +This is the **data-side** complement to MT.Zta.1130 / 1131 / 1132. Those +three call `Test-MtConditionalAccessWhatIf` to simulate what WOULD happen for +a sample user; 1133 reads the historical sign-in stream and answers what +actually DID happen — which user-app combinations escape the CA net in +practice. + +Mirrors the "No CA applied" metric ZTA's own HTML report surfaces in its +`TenantInfo.OverviewCaMfaAllUsers` Sankey. Failing this test means a +non-trivial share of real sign-ins authenticated without CA gating — +typically guests, service principals, specific apps in "Other Cloud Apps", +or specific client-app types (e.g. legacy auth) escaping CA scope. + +## How to remediate +1. Open the sample table below; identify the top users with most + `notApplied` sign-ins. +2. Entra ID → Sign-in logs → filter by one of those users → Conditional + Access tab on a recent sign-in. The "Not applied" line shows which + condition(s) excluded the sign-in from every policy in scope. +3. Common gap shapes: + - **Guests** without a guest-targeted CA → add a B2B / external-user policy. + - **Service principals** signing in → add a service-principal-targeted CA + (Microsoft Entra ID P2 / Workload Identities Premium). + - **Specific applications** excluded from CA scope → review per-app + exclusions on top policies. + - **Specific client app types** (e.g. exchangeActiveSync, other) → + ensure a legacy-auth-block CA exists and matches the client type. +4. Add a catch-all "Block by default" CA targeting the gap surface. Save + as Report-only, monitor for a week, then enable. + +## Related Maester core tests (read together) +This is the **only data-side coverage check** in the entire Maester test corpus. The Maester core CA family inspects POLICY STATE (does a CA with the right grant exist?); none of them tell you whether the policies actually cover the sign-ins they were intended to cover. + +Policy-state counterparts (all "is there a CA that ...?"): + +- `MT.1001` — at least one CA configured with device compliance requirement. +- `MT.1003` / `MT.1004` — at least one CA targeting all cloud apps / all users. +- `MT.1005` — all CAs exclude at least one break-glass account. +- `MT.1006` / `MT.1007` / `MT.1008` — at least one CA requires MFA. +- `MT.1009` / `MT.1010` / `MT.1011` — block legacy auth / require auth context / secure named-location use. +- `CISA.MS.AAD.1.1` — legacy authentication SHALL be blocked. + +**Joint reading**: + +- Maester core CA tests Passed + ``MT.Zta.1133`` Passed → policies exist AND they actually cover ≥99% of sign-ins (default 1% bypass band). ✅ +- Maester core CA tests Passed + ``MT.Zta.1133`` Failed → the right policies exist but a non-trivial share of sign-ins escape them. **Triage by looking at the user / app / clientApp top-offenders sample below.** Common root causes: guest sign-ins without a guest-targeted CA, service-principal sign-ins, app exclusions on top policies, legacy-auth client types not blocked. +- Maester core CA tests Failed + ``MT.Zta.1133`` Passed → unusual; the named CISA-flavored policies aren't present but some OTHER CA happens to cover sign-ins. Solid by luck, fragile by design — add the missing named policies before this changes. + +A 0% bypass rate is the right target. Anything above that is gap surface waiting to be exploited. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + $total = 0 + $statusCounts = @{} + $notAppliedByUser = @{} + & $reader.GetRows 'SignIn' | ForEach-Object { + $total++ + $status = if ($_.conditionalAccessStatus) { [string]$_.conditionalAccessStatus } else { '(none)' } + if (-not $statusCounts.ContainsKey($status)) { $statusCounts[$status] = 0 } + $statusCounts[$status]++ + if ($status -eq 'notApplied' -and $_.userId) { + $uid = [string]$_.userId + if (-not $notAppliedByUser.ContainsKey($uid)) { $notAppliedByUser[$uid] = 0 } + $notAppliedByUser[$uid]++ + } + } + $notApplied = if ($statusCounts.ContainsKey('notApplied')) { $statusCounts['notApplied'] } else { 0 } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + # Statistical-relevance gate. Tiny lab tenants with < 100 sign-ins can + # trip a 5% threshold off a single bypass; that's not signal. + if ($total -lt 100) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "Sign-in history too small ($total rows) for statistical relevance — need ≥ 100 sign-ins to evaluate CA bypass rate." + return + } + + # Threshold in PERCENT — operator-tunable via ZtaSettings.Thresholds. + # Default 0: every sign-in MUST be processed by some CA policy. The + # statistical-relevance gate above (skip when total < 100 sign-ins) + # already filters out tenants where the metric isn't meaningful, so + # zero-tolerance is the right default at the policy layer. Operators + # who want a warn-band can raise the threshold per tenant. + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1133' -Default 0 + $notAppliedPct = [math]::Round(($notApplied / $total) * 100, 1) + + # Resolve top-10 violators with UPN + userType from the User table. + $userIdx = $null + try { $userIdx = & $reader.BuildIndex 'User' 'id' } catch { } + $topUsersRows = @($notAppliedByUser.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 10) + $topUsersTable = if ($topUsersRows.Count -gt 0) { + ($topUsersRows | ForEach-Object { + $uid = $_.Key; $count = $_.Value + $u = if ($userIdx) { $userIdx[$uid] } else { $null } + $upn = if ($u -and $u.userPrincipalName) { $u.userPrincipalName } else { '(unresolved)' } + $userType = if ($u -and $u.userType) { $u.userType } else { '?' } + "| $upn | $userType | $count |" + }) -join "`n" + } else { '_no per-user `notApplied` sign-ins detected._' } + + $statusBreakdown = ($statusCounts.GetEnumerator() | Sort-Object Value -Descending | ForEach-Object { + $pct = if ($total -gt 0) { [math]::Round(($_.Value / $total) * 100, 1) } else { 0 } + "- ``$($_.Key)``: $($_.Value) ($pct`%)" + }) -join "`n" + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Total sign-ins evaluated | $total | n/a | +| Sign-ins with ``conditionalAccessStatus = 'notApplied'`` | **$notApplied** | n/a | +| **% bypassing CA entirely** | **$notAppliedPct%** | < $threshold% | +| Distinct users with at least one bypass | $($notAppliedByUser.Count) | n/a | + +### Top 10 users by 'notApplied' sign-in count + +| UPN | userType | notApplied count | +|---|---|---| +$topUsersTable + +### Full ``conditionalAccessStatus`` distribution + +$statusBreakdown +"@ + + Add-MtTestResultDetail -Description $description -Result $result + $notAppliedPct | Should -BeLessThan $threshold -Because ( + "Over the window represented in the ZTA SignIn export, $notAppliedPct% of $total sign-ins ($notApplied) " + + "completed without ANY Conditional Access policy evaluating them. The configured ceiling is ${threshold}%. " + + 'Identify the top users above in Entra ID → Sign-in logs → Conditional Access tab to see which condition(s) excluded them.' + ) + } +} diff --git a/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1 b/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1 new file mode 100644 index 000000000..2661847c0 --- /dev/null +++ b/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1 @@ -0,0 +1,393 @@ +# Device compensation gap-fill — when ZTA flags unmanaged / non-compliant / +# personally-owned devices, verify the compensating controls (Intune App +# Protection Policy with proper assignment, CA blocking non-compliant, +# compliance-failure analysis) are actually in place. +# +# Per user feedback (2026-05-10): MT.Zta.1110 / 1111 must verify that the +# APP ASSIGNMENT targets a real user/group, not the all-licensed-users +# placeholder which exists by default but isn't actionable. +# +# ZTA TestId triggers used in this file: +# +# 24543 Devices / Tenant +# Compliance policies protect iOS/iPadOS devices +# 24545 Devices / Tenant +# Compliance policies protect fully managed and corporate-owned Android devices +# 24547 Devices / Tenant +# Compliance policies protect personally owned Android devices +# 24548 Devices / Data +# Data on iOS/iPadOS is protected by app protection policies +# 24823 Devices / Tenant +# Company Portal branding and support settings enhance user experience +# 24824 Devices / Data +# Conditional Access policies block access from noncompliant devices +# +# References: +# ZTA project https://microsoft.github.io/zerotrustassessment/ +# Microsoft Learn https://learn.microsoft.com/security/zero-trust/assessment/ + +Describe 'ZTA device compensation' -Tag 'ZTA' { + + It 'MT.Zta.1110: iOS App Protection Policy covers unmanaged devices and is assigned to user/group. See https://maester.dev/docs/tests/MT.Zta.1110' ` + -Tag 'MT.Zta.1110','Severity:High','Devices','Intune','MAM','iOS' { + + $description = @' +## What this test checks +When ZTA [`24543`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24543.md) (compliance policies protect iOS) or [`24548`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24548.md) (data on iOS protected by APP) is Failed, verifies that an Intune App Protection Policy (APP / MAM-WE) for iOS: +1. Targets `unmanagedAndManaged` device states (not just `managedDevices`). +2. Is enabled (not in draft). +3. Has at least one **`groupAssignmentTarget`** assignment — i.e. the policy is assigned to a real user/security group, NOT just the `allLicensedUsersAssignmentTarget` placeholder which Intune injects by default but which doesn't surface in the operator's assigned-policy list and is easy to leave un-assigned in practice. + +## How to remediate +1. Intune → Apps → App protection policies → iOS/iPadOS → either create or edit the policy. +2. Set **Target apps** to all Microsoft 365 apps (or your scoped list). +3. Under **Targeted app management level**, choose **Unmanaged AND Managed** (or **All app types**). +4. Under **Assignments**, assign to a real group (e.g., "All employees" security group) — not the empty default. +5. Save and verify rollout via Intune → Apps → Monitor. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('24543','24548') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 24543/24548 — iOS APP gap-fill not applicable." + return + } + + try { + # $expand is NOT a $filter clause — bake it directly into the relative URI. + # Single-quoted string keeps the literal $ for Graph; PowerShell otherwise + # treats $expand as variable interpolation. + $apps = Invoke-MtGraphRequest -RelativeUri 'deviceAppManagement/iosManagedAppProtections?$expand=assignments' -ApiVersion beta + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $compliant = @($apps | Where-Object { + $hasUnmanagedScope = ($_.targetedAppManagementLevels -in @('unmanagedAndManaged','unspecified')) -or ($_.targetedAppManagementLevels -contains 'unmanaged') + $hasRealAssignment = @($_.assignments | Where-Object { + $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' + }).Count -gt 0 + $isEnabled = ($_.disabled -ne $true) + $hasUnmanagedScope -and $hasRealAssignment -and $isEnabled + }) + + $sample = if ($apps) { + ($apps | Select-Object -First 5 | ForEach-Object { + $level = if ($_.targetedAppManagementLevels) { $_.targetedAppManagementLevels } else { '(unset)' } + $assigns = @($_.assignments | Where-Object { $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' }).Count + "| $($_.displayName) | $level | $assigns group target(s) |" + }) -join "`n" + } else { '_no iOS APP policies exist in the tenant._' } + + $result = @" +| Metric | Value | +|---|---| +| iOS App Protection Policies (total) | $(@($apps).Count) | +| Compliant (unmanaged scope + real group assignment + enabled) | **$($compliant.Count)** | +| ZTA trigger tests | $($triggered.Count) Failed | + +### Sample policies (first 5) + +| Name | targetedAppManagementLevels | groupAssignmentTarget assignments | +|---|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $compliant.Count | Should -BeGreaterThan 0 + } + + It 'MT.Zta.1111: Android App Protection Policy covers unmanaged devices and is assigned to user/group. See https://maester.dev/docs/tests/MT.Zta.1111' ` + -Tag 'MT.Zta.1111','Severity:High','Devices','Intune','MAM','Android' { + + $description = @' +## What this test checks +Android counterpart of MT.Zta.1110. Triggered when ZTA [`24547`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24547.md) or [`24545`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24545.md) Failed. Verifies an Android APP exists with `targetedAppManagementLevels` covering unmanaged devices AND `assignments[].target` is a real groupAssignmentTarget (not the all-users placeholder). + +## How to remediate +1. Intune → Apps → App protection policies → Android → create / edit. +2. Set targeted app management level to include unmanaged scope. +3. Assign to a real security group. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('24547','24545') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 24547/24545 — Android APP gap-fill not applicable." + return + } + + try { + $apps = Invoke-MtGraphRequest -RelativeUri 'deviceAppManagement/androidManagedAppProtections?$expand=assignments' -ApiVersion beta + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $compliant = @($apps | Where-Object { + $hasUnmanagedScope = ($_.targetedAppManagementLevels -in @('unmanagedAndManaged','unspecified')) -or ($_.targetedAppManagementLevels -contains 'unmanaged') + $hasRealAssignment = @($_.assignments | Where-Object { + $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' + }).Count -gt 0 + $isEnabled = ($_.disabled -ne $true) + $hasUnmanagedScope -and $hasRealAssignment -and $isEnabled + }) + + $sample = if ($apps) { + ($apps | Select-Object -First 5 | ForEach-Object { + $level = if ($_.targetedAppManagementLevels) { $_.targetedAppManagementLevels } else { '(unset)' } + $assigns = @($_.assignments | Where-Object { $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' }).Count + "| $($_.displayName) | $level | $assigns group target(s) |" + }) -join "`n" + } else { '_no Android APP policies exist in the tenant._' } + + $result = @" +| Metric | Value | +|---|---| +| Android App Protection Policies (total) | $(@($apps).Count) | +| Compliant (unmanaged scope + real group assignment + enabled) | **$($compliant.Count)** | +| ZTA trigger tests | $($triggered.Count) Failed | + +### Sample policies (first 5) + +| Name | targetedAppManagementLevels | groupAssignmentTarget assignments | +|---|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $compliant.Count | Should -BeGreaterThan 0 + } + + It 'MT.Zta.1112: Personal-device APP enforces wipe-on-uninstall / data backup blocked. See https://maester.dev/docs/tests/MT.Zta.1112' ` + -Tag 'MT.Zta.1112','Severity:Medium','Devices','Intune','MAM','BYOD' { + + $description = @' +## What this test checks +Beyond mere existence of an APP (covered by 1110/1111), this test verifies the policy actually enforces work-personal data separation: +- `dataBackupBlocked = true` (no iCloud/Google backup of corporate data) +- `appActionIfDeviceComplianceRequired` is `'wipe'` or `'block'` (not `'warn'`) + +These two settings are what makes APP protect data on a personal device. Without them, MAM is window-dressing. + +## How to remediate +1. Edit the APP policy → Data protection settings. +2. Set "Backup org data to iTunes / iCloud / Google" to **Block**. +3. Under Conditional launch → Device conditions → "Maximum allowed device threat level" set to **Block** or **Wipe** when device becomes non-compliant. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('24547','24543') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 24547/24543 — BYOD-data-security gap-fill not applicable." + return + } + + try { + $iosApps = @(Invoke-MtGraphRequest -RelativeUri 'deviceAppManagement/iosManagedAppProtections' -ApiVersion beta) + $androidApps = @(Invoke-MtGraphRequest -RelativeUri 'deviceAppManagement/androidManagedAppProtections' -ApiVersion beta) + $apps = $iosApps + $androidApps + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $weak = @($apps | Where-Object { + ($_.dataBackupBlocked -ne $true) -or + ($_.appActionIfDeviceComplianceRequired -notin @('wipe','block')) + }) + + $sample = if ($weak) { + ($weak | Select-Object -First 10 | ForEach-Object { + "| $($_.displayName) | dataBackupBlocked=$($_.dataBackupBlocked) | appActionIfDeviceComplianceRequired=$($_.appActionIfDeviceComplianceRequired) |" + }) -join "`n" + } else { '_all APPs enforce wipe-or-block on non-compliance AND block backup._' } + + $result = @" +| Metric | Value | +|---|---| +| App Protection Policies (iOS+Android) | $(@($apps).Count) | +| Policies with weak BYOD settings | **$($weak.Count)** | + +### Weak policies (first 10) + +| Name | dataBackup setting | non-compliance action | +|---|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $weak.Count | Should -Be 0 + } + + It 'MT.Zta.1180: Top compliance failure reasons enumerated. See https://maester.dev/docs/tests/MT.Zta.1180' ` + -Tag 'MT.Zta.1180','Severity:Medium','Devices','Intune','Compliance' { + + $description = @' +## What this test checks +When ≥ 5 ZTA Devices-pillar tests are Failed, queries DuckDB `Device` to enumerate the top reasons devices are non-compliant. ZTA flags non-compliance at policy level; this test surfaces the **most common per-device root causes** so the operator knows where to focus remediation effort. + +Common categories: encryption not enforced, OS version too old, password policy not met, antivirus signature stale, managementAgent='unknown'. + +## How to remediate +1. Intune → Devices → Compliance → review the top reason group. +2. For each reason: either fix the underlying gap (e.g. push BitLocker policy) or relax the compliance rule if it was over-strict. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $summary = Get-MtZta -Section Summary + if (-not $summary -or $summary.DevicesFailed -lt 5) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA Devices-pillar Failed count ($(if ($summary) { $summary.DevicesFailed } else { 'n/a' })) below trigger threshold (5) — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Stream non-compliant devices and group by the most diagnostic combination. + # Group-Object scales — builds a small key->count map, not a row copy. + $nonCompliant = & $reader.GetRows 'Device' { param($d) $d.isCompliant -eq $false } + $reasons = $nonCompliant | + Group-Object -Property trustType, operatingSystem, managementAgent | + Sort-Object Count -Descending | + Select-Object -First 10 | + ForEach-Object { + $key = $_.Name -split ', ' + [pscustomobject]@{ + trustType = $key[0] + operatingSystem = $key[1] + managementAgent = $key[2] + deviceCount = $_.Count + } + } + $reasons = @($reasons) + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $sample = if ($reasons) { + ($reasons | ForEach-Object { + "| $($_.trustType) | $($_.operatingSystem) | $($_.managementAgent) | **$($_.deviceCount)** |" + }) -join "`n" + } else { '_no non-compliant devices in the bundle._' } + + $result = @" +| trustType | operatingSystem | managementAgent | Device count | +|---|---|---|---| +$sample + +Use the dominant row as the **first remediation target** — fixing it usually clears 50%+ of fleet non-compliance. +"@ + Add-MtTestResultDetail -Description $description -Result $result + # Informational — passes as long as enumeration succeeded. + $reasons.Count | Should -BeGreaterThan 0 + } + + It 'MT.Zta.1181: CA What-If: typical user is BLOCKED on a non-compliant device. See https://maester.dev/docs/tests/MT.Zta.1181' ` + -Tag 'MT.Zta.1181','Severity:High','Devices','ConditionalAccess','WhatIf','Beta' { + + $description = @' +## What this test checks +Triggered when ZTA [`24824`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24824.md) Failed (CA policies block access from noncompliant devices). Uses Maester's `Test-MtConditionalAccessWhatIf` (BETA Graph API) to simulate a sample non-privileged user signing in to Office 365 from a Windows browser flagged as **non-compliant**, and verifies the returned grant includes `block` OR `compliantDevice`. + +What-If is more rigorous than reading policy state because it reflects the actual policy graph evaluation including exclusions, group memberships, and authentication-strength compositions. + +## How to remediate +1. Conditional Access → policy targeting Office 365 → ensure `Require device to be marked as compliant` is in the Grant block. +2. Or use `Block access` for non-compliant devices on a separate policy. +3. Re-run; the What-If output should change to `block` or grant containing `compliantDevice`. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('24824','24823') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 24824/24823 — non-compliant-device CA gap-fill not applicable." + return + } + + # Pick a sample non-privileged user from DuckDB if available, otherwise fall back + # to the first Member returned by Graph. + $sampleUser = $null + if ($zta.Database -and $zta.Database.Query) { + try { + $sampleUser = & $zta.Database.Query "SELECT id, userPrincipalName FROM `"User`" WHERE userType = 'member' AND accountEnabled = true LIMIT 1" | Select-Object -First 1 + } catch { } + } + if (-not $sampleUser) { + try { + $rows = Invoke-MtGraphRequest -RelativeUri 'users?$filter=accountEnabled eq true and userType eq ''Member''&$top=1&$select=id,userPrincipalName' -ApiVersion beta + $sampleUser = @($rows)[0] + } catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + } + if (-not $sampleUser -or -not $sampleUser.id) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'Could not pick a sample non-privileged user for the simulation.' + return + } + + # Office 365 well-known app id (used as the simulated app target). + $office365AppId = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' + + try { + $whatIf = Test-MtConditionalAccessWhatIf -UserId $sampleUser.id ` + -IncludeApplications $office365AppId ` + -ClientAppType 'browser' ` + -DevicePlatform 'Windows' + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $controls = @($whatIf.grantControls.builtInControls) + @($whatIf.grantControls.operator) + $blocksOrRequiresCompliant = ($controls -contains 'block') -or ($controls -contains 'compliantDevice') + + $matchedPolicies = if ($whatIf.policies) { + ($whatIf.policies | Select-Object -First 5 | ForEach-Object { "- $($_.displayName)" }) -join "`n" + } else { '_no CA policies in scope for the simulated scenario._' } + + $result = @" +| Field | Value | +|---|---| +| Sample user | ``$($sampleUser.userPrincipalName)`` | +| Simulated app | Office 365 (``$office365AppId``) | +| Client | browser / Windows / non-compliant | +| Returned grant controls | $(($controls | Sort-Object -Unique) -join ', ') | +| Block or compliantDevice required? | **$blocksOrRequiresCompliant** | + +### Policies in scope (first 5) + +$matchedPolicies +"@ + Add-MtTestResultDetail -Description $description -Result $result + $blocksOrRequiresCompliant | Should -BeTrue + } +} diff --git a/tests/Zta/Test-MtZta.DuckDbEnrichment.Tests.ps1 b/tests/Zta/Test-MtZta.DuckDbEnrichment.Tests.ps1 new file mode 100644 index 000000000..10f35b420 --- /dev/null +++ b/tests/Zta/Test-MtZta.DuckDbEnrichment.Tests.ps1 @@ -0,0 +1,185 @@ +# ZTA enrichment tests — high-leverage queries against the loaded ZTA bundle. +# +# Phase 4 (2026-05-10): each test reads via `Get-MtZta -Section Reader` which +# returns whichever tier is available — Tier 2 (DuckDB) when the assembly is +# loadable, else Tier 1 (JSON shadow). Both tiers expose the same primitives +# (`GetRows`, `BuildIndex`) so the test logic is tier-agnostic. As a result +# these tests now run universally — no DuckDB binary in `lib/` required. +# +# Graph role template IDs used in this file (canonical, tenant-invariant — +# documented at https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference): +# +# 62e90394-69f5-4237-9190-012177145e10 Global Administrator +# +# References: +# ZTA project https://microsoft.github.io/zerotrustassessment/ +# Microsoft Learn https://learn.microsoft.com/security/zero-trust/assessment/ + +Describe 'ZTA enrichment — Identity + RoleAssignment cross-checks' -Tag 'ZTA' { + + It 'MT.Zta.1104: Stale-signin user count is below the warn threshold. See https://maester.dev/docs/tests/MT.Zta.1104' -Tag 'MT.Zta.1104','Severity:High','Identity','StaleSignIn' { + $zta = Get-MtZta + + $description = @' +## What this test checks +Counts users whose **most-recent successful sign-in is older than 90 days** via the ZTA `SignIn` table. Stale users with active accounts are the easiest credential-theft entry point — disabling or removing them is high-leverage. Threshold: warn at 25. + +## How to remediate +1. Entra ID → Users → filter by ``signInActivity.lastSignInDateTime < 90 days``. +2. For each: confirm with the user's manager whether the account is still required. +3. Disable (preferred) or delete; for service accounts, rotate to managed identity / service principal with rotation. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'Neither Tier 1 (JSON shadow) nor Tier 2 (DuckDB) is available — bundle may be malformed or missing zt-export/.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1104' -Default 25 + $cutoff = (Get-Date).ToUniversalTime().AddDays(-90) + try { + # Stream SignIn rows; collect distinct userIds whose latest createdDateTime is past the cutoff. + $latestByUser = @{} + $signInRows = & $reader.GetRows 'SignIn' + foreach ($r in $signInRows) { + if (-not $r.userId) { continue } + if (-not $r.createdDateTime) { continue } + $dt = $null + try { $dt = [datetime]::Parse([string]$r.createdDateTime).ToUniversalTime() } catch { continue } + if (-not $latestByUser.ContainsKey($r.userId) -or $dt -gt $latestByUser[$r.userId]) { + $latestByUser[$r.userId] = $dt + } + } + $count = ($latestByUser.GetEnumerator() | Where-Object { $_.Value -lt $cutoff }).Count + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Distinct users whose latest sign-in is > 90 days old | **$count** | $threshold | + +Logic: stream SignIn rows, track latest ``createdDateTime`` per ``userId``, count those past the 90-day cutoff. +"@ + Add-MtTestResultDetail -Description $description -Result $result + $count | Should -BeLessThan $threshold + } + + It 'MT.Zta.1107: No permanent non-break-glass Global Administrator role assignments. See https://maester.dev/docs/tests/MT.Zta.1107' -Tag 'MT.Zta.1107','Severity:Critical','PIM','PrivilegedAccess' { + $zta = Get-MtZta + + # Global Administrator role definition ID — well-known, tenant-invariant. + $globalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10' + + $description = @" +## What this test checks +Lists **all** permanent (non-PIM-eligible) Global Administrator role assignments via the ZTA ``RoleAssignment`` table. Each assignment is annotated as either: + +- ``✓ break-glass`` — declared in ``maester-config.json`` ``GlobalSettings.EmergencyAccessAccounts``. Permanent grant is **expected** for these (compliant by config). +- ``❌ permanent grant`` — non-break-glass account with a permanent grant. **Critical finding** — convert to PIM-eligible. + +The assertion fails only when there is at least one ``❌`` row. + +## How to declare break-glass accounts +Add the account to ``maester-config.json`` under ``GlobalSettings.EmergencyAccessAccounts``. Three accepted shapes: + +```json +"GlobalSettings": { + "EmergencyAccessAccounts": [ + "breakglass1@contoso.onmicrosoft.com", + "12345678-1234-1234-1234-123456789012", + { "userPrincipalName": "breakglass2@contoso.onmicrosoft.com", "displayName": "Tier-0 emergency #2" } + ] +} +``` + +## How to remediate ❌ rows +1. Entra ID → Roles & administrators → Global administrator → list current assignments. +2. For each non-break-glass row: convert to PIM-eligible (Eligible assignments tab) and remove the permanent grant. + +## Related Maester core tests (read together) +This test answers a question the policy-state family does NOT: *"is the grant standing, or just-in-time?"*. Run alongside: + +- ``MT.1032`` — *Limited number of Global Admins are assigned* (Maester core). Caps the COUNT but does not distinguish permanent vs PIM-eligible. +- ``CIS.M365.1.1.3`` — *Between two and four global admins are designated*. Same: count-only. +- ``CISA.MS.AAD.7.1`` — *A minimum of two and a maximum of eight users SHALL be provisioned with Global Administrator*. Count-only. +- ``CISA.MS.AAD.7.6`` — *Activation of the Global Administrator role SHALL require approval*. Policy-side; does not check whether anyone bypasses activation via a standing grant. +- ``CISA.MS.AAD.7.7`` — *Eligible and Active highly privileged role assignments SHALL be monitored*. Closest in spirit; ``MT.Zta.1107`` provides the specific assertion ("zero permanent grants except break-glass"). + +**Joint reading**: passing ``MT.1032`` / ``CIS.M365.1.1.3`` with 2 GAs assigned is NOT sufficient if both are permanent grants and neither is declared break-glass. ``MT.Zta.1107`` catches that specific failure mode. +"@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'Neither Tier 1 (JSON shadow) nor Tier 2 (DuckDB) is available — bundle may be malformed.' + return + } + + try { + # Collect all permanent GA assignments + resolve UPN/displayName via User table. + $assignments = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $globalAdminRoleId } + $userIdx = & $reader.BuildIndex 'User' 'id' + + $rows = @($assignments | ForEach-Object { + $principalId = [string]$_.principalId + $u = $userIdx[$principalId] + $upn = if ($u) { $u.userPrincipalName } else { $null } + $dn = if ($u) { $u.displayName } else { '(unresolved — possibly service principal or group)' } + $isBreakGlass = Test-MtZtaIsEmergencyAccess -Id $principalId -UserPrincipalName $upn + [pscustomobject]@{ + PrincipalId = $principalId + UPN = if ($upn) { $upn } else { '(no UPN)' } + DisplayName = $dn + IsBreakGlass = $isBreakGlass + } + }) + $nonBreakGlass = @($rows | Where-Object { -not $_.IsBreakGlass }) + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $declared = @(Get-MtZta -Section EmergencyAccessAccounts).Count + + # Render ALL assignments — break-glass first, then findings. + $tableRows = ($rows | Sort-Object IsBreakGlass -Descending | ForEach-Object { + $status = if ($_.IsBreakGlass) { + '✓ break-glass (declared in maester-config)' + } else { + '❌ permanent grant — convert to PIM-eligible' + } + "| $($_.UPN) | $($_.DisplayName) | $status |" + }) -join "`n" + + $result = @" +| UPN | Display name | Status | +|---|---|---| +$(if ($tableRows) { $tableRows } else { '| _no permanent Global Administrator assignments_ | — | — |' }) + +| Metric | Value | +|---|---| +| Total permanent GA assignments | $($rows.Count) | +| ✓ break-glass (compliant by config) | $($rows.Count - $nonBreakGlass.Count) | +| ❌ findings (permanent grants requiring conversion) | **$($nonBreakGlass.Count)** | +| Declared break-glass entries in maester-config | $declared | +"@ + Add-MtTestResultDetail -Description $description -Result $result + $nonBreakGlass.Count | Should -Be 0 -Because ( + "Found $($nonBreakGlass.Count) permanent Global Admin assignment(s) that are NOT declared as break-glass in maester-config.json. " + + 'Either convert them to PIM-eligible OR add them to GlobalSettings.EmergencyAccessAccounts if they are intentional break-glass accounts.' + ) + } +} diff --git a/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1 b/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1 new file mode 100644 index 000000000..af1af2340 --- /dev/null +++ b/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1 @@ -0,0 +1,147 @@ +# ZTA focus mechanism #2 — CONDITIONAL `It` (gate inside body). +# +# Pester 5 BeforeDiscovery → It cross-scope variables don't reliably survive in some +# Pester runtime configurations (Invoke-InNewScriptScope), so each It body fetches +# fresh data via Get-MtZta directly. Get-MtZta self-heals from $env:ZTA_RESULTS_REF +# when context is null, making this pattern robust regardless of scope behaviour. + +Describe 'ZTA Guest posture — runs only when guest exposure is significant' -Tag 'ZTA' { + + It 'MT.Zta.1101: Identity fail ratio is high enough to warrant guest deep-dive. See https://maester.dev/docs/tests/MT.Zta.1101' ` + -Tag 'MT.Zta.1101','Severity:Medium' { + # Gate evaluated INSIDE the body so Add-MtTestResultDetail always runs and the + # report shows WHY the test skipped, not just a blank Skipped row. + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + $ratio = if ($summary) { $summary.IdentityFailRatio } else { 0 } + + $description = @' +## What this test checks +**Gate test.** Runs only when the Identity-pillar fail ratio is **≥ 0.5** — the threshold below which deep-dive analysis isn't cost-effective. When this test is reported as Passed, it means ZTA found enough Identity failures that the per-bucket guest-posture tests below carry meaningful signal. + +## How to interpret +- Skipped — Identity posture is healthy enough that guest-specific deep-dive isn't warranted. +- Passed — Identity fail ratio crossed the gate; review the GuestUnconstrained tests (1102, 1103) for actionable detail. +'@ + + $result = @" +| Metric | Value | Gate | +|---|---|---| +| Identity fail ratio | $ratio | ≥ 0.5 | +| Identity Failed | $(if ($summary) { $summary.IdentityFailed } else { '—' }) | — | +| Identity Passed | $(if ($summary) { $summary.IdentityPassed } else { '—' }) | — | +"@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + if ($ratio -lt 0.5) { + Add-MtTestResultDetail -Description $description -Result $result -SkippedBecause Custom -SkippedCustomReason "Identity fail ratio $ratio is below the 0.5 deep-dive threshold — guest-specific tests aren't cost-effective on a healthy Identity baseline. **This is wanted behaviour**: the gate is by design and means the tenant's Identity posture is healthy enough that guest deep-dive doesn't add signal." + return + } + + Add-MtTestResultDetail -Description $description -Result $result + $ratio | Should -BeGreaterOrEqual 0.5 + } + + It 'MT.Zta.1102: GuestUnconstrained bucket has fewer than 25 entries. See https://maester.dev/docs/tests/MT.Zta.1102' -Tag 'MT.Zta.1102','Severity:High' { + $zta = Get-MtZta + $description = @' +## What this test checks +The **GuestUnconstrained** cross-cut groups guest accounts that ZTA flagged as having weak external-collaboration controls — typically guests outside conditional-access scope, with no compliant device, or never used yet still enabled. + +A bucket with more than 25 entries indicates **systemic guest-lifecycle drift**, not isolated cases. Address policy first (CA exclusions, lifecycle workflows, access reviews) before per-guest cleanup. + +## How to remediate +1. Entra ID → External Identities → External collaboration settings — review guest invite restrictions. +2. Entra ID → Identity Governance → Access Reviews — ensure recurring reviews on guest membership. +3. Conditional Access — verify a guest-targeted policy enforces MFA + device compliance. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $bucket = @(Get-MtZta -Section FlaggedUsers) | Where-Object { $_.Category -eq 'GuestUnconstrained' } | Select-Object -First 1 + if (-not $bucket) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No GuestUnconstrained bucket present in this run — no failed tests in guest-related categories.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1102' -Default 25 + $sample = ($bucket.Group | Select-Object -First 10 | ForEach-Object { + $upn = if ($_.UserPrincipalName) { $_.UserPrincipalName } else { '(no UPN)' } + $id = if ($_.UserId) { $_.UserId } else { '—' } + "| $upn | $id |" + }) -join "`n" + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| GuestUnconstrained entries | **$($bucket.Count)** | $threshold | + +### Sample (first 10 of $($bucket.Count)) + +| UPN | Id | +|---|---| +$sample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $bucket.Count | Should -BeLessThan $threshold -Because ( + "ZTA flagged $($bucket.Count) guests as unconstrained. " + + 'Review external-collaboration policy and conditional-access coverage.' + ) + } + + It 'MT.Zta.1103: GuestUnconstrained bucket entries each carry evidence. See https://maester.dev/docs/tests/MT.Zta.1103' -Tag 'MT.Zta.1103','Severity:Medium' { + $zta = Get-MtZta + $description = @' +## What this test checks +Every entry in the **GuestUnconstrained** bucket should carry at least one Evidence string explaining *why* it was flagged (which ZTA TestId surfaced it, or which DuckDB enrichment query). Entries with no evidence are unactionable and indicate a CategoryMappings or extraction bug. + +## How to interpret +- Passed — every flagged guest has at least one evidence entry. +- Failed — at least one guest landed in this bucket without evidence; investigate CategoryMappings or `Group-MtZtaFlaggedIdentity` parsing logic. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $bucket = @(Get-MtZta -Section FlaggedUsers) | Where-Object { $_.Category -eq 'GuestUnconstrained' } | Select-Object -First 1 + if (-not $bucket) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No GuestUnconstrained bucket present in this run.' + return + } + + $missing = @($bucket.Group | Where-Object { -not $_.Evidence -or @($_.Evidence).Count -eq 0 }) + $sample = if ($missing) { + ($missing | Select-Object -First 5 | ForEach-Object { + $upn = if ($_.UserPrincipalName) { $_.UserPrincipalName } else { '(no UPN)' } + "| $upn | $($_.UserId) |" + }) -join "`n" + } else { '_none — every entry has at least one evidence string._' } + + $result = @" +| Metric | Value | +|---|---| +| Total bucket entries | $($bucket.Count) | +| Entries missing evidence | **$($missing.Count)** | + +### Entries with no evidence (sample of 5) + +| UPN | Id | +|---|---| +$sample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $missing.Count | Should -Be 0 + } +} diff --git a/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1 b/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1 new file mode 100644 index 000000000..0656ebf56 --- /dev/null +++ b/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1 @@ -0,0 +1,140 @@ +# ZTA focus mechanism #1 — TAG-BASED selection. +# +# Each It body fetches fresh data via Get-MtZta (which self-heals from $env:ZTA_RESULTS_REF +# when Pester's runtime scope sees an empty MtZtaContext). This avoids the +# BeforeAll/BeforeDiscovery → It cross-scope issues that produce empty data and +# false-pass results. + +Describe 'ZTA Identity focus — failed-pillar deep dive' -Tag 'ZTA' { + + It 'MT.Zta.1001: Identity pillar fail count is below the warn threshold. See https://maester.dev/docs/tests/MT.Zta.1001' -Tag 'MT.Zta.1001','Severity:High' { + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + + $description = @' +## What this test checks +ZTA's **Identity pillar** covers authentication methods, conditional access, sign-in risk, PIM coverage, and external-collaboration exposure. When more than 30 Identity-pillar tests fail, the most likely cause is a **policy-level regression** (e.g. baseline CA policy disabled, security defaults removed) rather than per-control drift. This test surfaces the bulk-failure signal before deeper per-bucket analysis. + +## How to remediate +1. Open the ZTA report and sort the Identity pillar Tests[] by TestId. +2. Compare against a known-good configuration baseline. +3. Restore policy-level controls FIRST, then re-run ZTA, then resume per-finding remediation. +'@ + + if (-not $summary) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30 + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Identity Failed | **$($summary.IdentityFailed)** | $threshold | +| Identity Passed | $($summary.IdentityPassed) | — | +| Identity Skipped | $($summary.IdentitySkipped) | — | +| Identity Investigate | $($summary.IdentityInvestigate) | — | +| Fail ratio | $($summary.IdentityFailRatio) | (see MT.Zta.1002) | +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $summary.IdentityFailed | Should -BeLessThan $threshold -Because ( + "ZTA flagged $($summary.IdentityFailed) Identity tests as Failed (ratio: $($summary.IdentityFailRatio))." + ) + } + + It 'MT.Zta.1002: Identity fail ratio stays below 0.5 (50% of evaluated tests). See https://maester.dev/docs/tests/MT.Zta.1002' -Tag 'MT.Zta.1002','Severity:High' { + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + + $description = @' +## What this test checks +**Fail ratio = Failed / (Total - Skipped - Planned).** Skipped/Planned tests are excluded from the denominator so a fully-licensed pillar with 10 failures is comparable to an under-licensed pillar with 10 failures plus 50 skipped tests. + +A ratio above 0.5 means **more than half** of evaluated Identity tests failed — a strong signal that core Identity posture is broken, not just drifting on individual controls. + +## How to interpret +- 0.0–0.25 — healthy +- 0.25–0.5 — drift; review ZTA flagged categories +- 0.5+ — failing baseline; treat as an incident +'@ + + if (-not $summary) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1002' -Default 0.5 + $ratio = $summary.IdentityFailRatio + $band = if ($ratio -lt 0.25) { 'Healthy' } elseif ($ratio -lt 0.5) { 'Drift' } else { 'Failing' } + $result = @" +| Metric | Value | +|---|---| +| **Fail ratio** | **$ratio** (threshold: $threshold) | +| Passed | $($summary.IdentityPassed) | +| Failed | $($summary.IdentityFailed) | +| Skipped | $($summary.IdentitySkipped) | +| Investigate | $($summary.IdentityInvestigate) | + +Health band: **$band** +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $ratio | Should -BeLessThan $threshold + } + + It 'MT.Zta.1003: No PrivilegedAccess findings flagged users above the bar. See https://maester.dev/docs/tests/MT.Zta.1003' -Tag 'MT.Zta.1003','Severity:High','PIM','PrivilegedAccess' { + $zta = Get-MtZta + + $description = @' +## What this test checks +The **PrivilegedAccess** cross-cut bucket aggregates ZTA findings about role assignments, PIM eligibility, and credential management — across all four pillars (Identity / Devices / Network / Data). When more than 10 unique entries land in this bucket, role hygiene is the most cost-effective remediation lever. + +## How to remediate +1. Open Entra ID → Privileged Identity Management → Roles → Assignments. +2. For each entry below: confirm whether the assignment is permanent (should be PIM-eligible), unmanaged (no review), or expired-but-still-active. +3. Convert permanent role assignments to PIM-eligible with access reviews. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $buckets = @(Get-MtZta -Section FlaggedUsers) + $priv = $buckets | Where-Object { $_.Category -eq 'PrivilegedAccess' } | Select-Object -First 1 + + if (-not $priv) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No PrivilegedAccess bucket present in this run — either no failed tests in privileged-access categories, or CategoryMappings is missing the cross-cut rule.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1003' -Default 10 + $sample = ($priv.Group | Select-Object -First 5 | ForEach-Object { + $upn = if ($_.UserPrincipalName) { $_.UserPrincipalName } else { '(no UPN)' } + $id = if ($_.UserId) { $_.UserId } else { '—' } + "| $upn | $id | $($_.Pillar) |" + }) -join "`n" + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| PrivilegedAccess entries | **$($priv.Count)** | $threshold | +| Pillar | $($priv.Pillar) | — | + +### Sample (first 5 of $($priv.Count)) + +| UPN | Id | Source pillar | +|---|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + + $priv.Count | Should -BeLessThan $threshold -Because ( + "ZTA bucketed $($priv.Count) entries into PrivilegedAccess. " + + 'Investigate role assignments and PIM eligibility coverage.' + ) + } +} diff --git a/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1 b/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1 new file mode 100644 index 000000000..8a42bf272 --- /dev/null +++ b/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1 @@ -0,0 +1,335 @@ +# Lifecycle hygiene gap-fill — when ZTA flags inactive guests, app-credential +# rotation, or stale users, verify whether lifecycle policy / access reviews / +# secret rotation actually catch these via Reader queries (Tier 2 DuckDB or +# Tier 1 JSON shadow — whichever is loaded). +# +# These tests COMPLEMENT (not duplicate) the existing Maester checks: +# - MT.1029 (privileged-only stale users) → MT.Zta.1170 covers ALL users +# - MT.1057 (app secrets exist) → MT.Zta.1160 covers secret AGE +# - MT.1016 (guest CA exists) → MT.Zta.1150 covers inactive-guests-with-creds +# +# ZTA TestId triggers used in this file: +# +# 21772 Identity / Application management +# Applications don't have client secrets configured +# 21858 Identity / External collaboration +# Inactive guest identities are disabled or removed from the tenant +# 21874 Identity / External collaboration +# Guest access is limited to approved tenants +# 21992 Identity / Application management +# Application certificates must be rotated on a regular basis +# +# References: +# ZTA project https://microsoft.github.io/zerotrustassessment/ +# Microsoft Learn https://learn.microsoft.com/security/zero-trust/assessment/ + +Describe 'ZTA lifecycle hygiene' -Tag 'ZTA' { + + It 'MT.Zta.1150: Inactive guest accounts with active credentials. See https://maester.dev/docs/tests/MT.Zta.1150' ` + -Tag 'MT.Zta.1150','Severity:High','Identity','Guest','Lifecycle' { + + $description = @' +## What this test checks +Streams the ZTA `User` table (where `userType='Guest'` AND `accountEnabled=true`) and surfaces guests whose most-recent successful sign-in is older than 90 days. Each one is a potential phishing target. + +ZTA [`21858`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21858.md) flags this category at policy level; MT.Zta.1150 enumerates the actual users so the operator can take action without leaving the report. + +## How to remediate +1. Entra ID → Identity Governance → Access Reviews — set up a recurring review on the guest user set. +2. Entra ID → External Identities → Lifecycle workflow — auto-disable inactive guests after 90 days of no sign-in. +3. For ad-hoc cleanup: disable each listed guest, then delete after a grace period. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21858','21874') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21858 (inactive guests disabled/removed) or 21874 (guest tenant restriction) — both are Passed in this run, so the inactive-guests-with-active-creds enumeration is N/A. **This is wanted behaviour**: ZTA already verified guest hygiene at policy level; the per-account follow-up only fires when ZTA detected the underlying weakness." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + $cutoff = (Get-Date).ToUniversalTime().AddDays(-90) + $stale = New-Object System.Collections.Generic.List[object] + & $reader.GetRows 'User' { param($u) + if ($u.userType -ne 'Guest') { return $false } + if (-not $u.accountEnabled) { return $false } + # ZTA flattens nested signInActivity into snake_case columns. Some + # exports preserve the camel-case nested shape; probe both. + $lastRaw = $null + if ($u.PSObject.Properties['signInActivity_lastSignInDateTime']) { + $lastRaw = $u.signInActivity_lastSignInDateTime + } elseif ($u.PSObject.Properties['signInActivity']) { + $lastRaw = $u.signInActivity.lastSignInDateTime + } + if (-not $lastRaw) { return $true } # never signed in -> stale + try { $dt = [datetime]::Parse([string]$lastRaw).ToUniversalTime() } catch { return $true } + return $dt -lt $cutoff + } | ForEach-Object { + $stale.Add([pscustomobject]@{ + id = $_.id + userPrincipalName = $_.userPrincipalName + displayName = $_.displayName + mail = $_.mail + }) + if ($stale.Count -ge 50) { return } + } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1150' -Default 5 +$sample = if ($stale.Count -gt 0) { + ($stale | Select-Object -First 10 | ForEach-Object { + "| $($_.userPrincipalName) | $($_.displayName) | $($_.mail) |" + }) -join "`n" + } else { '_none — every active guest has signed in in the last 90 days._' } + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Inactive guests with active accounts | **$($stale.Count)** | $threshold | + +### Sample (first 10) + +| UPN | Display name | Mail | +|---|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $stale.Count | Should -BeLessThan $threshold + } + + It 'MT.Zta.1160: Application credentials older than 1 year. See https://maester.dev/docs/tests/MT.Zta.1160' ` + -Tag 'MT.Zta.1160','Severity:Critical','Identity','Apps','Lifecycle' { + + $description = @' +## What this test checks +Inspects `Application.passwordCredentials` (a JSON-array column) and reports apps where any credential's `endDateTime - startDateTime` exceeds 365 days. Long-lived secrets are the canonical phishing-resistant-bypass vector — short rotation cadence is the compensating control ZTA [`21992`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21992.md) flags as missing. + +Findings are split into two buckets: + +- **Never-expiring secrets** (Critical) — credentials whose `endDateTime` is the year-9999 sentinel (or any lifetime > 50 years). These are higher severity than long-but-finite lifetimes because there is no remediation deadline at all; once leaked, the credential is valid forever. Treat each as an open incident. +- **Long-lived secrets** (Medium) — credentials with `endDateTime - startDateTime > 365 days` but a real expiry. Lower severity because they self-mitigate at expiry, but still drift well outside policy. + +## How to remediate +1. **Never-expiring secrets first** — Entra ID → Application registrations → filter by app — regenerate with a real expiry (≤ 90 days), then revoke the old one. Treat as an open incident; assume the secret is in scope of any past compromise. +2. Replace client secrets with **certificate** auth or **federated credentials** (workload identity federation) where possible — both eliminate long-lived secrets entirely. +3. Set an app-management policy enforcing max secret lifetime (90 days) tenant-wide. + +## Related Maester core tests (read together) +This test is the **warn-band** for app-credential hygiene. The Maester core family has a stricter pass/fail bar (no static secrets at all) plus operational reminders that overlap in intent: + +- ``MT.1057`` — *App registrations should no longer use secrets* (cert-only / federated-credentials). Strict pass/fail: any password credential fails. **Stricter target than 1160.** +- ``MT.1024.applicationCredentialExpiry`` — *Renew expiring application credentials*. Closest sibling — surfaces near-expiry credentials so they don't lapse silently. Operational reminder; not a strict gate. +- ``MT.1024.staleAppCreds`` — *Remove unused credentials from applications*. Catches credentials that exist but haven't been used recently. Orthogonal. +- ``MT.1077`` / ``MT.1078`` — *App registrations with privileged API permissions / directory roles should not have …* — additional risk overlays for high-impact apps. + +**Joint reading**: + +- ``MT.1057`` Failed + ``MT.Zta.1160`` Failed → secrets exist AND some are long-lived/never-expiring. **1160 lists the urgent ones to rotate first; MT.1057 is the long-term target (move to cert / federated identity).** +- ``MT.1057`` Failed + ``MT.Zta.1160`` Passed → secrets exist but all have reasonable lifetimes (≤ 1y). The cleanup is operational hygiene, not an incident. +- ``MT.1057`` Passed + ``MT.Zta.1160`` Passed → cert-only / federated tenant. ✅ ideal end-state. +- ``MT.1057`` Passed but ``MT.Zta.1160`` Failed should be impossible (1160 only fires when secrets exist); if it happens, file a bug. + +Treat ``MT.Zta.1160`` Critical findings (year-9999 secrets) as incidents regardless of ``MT.1057`` status. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21992','21772') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21992/21772 — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Never-expiring sentinel: Graph emits 9999-12-31T23:59:59Z (or similar + # 9000+ year) for credentials with no real expiry. Treat any lifetime + # > 50 years (~18250 days) as the sentinel bucket regardless of exact + # value, since we've observed both year-9999 and other absurdly-future + # endDateTime patterns in real tenants. + $sentinelDays = 50 * 365 # 18250 + $neverExpiring = New-Object System.Collections.Generic.List[object] + $longLived = New-Object System.Collections.Generic.List[object] + & $reader.GetRows 'Application' | ForEach-Object { + $app = $_ + $maxDays = 0 + $maxEnd = $null + $creds = if ($app.passwordCredentials) { @($app.passwordCredentials) } else { @() } + foreach ($c in $creds) { + if (-not $c.startDateTime -or -not $c.endDateTime) { continue } + try { + $start = [datetime]::Parse([string]$c.startDateTime).ToUniversalTime() + $end = [datetime]::Parse([string]$c.endDateTime).ToUniversalTime() + $days = ($end - $start).TotalDays + if ($days -gt $maxDays) { $maxDays = [int]$days; $maxEnd = $end } + } catch { } + } + if ($maxDays -le 365) { return } + $row = [pscustomobject]@{ + appId = $app.appId + displayName = $app.displayName + maxLifetimeDays = $maxDays + maxEndDateTime = if ($maxEnd) { $maxEnd.ToString('yyyy-MM-dd') } else { $null } + } + if ($maxDays -gt $sentinelDays) { + $neverExpiring.Add($row) + } else { + $longLived.Add($row) + } + } + $neverExpiring = @($neverExpiring | Sort-Object maxLifetimeDays -Descending | Select-Object -First 50) + $longLived = @($longLived | Sort-Object maxLifetimeDays -Descending | Select-Object -First 50) + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $neverExpiringSample = if ($neverExpiring.Count -gt 0) { + ($neverExpiring | Select-Object -First 10 | ForEach-Object { + "| $($_.displayName) | $($_.appId) | $($_.maxEndDateTime) |" + }) -join "`n" + } else { '_none — no apps have never-expiring secrets._' } + $longLivedSample = if ($longLived.Count -gt 0) { + ($longLived | Select-Object -First 10 | ForEach-Object { + "| $($_.displayName) | $($_.appId) | $($_.maxLifetimeDays) | $($_.maxEndDateTime) |" + }) -join "`n" + } else { '_none — every other app secret has a lifetime ≤ 1 year._' } + + $result = @" +| Severity | Metric | Value | +|---|---|---| +| **Critical** | Apps with **never-expiring** secrets (sentinel year-9999 or > 50y lifetime) | **$($neverExpiring.Count)** | +| Medium | Apps with secrets > 1y lifetime (finite expiry) | **$($longLived.Count)** | + +### Critical: never-expiring secrets (first 10) + +| App displayName | appId | endDateTime | +|---|---|---| +$neverExpiringSample + +### Long-lived but finite secrets (first 10, sorted by max lifetime desc) + +| App displayName | appId | Max lifetime (days) | endDateTime | +|---|---|---|---| +$longLivedSample +"@ + Add-MtTestResultDetail -Description $description -Result $result + ($neverExpiring.Count + $longLived.Count) | Should -Be 0 + } + + It 'MT.Zta.1170: Stale non-privileged users with active accounts. See https://maester.dev/docs/tests/MT.Zta.1170' ` + -Tag 'MT.Zta.1170','Severity:Medium','Identity','Lifecycle','StaleUser' { + + $description = @' +## What this test checks +Maester `MT.1029` covers stale **privileged** users via PIM alerts. This gap-fill extends the check to **non-privileged** users — the population PIM alerts ignore but which still represent ~80%+ of typical tenant identity sprawl. Streams `User` ⨝ anti-join with `RoleAssignment` and filters to `accountEnabled=true` AND last-sign-in older than 90 days. + +**Break-glass exclusion**: accounts listed in `GlobalSettings.EmergencyAccessAccounts` are excluded — break-glass accounts intentionally lack recent sign-ins. + +## How to remediate +1. Identity Governance → Access Reviews — recurring review on all-users, auto-disable on no response. +2. Lifecycle workflow → trigger join/leave/mover automation for HR-driven changes. +3. For one-time cleanup: bulk-disable the listed accounts, then delete after grace period. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $summary = Get-MtZta -Section Summary + if (-not $summary -or $summary.IdentityFailed -lt 5) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA Identity-pillar Failed count ($(if ($summary) { $summary.IdentityFailed } else { 'n/a' })) below the trigger threshold (5) — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Build privileged-id index from RoleAssignment. + $privilegedIds = @{} + $raRows = & $reader.GetRows 'RoleAssignment' + foreach ($r in $raRows) { if ($r.principalId) { $privilegedIds[[string]$r.principalId] = $true } } + + $cutoff = (Get-Date).ToUniversalTime().AddDays(-90) + $stale = New-Object System.Collections.Generic.List[object] + $excludedBreakGlass = 0 + & $reader.GetRows 'User' { param($u) + if ($u.userType -ne 'member') { return $false } + if (-not $u.accountEnabled) { return $false } + if ($u.id -and $privilegedIds.ContainsKey([string]$u.id)) { return $false } + $lastRaw = $null + if ($u.PSObject.Properties['signInActivity_lastSignInDateTime']) { + $lastRaw = $u.signInActivity_lastSignInDateTime + } elseif ($u.PSObject.Properties['signInActivity']) { + $lastRaw = $u.signInActivity.lastSignInDateTime + } + if (-not $lastRaw) { return $true } + try { $dt = [datetime]::Parse([string]$lastRaw).ToUniversalTime() } catch { return $true } + return $dt -lt $cutoff + } | ForEach-Object { + # Break-glass accounts intentionally lack recent sign-ins — exclude. + if (Test-MtZtaIsEmergencyAccess -Id $_.id -UserPrincipalName $_.userPrincipalName) { + $excludedBreakGlass++ + return + } + $stale.Add([pscustomobject]@{ + id = $_.id + userPrincipalName = $_.userPrincipalName + displayName = $_.displayName + }) + if ($stale.Count -ge 50) { return } + } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1170' -Default 25 +$sample = if ($stale.Count -gt 0) { + ($stale | Select-Object -First 10 | ForEach-Object { + "| $($_.userPrincipalName) | $($_.displayName) |" + }) -join "`n" + } else { '_none — every active non-privileged user has signed in within 90 days._' } + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Stale non-privileged users (active accounts, no sign-in 90d) | **$($stale.Count)** | $threshold | +| Break-glass accounts excluded (compliant by config) | $excludedBreakGlass | n/a | + +### Sample (first 10) + +| UPN | Display name | +|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $stale.Count | Should -BeLessThan $threshold + } +} diff --git a/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1 b/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1 new file mode 100644 index 000000000..9c4836f79 --- /dev/null +++ b/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1 @@ -0,0 +1,597 @@ +# MFA uplift gap-fill — when ZTA flags weak/single-factor authentication usage, +# verify whether single-factor / phishable users have a CAPABLE corporate device +# to register a stronger factor (Windows Hello for Business, Authenticator, +# Passkey/FIDO2). Without that link, the recommendation "use phish-resistant MFA" +# is unactionable. +# +# Phase 4 (2026-05-10): tier-agnostic via `Get-MtZta -Section Reader`. Runs on +# Tier 1 (JSON shadow) by default, accelerated by Tier 2 (DuckDB) when the +# assembly is auto-detected. No DuckDB binary required in `lib/`. +# +# ZTA TestId triggers used in this file (canonical names from +# ZeroTrustAssessmentReport.json `Tests[].TestTitle`): +# +# 21782 Identity / Privileged access +# Privileged accounts have phishing-resistant methods registered +# 21784 Identity / Access control + Credential management +# All user sign-in activity uses phishing-resistant authentication +# 21801 Identity / Credential management +# Users have strong authentication methods configured +# 21804 Identity / Credential management +# SMS and Voice Call authentication methods are disabled +# +# References: +# ZTA project https://microsoft.github.io/zerotrustassessment/ +# Microsoft Learn https://learn.microsoft.com/security/zero-trust/assessment/ + +Describe 'ZTA MFA uplift readiness' -Tag 'ZTA' { + + It 'MT.Zta.1140: Users without phish-resistant MFA registered. See https://maester.dev/docs/tests/MT.Zta.1140' ` + -Tag 'MT.Zta.1140','Severity:High','Identity','MFA' { + + $description = @' +## What this test checks +Inspects `UserRegistrationDetails.methodsRegistered` and surfaces members who have **zero** phish-resistant methods registered. Phish-resistant methods are tenant-invariant per Microsoft Graph (FIDO2, Windows Hello for Business, X.509 cert with PIN, device-bound passkeys). Anyone without one is in either of two failure modes: + +- **No MFA at all** (`methodsRegistered` is empty) — worst case; password is the only factor. +- **Phishable methods only** — the user has SMS / voice / email / Authenticator-push / TOTP (software or hardware) / `microsoftAuthenticatorPasswordless`. All of these can be relayed by an AiTM proxy or, in the passwordless case, collapse to "approve push on the same device that owns the session" under a stolen-device threat model. + +The previous "single-factor = methodsRegistered.Count <= 1" heuristic conflated *no MFA* with *single FIDO2 key*, which is the opposite signal. The classification used here comes from `Get-MtZtaAuthMethodSet`, which is the single source of truth across MT.Zta.1140 / 1141 / 1142 / 1143. + +Gap-fill triggered by ZTA [`21801`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21801.md) (strong auth methods configured) or [`21784`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21784.md) (phish-resistant auth) when Failed. + +## How to remediate +1. Entra ID → Security → Authentication methods → Registration campaign — push a phish-resistant registration nudge. +2. Conditional Access → enforce **Phishing-resistant MFA** authentication strength on privileged users first, then broaden. +3. Track week-over-week reduction in the `No MFA` and `Phishable-only` rows. + +## Related Maester core tests (read together) +This test inspects **user-registration state** (have users actually registered phish-resistant methods?). The Maester core family inspects **policy state** (does the tenant configuration allow / enforce / disable specific methods?). Both layers must align for end-to-end protection. + +Policy-state counterparts: + +- `CISA.MS.AAD.3.1` / `CISA.MS.AAD.3.2` — *Phishing-resistant MFA SHALL be enforced for all users* (and the alternative-auth-strength fallback). Verifies a CA policy exists requiring phish-resistant MFA. +- `EIDSCA.AF01` — FIDO2 security key — State (enabled at tenant level). +- `EIDSCA.AF02` / `AF03` / `AF04` / `AF05` — FIDO2 self-service / attestation / key restriction / disallow restricted keys. +- `CISA.MS.AAD.3.5` — *Authentication methods SMS, Voice Call, and Email OTP SHALL be disabled*. +- `EIDSCA.AS04` — SMS for sign-in. +- `EIDSCA.AV01` — Voice call state. +- `MT.1063` — *App registration owners should have MFA registered* (overlapping intent, narrower scope: owners only). + +**Joint reading**: + +- ``CISA.MS.AAD.3.1`` Passed + ``MT.Zta.1140`` Failed → policy enforces phish-resistant, but users haven't migrated. At sign-in time the unprepared users will be hard-blocked or fall back via legacy escape paths. **Run a registration campaign — don't celebrate yet.** +- ``CISA.MS.AAD.3.1`` Failed + ``MT.Zta.1140`` Passed → users have phish-resistant methods registered, but the CA policy doesn't enforce. Attackers can social-engineer users back to a phishable method. **Add the CA policy now.** +- Both Passed → end-to-end phish-resistant for the population covered. ✅ +- Both Failed → no policy AND no registrations. Highest-impact gap. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21801','21784') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag the trigger condition (21801/21784 not Failed) — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Build sync-account principal-id set (Directory Sync role + Sync_* fallback). + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + + # Build a disabled-user id set so we can exclude `accountEnabled=false` + # users from the flagged total. `UserRegistrationDetails` does not + # carry `accountEnabled`; the `User` table does. License / sign-in- + # frequency is intentionally NOT used as the filter — disabled is + # the only definitive "this is not a real user" signal (per operator + # decision 2026-05-11): shared mailboxes / functional accounts may + # legitimately not have a license, may rarely sign in, but still + # require MFA when they DO authenticate. + $disabledUserIds = @{} + try { + $disabledUsers = & $reader.GetRows 'User' { param($u) $u.accountEnabled -eq $false } + foreach ($u in @($disabledUsers)) { + if ($u.id) { $disabledUserIds[[string]$u.id] = $true } + } + } catch { } + + $methodSet = Get-MtZtaAuthMethodSet + $phishResistant = $methodSet.PhishResistant + + # Stream candidates: any member without at least one phish-resistant method. + $candidates = & $reader.GetRows 'UserRegistrationDetails' { + param($u) + if ($u.userType -ne 'member') { return $false } + $methods = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + # Use the upvalue from enclosing scope. + @($methods | Where-Object { $_ -in $phishResistant }).Count -eq 0 + } + + $excludedBreakGlass = 0 + $excludedSync = 0 + $excludedDisabled = 0 + $noMfa = New-Object System.Collections.Generic.List[object] + $weakOnly = New-Object System.Collections.Generic.List[object] + foreach ($u in @($candidates)) { + if ($u.id -and $disabledUserIds.ContainsKey([string]$u.id)) { $excludedDisabled++; continue } + if ($u.id -and $syncPrincipalIds.ContainsKey([string]$u.id)) { $excludedSync++; continue } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { $excludedSync++; continue } + if (Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) { $excludedBreakGlass++; continue } + $methods = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + if ($methods.Count -eq 0) { $noMfa.Add($u) } else { $weakOnly.Add($u) } + } + $totalFlagged = $noMfa.Count + $weakOnly.Count + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + # Sub-thresholds (introduced 2026-05-11): the two failure modes have very + # different remediation cost and risk profile, so they get separate + # operator-tunable budgets rather than one combined total: + # + # MT.Zta.1140.NoMfa "no MFA at all" — default 0 (zero tolerance — + # a password-only account is the highest-impact + # uplift opportunity and should never sit in + # warn-band). + # MT.Zta.1140.Phishable "phishable methods only" — default 5 (small + # warn-band because Authenticator-push + SMS + # users still have *some* compensation; bulk + # uplift is acceptable as long as the count is + # actively trending down). + # + # The legacy `MT.Zta.1140` key (total) is kept as a third assertion so + # existing maester-config files still gate. Operators wanting a single + # rolling number can keep using that key; tenants wanting sharper + # control set the sub-thresholds and remove the total. + $noMfaMax = [int](Get-MtZtaThreshold -TestId 'MT.Zta.1140.NoMfa' -Default 0) + $phishableMax = [int](Get-MtZtaThreshold -TestId 'MT.Zta.1140.Phishable' -Default 5) + $totalMax = [int](Get-MtZtaThreshold -TestId 'MT.Zta.1140' -Default ($noMfaMax + $phishableMax + 5)) + + $renderRows = { + param($rows, $tag) + if ($rows.Count -eq 0) { return $null } + ($rows | Select-Object -First 10 | ForEach-Object { + $upn = if ($_.userPrincipalName) { $_.userPrincipalName } else { '(no UPN)' } + $methods = if ($_.methodsRegistered) { (@($_.methodsRegistered) -join ', ') } else { '(none)' } + "| $tag | $upn | $methods |" + }) -join "`n" + } + $sampleRows = @() + $r = & $renderRows $noMfa 'No MFA' ; if ($r) { $sampleRows += $r } + $r = & $renderRows $weakOnly 'Phishable-only' ; if ($r) { $sampleRows += $r } + $sample = if ($sampleRows) { ($sampleRows -join "`n") } else { '_no flagged members — every active member has at least one phish-resistant method registered._' } + + $noMfaStatus = if ($noMfa.Count -le $noMfaMax) { 'within' } else { 'over' } + $phishableStatus = if ($weakOnly.Count -le $phishableMax) { 'within' } else { 'over' } + $totalStatus = if ($totalFlagged -le $totalMax) { 'within' } else { 'over' } + + $result = @" +| Metric | Value | Threshold | Status | +|---|---|---|---| +| Members with **no MFA** registered | **$($noMfa.Count)** | $noMfaMax | $noMfaStatus | +| Members with **phishable-only** methods | **$($weakOnly.Count)** | $phishableMax | $phishableStatus | +| **Total flagged (no MFA + phishable-only)** | **$totalFlagged** | $totalMax | $totalStatus | +| Disabled accounts excluded | $excludedDisabled | — | — | +| Break-glass accounts excluded | $excludedBreakGlass | — | — | +| Sync accounts excluded | $excludedSync | — | — | +| ZTA trigger tests Failed | $($triggered.Count) | — | — | + +Configure thresholds in ``maester-config.json`` → ``ZtaSettings.Thresholds``: + +- ``MT.Zta.1140.NoMfa`` (default 0) — members with NO MFA registered +- ``MT.Zta.1140.Phishable`` (default 5) — members with only phishable methods +- ``MT.Zta.1140`` (default $($noMfaMax + $phishableMax + 5)) — combined total (legacy) + +### Sample (first 10 per bucket) + +| Bucket | UPN | methodsRegistered | +|---|---|---| +$sample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + # All three must hold. Listing them on separate lines keeps the failure + # message specific so the operator can tell WHICH budget was breached. + $noMfa.Count | Should -BeLessOrEqual $noMfaMax -Because "members with no MFA must stay within MT.Zta.1140.NoMfa (limit: $noMfaMax)" + $weakOnly.Count | Should -BeLessOrEqual $phishableMax -Because "members with phishable-only methods must stay within MT.Zta.1140.Phishable (limit: $phishableMax)" + $totalFlagged | Should -BeLessOrEqual $totalMax -Because "combined flagged total must stay within MT.Zta.1140 (limit: $totalMax)" + } + + It 'MT.Zta.1141: WHfB uplift candidates — users without phish-resistant MFA who already have a corporate device. See https://maester.dev/docs/tests/MT.Zta.1141' ` + -Tag 'MT.Zta.1141','Severity:Medium','Identity','MFA','Uplift' { + + $description = @' +## What this test checks +Cross-references `UserRegistrationDetails` (members without any phish-resistant method registered) with `Device` (`trustType` in `AzureAd` / `ServerAd` / `Workplace`). Surfaces users who **could be moved to Windows Hello for Business** because they already have a corporate-trusted device — the highest-leverage MFA uplift path with no procurement and no shipping new tokens. + +The phish-resistant classification comes from `Get-MtZtaAuthMethodSet -Bucket PhishResistant` (FIDO2, WHfB, X.509-with-PIN, device-bound passkeys). + +The `Device.trustType` enum is the Graph-canonical set: `AzureAd` = Entra-joined, `ServerAd` = hybrid (on-prem AD + Entra), `Workplace` = workplace-joined for SSO. Hybrid-joined devices do NOT emit a free-text `"Hybrid Azure AD joined"` value — that's a portal display string. ZTA emits the raw enum. + +## How to remediate +1. For each candidate: open Entra ID → User → Authentication methods → register Windows Hello for Business. +2. Optionally, apply a registration-campaign authentication-strength policy targeted at this group. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21801','21784') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21801/21784 — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Build sync-account principal-id set (Directory Sync role + Sync_* fallback). + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + + # Build user-id -> "no phish-resistant" flag index, excluding break-glass + sync. + # (Break-glass accounts shouldn't appear on the uplift list — they're + # intentionally configured with their target factor already. Sync accounts + # use cert-based auth and aren't candidates for interactive WHfB rollout.) + $phishResistant = (Get-MtZtaAuthMethodSet).PhishResistant + $weakUserIds = @{} + $userRegRows = & $reader.GetRows 'UserRegistrationDetails' { + param($u) + if ($u.userType -ne 'member') { return $false } + $methods = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + @($methods | Where-Object { $_ -in $phishResistant }).Count -eq 0 + } + foreach ($u in $userRegRows) { + if (-not $u.id) { continue } + if ($syncPrincipalIds.ContainsKey([string]$u.id)) { continue } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { continue } + if (Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) { continue } + $weakUserIds[[string]$u.id] = $u + } + + # Stream Device rows; surface the user if device is corporate-trusted AND + # user lacks phish-resistant MFA. Trust-type values are the canonical + # Microsoft Graph enum: AzureAd (Entra-joined), ServerAd (hybrid-joined), + # Workplace (registered for SSO). Hybrid-joined devices emit `ServerAd`, + # NOT a free-text "Hybrid Azure AD joined" label. + $managedTrustTypes = @('AzureAd','Workplace','ServerAd') + $candidates = New-Object System.Collections.Generic.List[object] + $seen = @{} + $deviceRows = & $reader.GetRows 'Device' { + param($d) + ($d.trustType -in $managedTrustTypes) -and $d.userId -and $weakUserIds.ContainsKey([string]$d.userId) + } + foreach ($d in $deviceRows) { + $uid = [string]$d.userId + if ($seen.ContainsKey($uid)) { continue } + $seen[$uid] = $true + $u = $weakUserIds[$uid] + $candidates.Add([pscustomobject]@{ + id = $u.id + userPrincipalName = $u.userPrincipalName + }) + if ($candidates.Count -ge 50) { break } + } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + # Threshold semantics: "must have AT LEAST <threshold> uplift candidates"; + # below that we treat as "no easy uplift path" and SKIP with reason rather + # than fail. Skipping is more honest than a Failed row with "0 candidates" + # because the framework can't tell the operator anything new — it's a + # deliberate "needs strategic intervention, not test failure" signal. + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1141' -Default 1 + if ($candidates.Count -lt $threshold) { + Add-MtTestResultDetail ` + -Description $description ` + -SkippedBecause Custom ` + -SkippedCustomReason ("ZTA flagged single-factor users ($($triggered.Count) trigger test(s) Failed) but only $($candidates.Count) uplift candidate(s) found " + + "(threshold: at least $threshold). WHfB rollout requires capable corporate devices first — provision Windows / Entra-joined " + + "devices for these users before this gap can be closed by MFA registration alone. " + + "**This is wanted behaviour**: skipping signals 'needs strategic intervention, not test failure'.") + return + } + + $sample = ($candidates | Select-Object -First 10 | ForEach-Object { + "| $($_.userPrincipalName) | $($_.id) |" + }) -join "`n" + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Uplift candidates (single-factor + corporate device) | **$($candidates.Count)** | at least $threshold | +| ZTA trigger tests | $($triggered.Count) Failed | — | + +### Sample (first 10) + +| UPN | UserId | +|---|---| +$sample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + # We have at least `threshold` candidates; this test passes — operators + # have an actionable list. The Failed counterpart in earlier versions + # ("0 candidates = Failed") was confusing; replaced with explicit Skip. + $candidates.Count | Should -BeGreaterOrEqual $threshold + } + + It 'MT.Zta.1142: Phishable-method users with mobile device registered. See https://maester.dev/docs/tests/MT.Zta.1142' ` + -Tag 'MT.Zta.1142','Severity:Medium','Identity','MFA','Phishable' { + + $description = @' +## What this test checks +Cross-references users registered with phishable methods (SMS / voice / email-OTP / TOTP / Authenticator-push) against `Device` rows for iOS / Android. These users CAN be moved to Passkey or Windows Hello for Business — both phish-resistant — using a device they already have. + +The phishable set comes from `Get-MtZtaAuthMethodSet -Bucket Phishable` (single source of truth across MT.Zta.1140 / 1142 / 1143). Exact array membership rather than substring regex — Graph emits these as a closed enum, so a substring match like `email` would falsely catch any future enum value containing the word "email". + +The mobile-OS check uses the actual `Device.operatingSystem` values ZTA emits: `iOS`, `IPad` (capital-I capital-P — that's the literal column value, not "iPadOS"), and `Android`. + +Break-glass and Entra Connect sync accounts are excluded — they do not appear on the typical-user uplift list. + +## How to remediate +1. Push Authenticator app via Intune to the listed devices. +2. Authentication-methods policy → require Passkey or Authenticator with phishing-resistant requirement. +3. Block phishable methods (SMS / voice / TOTP / Authenticator-push) once registration completes. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21804','21784') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21804/21784 — gap-fill not applicable." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Build sync-account principal-id set (Directory Sync role + Sync_* fallback). + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + + $phishable = (Get-MtZtaAuthMethodSet).Phishable + $phishableIds = @{} + $userRegRows = & $reader.GetRows 'UserRegistrationDetails' { + param($u) + if ($u.userType -ne 'member') { return $false } + $methods = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + @($methods | Where-Object { $_ -in $phishable }).Count -gt 0 + } + foreach ($u in $userRegRows) { + if (-not $u.id) { continue } + if ($syncPrincipalIds.ContainsKey([string]$u.id)) { continue } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { continue } + if (Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) { continue } + $phishableIds[[string]$u.id] = $u + } + + # Mobile OS values per actual ZTA emission: 'iOS', 'IPad', 'Android'. + # 'iPadOS' is portal-display; the column carries the legacy 'IPad' string. + $mobileOs = @('iOS','IPad','Android') + $candidates = New-Object System.Collections.Generic.List[object] + $seen = @{} + $deviceRows = & $reader.GetRows 'Device' { + param($d) + ($d.operatingSystem -in $mobileOs) -and $d.userId -and $phishableIds.ContainsKey([string]$d.userId) + } + foreach ($d in $deviceRows) { + $uid = [string]$d.userId + if ($seen.ContainsKey($uid)) { continue } + $seen[$uid] = $true + $u = $phishableIds[$uid] + $candidates.Add([pscustomobject]@{ + id = $u.id + userPrincipalName = $u.userPrincipalName + }) + if ($candidates.Count -ge 50) { break } + } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + # Same semantics as MT.Zta.1141: "must have at least <threshold> candidates"; + # below that we Skip with a deliberate reason, not Fail. See 1141 for rationale. + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1142' -Default 1 + if ($candidates.Count -lt $threshold) { + Add-MtTestResultDetail ` + -Description $description ` + -SkippedBecause Custom ` + -SkippedCustomReason ("ZTA flagged phishable-method users ($($triggered.Count) trigger test(s) Failed) but only $($candidates.Count) " + + "user(s) with a mobile device found (threshold: at least $threshold). Authenticator-app rollout requires " + + "mobile-device enrolment first — provision iOS/Android devices and enrol via Intune before phishable-method " + + "migration can complete via this path. **This is wanted behaviour**: the skip signals 'needs strategic " + + "intervention, not test failure'.") + return + } + + $sample = ($candidates | Select-Object -First 10 | ForEach-Object { + "| $($_.userPrincipalName) | $($_.id) |" + }) -join "`n" + + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Phishable-method users with mobile device | **$($candidates.Count)** | at least $threshold | +| ZTA trigger tests | $($triggered.Count) Failed | — | + +### Sample (first 10) + +| UPN | UserId | +|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $candidates.Count | Should -BeGreaterOrEqual $threshold + } + + It 'MT.Zta.1143: Privileged accounts on phishable methods (Critical gap). See https://maester.dev/docs/tests/MT.Zta.1143' ` + -Tag 'MT.Zta.1143','Severity:Critical','Identity','MFA','Privileged','Phishable' { + + $description = @' +## What this test checks +**Critical gap.** Joins `UserRegistrationDetails` (users with phishable methods) with `RoleAssignment` (any directory role) to find privileged users who could be phished. Privileged accounts on SMS / voice / email-OTP / TOTP / Authenticator-push MUST be uplifted to phish-resistant MFA before any other remediation work. + +The phishable set comes from `Get-MtZtaAuthMethodSet -Bucket Phishable` (single source of truth shared with MT.Zta.1140 / 1142). Exact array membership against the closed Graph enum. + +### Why a privileged user with BOTH strong AND weak methods still flags + +The assertion fires whenever a privileged account has **any** phishable method *registered* — even if WHfB / FIDO2 / Passkey are also registered on the same account. This is intentional. A registered phishable method is an authentication PATH the attacker can drive the user toward (via AiTM phishing, fatigue push, SIM-swap on `mobilePhone`, SMTP-relay on `email`). Strong methods don't neutralise weak ones unless **Conditional Access enforces an authentication strength that excludes them at sign-in time**. + +So this test surfaces the method *inventory* risk: "what methods CAN this admin use to sign in?" The compensating CA check lives in [`MT.Zta.1131`](https://maester.dev/docs/tests/MT.Zta.1131) (What-If returns a phish-resistant `authenticationStrength` for privileged users). + +Break-glass accounts (declared in `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (members of the Directory Synchronization Accounts role) are excluded — break-glass is covered by a dedicated CA + auth-strength path, sync uses cert-based auth and doesn't register interactive MFA methods. + +## How to remediate +**Treat as an incident** when 1143 + 1131 both Failed. When only 1143 Failed, treat as a defence-in-depth gap. For each user listed: +1. Block phishable methods on this account immediately via authentication-methods policy. +2. Force re-registration with FIDO2 / Passkey / Windows Hello for Business. +3. If MFA registration cannot complete in <24h: temporarily remove privileged role until re-registration is verified. +4. Verify CA `authenticationStrength` enforces phish-resistant for the role — see MT.Zta.1131. + +## Related Maester core tests (read together) +This test inspects **registration inventory** (what phishable methods are registered on a priv account). It must be read alongside the policy-state and live-enforcement counterparts to avoid mis-triaging. + +- ``CISA.MS.AAD.3.6`` — *Phishing-resistant MFA SHALL be required for highly privileged roles* (policy state). +- ``MT.Zta.1131`` — CA What-If for a sample priv user (live enforcement). +- ``MT.Zta.1140`` — All members without phish-resistant MFA registered (registration inventory, all-user scope; 1143 is the priv-user subset with the Critical severity overlay). + +**Joint reading (1143 + 1131)**: + +- **1143 Failed + 1131 Passed** → inventory is risky but live sign-in is gated. An authentication-methods policy change or CA misconfiguration could expose the weak path. **Defence-in-depth gap — reduce the inventory.** +- **1143 Failed + 1131 Failed** → both the inventory AND the live enforcement are weak. **Treat as an incident** — the priv user can sign in with a phishable method right now. +- **1143 Passed + 1131 Passed** → both clean. ✅ +- **1143 Passed + 1131 Failed** → unusual; investigate the CA scope (the auth-strength policy may target an OU/role that excludes the admin in question). + +**Three-way reading (1143 + 1131 + CISA.MS.AAD.3.6)**: + +- All three Passed → end-to-end phish-resistant for priv. ✅ +- ``CISA.MS.AAD.3.6`` Passed + 1131 Failed → policy exists but doesn't actually scope this priv user. CA exclusions or group-target mistake. +- ``CISA.MS.AAD.3.6`` Failed + 1131 Passed → no named CISA policy but some other CA happens to enforce. Add the named policy explicitly. +'@ + + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $triggered = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' -and $_.TestId -in @('21782','21804') }) + if ($triggered.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason "ZTA didn't flag 21782 (privileged accounts have phish-resistant methods registered) or 21804 (SMS+Voice disabled) — both are Passed in this run, so privileged-on-phishable check is N/A. **This is wanted behaviour**: the tenant is healthy in this area; the gap-fill only fires when ZTA detects the underlying weakness." + return + } + $reader = Get-MtZta -Section Reader + if (-not $reader) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No Tier 1/Tier 2 reader available.' + return + } + + try { + # Build a set of all principalIds with at least one role assignment. + $privilegedIds = @{} + $raRows = & $reader.GetRows 'RoleAssignment' + foreach ($r in $raRows) { + if ($r.principalId) { $privilegedIds[[string]$r.principalId] = $true } + } + + # Build sync-account principal-id set (Directory Sync role + Sync_* fallback). + # Sync accounts are technically privileged (they hold the sync role) but + # they don't authenticate interactively, so they shouldn't appear on the + # phishable-priv list. + $syncRoleTemplateId = 'd29b2b05-8046-44ba-8758-1e26182fcf32' + $syncPrincipalIds = @{} + try { + $raSync = & $reader.GetRows 'RoleAssignment' { param($r) $r.roleDefinitionId -eq $syncRoleTemplateId } + foreach ($r in @($raSync)) { if ($r.principalId) { $syncPrincipalIds[[string]$r.principalId] = $true } } + } catch { } + + $phishable = (Get-MtZtaAuthMethodSet).Phishable + $hits = New-Object System.Collections.Generic.List[object] + $excludedBreakGlass = 0 + $excludedSync = 0 + $userRegRows = & $reader.GetRows 'UserRegistrationDetails' { + param($u) + if ($u.userType -ne 'member') { return $false } + if (-not $u.id -or -not $privilegedIds.ContainsKey([string]$u.id)) { return $false } + $methods = if ($u.methodsRegistered) { @($u.methodsRegistered) } else { @() } + @($methods | Where-Object { $_ -in $phishable }).Count -gt 0 + } + foreach ($u in $userRegRows) { + if ($syncPrincipalIds.ContainsKey([string]$u.id)) { $excludedSync++; continue } + if ($u.userPrincipalName -and $u.userPrincipalName -match '^Sync_') { $excludedSync++; continue } + if (Test-MtZtaIsEmergencyAccess -Id $u.id -UserPrincipalName $u.userPrincipalName) { $excludedBreakGlass++; continue } + $hits.Add([pscustomobject]@{ + id = $u.id + userPrincipalName = $u.userPrincipalName + methods = if ($u.methodsRegistered) { (@($u.methodsRegistered) -join ', ') } else { '(none)' } + }) + if ($hits.Count -ge 50) { break } + } + } + catch { + Add-MtTestResultDetail -Description $description -SkippedBecause Error -SkippedError $_ + return + } + + $sample = if ($hits.Count -gt 0) { + ($hits | Select-Object -First 10 | ForEach-Object { + "| **$($_.userPrincipalName)** | $($_.methods) |" + }) -join "`n" + } else { '_none — no privileged user has a phishable method registered._' } + + $result = @" +| Metric | Value | +|---|---| +| Privileged users on phishable methods | **$($hits.Count)** | +| Break-glass accounts excluded | $excludedBreakGlass | +| Sync accounts excluded | $excludedSync | + +### Affected accounts (sample of 10) + +| UPN | methodsRegistered | +|---|---| +$sample +"@ + Add-MtTestResultDetail -Description $description -Result $result + $hits.Count | Should -Be 0 + } +} diff --git a/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1 b/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1 new file mode 100644 index 000000000..00e119936 --- /dev/null +++ b/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1 @@ -0,0 +1,121 @@ +# ZTA operator-side drift + integration sanity tests. +# These verify the focus-mode plumbing and surface drift-vs-prior-run signal +# when the Maester stage's last-good fallback supplied an older bundle. + +Describe 'ZTA operator drift checks' -Tag 'ZTA' { + + It 'MT.Zta.1010: Bundle freshness is within tolerance (warn-but-proceed band). See https://maester.dev/docs/tests/MT.Zta.1010' -Tag 'MT.Zta.1010','Severity:Medium','Drift' { + $zta = Get-MtZta + + $description = @' +## What this test checks +ZTA bundles older than `FreshnessDays` (default 14) are considered stale. `Test-MtZtaFreshness` warns and sets `IsStale = $true` on the context; this test surfaces that flag explicitly so the operator can see the staleness without inspecting every test's detail panel. The most common cause of staleness is the resolver step falling back to last-good after the current ZTA stage failed. + +## How to remediate +1. Open the ZeroTrustAssessment stage logs from the current run. +2. Identify the failure root cause (auth, missing module, connectivity). +3. Re-run with `enableZtaExperimental=true` once the stage is healthy. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $age = if ($zta.Freshness -and $null -ne $zta.Freshness.AgeDays) { [int]$zta.Freshness.AgeDays } else { -1 } + $threshold = if ($zta.Freshness -and $zta.Freshness.PSObject.Properties['Threshold']) { [int]$zta.Freshness.Threshold } else { 14 } + $source = if ($zta.Freshness -and $zta.Freshness.PSObject.Properties['TimestampSource']) { $zta.Freshness.TimestampSource } else { 'unknown' } + + $result = @" +| Field | Value | +|---|---| +| Bundle path | ``$($zta.BundlePath)`` | +| Age (days) | **$age** | +| Threshold | $threshold | +| Timestamp source | $source | +| IsStale flag | $($zta.IsStale) | +"@ + Add-MtTestResultDetail -Description $description -Result $result + + $zta.IsStale | Should -Be $false + } + + It 'MT.Zta.1305: Severity overlay rule count + applied summary. See https://maester.dev/docs/tests/MT.Zta.1305' -Tag 'MT.Zta.1305','Severity:Low','SeverityOverlay' { + $zta = Get-MtZta + + $description = @' +## What this test checks +Smoke-tests the SeverityEscalationRules block by reporting how many rules exist and how many are wired with concrete selectors. This is mostly informational — failures of MT.Zta.1303 / 1304 already cover rule-shape correctness. This test exists to give the operator an at-a-glance summary in the report tab. + +(Note: the actual escalation mutation runs inside `Update-MtSeverityFromZta` which is invoked from `Invoke-Maester`. PR-E does not yet wire that call from the customer pipeline — it lands once the upstream Maester PR adds the `-ZtaResultsPath` parameter natively.) +'@ + + if (-not $zta -or -not $zta.PSObject.Properties['ZtaSettings'] -or -not $zta.ZtaSettings) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZtaSettings on context — overlay summary N/A.' + return + } + + $settings = $zta.ZtaSettings + if (-not $settings.PSObject.Properties['SeverityEscalationRules'] -or -not $settings.SeverityEscalationRules) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No SeverityEscalationRules block in ZtaSettings.' + return + } + + $rules = @($settings.SeverityEscalationRules) + $tagSelectors = @($rules | Where-Object { $_.PSObject.Properties['EscalateMaesterTagged'] -and $_.EscalateMaesterTagged }).Count + $idSelectors = @($rules | Where-Object { $_.PSObject.Properties['EscalateMaesterTestId'] -and $_.EscalateMaesterTestId }).Count + + $result = @" +| Field | Value | +|---|---| +| Total rules | $($rules.Count) | +| Rules using tag selectors | $tagSelectors | +| Rules using TestId selectors | $idSelectors | + +The actual mutation will run when Invoke-Maester gains the `-ZtaResultsPath` parameter (upstream PR). +"@ + Add-MtTestResultDetail -Description $description -Result $result + $rules.Count | Should -BeGreaterThan 0 + } + + It 'MT.Zta.1402: Get-MtZtaRecommendedTag produces a non-empty tag list. See https://maester.dev/docs/tests/MT.Zta.1402' -Tag 'MT.Zta.1402','Severity:Low','TagDerivation' { + $zta = Get-MtZta + + $description = @' +## What this test checks +Verifies that `Get-MtZtaRecommendedTag` (focus mechanism #1) emits a non-empty `[string[]]` of Maester tags derived from the loaded ZTA findings. When this is empty even though ZTA has failed tests, either the CategoryMappings block is missing matching rules or PillarTagMap is empty. + +## How to remediate +1. Confirm `ZtaSettings.CategoryMappings` covers the pillars that have failed tests (4 pillar-level rules + 2 cross-cuts is the recommended baseline). +2. Verify `ZtaSettings.PillarTagMap` lists the Maester-side tag aliases for each pillar. +3. Re-run with `WarningAction Continue` to surface the ">10% Other" coverage warning if many tests classify into Other. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $failed = @($zta.Tests | Where-Object { $_.TestStatus -eq 'Failed' }) + if ($failed.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No failed ZTA tests — recommended-tag set is correctly empty by design.' + return + } + + $tags = @(Get-MtZtaRecommendedTag -WarningAction SilentlyContinue) + $sample = if ($tags) { ($tags | Sort-Object | Select-Object -First 20) -join ', ' } else { '(empty)' } + + $result = @" +| Metric | Value | +|---|---| +| Failed ZTA tests | $($failed.Count) | +| Derived tags | **$($tags.Count)** | + +### Sample (first 20) + +``$sample`` +"@ + Add-MtTestResultDetail -Description $description -Result $result + $tags.Count | Should -BeGreaterThan 0 + } +} diff --git a/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1 b/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1 new file mode 100644 index 000000000..7e63b9da1 --- /dev/null +++ b/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1 @@ -0,0 +1,101 @@ +# ZTA pillar-level fail-count gates for the three non-Identity pillars. +# Mirrors MT.Zta.1001 (Identity) for Devices/Network/Data so each pillar gets a +# single bulk-failure signal independent of bucket-level analysis. + +Describe 'ZTA per-pillar fail count' -Tag 'ZTA' { + + It 'MT.Zta.1004: Devices pillar fail count is below the warn threshold. See https://maester.dev/docs/tests/MT.Zta.1004' -Tag 'MT.Zta.1004','Severity:High','Devices','Intune','Compliance' { + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + + $description = @' +## What this test checks +ZTA's **Devices pillar** covers Intune compliance, BitLocker / FileVault enforcement, OS-version posture, and conditional-access compliant-device requirements. A bulk failure (≥ 20 Devices tests Failed) usually indicates a missing compliance policy assignment or a stale grace period rather than per-device drift. + +## How to remediate +1. Intune → Devices → Compliance policies — verify a baseline policy is assigned to all platforms in scope. +2. Intune → Endpoint security → Disk encryption — confirm enforcement on Windows + macOS. +3. Conditional Access — verify "require compliant device" is enforced on the platform pillars. +'@ + + if (-not $summary) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1004' -Default 20 + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Devices Failed | **$($summary.DevicesFailed)** | $threshold | +| Devices Passed | $($summary.DevicesPassed) | — | +| Devices Skipped | $($summary.DevicesSkipped) | — | +| Fail ratio | $($summary.DevicesFailRatio) | — | +"@ + Add-MtTestResultDetail -Description $description -Result $result + $summary.DevicesFailed | Should -BeLessThan $threshold + } + + It 'MT.Zta.1005: Network pillar fail count is below the warn threshold. See https://maester.dev/docs/tests/MT.Zta.1005' -Tag 'MT.Zta.1005','Severity:Medium','Network','GSA','GlobalSecureAccess' { + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + + $description = @' +## What this test checks +ZTA's **Network pillar** covers Global Secure Access (GSA), private-network access, internet access policy, and network-aware conditional access. Bulk failures here usually mean GSA is not deployed, or GSA tunnels are mis-scoped. + +## How to remediate +1. Entra ID → Global Secure Access — verify the tenant is enrolled and at least one of {Microsoft Traffic, Internet Access, Private Access} is provisioned. +2. CA — verify a network-aware policy enforces GSA for in-scope users. +'@ + + if (-not $summary) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1005' -Default 15 + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Network Failed | **$($summary.NetworkFailed)** | $threshold | +| Network Passed | $($summary.NetworkPassed) | — | +| Network Skipped | $($summary.NetworkSkipped) | — | +| Fail ratio | $($summary.NetworkFailRatio) | — | +"@ + Add-MtTestResultDetail -Description $description -Result $result + $summary.NetworkFailed | Should -BeLessThan $threshold + } + + It 'MT.Zta.1006: Data pillar fail count is below the warn threshold. See https://maester.dev/docs/tests/MT.Zta.1006' -Tag 'MT.Zta.1006','Severity:Medium','Data','Purview','Sensitivity' { + $zta = Get-MtZta + $summary = if ($zta) { Get-MtZta -Section Summary } else { $null } + + $description = @' +## What this test checks +ZTA's **Data pillar** covers sensitivity-label coverage, DLP policy reach, and Purview-driven data classification. Bulk failures usually mean Purview isn't licensed/configured, OR labels exist but aren't published to the right scope. + +## How to remediate +1. Purview portal → Information protection → Labels — verify at least one published label policy. +2. Purview → Data loss prevention → Policies — verify default DLP policies for Exchange + SharePoint + Teams. +3. Sensitivity label auto-labelling — verify it's enabled for E5/AIP-licensed users. +'@ + + if (-not $summary) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1006' -Default 15 + $result = @" +| Metric | Value | Threshold | +|---|---|---| +| Data Failed | **$($summary.DataFailed)** | $threshold | +| Data Passed | $($summary.DataPassed) | — | +| Data Skipped | $($summary.DataSkipped) | — | +| Fail ratio | $($summary.DataFailRatio) | — | +"@ + Add-MtTestResultDetail -Description $description -Result $result + $summary.DataFailed | Should -BeLessThan $threshold + } +} diff --git a/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1 b/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1 new file mode 100644 index 000000000..35f5ffa12 --- /dev/null +++ b/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1 @@ -0,0 +1,209 @@ +# ZTA focus mechanism #4 — SEVERITY ESCALATION smoke test. +# +# In-body data-fetch pattern (Get-MtZta self-heals from $env:ZTA_RESULTS_REF when +# Pester runtime scope sees an empty MtZtaContext). All gating is done inside the +# It body via Set-ItResult / SkippedBecause so the report always renders a row +# with description + reason instead of an opaque blank Skipped. + +Describe 'ZTA severity overlay — smoke test' -Tag 'ZTA' { + + It 'MT.Zta.1301: ZTA context is populated for this run. See https://maester.dev/docs/tests/MT.Zta.1301' -Tag 'MT.Zta.1301','Severity:High' { + $zta = Get-MtZta + + $description = @' +## What this test checks +End-to-end smoke test that the orchestration script's `Import-MtZtaResult` call succeeded and `$script:MtZtaContext` is visible from the test runtime. When this fails, the ZTA wiring is broken — most likely the resolver step set `ZTA_RESULTS_REF` to an empty path, or the Get-MtZta self-heal couldn't find a usable bundle on disk. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded — Get-MtZta returned $null. Check that ZTA stage succeeded and the Maester stage''s resolver step set ZTA_RESULTS_REF.' + return + } + + $tenant = if ($zta.TenantName) { $zta.TenantName } else { '(unknown)' } + $age = if ($zta.Freshness -and $null -ne $zta.Freshness.AgeDays) { "$($zta.Freshness.AgeDays) days" } else { 'n/a' } + $db = if ($zta.DatabaseStatus) { $zta.DatabaseStatus } else { 'n/a' } + $stale = if ($zta.IsStale) { 'YES' } else { 'no' } + $tests = if ($zta.Tests) { @($zta.Tests).Count } else { 0 } + + $result = @" +| Field | Value | +|---|---| +| Tenant | ``$tenant`` | +| TenantId | ``$($zta.TenantId)`` | +| Source path | ``$($zta.Source)`` | +| Bundle path | ``$($zta.BundlePath)`` | +| Tests in report | $tests | +| Bundle age | $age | +| Stale (per FreshnessDays) | $stale | +| DuckDB status | $db | +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $zta | Should -Not -BeNullOrEmpty + $zta.TenantId | Should -Not -BeNullOrEmpty + } + + It 'MT.Zta.1302: ZtaSettings is wired into the context. See https://maester.dev/docs/tests/MT.Zta.1302' -Tag 'MT.Zta.1302','Severity:Medium' { + $zta = Get-MtZta + + $description = @' +## What this test checks +Operator opted into ZTA-aware behaviour by adding a `ZtaSettings` block to `maester-config.json` AND the orchestration script forwarded it to `Import-MtZtaResult` via the `-ZtaSettings` parameter (or Get-MtZta's self-heal re-read it from `$env:MAESTER_ZTA_CONFIG_PATH`). When this is null, the data-driven and severity-overlay focus mechanisms (#3 and #4) silently degrade — the cmdlets exist but use vendor-neutral defaults. + +## How to remediate +Add a `ZtaSettings` block to `maester-config.json` (see plan Section B). At minimum: `CategoryMappings` for the data-driven mechanism and `SeverityEscalationRules` for the severity overlay. +'@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + + $hasSettings = ($zta.PSObject.Properties['ZtaSettings'] -and $zta.ZtaSettings) + if (-not $hasSettings) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZtaSettings supplied at Import-MtZtaResult time and MAESTER_ZTA_CONFIG_PATH did not surface a parsable maester-config.json. Focus mechanisms run with vendor-neutral defaults.' + return + } + + $settings = $zta.ZtaSettings + $catCount = if ($settings.PSObject.Properties['CategoryMappings'] -and $settings.CategoryMappings) { + @($settings.CategoryMappings).Count + } else { 0 } + $ruleCount = if ($settings.PSObject.Properties['SeverityEscalationRules'] -and $settings.SeverityEscalationRules) { + @($settings.SeverityEscalationRules).Count + } else { 0 } + $freshDays = if ($settings.PSObject.Properties['FreshnessDays']) { $settings.FreshnessDays } else { '(default 14)' } + + $result = @" +| Block | Entries | +|---|---| +| CategoryMappings | $catCount | +| SeverityEscalationRules | $ruleCount | +| FreshnessDays | $freshDays | +"@ + Add-MtTestResultDetail -Description $description -Result $result + $settings | Should -Not -BeNullOrEmpty + } + + It 'MT.Zta.1303: Each severity escalation rule has a To severity and at least one selector. See https://maester.dev/docs/tests/MT.Zta.1303' -Tag 'MT.Zta.1303','Severity:Medium' { + $zta = Get-MtZta + + $description = @' +## What this test checks +Every `SeverityEscalationRule` in `ZtaSettings` must specify both: +- `To` — the target severity (Medium / High / Critical), +- One of `EscalateMaesterTagged` (tag selector) or `EscalateMaesterTestId` (id wildcard selector). + +Without `To`, the rule has no destination. Without a selector, the rule matches no tests. Either case makes the rule a no-op and indicates a configuration mistake. +'@ + + if (-not $zta -or -not $zta.PSObject.Properties['ZtaSettings'] -or -not $zta.ZtaSettings) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZtaSettings on context — rule shape check N/A.' + return + } + $settings = $zta.ZtaSettings + if (-not $settings.PSObject.Properties['SeverityEscalationRules'] -or -not $settings.SeverityEscalationRules) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No SeverityEscalationRules block in ZtaSettings.' + return + } + + $rules = @($settings.SeverityEscalationRules) + $invalid = @($rules | Where-Object { + $r = $_ + if (-not $r) { return $true } + $hasTo = ($r.PSObject.Properties['To'] -and $r.To) + $hasTagSel = ($r.PSObject.Properties['EscalateMaesterTagged'] -and $r.EscalateMaesterTagged) + $hasIdSel = ($r.PSObject.Properties['EscalateMaesterTestId'] -and $r.EscalateMaesterTestId) + -not $hasTo -or -not ($hasTagSel -or $hasIdSel) + }) + + $rulesTable = ($rules | ForEach-Object { + $r = $_ + $sel = @() + if ($r.PSObject.Properties['EscalateMaesterTagged'] -and $r.EscalateMaesterTagged) { + $sel += "tags=[$(@($r.EscalateMaesterTagged) -join ', ')]" + } + if ($r.PSObject.Properties['EscalateMaesterTestId'] -and $r.EscalateMaesterTestId) { + $sel += "ids=[$(@($r.EscalateMaesterTestId) -join ', ')]" + } + $when = if ($r.PSObject.Properties['WhenPillarFailedAtLeast'] -and $r.PSObject.Properties['Pillar']) { + "Pillar $($r.Pillar) ≥ $($r.WhenPillarFailedAtLeast)" + } elseif ($r.PSObject.Properties['WhenCategoryFlaggedUsersAtLeast'] -and $r.PSObject.Properties['Category']) { + "Category $($r.Category) ≥ $($r.WhenCategoryFlaggedUsersAtLeast)" + } else { '(no condition — always fires)' } + $from = if ($r.PSObject.Properties['From']) { $r.From } else { '*' } + $to = if ($r.PSObject.Properties['To']) { $r.To } else { '?' } + "| $when | $($sel -join '; ') | $from → **$to** |" + }) -join "`n" + + $result = @" +| Trigger | Selector | From → To | +|---|---|---| +$rulesTable + +| Validation | Value | +|---|---| +| Rules with missing ``To`` or selector | **$($invalid.Count)** | +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + $invalid.Count | Should -Be 0 + } + + It 'MT.Zta.1304: No escalation rule lowers severity (To is in {Medium,High,Critical}). See https://maester.dev/docs/tests/MT.Zta.1304' -Tag 'MT.Zta.1304','Severity:Medium' { + $zta = Get-MtZta + + $description = @' +## What this test checks +The severity overlay is a one-way escalation — it should never **lower** a test's severity. Allowed `To` values are limited to {Medium, High, Critical}. A rule with `To: Low` or `To: Info` indicates a misconfiguration that would silently downgrade findings. + +(Note: the actual ladder check happens at runtime in `Test-MtZtaSeverityHigher` inside `Update-MtSeverityFromZta` — this test catches the misconfiguration at the rule shape level so the operator gets feedback before the pipeline runs.) +'@ + + if (-not $zta -or -not $zta.PSObject.Properties['ZtaSettings'] -or -not $zta.ZtaSettings) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZtaSettings on context — ladder check N/A.' + return + } + $settings = $zta.ZtaSettings + if (-not $settings.PSObject.Properties['SeverityEscalationRules'] -or -not $settings.SeverityEscalationRules) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No SeverityEscalationRules block in ZtaSettings.' + return + } + + $allowed = @('Medium','High','Critical') + $rules = @($settings.SeverityEscalationRules) + $bad = @($rules | Where-Object { $_ -and $_.PSObject.Properties['To'] -and $_.To -notin $allowed }) + + $sample = if ($bad) { + ($bad | ForEach-Object { + $cat = if ($_.PSObject.Properties['Category']) { $_.Category } else { '(none)' } + $pil = if ($_.PSObject.Properties['Pillar']) { $_.Pillar } else { '(none)' } + "| $cat / Pillar=$pil | $($_.To) |" + }) -join "`n" + } else { '_none — all rules use Medium / High / Critical._' } + + $result = @" +| Metric | Value | +|---|---| +| Total rules | $($rules.Count) | +| Rules with disallowed ``To`` | **$($bad.Count)** | + +### Disallowed rules (sample) + +| Rule | To | +|---|---| +$sample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + + foreach ($rule in $rules) { + if ($rule -and $rule.PSObject.Properties['To'] -and $rule.To) { + $rule.To | Should -BeIn $allowed + } + } + } +} diff --git a/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1 b/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1 new file mode 100644 index 000000000..718c6089e --- /dev/null +++ b/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1 @@ -0,0 +1,205 @@ +# ZTA focus mechanism #3 — DATA-DRIVEN matrix. +# +# Earlier version used Pester `-ForEach $buckets` to fan each per-bucket test +# out into one row per bucket category (so 1201 / 1202 / 1203 produced 6 rows +# each = 18 rows in the report). User feedback 2026-05-11: this was hard to +# scan in the test results table — too many near-identical rows for what is +# fundamentally one assertion per quality dimension. Refactored so each test +# emits exactly ONE row containing a per-bucket matrix inside +# `Add-MtTestResultDetail -Result`, and the assertion aggregates across all +# buckets ("ALL buckets must satisfy condition X"). + +Describe 'ZTA per-bucket user posture' -Tag 'ZTA' { + + It 'MT.Zta.1200: ZTA bucket family is populated. See https://maester.dev/docs/tests/MT.Zta.1200' -Tag 'MT.Zta.1200','Severity:Low' { + $zta = Get-MtZta + $buckets = if ($zta) { @(Get-MtZta -Section FlaggedUsers | Where-Object { $_.Count -gt 0 }) } else { @() } + + $description = @' +## What this test checks +Sentinel for the data-driven bucket family. Always emits one row so the family is visible in the report whether ZTA loaded or not, with a clear count of how many buckets were discovered. + +`MT.Zta.1201` / `1202` / `1203` below evaluate quality dimensions across ALL populated buckets and render the per-bucket result as a matrix inside a single row each. +'@ + + $rowsTable = if ($buckets) { + ($buckets | ForEach-Object { "| $($_.Category) | $($_.Pillar) | $($_.Count) |" }) -join "`n" + } else { '_no buckets — ZTA context not loaded or no flagged identities._' } + + $result = @" +| Category | Pillar | Pre-cap Count | +|---|---|---| +$rowsTable +"@ + + if (-not $zta) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run; bucket family is empty by design.' + return + } + + Add-MtTestResultDetail -Description $description -Result $result + $buckets.Count | Should -BeGreaterThan 0 + } + + It 'MT.Zta.1201: All populated buckets carry a non-empty Pillar. See https://maester.dev/docs/tests/MT.Zta.1201' -Tag 'MT.Zta.1201','Severity:Low' { + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $buckets = @(Get-MtZta -Section FlaggedUsers | Where-Object { $_.Count -gt 0 }) + + $description = @' +## What this test checks +Every populated bucket must carry a non-empty `Pillar` value (Identity / Devices / Network / Data) so downstream reporting can route findings to the right pillar owner. A null `Pillar` typically means a CategoryMappings rule was misconfigured (no `MatchPillar` value) — usually a category that ended up in `Other`. + +The assertion aggregates across ALL populated buckets: every row in the matrix below must show a non-empty Pillar; the test fails only when at least one bucket has a missing Pillar. +'@ + + if ($buckets.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No populated buckets — nothing to validate.' + return + } + + $offenders = @($buckets | Where-Object { -not $_.Pillar }) + + $matrix = ($buckets | ForEach-Object { + $ok = if ($_.Pillar) { 'yes' } else { 'NO — missing pillar' } + "| $($_.Category) | $($_.Pillar) | $($_.Count) | $ok |" + }) -join "`n" + + $result = @" +| Metric | Value | +|---|---| +| Populated buckets | $($buckets.Count) | +| Buckets with missing Pillar | **$($offenders.Count)** | + +### Per-bucket result + +| Category | Pillar | Pre-cap Count | Has Pillar? | +|---|---|---|---| +$matrix +"@ + + Add-MtTestResultDetail -Description $description -Result $result + $offenders.Count | Should -Be 0 -Because 'every populated bucket must declare a Pillar so findings are routable to the right owner' + } + + It 'MT.Zta.1202: Across all buckets, Group sample size never exceeds pre-cap Count. See https://maester.dev/docs/tests/MT.Zta.1202' -Tag 'MT.Zta.1202','Severity:Low' { + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $buckets = @(Get-MtZta -Section FlaggedUsers | Where-Object { $_.Count -gt 0 }) + + $description = @' +## What this test checks +`Count` is the **pre-cap** total number of unique entities ZTA flagged for this category. `Group` is the (capped) sample of up to `MaxUsersPerCategory` entries. The sample size must never exceed the pre-cap total — a violation indicates a bucketing-logic bug in `Group-MtZtaFlaggedIdentity`. + +The matrix below lists every populated bucket and whether its `MaxUsersPerCategory` cap was applied (sample size < pre-cap total). The assertion fails only when at least one bucket's Group is larger than its Count. +'@ + + if ($buckets.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No populated buckets — nothing to validate.' + return + } + + $offenders = @($buckets | Where-Object { @($_.Group).Count -gt $_.Count }) + + $matrix = ($buckets | ForEach-Object { + $g = @($_.Group).Count + $capApplied = if ($g -lt $_.Count) { 'yes' } else { 'no (within cap)' } + $ok = if ($g -le $_.Count) { 'yes' } else { 'NO — Group exceeds Count' } + "| $($_.Category) | $($_.Count) | $g | $capApplied | $ok |" + }) -join "`n" + + $result = @" +| Metric | Value | +|---|---| +| Populated buckets | $($buckets.Count) | +| Buckets where Group > Count (bug) | **$($offenders.Count)** | + +### Per-bucket result + +| Category | Pre-cap Count | Group sample size | Cap applied | Group ≤ Count? | +|---|---|---|---|---| +$matrix +"@ + + Add-MtTestResultDetail -Description $description -Result $result + $offenders.Count | Should -Be 0 -Because 'no bucket sample (Group) may exceed the pre-cap Count; that would indicate a bug in Group-MtZtaFlaggedIdentity' + } + + It 'MT.Zta.1203: Every bucket entry has UPN, UserId, or test-level evidence. See https://maester.dev/docs/tests/MT.Zta.1203' -Tag 'MT.Zta.1203','Severity:Medium' { + $zta = Get-MtZta + if (-not $zta) { + Add-MtTestResultDetail -SkippedBecause Custom -SkippedCustomReason 'No ZTA context loaded for this run.' + return + } + $buckets = @(Get-MtZta -Section FlaggedUsers | Where-Object { $_.Count -gt 0 }) + + $description = @' +## What this test checks +Every entry in a ZTA-derived user bucket must carry at least one of: `UserPrincipalName`, `UserId`, or a non-empty `Evidence` array. An entry with all three null/empty is unactionable — the operator can't pivot to Entra ID, a sign-in log, or even know which ZTA TestId surfaced it. This catches regressions in user-extraction (UPN/GUID regex) or DuckDB enrichment. + +The matrix below lists every populated bucket and the count of orphan entries (no UPN, no Id, no Evidence). The aggregate assertion fails only when any bucket has at least one orphan. +'@ + + if ($buckets.Count -eq 0) { + Add-MtTestResultDetail -Description $description -SkippedBecause Custom -SkippedCustomReason 'No populated buckets — nothing to validate.' + return + } + + $perBucket = foreach ($b in $buckets) { + $orphans = @($b.Group | Where-Object { + -not $_.UserPrincipalName -and -not $_.UserId -and (-not $_.Evidence -or $_.Evidence.Count -eq 0) + }) + [pscustomobject]@{ + Category = $b.Category + GroupCount = @($b.Group).Count + OrphanCount = $orphans.Count + Sample = @($orphans | Select-Object -First 3) + } + } + $totalOrphans = (@($perBucket | Measure-Object -Property OrphanCount -Sum).Sum) + if ($null -eq $totalOrphans) { $totalOrphans = 0 } + + $matrix = ($perBucket | ForEach-Object { + $ok = if ($_.OrphanCount -eq 0) { 'yes' } else { 'NO' } + "| $($_.Category) | $($_.GroupCount) | $($_.OrphanCount) | $ok |" + }) -join "`n" + + $offenderSample = @($perBucket | Where-Object { $_.OrphanCount -gt 0 }) + $orphanSample = if ($offenderSample.Count -gt 0) { + ($offenderSample | ForEach-Object { + $cat = $_.Category + ($_.Sample | ForEach-Object { + "| $cat | $(@($_.Evidence).Count) |" + }) -join "`n" + }) -join "`n" + } else { '_none — every entry is actionable._' } + + $result = @" +| Metric | Value | +|---|---| +| Populated buckets | $($buckets.Count) | +| Orphan entries across all buckets | **$totalOrphans** | + +### Per-bucket result + +| Category | Group sample size | Orphan entries | All actionable? | +|---|---|---|---| +$matrix + +### Orphan sample (up to 3 per affected bucket) + +| Bucket | Evidence count | +|---|---| +$orphanSample +"@ + + Add-MtTestResultDetail -Description $description -Result $result + $totalOrphans | Should -Be 0 -Because 'an entry with no UPN, no UserId, and no evidence is unactionable' + } +} diff --git a/website/docs/commands/Build-MtZtaBundle.mdx b/website/docs/commands/Build-MtZtaBundle.mdx new file mode 100644 index 000000000..7e2c9e178 --- /dev/null +++ b/website/docs/commands/Build-MtZtaBundle.mdx @@ -0,0 +1,68 @@ +--- +sidebar_class_name: hidden +description: Compiles a single hashtable of ZTA-derived analytics for embedding into the Maester report's HTML/JSON output (consumed by the ZTA tab in the React report). +id: Build-MtZtaBundle +title: Build-MtZtaBundle +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Build-MtZtaBundle.ps1 +--- + +## SYNOPSIS + +Compiles a single hashtable of ZTA-derived analytics for embedding into the Maester report's HTML/JSON output (consumed by the ZTA tab in the React report). + +## SYNTAX + +```powershell +Build-MtZtaBundle [<CommonParameters>] +``` + +## DESCRIPTION + +The Maester HTML report inlines its result JSON via `Get-MtHtmlReport`. +The ZTA tab needs a few extra fields beyond the standard test rows: +bundle metadata, per-pillar summary, inventory counts, and curated +analytics (auth-method posture, privileged exposure, application +credential hygiene, device trust mix). + +This helper walks `$script:MtZtaContext` (populated by +`Import-MtZtaResult`) and emits one hashtable that the orchestrator +injects into the Maester result object as `ZtaBundle`. When no ZTA +context is loaded the function returns `$null` — the orchestrator +skips augmentation and the ZTA tab degrades gracefully. + +Reuses `Get-MtZtaAuthMethodSet` for method classification and the same +Tier-0 role-template constants used by the ZTA-aware test surface. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +# In the orchestrator, right after Invoke-Maester returns: +$bundle = Build-MtZtaBundle +if ($bundle) { + $result | Add-Member -NotePropertyName 'ZtaBundle' -NotePropertyValue $bundle -Force +} +``` + +## PARAMETERS + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[hashtable] or $null + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Build-MtZtaBundle](https://maester.dev/docs/commands/Build-MtZtaBundle) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Get-MtZta.mdx b/website/docs/commands/Get-MtZta.mdx new file mode 100644 index 000000000..ee93165c9 --- /dev/null +++ b/website/docs/commands/Get-MtZta.mdx @@ -0,0 +1,123 @@ +--- +sidebar_class_name: hidden +description: Returns the ZTA context loaded by Import-MtZtaResult, or $null if ZTA was not ingested. +id: Get-MtZta +title: Get-MtZta +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Get-MtZta.ps1 +--- + +## SYNOPSIS + +Returns the ZTA context loaded by `Import-MtZtaResult`, or `$null` if ZTA was not ingested. + +## SYNTAX + +```powershell +Get-MtZta [[-Section] <String>] [<CommonParameters>] +``` + +## DESCRIPTION + +Accessor used inside Pester `BeforeDiscovery` and `It` blocks to consume ZTA findings. +Returns `$null` when ZTA was not loaded so tests can `Set-ItResult -Skipped` cleanly +without having to know whether the operator opted into ZTA-focused mode. + +When `-Section` is omitted the full `$script:MtZtaContext` object is returned. + +When the context is null but `$env:ZTA_RESULTS_REF` points to a valid path the cmdlet +self-heals by re-invoking `Import-MtZtaResult`. This covers the case where Pester's +child runspace resets the `$script:` scope to null during test execution. See the +"Environment variables" section in the [Zero Trust Assessment integration](../zero-trust-assessment.md) +page for details. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +BeforeDiscovery { $script:zta = Get-MtZta -Section Tests } +It 'Identity pillar has fewer than 30 failures' -Skip:(-not $script:zta) { + ($script:zta | Where-Object { $_.TestPillar -eq 'Identity' -and $_.TestStatus -eq 'Failed' }).Count | + Should -BeLessThan 30 +} +``` + +### EXAMPLE 2 + +```powershell +$summary = Get-MtZta -Section Summary +if ($summary.IdentityFailRatio -ge 0.5) { ... } +``` + +### EXAMPLE 3 + +```powershell +$buckets = Get-MtZta -Section FlaggedUsers +Describe 'Per-user posture' -ForEach $buckets { ... } +``` + +## PARAMETERS + +### -Section + +Which section of the ZTA context to return. When omitted, returns the full +`$script:MtZtaContext` object. + +Valid values: + +- `Tests` — raw Tests[] array from ZeroTrustAssessmentReport.json +- `Manifest` — manifest.json contents (tenant, run time, ZTA version, hashes) +- `Database` — DuckDB query context (Tier 2 — when ZTA's loaded assembly available) +- `JsonExport` — JSON-shadow query context (Tier 1 — always populated when bundle has zt-export/) +- `Reader` — highest-tier-available reader (Database if loaded, else JsonExport). Use this for tests that want to read tables without caring which tier services the request. +- `EmergencyAccessAccounts` — normalised break-glass list from GlobalSettings.EmergencyAccessAccounts. Returns [pscustomobject[]] with { Id, UserPrincipalName, DisplayName }. +- `Summary` — per-pillar fail counts + ratios + tenant id +- `FlaggedUsers` — output of Group-MtZtaFlaggedIdentity + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[object] or [object[]] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Get-MtZta](https://maester.dev/docs/commands/Get-MtZta) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Get-MtZtaAuthMethodSet.mdx b/website/docs/commands/Get-MtZtaAuthMethodSet.mdx new file mode 100644 index 000000000..e520fd98d --- /dev/null +++ b/website/docs/commands/Get-MtZtaAuthMethodSet.mdx @@ -0,0 +1,123 @@ +--- +sidebar_class_name: hidden +description: Returns curated classification of Microsoft Graph methodsRegistered enum values into PhishResistant / Phishable / SingleFactor / All buckets. +id: Get-MtZtaAuthMethodSet +title: Get-MtZtaAuthMethodSet +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Get-MtZtaAuthMethodSet.ps1 +--- + +## SYNOPSIS + +Returns curated classification of Microsoft Graph `methodsRegistered` enum values into PhishResistant / Phishable / SingleFactor / All buckets. + +## SYNTAX + +```powershell +Get-MtZtaAuthMethodSet [[-Bucket] <String>] [<CommonParameters>] +``` + +## DESCRIPTION + +Centralises the classification used by ZTA-aware MFA-uplift tests +(MT.Zta.1140 / 1141 / 1142 / 1143) so a single source of truth feeds every +check. Without this helper, each test inlines a different ad-hoc regex / +array which drifts as Microsoft adds new methods. + +Classification rationale: + +- **PhishResistant** — methods whose protocol prevents an attacker-controlled + relay site from harvesting the credential (FIDO2/WebAuthn binding the + credential to the relying-party origin, X.509 cert with PIN, Windows Hello + for Business, device-bound passkeys). + +- **Phishable** — every interactive method that an AiTM proxy or social-eng + attack can capture or replay: phone-bound (`mobilePhone` / + `alternateMobilePhone` / `officePhone` — these are the URD enum values + for what user-facing tooling calls "SMS"; URD does NOT emit `sms`), + `voice`, email OTP, software and hardware TOTP, Authenticator push (consent + fatigue), and `microsoftAuthenticatorPasswordless`. Note: `sms` is a value + in the CA `authenticationMethodModes` enum (used by authStrength + `allowedCombinations`), NOT the URD `methodsRegistered` enum this cmdlet + models. The CA-side vocabulary is checked inline in MT.Zta.1131; this cmdlet + feeds the user-data-side checks (MT.Zta.1140 / 1141 / 1142 / 1143) that + read URD rows. + +- **SingleFactor** — methods that are explicitly NOT MFA at all + (`x509CertificateSingleFactor`, `password`). Listed for completeness. + +References: + +- Graph schema: https://learn.microsoft.com/graph/api/resources/userregistrationdetails +- Microsoft phish-resistant MFA list: https://learn.microsoft.com/azure/active-directory/authentication/concept-authentication-strengths + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$classes = Get-MtZtaAuthMethodSet +$hasOnlyWeak = -not (@($u.methodsRegistered | Where-Object { $_ -in $classes.PhishResistant }).Count -gt 0) +``` + +### EXAMPLE 2 + +```powershell +$phishable = Get-MtZtaAuthMethodSet -Bucket Phishable +if (@($u.methodsRegistered | Where-Object { $_ -in $phishable }).Count -gt 0) { ... } +``` + +## PARAMETERS + +### -Bucket + +Which classification bucket to return. Default returns the full hashtable. + +Valid values: `PhishResistant`, `Phishable`, `SingleFactor`, `All`. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: All +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[hashtable] or [string[]] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Get-MtZtaAuthMethodSet](https://maester.dev/docs/commands/Get-MtZtaAuthMethodSet) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Get-MtZtaRecommendedTag.mdx b/website/docs/commands/Get-MtZtaRecommendedTag.mdx new file mode 100644 index 000000000..b3aa54270 --- /dev/null +++ b/website/docs/commands/Get-MtZtaRecommendedTag.mdx @@ -0,0 +1,66 @@ +--- +sidebar_class_name: hidden +description: Derives a Pester -Tag list from current ZTA findings so Maester runs only the tests relevant to the areas ZTA flagged. +id: Get-MtZtaRecommendedTag +title: Get-MtZtaRecommendedTag +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Get-MtZtaRecommendedTag.ps1 +--- + +## SYNOPSIS + +Derives a Pester `-Tag` list from current ZTA findings so Maester runs only the tests relevant to the areas ZTA flagged. + +## SYNTAX + +```powershell +Get-MtZtaRecommendedTag [<CommonParameters>] +``` + +## DESCRIPTION + +Walks `$script:MtZtaContext.Tests` (failed entries only), classifies each into a bucket +per `ZtaSettings.CategoryMappings`, and emits the union of `MaesterTagBoost` arrays +plus the literal pillar names as a `[string[]]`. + +Defaults when no `ZtaSettings` is present on the context: + +- `PillarTagMap` falls back to a vendor-neutral baseline that mirrors the pillar + keywords already used in upstream Maester tests + (Identity, Devices, Network, Data, MFA, ConditionalAccess, PIM, Intune, Compliance, ...). +- `CategoryMappings` empty — all failures classify as `Other` (no boost tags emitted). + +Returns an empty array when ZTA was not loaded or has no failures. + +**Coverage warning:** if more than 10% of failed tests classify as `Other`, the cmdlet +emits a `Write-Warning` so the operator can revisit the `CategoryMappings` block. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$tags = Get-MtZtaRecommendedTag +Invoke-Maester -Tag $tags +``` + +## PARAMETERS + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[string[]] or [object[]] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Get-MtZtaRecommendedTag](https://maester.dev/docs/commands/Get-MtZtaRecommendedTag) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Get-MtZtaThreshold.mdx b/website/docs/commands/Get-MtZtaThreshold.mdx new file mode 100644 index 000000000..5d84beb4a --- /dev/null +++ b/website/docs/commands/Get-MtZtaThreshold.mdx @@ -0,0 +1,126 @@ +--- +sidebar_class_name: hidden +description: Returns a per-test threshold value, sourced from ZtaSettings.Thresholds.<TestId> in maester-config.json with a caller-supplied default fallback. +id: Get-MtZtaThreshold +title: Get-MtZtaThreshold +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Get-MtZtaThreshold.ps1 +--- + +## SYNOPSIS + +Returns a per-test threshold value, sourced from `ZtaSettings.Thresholds.<TestId>` in maester-config.json with a caller-supplied default fallback. + +## SYNTAX + +```powershell +Get-MtZtaThreshold [-TestId] <String> [-Default] <Object> [<CommonParameters>] +``` + +## DESCRIPTION + +Lets ZTA-aware tests expose their numeric thresholds (warn-band counts, +fail-ratio cutoffs, sample caps) to operator tuning via maester-config +without forking the test code. + +Lookup is two-step: + +1. `(Get-MtZta).ZtaSettings.Thresholds.<TestId>` if present and non-null +2. otherwise the `-Default` parameter + +If `ZtaSettings` carries a hashtable / pscustomobject under `Thresholds`, +both shapes are accepted — JSON-deserialised pscustomobject (default +`ConvertFrom-Json` behaviour) or hashtable (operator passing `-AsHashtable`). + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$threshold = Get-MtZtaThreshold -TestId 'MT.Zta.1001' -Default 30 +$summary.IdentityFailed | Should -BeLessThan $threshold +``` + +### EXAMPLE 2 + +```powershell +# In maester-config.json: +# "ZtaSettings": { +# "Thresholds": { +# "MT.Zta.1001": 50, +# "MT.Zta.1140": 5 +# } +# } +``` + +## PARAMETERS + +### -TestId + +The Maester test id (e.g. `MT.Zta.1001`). Conventionally the same as the `It` +block tag — that way the threshold key in maester-config matches what operators +see in the report. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Default + +The threshold value to return when no operator override exists. Required — +every threshold-bearing test must declare its built-in default. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: + +Required: True +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[object] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Get-MtZtaThreshold](https://maester.dev/docs/commands/Get-MtZtaThreshold) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Import-MtZtaResult.mdx b/website/docs/commands/Import-MtZtaResult.mdx new file mode 100644 index 000000000..e6a4f45e5 --- /dev/null +++ b/website/docs/commands/Import-MtZtaResult.mdx @@ -0,0 +1,214 @@ +--- +sidebar_class_name: hidden +description: Loads a Zero Trust Assessment (ZTA) result bundle into Maester so subsequent tests can focus on the areas ZTA flagged. +id: Import-MtZtaResult +title: Import-MtZtaResult +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Import-MtZtaResult.ps1 +--- + +## SYNOPSIS + +Loads a Zero Trust Assessment (ZTA) result bundle into Maester so subsequent tests can focus on the areas ZTA flagged. + +## SYNTAX + +```powershell +Import-MtZtaResult [[-ZtaResultsPath] <String>] [[-FreshnessDays] <Int32>] [-ForceJsonFallback] + [[-ExpectedTenantId] <String>] [[-ZtaSettings] <Object>] [[-GlobalSettings] <Object>] + [-ProgressAction <ActionPreference>] [<CommonParameters>] +``` + +## DESCRIPTION + +Resolves a ZTA result source (local path, Azure Blob URI, or Azure Artifacts +Universal Package reference) via `Resolve-MtZtaArtifact`, validates the bundle +contains the expected files (manifest.json + ZeroTrustAssessmentReport.json + db/zt.db), +opens the DuckDB database read-only via `Read-MtZtaDatabase` (with JSON fallback on +any failure), checks freshness via `Test-MtZtaFreshness`, and populates the +module-private `$script:MtZtaContext` with the normalised data. + +Idempotent — subsequent calls with the same source short-circuit. + +No-ops gracefully when `-ZtaResultsPath` is `$null` or empty (keeps vanilla +`Invoke-Maester` runs byte-identical to upstream). + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +Import-MtZtaResult -ZtaResultsPath .\zta-results-2026-05-01.tar.gz +``` + +### EXAMPLE 2 + +```powershell +Import-MtZtaResult -ZtaResultsPath 'https://contoso-sec.blob.core.windows.net/zta/2026-05-01.tar.gz' +``` + +### EXAMPLE 3 + +```powershell +Import-MtZtaResult -ZtaResultsPath 'upkg://OnTrask-Security/Assessments/zta-results/customer-a-2026-05-01@1.0.0' +``` + +### EXAMPLE 4 + +```powershell +Import-MtZtaResult -ZtaResultsPath .\zta -ForceJsonFallback +``` + +## PARAMETERS + +### -ZtaResultsPath + +ZTA result source string. Three patterns recognised, in priority order: + +1. `https://<account>.blob.core.windows.net/...` — Azure Blob (SAS in URI / WIF / -Identity) +2. `upkg://<org>/<project>/<feed>/<name>@<ver>` — Azure Artifacts Universal Package +3. `<local path>` — folder, `.tar.gz`, or `.zip` + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -FreshnessDays + +Override the default 14-day freshness threshold. Stale runs proceed (warn-but-proceed) +but set `$script:MtZtaContext.IsStale = $true`. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: 14 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ForceJsonFallback + +Skip DuckDB entirely and use the JSON-only path. Useful on Linux without the +DuckDB.NET native binary or for repro tests. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ExpectedTenantId + +Optional tenant-id pin. When set, the manifest's tenantId must match exactly or +the load aborts before any test runs. Cross-tenant data leakage guard. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 3 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ZtaSettings + +The `ZtaSettings` block from `maester-config.json` (already deserialised), passed +through to subsequent cmdlets via `$script:MtZtaContext.ZtaSettings`. Drives: + +- CategoryMappings (`Get-MtZta -Section FlaggedUsers`, `Get-MtZtaRecommendedTag`) +- SeverityEscalationRules (`Update-MtSeverityFromZta`) +- DataDrivenSettings (`Group-MtZtaFlaggedIdentity` caps) +- PillarTagMap (`Get-MtZtaRecommendedTag` pillar-tag union) + +Optional — when omitted, callers default to vendor-neutral baselines. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: + +Required: False +Position: 4 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -GlobalSettings + +The `GlobalSettings` block from `maester-config.json` (Maester's standard +section). Today only `EmergencyAccessAccounts` is consumed: it is normalised +and surfaced via `Get-MtZta -Section EmergencyAccessAccounts` and used by +`Test-MtZtaIsEmergencyAccess` to mark legitimate break-glass identities as +compliant-by-design in tests like MT.Zta.1107 (permanent Global Admins). +Each entry can be a string (UPN or GUID) OR an object with `id` / +`userPrincipalName` / `displayName` properties — all three shapes accepted. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: + +Required: False +Position: 5 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES + +This cmdlet has the alias `Import-MtZtaResults` for backward compatibility. + +## RELATED LINKS + +[https://maester.dev/docs/commands/Import-MtZtaResult](https://maester.dev/docs/commands/Import-MtZtaResult) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Invoke-Maester.mdx b/website/docs/commands/Invoke-Maester.mdx index 59ff69d39..e1282f330 100644 --- a/website/docs/commands/Invoke-Maester.mdx +++ b/website/docs/commands/Invoke-Maester.mdx @@ -1,4 +1,4 @@ ---- +--- sidebar_class_name: hidden description: This is the main Maester command that runs the tests and generates a report of the results. id: Invoke-Maester @@ -21,7 +21,9 @@ Invoke-Maester [[-Path] <String>] [-Tag <String[]>] [-ExcludeTag <String[]>] [-I [-Verbosity <String>] [-NonInteractive] [-PassThru] [-MailRecipient <String[]>] [-MailTestResultsUri <String>] [-MailUserId <String>] [-TeamId <String>] [-TeamChannelId <String>] [-TeamChannelWebhookUri <String>] [-SkipGraphConnect] [-DisableTelemetry] [-SkipVersionCheck] [-ExportCsv] [-ExportExcel] [-NoLogo] - [-DriftRoot <String>] [-ProgressAction <ActionPreference>] [<CommonParameters>] + [-DriftRoot <String>] [-ZtaResultsPath <String>] [-DisableZta] [-ZtaForceJsonFallback] + [-ZtaFreshnessDays <Int32>] [-ExpectedTenantId <String>] [-ProgressAction <ActionPreference>] + [<CommonParameters>] ``` ## DESCRIPTION @@ -146,6 +148,30 @@ Invoke-Maester -IncludeLongRunning -IncludePreview Connect to all tested services and run all tests, including the long-running and preview tests. +### EXAMPLE 14 + +```powershell +Connect-Maester +Invoke-Maester -ZtaResultsPath './zta-bundle' -Path './tests' +``` + +Loads a Zero Trust Assessment result bundle (local folder, `.tar.gz`, `.zip`, +blob URI, or `upkg://`) before running tests so the 38 `MT.Zta.*` tests under +`tests/Zta/` can consume it. After Pester finishes, attaches a `ZtaBundle` +analytics object to the result so the HTML report renders a dedicated ZTA tab +and the JSON output carries the data. See [Zero Trust Assessment](../zero-trust-assessment.md). + +### EXAMPLE 15 + +```powershell +Invoke-Maester -ZtaResultsPath 'https://contoso-sec.blob.core.windows.net/zta/2026-05-01.tar.gz' ` + -ExpectedTenantId '00000000-0000-0000-0000-000000000000' ` + -ZtaFreshnessDays 7 +``` + +Loads a ZTA bundle from Azure Blob storage with a cross-tenant safety pin and +a tighter 7-day freshness threshold (default is 14 days). + ## PARAMETERS ### -Path @@ -622,6 +648,109 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ZtaResultsPath + +Path or URL to a Zero Trust Assessment (ZTA) result bundle. When supplied, +Maester pre-loads the bundle via `Import-MtZtaResult` before Pester +discovery, applies any ZTA-driven severity overlay via +`Update-MtSeverityFromZta`, and after the run compiles a `ZtaBundle` +analytics object onto the returned results so the HTML / JSON / Markdown +outputs all carry per-tenant analytics (Inventory, AuthMethodScore, CA +coverage, Sign-in funnel, Privileged snapshot, Devices / Applications mix). + +Three source patterns are accepted (priority order): + +1. Local path — folder, `.tar.gz`, or `.zip` +2. Azure Blob URL — `https://<account>.blob.core.windows.net/...` (SAS in URI, WIF, or `-Identity`) +3. Azure Artifacts Universal Package — `upkg://<org>/<project>/<feed>/<name>@<ver>` + +Omitting this parameter preserves stock Maester behaviour byte-for-byte. See +[Zero Trust Assessment](../zero-trust-assessment.md) for the full integration +guide. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DisableZta + +Opt-out switch. When set, Maester ignores `-ZtaResultsPath` even if supplied +— useful for repro runs or when the bundle is known-stale. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ZtaForceJsonFallback + +Skip the DuckDB reader tier and use the JSON-shadow tier only. Useful on hosts +without the `DuckDB.NET` native binary, on PowerShell 5.1, or for +reproducibility tests where DuckDB version drift could change results. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ZtaFreshnessDays + +Override the default 14-day artifact freshness threshold. Stale bundles still +load (warn-but-proceed); `MtZtaContext.IsStale` lets tests react to age. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: 14 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ExpectedTenantId + +Cross-tenant safety pin. When set, the bundle's `manifest.tenantId` must +match exactly or the load aborts before any test runs. Use to prevent +accidentally running tests against a bundle from a different tenant than +your live Graph session. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction \{\{ Fill ProgressAction Description \}\} diff --git a/website/docs/commands/Test-MtZtaIsEmergencyAccess.mdx b/website/docs/commands/Test-MtZtaIsEmergencyAccess.mdx new file mode 100644 index 000000000..04ee60ff8 --- /dev/null +++ b/website/docs/commands/Test-MtZtaIsEmergencyAccess.mdx @@ -0,0 +1,114 @@ +--- +sidebar_class_name: hidden +description: Returns $true when the supplied principalId or UPN matches an entry in the operator's break-glass list sourced from GlobalSettings.EmergencyAccessAccounts. +id: Test-MtZtaIsEmergencyAccess +title: Test-MtZtaIsEmergencyAccess +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Test-MtZtaIsEmergencyAccess.ps1 +--- + +## SYNOPSIS + +Returns `$true` when the supplied principalId or UPN matches an entry in +`$script:MtZtaContext.EmergencyAccessAccounts` (the operator's break-glass +list, sourced from `GlobalSettings.EmergencyAccessAccounts` in maester-config.json). + +## SYNTAX + +```powershell +Test-MtZtaIsEmergencyAccess [[-Id] <String>] [[-UserPrincipalName] <String>] [<CommonParameters>] +``` + +## DESCRIPTION + +Use from ZTA gap-fill tests that surface privileged identities (permanent role +grants, stale users, single-factor users, etc.) so legitimate break-glass accounts +are flagged as compliant-by-design rather than reported as findings. + +Match is permissive: either Id or UPN match counts. UPN comparison is +case-insensitive (Entra UPNs are case-insensitive). Returns `$false` on any +missing context or empty input. + +Companion to `Get-MtZta -Section EmergencyAccessAccounts` which returns the +normalised array. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +if (Test-MtZtaIsEmergencyAccess -Id $r.principalId -UserPrincipalName $u.userPrincipalName) { + # mark as break-glass in the report; don't include in finding count +} +``` + +## PARAMETERS + +### -Id + +Object ID (GUID) of the principal under inspection. Matched against entries +whose `Id` is populated. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -UserPrincipalName + +UPN of the principal. Matched case-insensitively against entries whose +`UserPrincipalName` is populated. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[bool] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Test-MtZtaIsEmergencyAccess](https://maester.dev/docs/commands/Test-MtZtaIsEmergencyAccess) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/commands/Update-MtSeverityFromZta.mdx b/website/docs/commands/Update-MtSeverityFromZta.mdx new file mode 100644 index 000000000..926405078 --- /dev/null +++ b/website/docs/commands/Update-MtSeverityFromZta.mdx @@ -0,0 +1,142 @@ +--- +sidebar_class_name: hidden +description: Mutates an in-memory TestSettings[] array per ZtaSettings.SeverityEscalationRules before Pester discovery, so ZTA findings can escalate severity on matching Maester tests. +id: Update-MtSeverityFromZta +title: Update-MtSeverityFromZta +hide_title: false +hide_table_of_contents: false +custom_edit_url: https://github.com/maester365/maester/blob/main/powershell/public/Update-MtSeverityFromZta.ps1 +--- + +## SYNOPSIS + +Mutates an in-memory `TestSettings[]` array per `ZtaSettings.SeverityEscalationRules` +before Pester discovery, so ZTA findings can escalate severity on matching Maester tests. + +## SYNTAX + +```powershell +Update-MtSeverityFromZta [-TestSettings] <Object[]> [-WhatIf] [-Confirm] + [-ProgressAction <ActionPreference>] [<CommonParameters>] +``` + +## DESCRIPTION + +Reads `$script:MtZtaContext.ZtaSettings.SeverityEscalationRules`, evaluates each rule +against the loaded context, and mutates entries in `-TestSettings` whose `Id` or `Tag` +matches an active rule's selector. + +Rule shape: + +```jsonc +{ + "WhenPillarFailedAtLeast": <int>, // count of Failed tests in pillar + "WhenCategoryFlaggedUsersAtLeast": <int>, // count of users in CategoryMappings bucket + "Pillar": "<Identity|Devices|Network|Data>", + "Category": "<bucket name from CategoryMappings>", + "EscalateMaesterTagged": ["<tag>", ...], // selector — match TestSetting.Tag + "EscalateMaesterTestId": ["<id>", ...], // selector — match TestSetting.Id (wildcards allowed) + "From": "<severity>", // optional — only escalate if current severity == From + "To": "<severity>" +} +``` + +Idempotent — running twice does not double-escalate (the second run finds severity +already at the target). Safe to call before Pester discovery on each `Invoke-Maester`. + +No-op when `$script:MtZtaContext` is unset, ZtaSettings is missing, or no rules fire. + +## EXAMPLES + +### EXAMPLE 1 + +```powershell +$cfg = Get-Content maester-config.json -Raw | ConvertFrom-Json +Import-MtZtaResult -ZtaResultsPath .\zta -ZtaSettings $cfg.ZtaSettings +$cfg.TestSettings = Update-MtSeverityFromZta -TestSettings $cfg.TestSettings +``` + +## PARAMETERS + +### -TestSettings + +The TestSettings array from `maester-config.json` (already deserialised to PSObject). +Mutated in place AND returned for pipeline-style chaining. + +```yaml +Type: Object[] +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows what would happen if the cmdlet runs without actually making changes. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +\{\{ Fill ProgressAction Description \}\} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +[object[]] + +## NOTES + +## RELATED LINKS + +[https://maester.dev/docs/commands/Update-MtSeverityFromZta](https://maester.dev/docs/commands/Update-MtSeverityFromZta) + +[https://maester.dev/docs/zero-trust-assessment](https://maester.dev/docs/zero-trust-assessment) diff --git a/website/docs/tests/maester/MT.Zta.1001.md b/website/docs/tests/maester/MT.Zta.1001.md new file mode 100644 index 000000000..41d09b16c --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1001.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1001 - Identity pillar fail count is below the warn threshold +description: "ZTA's **Identity pillar** covers authentication methods, conditional access, sign-in risk, PIM coverage, and external-collaboration exposure. When more than 30 Identity-pillar tests fail, the most likely cause is a **policy-level regression** (e.g. baseline CA policy disabled,..." +slug: /tests/MT.Zta.1001 +sidebar_class_name: hidden +--- + +# Identity pillar fail count is below the warn threshold + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.IdentityFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1) | + +## Description + +ZTA's **Identity pillar** covers authentication methods, conditional access, sign-in risk, PIM coverage, and external-collaboration exposure. When more than 30 Identity-pillar tests fail, the most likely cause is a **policy-level regression** (e.g. baseline CA policy disabled, security defaults removed) rather than per-control drift. This test surfaces the bulk-failure signal before deeper per-bucket analysis. +## How to fix + +1. Open the ZTA report and sort the Identity pillar Tests[] by TestId. +2. Compare against a known-good configuration baseline. +3. Restore policy-level controls FIRST, then re-run ZTA, then resume per-finding remediation. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1002.md b/website/docs/tests/maester/MT.Zta.1002.md new file mode 100644 index 000000000..b5394792e --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1002.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1002 - Identity fail ratio stays below 0.5 (50% of evaluated tests) +description: "**Fail ratio = Failed / (Total - Skipped - Planned).** Skipped/Planned tests are excluded from the denominator so a fully-licensed pillar with 10 failures is comparable to an under-licensed pillar with 10 failures plus 50 skipped tests." +slug: /tests/MT.Zta.1002 +sidebar_class_name: hidden +--- + +# Identity fail ratio stays below 0.5 (50% of evaluated tests) + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.IdentityFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1) | + +## Description + +**Fail ratio = Failed / (Total - Skipped - Planned).** Skipped/Planned tests are excluded from the denominator so a fully-licensed pillar with 10 failures is comparable to an under-licensed pillar with 10 failures plus 50 skipped tests. + +A ratio above 0.5 means **more than half** of evaluated Identity tests failed — a strong signal that core Identity posture is broken, not just drifting on individual controls. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1003.md b/website/docs/tests/maester/MT.Zta.1003.md new file mode 100644 index 000000000..766427c72 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1003.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1003 - No PrivilegedAccess findings flagged users above the bar +description: "The **PrivilegedAccess** cross-cut bucket aggregates ZTA findings about role assignments, PIM eligibility, and credential management — across all four pillars (Identity / Devices / Network / Data). When more than 10 unique entries land in this bucket, role hygiene is the most ..." +slug: /tests/MT.Zta.1003 +sidebar_class_name: hidden +--- + +# No PrivilegedAccess findings flagged users above the bar + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.IdentityFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.IdentityFocus.Tests.ps1) | + +## Description + +The **PrivilegedAccess** cross-cut bucket aggregates ZTA findings about role assignments, PIM eligibility, and credential management — across all four pillars (Identity / Devices / Network / Data). When more than 10 unique entries land in this bucket, role hygiene is the most cost-effective remediation lever. +## How to fix + +1. Open Entra ID → Privileged Identity Management → Roles → Assignments. +2. For each entry below: confirm whether the assignment is permanent (should be PIM-eligible), unmanaged (no review), or expired-but-still-active. +3. Convert permanent role assignments to PIM-eligible with access reviews. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1004.md b/website/docs/tests/maester/MT.Zta.1004.md new file mode 100644 index 000000000..7dddf789b --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1004.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1004 - Devices pillar fail count is below the warn threshold +description: "ZTA's **Devices pillar** covers Intune compliance, BitLocker / FileVault enforcement, OS-version posture, and conditional-access compliant-device requirements. A bulk failure (≥ 20 Devices tests Failed) usually indicates a missing compliance policy assignment or a stale grace ..." +slug: /tests/MT.Zta.1004 +sidebar_class_name: hidden +--- + +# Devices pillar fail count is below the warn threshold + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.PillarFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1) | + +## Description + +ZTA's **Devices pillar** covers Intune compliance, BitLocker / FileVault enforcement, OS-version posture, and conditional-access compliant-device requirements. A bulk failure (≥ 20 Devices tests Failed) usually indicates a missing compliance policy assignment or a stale grace period rather than per-device drift. +## How to fix + +1. Intune → Devices → Compliance policies — verify a baseline policy is assigned to all platforms in scope. +2. Intune → Endpoint security → Disk encryption — confirm enforcement on Windows + macOS. +3. Conditional Access — verify "require compliant device" is enforced on the platform pillars. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1005.md b/website/docs/tests/maester/MT.Zta.1005.md new file mode 100644 index 000000000..9d8af061f --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1005.md @@ -0,0 +1,24 @@ +--- +title: MT.Zta.1005 - Network pillar fail count is below the warn threshold +description: "ZTA's **Network pillar** covers Global Secure Access (GSA), private-network access, internet access policy, and network-aware conditional access. Bulk failures here usually mean GSA is not deployed, or GSA tunnels are mis-scoped." +slug: /tests/MT.Zta.1005 +sidebar_class_name: hidden +--- + +# Network pillar fail count is below the warn threshold + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.PillarFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1) | + +## Description + +ZTA's **Network pillar** covers Global Secure Access (GSA), private-network access, internet access policy, and network-aware conditional access. Bulk failures here usually mean GSA is not deployed, or GSA tunnels are mis-scoped. +## How to fix + +1. Entra ID → Global Secure Access — verify the tenant is enrolled and at least one of {Microsoft Traffic, Internet Access, Private Access} is provisioned. +2. CA — verify a network-aware policy enforces GSA for in-scope users. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1006.md b/website/docs/tests/maester/MT.Zta.1006.md new file mode 100644 index 000000000..6e4785209 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1006.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1006 - Data pillar fail count is below the warn threshold +description: "ZTA's **Data pillar** covers sensitivity-label coverage, DLP policy reach, and Purview-driven data classification. Bulk failures usually mean Purview isn't licensed/configured, OR labels exist but aren't published to the right scope." +slug: /tests/MT.Zta.1006 +sidebar_class_name: hidden +--- + +# Data pillar fail count is below the warn threshold + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.PillarFocus.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.PillarFocus.Tests.ps1) | + +## Description + +ZTA's **Data pillar** covers sensitivity-label coverage, DLP policy reach, and Purview-driven data classification. Bulk failures usually mean Purview isn't licensed/configured, OR labels exist but aren't published to the right scope. +## How to fix + +1. Purview portal → Information protection → Labels — verify at least one published label policy. +2. Purview → Data loss prevention → Policies — verify default DLP policies for Exchange + SharePoint + Teams. +3. Sensitivity label auto-labelling — verify it's enabled for E5/AIP-licensed users. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1010.md b/website/docs/tests/maester/MT.Zta.1010.md new file mode 100644 index 000000000..385ad9cc5 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1010.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1010 - Bundle freshness is within tolerance (warn-but-proceed band) +description: "ZTA bundles older than 'FreshnessDays' (default 14) are considered stale. 'Test-MtZtaFreshness' warns and sets 'IsStale = $true' on the context; this test surfaces that flag explicitly so the operator can see the staleness without inspecting every test's detail panel. The most..." +slug: /tests/MT.Zta.1010 +sidebar_class_name: hidden +--- + +# Bundle freshness is within tolerance (warn-but-proceed band) + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.OperatorDriftCheck.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1) | + +## Description + +ZTA bundles older than `FreshnessDays` (default 14) are considered stale. `Test-MtZtaFreshness` warns and sets `IsStale = $true` on the context; this test surfaces that flag explicitly so the operator can see the staleness without inspecting every test's detail panel. The most common cause of staleness is the resolver step falling back to last-good after the current ZTA stage failed. +## How to fix + +1. Open the ZeroTrustAssessment stage logs from the current run. +2. Identify the failure root cause (auth, missing module, connectivity). +3. Re-run with `enableZtaExperimental=true` once the stage is healthy. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1101.md b/website/docs/tests/maester/MT.Zta.1101.md new file mode 100644 index 000000000..60f2a311c --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1101.md @@ -0,0 +1,20 @@ +--- +title: MT.Zta.1101 - Identity fail ratio is high enough to warrant guest deep-dive +description: "**Gate test.** Runs only when the Identity-pillar fail ratio is **≥ 0.5** — the threshold below which deep-dive analysis isn't cost-effective. When this test is reported as Passed, it means ZTA found enough Identity failures that the per-bucket guest-posture tests below carry ..." +slug: /tests/MT.Zta.1101 +sidebar_class_name: hidden +--- + +# Identity fail ratio is high enough to warrant guest deep-dive + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.GuestPosture.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1) | + +## Description + +**Gate test.** Runs only when the Identity-pillar fail ratio is **≥ 0.5** — the threshold below which deep-dive analysis isn't cost-effective. When this test is reported as Passed, it means ZTA found enough Identity failures that the per-bucket guest-posture tests below carry meaningful signal. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1102.md b/website/docs/tests/maester/MT.Zta.1102.md new file mode 100644 index 000000000..a6898bc9c --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1102.md @@ -0,0 +1,27 @@ +--- +title: MT.Zta.1102 - GuestUnconstrained bucket has fewer than 25 entries +description: "The **GuestUnconstrained** cross-cut groups guest accounts that ZTA flagged as having weak external-collaboration controls — typically guests outside conditional-access scope, with no compliant device, or never used yet still enabled." +slug: /tests/MT.Zta.1102 +sidebar_class_name: hidden +--- + +# GuestUnconstrained bucket has fewer than 25 entries + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.GuestPosture.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1) | + +## Description + +The **GuestUnconstrained** cross-cut groups guest accounts that ZTA flagged as having weak external-collaboration controls — typically guests outside conditional-access scope, with no compliant device, or never used yet still enabled. + +A bucket with more than 25 entries indicates **systemic guest-lifecycle drift**, not isolated cases. Address policy first (CA exclusions, lifecycle workflows, access reviews) before per-guest cleanup. +## How to fix + +1. Entra ID → External Identities → External collaboration settings — review guest invite restrictions. +2. Entra ID → Identity Governance → Access Reviews — ensure recurring reviews on guest membership. +3. Conditional Access — verify a guest-targeted policy enforces MFA + device compliance. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1103.md b/website/docs/tests/maester/MT.Zta.1103.md new file mode 100644 index 000000000..07bd10df1 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1103.md @@ -0,0 +1,20 @@ +--- +title: MT.Zta.1103 - GuestUnconstrained bucket entries each carry evidence +description: "Every entry in the **GuestUnconstrained** bucket should carry at least one Evidence string explaining *why* it was flagged (which ZTA TestId surfaced it, or which DuckDB enrichment query). Entries with no evidence are unactionable and indicate a CategoryMappings or extraction ..." +slug: /tests/MT.Zta.1103 +sidebar_class_name: hidden +--- + +# GuestUnconstrained bucket entries each carry evidence + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.GuestPosture.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.GuestPosture.Tests.ps1) | + +## Description + +Every entry in the **GuestUnconstrained** bucket should carry at least one Evidence string explaining *why* it was flagged (which ZTA TestId surfaced it, or which DuckDB enrichment query). Entries with no evidence are unactionable and indicate a CategoryMappings or extraction bug. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1104.md b/website/docs/tests/maester/MT.Zta.1104.md new file mode 100644 index 000000000..7fc5e6d27 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1104.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1104 - Stale-signin user count is below the warn threshold +description: "Counts users whose **most-recent successful sign-in is older than 90 days** via the ZTA 'SignIn' table. Stale users with active accounts are the easiest credential-theft entry point — disabling or removing them is high-leverage. Threshold: warn at 25." +slug: /tests/MT.Zta.1104 +sidebar_class_name: hidden +--- + +# Stale-signin user count is below the warn threshold + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.DuckDbEnrichment.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DuckDbEnrichment.Tests.ps1) | + +## Description + +Counts users whose **most-recent successful sign-in is older than 90 days** via the ZTA `SignIn` table. Stale users with active accounts are the easiest credential-theft entry point — disabling or removing them is high-leverage. Threshold: warn at 25. +## How to fix + +1. Entra ID → Users → filter by ``signInActivity.lastSignInDateTime < 90 days``. +2. For each: confirm with the user's manager whether the account is still required. +3. Disable (preferred) or delete; for service accounts, rotate to managed identity / service principal with rotation. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1107.md b/website/docs/tests/maester/MT.Zta.1107.md new file mode 100644 index 000000000..0af447077 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1107.md @@ -0,0 +1,41 @@ +--- +title: MT.Zta.1107 - No permanent non-break-glass Global Administrator role assignments +description: "Lists **all** permanent (non-PIM-eligible) Global Administrator role assignments via the ZTA 'RoleAssignment' table. Each assignment is annotated as either:" +slug: /tests/MT.Zta.1107 +sidebar_class_name: hidden +--- + +# No permanent non-break-glass Global Administrator role assignments + +| Severity | Source | +| --- | --- | +| Critical | [`Test-MtZta.DuckDbEnrichment.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DuckDbEnrichment.Tests.ps1) | + +## Description + +Lists **all** permanent (non-PIM-eligible) Global Administrator role assignments via the ZTA `RoleAssignment` table. Each assignment is annotated as either: + +- `✓ break-glass` — declared in `maester-config.json` `GlobalSettings.EmergencyAccessAccounts`. Permanent grant is **expected** for these (compliant by config). +- `❌ permanent grant` — non-break-glass account with a permanent grant. **Critical finding** — convert to PIM-eligible. + +The assertion fails only when there is at least one `❌` row. +## How to fix + +How to remediate ❌ rows +1. Entra ID → Roles & administrators → Global administrator → list current assignments. +2. For each non-break-glass row: convert to PIM-eligible (Eligible assignments tab) and remove the permanent grant. +## Related Maester core tests + +This test answers a question the policy-state family does NOT: *"is the grant standing, or just-in-time?"*. Run alongside: + +- `MT.1032` — *Limited number of Global Admins are assigned* (Maester core). Caps the COUNT but does not distinguish permanent vs PIM-eligible. +- `CIS.M365.1.1.3` — *Between two and four global admins are designated*. Same: count-only. +- `CISA.MS.AAD.7.1` — *A minimum of two and a maximum of eight users SHALL be provisioned with Global Administrator*. Count-only. +- `CISA.MS.AAD.7.6` — *Activation of the Global Administrator role SHALL require approval*. Policy-side; does not check whether anyone bypasses activation via a standing grant. +- `CISA.MS.AAD.7.7` — *Eligible and Active highly privileged role assignments SHALL be monitored*. Closest in spirit; `MT.Zta.1107` provides the specific assertion ("zero permanent grants except break-glass"). + +**Joint reading**: passing `MT.1032` / `CIS.M365.1.1.3` with 2 GAs assigned is NOT sufficient if both are permanent grants and neither is declared break-glass. `MT.Zta.1107` catches that specific failure mode. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1110.md b/website/docs/tests/maester/MT.Zta.1110.md new file mode 100644 index 000000000..4b6773529 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1110.md @@ -0,0 +1,30 @@ +--- +title: MT.Zta.1110 - iOS App Protection Policy covers unmanaged devices and is assigned to user/group +description: "When ZTA ['24543'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24543.md) (compliance policies protect iOS) or ['24548'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24548.md)..." +slug: /tests/MT.Zta.1110 +sidebar_class_name: hidden +--- + +# iOS App Protection Policy covers unmanaged devices and is assigned to user/group + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.DeviceCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1) | + +## Description + +When ZTA [`24543`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24543.md) (compliance policies protect iOS) or [`24548`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24548.md) (data on iOS protected by APP) is Failed, verifies that an Intune App Protection Policy (APP / MAM-WE) for iOS: +1. Targets `unmanagedAndManaged` device states (not just `managedDevices`). +2. Is enabled (not in draft). +3. Has at least one **`groupAssignmentTarget`** assignment — i.e. the policy is assigned to a real user/security group, NOT just the `allLicensedUsersAssignmentTarget` placeholder which Intune injects by default but which doesn't surface in the operator's assigned-policy list and is easy to leave un-assigned in practice. +## How to fix + +1. Intune → Apps → App protection policies → iOS/iPadOS → either create or edit the policy. +2. Set **Target apps** to all Microsoft 365 apps (or your scoped list). +3. Under **Targeted app management level**, choose **Unmanaged AND Managed** (or **All app types**). +4. Under **Assignments**, assign to a real group (e.g., "All employees" security group) — not the empty default. +5. Save and verify rollout via Intune → Apps → Monitor. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1111.md b/website/docs/tests/maester/MT.Zta.1111.md new file mode 100644 index 000000000..a8b960cb8 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1111.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1111 - Android App Protection Policy covers unmanaged devices and is assigned to user/group +description: "Android counterpart of MT.Zta.1110. Triggered when ZTA ['24547'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24547.md) or ['24545'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessme..." +slug: /tests/MT.Zta.1111 +sidebar_class_name: hidden +--- + +# Android App Protection Policy covers unmanaged devices and is assigned to user/group + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.DeviceCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1) | + +## Description + +Android counterpart of MT.Zta.1110. Triggered when ZTA [`24547`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24547.md) or [`24545`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24545.md) Failed. Verifies an Android APP exists with `targetedAppManagementLevels` covering unmanaged devices AND `assignments[].target` is a real groupAssignmentTarget (not the all-users placeholder). +## How to fix + +1. Intune → Apps → App protection policies → Android → create / edit. +2. Set targeted app management level to include unmanaged scope. +3. Assign to a real security group. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1112.md b/website/docs/tests/maester/MT.Zta.1112.md new file mode 100644 index 000000000..2be94d630 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1112.md @@ -0,0 +1,29 @@ +--- +title: MT.Zta.1112 - Personal-device APP enforces wipe-on-uninstall / data backup blocked +description: "Beyond mere existence of an APP (covered by 1110/1111), this test verifies the policy actually enforces work-personal data separation: - 'dataBackupBlocked = true' (no iCloud/Google backup of corporate data) - 'appActionIfDeviceComplianceRequired' is ''wipe'' or ''block'' (not..." +slug: /tests/MT.Zta.1112 +sidebar_class_name: hidden +--- + +# Personal-device APP enforces wipe-on-uninstall / data backup blocked + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.DeviceCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1) | + +## Description + +Beyond mere existence of an APP (covered by 1110/1111), this test verifies the policy actually enforces work-personal data separation: +- `dataBackupBlocked = true` (no iCloud/Google backup of corporate data) +- `appActionIfDeviceComplianceRequired` is `'wipe'` or `'block'` (not `'warn'`) + +These two settings are what makes APP protect data on a personal device. Without them, MAM is window-dressing. +## How to fix + +1. Edit the APP policy → Data protection settings. +2. Set "Backup org data to iTunes / iCloud / Google" to **Block**. +3. Under Conditional launch → Device conditions → "Maximum allowed device threat level" set to **Block** or **Wipe** when device becomes non-compliant. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1130.md b/website/docs/tests/maester/MT.Zta.1130.md new file mode 100644 index 000000000..feb261185 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1130.md @@ -0,0 +1,29 @@ +--- +title: MT.Zta.1130 - CA What-If: a normal user signing in to Office 365 is required to MFA +description: "Triggered when ZTA ['21784'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21784.md) (phish-resistant auth) or ['21801'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21801.md) ..." +slug: /tests/MT.Zta.1130 +sidebar_class_name: hidden +--- + +# CA What-If: a normal user signing in to Office 365 is required to MFA + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.CaCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1) | + +## Description + +Triggered when ZTA [`21784`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21784.md) (phish-resistant auth) or [`21801`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21801.md) (strong auth methods configured) Failed. Picks a sample non-privileged Member user and runs `Test-MtConditionalAccessWhatIf` simulating an Office 365 sign-in from a browser. Asserts the returned grant requires MFA — either via `builtInControls -contains 'mfa'` OR via an `authenticationStrength` reference. + +This is the rigorous check for "do we actually require MFA on a normal sign-in?" — independent of how many CA policies exist or how their exclusions compose. + +**Sample selection** — break-glass accounts (per `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (`Sync_*` UPN, members of "Directory Synchronization Accounts" / "On Premises Directory Sync Account" role) are excluded from the typical-user sample pool. Sync accounts intentionally bypass interactive MFA and are protected via a dedicated CA blocking sign-in from outside trusted named locations — that hardening is out of scope for "typical user MFA". +## How to fix + +1. Conditional Access → New policy → target All users (exclude break-glass) → All cloud apps → Grant: Require MFA OR Require authentication strength. +2. Save as Report-only first; verify via this same What-If; then Enable. +3. Re-run; the simulation should return mfa or authenticationStrength. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1131.md b/website/docs/tests/maester/MT.Zta.1131.md new file mode 100644 index 000000000..339574cd8 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1131.md @@ -0,0 +1,45 @@ +--- +title: MT.Zta.1131 - CA What-If: a privileged user is required phish-resistant MFA +description: "Triggered when ZTA ['21782'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21782.md) (privileged accounts have phish-resistant methods registered) or ['21783'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powe..." +slug: /tests/MT.Zta.1131 +sidebar_class_name: hidden +--- + +# CA What-If: a privileged user is required phish-resistant MFA + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.CaCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1) | + +## Description + +Triggered when ZTA [`21782`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21782.md) (privileged accounts have phish-resistant methods registered) or [`21783`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21783.md) (privileged role CA enforces phish-resistant) Failed. Picks a sample privileged user (someone with at least one role assignment) and runs What-If for a sign-in to Office 365. The What-If grant must reference an `authenticationStrength` policy whose `allowedCombinations` are ALL within the phish-resistant set: `fido2`, `windowsHelloForBusiness`, `x509CertificateMultiFactor`. + +**Why allowedCombinations and not displayName**: matching on the policy's display name is fragile — a custom auth strength named "Phishing-resistant MFA" with weak combinations would pass; localised display names would fail; an internally-named "FIDO2-only" strength would fail. Inspecting the actual permitted combinations is the only correct check. `x509CertificateSingleFactor` is single-factor cert auth and is explicitly **not** in the set (not MFA). + +**Sample selection** — break-glass accounts (per `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (members of the "Directory Synchronization Accounts" role, template ID `d29b2b05-8046-44ba-8758-1e26182fcf32`, with `Sync_*` UPN as a fallback heuristic) are excluded from the sample pool. Break-glass should be covered by a dedicated CA policy requiring phish-resistant MFA for that group only (see MT.1005 for break-glass exclusion correctness); sync accounts use cert-based auth + named-location restriction, not interactive MFA. + +The What-If approach is critical here: many tenants have a "Require MFA for admins" policy that uses `builtInControls=mfa`, which accepts SMS/voice — i.e. NOT phish-resistant. Reading the static policy says "MFA required"; What-If reveals the strength is wrong. +## How to fix + +1. Conditional Access → New policy → target privileged role membership (or admin-targeted group). +2. Grant: **Require authentication strength** → choose **Phishing-resistant MFA** (or a custom strength whose allowed combinations are all phish-resistant). +3. Re-run this test; the simulation should report `All phish-resistant? True`. +## Related Maester core tests + +This test answers a question the policy-state family does NOT: *"does the actual policy graph enforce phish-resistant MFA for a real privileged user, after all CA policies compose?"*. It uses Graph What-If — the same evaluation Entra runs at sign-in time. + +Policy-state counterparts: + +- `CISA.MS.AAD.3.6` — *Phishing-resistant MFA SHALL be required for highly privileged roles*. Verifies a CA policy with phish-resistant grant exists; does not verify it applies to every priv user after exclusions / scopes compose. +- `CISA.MS.AAD.7.6` / `CISA.MS.AAD.7.8` — *GA role activation SHALL require approval / auth context*. Activation-side controls; orthogonal to live sign-in strength. + +**Joint reading**: + +- ``CISA.MS.AAD.3.6`` Passed + ``MT.Zta.1131`` Passed → policy exists AND it actually enforces at sign-in for the sampled priv user. ✅ +- ``CISA.MS.AAD.3.6`` Passed + ``MT.Zta.1131`` Failed → there is a policy but the sampled priv user falls outside its scope (excludeUsers, excluded group, role-based-target with the wrong role IDs, etc.). **Audit the CA policy's user scope and exclusions** — the policy looks right on paper but doesn't apply where it should. +- ``CISA.MS.AAD.3.6`` Failed + ``MT.Zta.1131`` Passed → unusual; the named CISA-flavored policy isn't present, but some OTHER policy in scope happens to require phish-resistant for this user. Solid by luck, fragile by design. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1132.md b/website/docs/tests/maester/MT.Zta.1132.md new file mode 100644 index 000000000..9bdd3229c --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1132.md @@ -0,0 +1,27 @@ +--- +title: MT.Zta.1132 - CA What-If: legacy-auth client is blocked +description: "Fires when the Identity pillar Failed count is ≥ 5 (proxy for \"tenant Identity posture is in active drift\"). Simulates a sign-in via legacy-auth ('exchangeActiveSync') and asserts the grant is 'block'." +slug: /tests/MT.Zta.1132 +sidebar_class_name: hidden +--- + +# CA What-If: legacy-auth client is blocked + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.CaCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1) | + +## Description + +Fires when the Identity pillar Failed count is ≥ 5 (proxy for "tenant Identity posture is in active drift"). Simulates a sign-in via legacy-auth (`exchangeActiveSync`) and asserts the grant is `block`. + +Many tenants have multiple "Block legacy auth" policies that compose oddly with exclusions. What-If is the only reliable way to verify the actual outcome. +## How to fix + +1. Conditional Access → New / edit policy → target All users → Conditions → Client apps → check Exchange ActiveSync clients + Other clients. +2. Grant: Block access. +3. Re-run; the simulation should return block. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1133.md b/website/docs/tests/maester/MT.Zta.1133.md new file mode 100644 index 000000000..efd8629ac --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1133.md @@ -0,0 +1,72 @@ +--- +title: MT.Zta.1133 - Sign-ins not covered by Conditional Access stay below threshold +description: "Streams the ZTA 'SignIn' table and counts rows where 'conditionalAccessStatus' is 'notApplied' — i.e. the sign-in completed without ANY Conditional Access policy evaluating it. Asserts the ratio stays below the configured threshold (default 5%)." +slug: /tests/MT.Zta.1133 +sidebar_class_name: hidden +--- + +# Sign-ins not covered by Conditional Access stay below threshold + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.CaCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.CaCompensation.Tests.ps1) | + +## Description + +Streams the ZTA `SignIn` table and counts rows where `conditionalAccessStatus` +is `notApplied` — i.e. the sign-in completed without ANY Conditional Access +policy evaluating it. Asserts the ratio stays below the configured threshold +(default 5%). + +This is the **data-side** complement to MT.Zta.1130 / 1131 / 1132. Those +three call `Test-MtConditionalAccessWhatIf` to simulate what WOULD happen for +a sample user; 1133 reads the historical sign-in stream and answers what +actually DID happen — which user-app combinations escape the CA net in +practice. + +Mirrors the "No CA applied" metric ZTA's own HTML report surfaces in its +`TenantInfo.OverviewCaMfaAllUsers` Sankey. Failing this test means a +non-trivial share of real sign-ins authenticated without CA gating — +typically guests, service principals, specific apps in "Other Cloud Apps", +or specific client-app types (e.g. legacy auth) escaping CA scope. +## How to fix + +1. Open the sample table below; identify the top users with most + `notApplied` sign-ins. +2. Entra ID → Sign-in logs → filter by one of those users → Conditional + Access tab on a recent sign-in. The "Not applied" line shows which + condition(s) excluded the sign-in from every policy in scope. +3. Common gap shapes: + - **Guests** without a guest-targeted CA → add a B2B / external-user policy. + - **Service principals** signing in → add a service-principal-targeted CA + (Microsoft Entra ID P2 / Workload Identities Premium). + - **Specific applications** excluded from CA scope → review per-app + exclusions on top policies. + - **Specific client app types** (e.g. exchangeActiveSync, other) → + ensure a legacy-auth-block CA exists and matches the client type. +4. Add a catch-all "Block by default" CA targeting the gap surface. Save + as Report-only, monitor for a week, then enable. +## Related Maester core tests + +This is the **only data-side coverage check** in the entire Maester test corpus. The Maester core CA family inspects POLICY STATE (does a CA with the right grant exist?); none of them tell you whether the policies actually cover the sign-ins they were intended to cover. + +Policy-state counterparts (all "is there a CA that ...?"): + +- `MT.1001` — at least one CA configured with device compliance requirement. +- `MT.1003` / `MT.1004` — at least one CA targeting all cloud apps / all users. +- `MT.1005` — all CAs exclude at least one break-glass account. +- `MT.1006` / `MT.1007` / `MT.1008` — at least one CA requires MFA. +- `MT.1009` / `MT.1010` / `MT.1011` — block legacy auth / require auth context / secure named-location use. +- `CISA.MS.AAD.1.1` — legacy authentication SHALL be blocked. + +**Joint reading**: + +- Maester core CA tests Passed + ``MT.Zta.1133`` Passed → policies exist AND they actually cover ≥99% of sign-ins (default 1% bypass band). ✅ +- Maester core CA tests Passed + ``MT.Zta.1133`` Failed → the right policies exist but a non-trivial share of sign-ins escape them. **Triage by looking at the user / app / clientApp top-offenders sample below.** Common root causes: guest sign-ins without a guest-targeted CA, service-principal sign-ins, app exclusions on top policies, legacy-auth client types not blocked. +- Maester core CA tests Failed + ``MT.Zta.1133`` Passed → unusual; the named CISA-flavored policies aren't present but some OTHER CA happens to cover sign-ins. Solid by luck, fragile by design — add the missing named policies before this changes. + +A 0% bypass rate is the right target. Anything above that is gap surface waiting to be exploited. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1140.md b/website/docs/tests/maester/MT.Zta.1140.md new file mode 100644 index 000000000..4ab793b10 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1140.md @@ -0,0 +1,52 @@ +--- +title: MT.Zta.1140 - Users without phish-resistant MFA registered +description: "Inspects 'UserRegistrationDetails.methodsRegistered' and surfaces members who have **zero** phish-resistant methods registered. Phish-resistant methods are tenant-invariant per Microsoft Graph (FIDO2, Windows Hello for Business, X.509 cert with PIN, device-bound passkeys). Any..." +slug: /tests/MT.Zta.1140 +sidebar_class_name: hidden +--- + +# Users without phish-resistant MFA registered + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.MfaUplift.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1) | + +## Description + +Inspects `UserRegistrationDetails.methodsRegistered` and surfaces members who have **zero** phish-resistant methods registered. Phish-resistant methods are tenant-invariant per Microsoft Graph (FIDO2, Windows Hello for Business, X.509 cert with PIN, device-bound passkeys). Anyone without one is in either of two failure modes: + +- **No MFA at all** (`methodsRegistered` is empty) — worst case; password is the only factor. +- **Phishable methods only** — the user has SMS / voice / email / Authenticator-push / TOTP (software or hardware) / `microsoftAuthenticatorPasswordless`. All of these can be relayed by an AiTM proxy or, in the passwordless case, collapse to "approve push on the same device that owns the session" under a stolen-device threat model. + +The previous "single-factor = methodsRegistered.Count <= 1" heuristic conflated *no MFA* with *single FIDO2 key*, which is the opposite signal. The classification used here comes from `Get-MtZtaAuthMethodSet`, which is the single source of truth across MT.Zta.1140 / 1141 / 1142 / 1143. + +Gap-fill triggered by ZTA [`21801`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21801.md) (strong auth methods configured) or [`21784`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21784.md) (phish-resistant auth) when Failed. +## How to fix + +1. Entra ID → Security → Authentication methods → Registration campaign — push a phish-resistant registration nudge. +2. Conditional Access → enforce **Phishing-resistant MFA** authentication strength on privileged users first, then broaden. +3. Track week-over-week reduction in the `No MFA` and `Phishable-only` rows. +## Related Maester core tests + +This test inspects **user-registration state** (have users actually registered phish-resistant methods?). The Maester core family inspects **policy state** (does the tenant configuration allow / enforce / disable specific methods?). Both layers must align for end-to-end protection. + +Policy-state counterparts: + +- `CISA.MS.AAD.3.1` / `CISA.MS.AAD.3.2` — *Phishing-resistant MFA SHALL be enforced for all users* (and the alternative-auth-strength fallback). Verifies a CA policy exists requiring phish-resistant MFA. +- `EIDSCA.AF01` — FIDO2 security key — State (enabled at tenant level). +- `EIDSCA.AF02` / `AF03` / `AF04` / `AF05` — FIDO2 self-service / attestation / key restriction / disallow restricted keys. +- `CISA.MS.AAD.3.5` — *Authentication methods SMS, Voice Call, and Email OTP SHALL be disabled*. +- `EIDSCA.AS04` — SMS for sign-in. +- `EIDSCA.AV01` — Voice call state. +- `MT.1063` — *App registration owners should have MFA registered* (overlapping intent, narrower scope: owners only). + +**Joint reading**: + +- ``CISA.MS.AAD.3.1`` Passed + ``MT.Zta.1140`` Failed → policy enforces phish-resistant, but users haven't migrated. At sign-in time the unprepared users will be hard-blocked or fall back via legacy escape paths. **Run a registration campaign — don't celebrate yet.** +- ``CISA.MS.AAD.3.1`` Failed + ``MT.Zta.1140`` Passed → users have phish-resistant methods registered, but the CA policy doesn't enforce. Attackers can social-engineer users back to a phishable method. **Add the CA policy now.** +- Both Passed → end-to-end phish-resistant for the population covered. ✅ +- Both Failed → no policy AND no registrations. Highest-impact gap. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1141.md b/website/docs/tests/maester/MT.Zta.1141.md new file mode 100644 index 000000000..3a8c708af --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1141.md @@ -0,0 +1,28 @@ +--- +title: MT.Zta.1141 - WHfB uplift candidates — users without phish-resistant MFA who already have a corporate device +description: "Cross-references 'UserRegistrationDetails' (members without any phish-resistant method registered) with 'Device' ('trustType' in 'AzureAd' / 'ServerAd' / 'Workplace'). Surfaces users who **could be moved to Windows Hello for Business** because they already have a corporate-tru..." +slug: /tests/MT.Zta.1141 +sidebar_class_name: hidden +--- + +# WHfB uplift candidates — users without phish-resistant MFA who already have a corporate device + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.MfaUplift.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1) | + +## Description + +Cross-references `UserRegistrationDetails` (members without any phish-resistant method registered) with `Device` (`trustType` in `AzureAd` / `ServerAd` / `Workplace`). Surfaces users who **could be moved to Windows Hello for Business** because they already have a corporate-trusted device — the highest-leverage MFA uplift path with no procurement and no shipping new tokens. + +The phish-resistant classification comes from `Get-MtZtaAuthMethodSet -Bucket PhishResistant` (FIDO2, WHfB, X.509-with-PIN, device-bound passkeys). + +The `Device.trustType` enum is the Graph-canonical set: `AzureAd` = Entra-joined, `ServerAd` = hybrid (on-prem AD + Entra), `Workplace` = workplace-joined for SSO. Hybrid-joined devices do NOT emit a free-text `"Hybrid Azure AD joined"` value — that's a portal display string. ZTA emits the raw enum. +## How to fix + +1. For each candidate: open Entra ID → User → Authentication methods → register Windows Hello for Business. +2. Optionally, apply a registration-campaign authentication-strength policy targeted at this group. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1142.md b/website/docs/tests/maester/MT.Zta.1142.md new file mode 100644 index 000000000..b54e63c9b --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1142.md @@ -0,0 +1,31 @@ +--- +title: MT.Zta.1142 - Phishable-method users with mobile device registered +description: "Cross-references users registered with phishable methods (SMS / voice / email-OTP / TOTP / Authenticator-push) against 'Device' rows for iOS / Android. These users CAN be moved to Passkey or Windows Hello for Business — both phish-resistant — using a device they already have." +slug: /tests/MT.Zta.1142 +sidebar_class_name: hidden +--- + +# Phishable-method users with mobile device registered + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.MfaUplift.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1) | + +## Description + +Cross-references users registered with phishable methods (SMS / voice / email-OTP / TOTP / Authenticator-push) against `Device` rows for iOS / Android. These users CAN be moved to Passkey or Windows Hello for Business — both phish-resistant — using a device they already have. + +The phishable set comes from `Get-MtZtaAuthMethodSet -Bucket Phishable` (single source of truth across MT.Zta.1140 / 1142 / 1143). Exact array membership rather than substring regex — Graph emits these as a closed enum, so a substring match like `email` would falsely catch any future enum value containing the word "email". + +The mobile-OS check uses the actual `Device.operatingSystem` values ZTA emits: `iOS`, `IPad` (capital-I capital-P — that's the literal column value, not "iPadOS"), and `Android`. + +Break-glass and Entra Connect sync accounts are excluded — they do not appear on the typical-user uplift list. +## How to fix + +1. Push Authenticator app via Intune to the listed devices. +2. Authentication-methods policy → require Passkey or Authenticator with phishing-resistant requirement. +3. Block phishable methods (SMS / voice / TOTP / Authenticator-push) once registration completes. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1143.md b/website/docs/tests/maester/MT.Zta.1143.md new file mode 100644 index 000000000..53efeb0dd --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1143.md @@ -0,0 +1,57 @@ +--- +title: MT.Zta.1143 - Privileged accounts on phishable methods (Critical gap) +description: "**Critical gap.** Joins 'UserRegistrationDetails' (users with phishable methods) with 'RoleAssignment' (any directory role) to find privileged users who could be phished. Privileged accounts on SMS / voice / email-OTP / TOTP / Authenticator-push MUST be uplifted to phish-resis..." +slug: /tests/MT.Zta.1143 +sidebar_class_name: hidden +--- + +# Privileged accounts on phishable methods (Critical gap) + +| Severity | Source | +| --- | --- | +| Critical | [`Test-MtZta.MfaUplift.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.MfaUplift.Tests.ps1) | + +## Description + +**Critical gap.** Joins `UserRegistrationDetails` (users with phishable methods) with `RoleAssignment` (any directory role) to find privileged users who could be phished. Privileged accounts on SMS / voice / email-OTP / TOTP / Authenticator-push MUST be uplifted to phish-resistant MFA before any other remediation work. + +The phishable set comes from `Get-MtZtaAuthMethodSet -Bucket Phishable` (single source of truth shared with MT.Zta.1140 / 1142). Exact array membership against the closed Graph enum. + +### Why a privileged user with BOTH strong AND weak methods still flags + +The assertion fires whenever a privileged account has **any** phishable method *registered* — even if WHfB / FIDO2 / Passkey are also registered on the same account. This is intentional. A registered phishable method is an authentication PATH the attacker can drive the user toward (via AiTM phishing, fatigue push, SIM-swap on `mobilePhone`, SMTP-relay on `email`). Strong methods don't neutralise weak ones unless **Conditional Access enforces an authentication strength that excludes them at sign-in time**. + +So this test surfaces the method *inventory* risk: "what methods CAN this admin use to sign in?" The compensating CA check lives in [`MT.Zta.1131`](https://maester.dev/docs/tests/MT.Zta.1131) (What-If returns a phish-resistant `authenticationStrength` for privileged users). + +Break-glass accounts (declared in `GlobalSettings.EmergencyAccessAccounts`) and Entra Connect sync accounts (members of the Directory Synchronization Accounts role) are excluded — break-glass is covered by a dedicated CA + auth-strength path, sync uses cert-based auth and doesn't register interactive MFA methods. +## How to fix + +**Treat as an incident** when 1143 + 1131 both Failed. When only 1143 Failed, treat as a defence-in-depth gap. For each user listed: +1. Block phishable methods on this account immediately via authentication-methods policy. +2. Force re-registration with FIDO2 / Passkey / Windows Hello for Business. +3. If MFA registration cannot complete in <24h: temporarily remove privileged role until re-registration is verified. +4. Verify CA `authenticationStrength` enforces phish-resistant for the role — see MT.Zta.1131. +## Related Maester core tests + +This test inspects **registration inventory** (what phishable methods are registered on a priv account). It must be read alongside the policy-state and live-enforcement counterparts to avoid mis-triaging. + +- ``CISA.MS.AAD.3.6`` — *Phishing-resistant MFA SHALL be required for highly privileged roles* (policy state). +- ``MT.Zta.1131`` — CA What-If for a sample priv user (live enforcement). +- ``MT.Zta.1140`` — All members without phish-resistant MFA registered (registration inventory, all-user scope; 1143 is the priv-user subset with the Critical severity overlay). + +**Joint reading (1143 + 1131)**: + +- **1143 Failed + 1131 Passed** → inventory is risky but live sign-in is gated. An authentication-methods policy change or CA misconfiguration could expose the weak path. **Defence-in-depth gap — reduce the inventory.** +- **1143 Failed + 1131 Failed** → both the inventory AND the live enforcement are weak. **Treat as an incident** — the priv user can sign in with a phishable method right now. +- **1143 Passed + 1131 Passed** → both clean. ✅ +- **1143 Passed + 1131 Failed** → unusual; investigate the CA scope (the auth-strength policy may target an OU/role that excludes the admin in question). + +**Three-way reading (1143 + 1131 + CISA.MS.AAD.3.6)**: + +- All three Passed → end-to-end phish-resistant for priv. ✅ +- ``CISA.MS.AAD.3.6`` Passed + 1131 Failed → policy exists but doesn't actually scope this priv user. CA exclusions or group-target mistake. +- ``CISA.MS.AAD.3.6`` Failed + 1131 Passed → no named CISA policy but some other CA happens to enforce. Add the named policy explicitly. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1150.md b/website/docs/tests/maester/MT.Zta.1150.md new file mode 100644 index 000000000..21bbf72d6 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1150.md @@ -0,0 +1,27 @@ +--- +title: MT.Zta.1150 - Inactive guest accounts with active credentials +description: "Streams the ZTA 'User' table (where 'userType='Guest'' AND 'accountEnabled=true') and surfaces guests whose most-recent successful sign-in is older than 90 days. Each one is a potential phishing target." +slug: /tests/MT.Zta.1150 +sidebar_class_name: hidden +--- + +# Inactive guest accounts with active credentials + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.LifecycleHygiene.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1) | + +## Description + +Streams the ZTA `User` table (where `userType='Guest'` AND `accountEnabled=true`) and surfaces guests whose most-recent successful sign-in is older than 90 days. Each one is a potential phishing target. + +ZTA [`21858`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21858.md) flags this category at policy level; MT.Zta.1150 enumerates the actual users so the operator can take action without leaving the report. +## How to fix + +1. Entra ID → Identity Governance → Access Reviews — set up a recurring review on the guest user set. +2. Entra ID → External Identities → Lifecycle workflow — auto-disable inactive guests after 90 days of no sign-in. +3. For ad-hoc cleanup: disable each listed guest, then delete after a grace period. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1160.md b/website/docs/tests/maester/MT.Zta.1160.md new file mode 100644 index 000000000..9a4e5dfe6 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1160.md @@ -0,0 +1,47 @@ +--- +title: MT.Zta.1160 - Application credentials older than 1 year +description: "Inspects 'Application.passwordCredentials' (a JSON-array column) and reports apps where any credential's 'endDateTime - startDateTime' exceeds 365 days. Long-lived secrets are the canonical phishing-resistant-bypass vector — short rotation cadence is the compensating control Z..." +slug: /tests/MT.Zta.1160 +sidebar_class_name: hidden +--- + +# Application credentials older than 1 year + +| Severity | Source | +| --- | --- | +| Critical | [`Test-MtZta.LifecycleHygiene.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1) | + +## Description + +Inspects `Application.passwordCredentials` (a JSON-array column) and reports apps where any credential's `endDateTime - startDateTime` exceeds 365 days. Long-lived secrets are the canonical phishing-resistant-bypass vector — short rotation cadence is the compensating control ZTA [`21992`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.21992.md) flags as missing. + +Findings are split into two buckets: + +- **Never-expiring secrets** (Critical) — credentials whose `endDateTime` is the year-9999 sentinel (or any lifetime > 50 years). These are higher severity than long-but-finite lifetimes because there is no remediation deadline at all; once leaked, the credential is valid forever. Treat each as an open incident. +- **Long-lived secrets** (Medium) — credentials with `endDateTime - startDateTime > 365 days` but a real expiry. Lower severity because they self-mitigate at expiry, but still drift well outside policy. +## How to fix + +1. **Never-expiring secrets first** — Entra ID → Application registrations → filter by app — regenerate with a real expiry (≤ 90 days), then revoke the old one. Treat as an open incident; assume the secret is in scope of any past compromise. +2. Replace client secrets with **certificate** auth or **federated credentials** (workload identity federation) where possible — both eliminate long-lived secrets entirely. +3. Set an app-management policy enforcing max secret lifetime (90 days) tenant-wide. +## Related Maester core tests + +This test is the **warn-band** for app-credential hygiene. The Maester core family has a stricter pass/fail bar (no static secrets at all) plus operational reminders that overlap in intent: + +- ``MT.1057`` — *App registrations should no longer use secrets* (cert-only / federated-credentials). Strict pass/fail: any password credential fails. **Stricter target than 1160.** +- ``MT.1024.applicationCredentialExpiry`` — *Renew expiring application credentials*. Closest sibling — surfaces near-expiry credentials so they don't lapse silently. Operational reminder; not a strict gate. +- ``MT.1024.staleAppCreds`` — *Remove unused credentials from applications*. Catches credentials that exist but haven't been used recently. Orthogonal. +- ``MT.1077`` / ``MT.1078`` — *App registrations with privileged API permissions / directory roles should not have …* — additional risk overlays for high-impact apps. + +**Joint reading**: + +- ``MT.1057`` Failed + ``MT.Zta.1160`` Failed → secrets exist AND some are long-lived/never-expiring. **1160 lists the urgent ones to rotate first; MT.1057 is the long-term target (move to cert / federated identity).** +- ``MT.1057`` Failed + ``MT.Zta.1160`` Passed → secrets exist but all have reasonable lifetimes (≤ 1y). The cleanup is operational hygiene, not an incident. +- ``MT.1057`` Passed + ``MT.Zta.1160`` Passed → cert-only / federated tenant. ✅ ideal end-state. +- ``MT.1057`` Passed but ``MT.Zta.1160`` Failed should be impossible (1160 only fires when secrets exist); if it happens, file a bug. + +Treat ``MT.Zta.1160`` Critical findings (year-9999 secrets) as incidents regardless of ``MT.1057`` status. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1170.md b/website/docs/tests/maester/MT.Zta.1170.md new file mode 100644 index 000000000..fb45e4ba5 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1170.md @@ -0,0 +1,27 @@ +--- +title: MT.Zta.1170 - Stale non-privileged users with active accounts +description: "Maester 'MT.1029' covers stale **privileged** users via PIM alerts. This gap-fill extends the check to **non-privileged** users — the population PIM alerts ignore but which still represent ~80%+ of typical tenant identity sprawl. Streams 'User' ⨝ anti-join with 'RoleAssignment..." +slug: /tests/MT.Zta.1170 +sidebar_class_name: hidden +--- + +# Stale non-privileged users with active accounts + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.LifecycleHygiene.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.LifecycleHygiene.Tests.ps1) | + +## Description + +Maester `MT.1029` covers stale **privileged** users via PIM alerts. This gap-fill extends the check to **non-privileged** users — the population PIM alerts ignore but which still represent ~80%+ of typical tenant identity sprawl. Streams `User` ⨝ anti-join with `RoleAssignment` and filters to `accountEnabled=true` AND last-sign-in older than 90 days. + +**Break-glass exclusion**: accounts listed in `GlobalSettings.EmergencyAccessAccounts` are excluded — break-glass accounts intentionally lack recent sign-ins. +## How to fix + +1. Identity Governance → Access Reviews — recurring review on all-users, auto-disable on no response. +2. Lifecycle workflow → trigger join/leave/mover automation for HR-driven changes. +3. For one-time cleanup: bulk-disable the listed accounts, then delete after grace period. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1180.md b/website/docs/tests/maester/MT.Zta.1180.md new file mode 100644 index 000000000..bce4b20c9 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1180.md @@ -0,0 +1,26 @@ +--- +title: MT.Zta.1180 - Top compliance failure reasons enumerated +description: "When ≥ 5 ZTA Devices-pillar tests are Failed, queries DuckDB 'Device' to enumerate the top reasons devices are non-compliant. ZTA flags non-compliance at policy level; this test surfaces the **most common per-device root causes** so the operator knows where to focus remediatio..." +slug: /tests/MT.Zta.1180 +sidebar_class_name: hidden +--- + +# Top compliance failure reasons enumerated + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.DeviceCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1) | + +## Description + +When ≥ 5 ZTA Devices-pillar tests are Failed, queries DuckDB `Device` to enumerate the top reasons devices are non-compliant. ZTA flags non-compliance at policy level; this test surfaces the **most common per-device root causes** so the operator knows where to focus remediation effort. + +Common categories: encryption not enforced, OS version too old, password policy not met, antivirus signature stale, managementAgent='unknown'. +## How to fix + +1. Intune → Devices → Compliance → review the top reason group. +2. For each reason: either fix the underlying gap (e.g. push BitLocker policy) or relax the compliance rule if it was over-strict. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1181.md b/website/docs/tests/maester/MT.Zta.1181.md new file mode 100644 index 000000000..69832ea87 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1181.md @@ -0,0 +1,27 @@ +--- +title: MT.Zta.1181 - CA What-If: typical user is BLOCKED on a non-compliant device +description: "Triggered when ZTA ['24824'](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24824.md) Failed (CA policies block access from noncompliant devices). Uses Maester's 'Test-MtConditionalAccessWhatIf' (BETA Graph API) to simulate a sa..." +slug: /tests/MT.Zta.1181 +sidebar_class_name: hidden +--- + +# CA What-If: typical user is BLOCKED on a non-compliant device + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.DeviceCompensation.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.DeviceCompensation.Tests.ps1) | + +## Description + +Triggered when ZTA [`24824`](https://github.com/microsoft/zerotrustassessment/blob/main/src/powershell/tests/Test-Assessment.24824.md) Failed (CA policies block access from noncompliant devices). Uses Maester's `Test-MtConditionalAccessWhatIf` (BETA Graph API) to simulate a sample non-privileged user signing in to Office 365 from a Windows browser flagged as **non-compliant**, and verifies the returned grant includes `block` OR `compliantDevice`. + +What-If is more rigorous than reading policy state because it reflects the actual policy graph evaluation including exclusions, group memberships, and authentication-strength compositions. +## How to fix + +1. Conditional Access → policy targeting Office 365 → ensure `Require device to be marked as compliant` is in the Grant block. +2. Or use `Block access` for non-compliant devices on a separate policy. +3. Re-run; the What-If output should change to `block` or grant containing `compliantDevice`. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1200.md b/website/docs/tests/maester/MT.Zta.1200.md new file mode 100644 index 000000000..ba85dd3c0 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1200.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1200 - ZTA bucket family is populated +description: "Sentinel for the data-driven bucket family. Always emits one row so the family is visible in the report whether ZTA loaded or not, with a clear count of how many buckets were discovered." +slug: /tests/MT.Zta.1200 +sidebar_class_name: hidden +--- + +# ZTA bucket family is populated + +| Severity | Source | +| --- | --- | +| Low | [`Test-MtZta.UserBuckets.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1) | + +## Description + +Sentinel for the data-driven bucket family. Always emits one row so the family is visible in the report whether ZTA loaded or not, with a clear count of how many buckets were discovered. + +`MT.Zta.1201` / `1202` / `1203` below evaluate quality dimensions across ALL populated buckets and render the per-bucket result as a matrix inside a single row each. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1201.md b/website/docs/tests/maester/MT.Zta.1201.md new file mode 100644 index 000000000..b25675920 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1201.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1201 - All populated buckets carry a non-empty Pillar +description: "Every populated bucket must carry a non-empty 'Pillar' value (Identity / Devices / Network / Data) so downstream reporting can route findings to the right pillar owner. A null 'Pillar' typically means a CategoryMappings rule was misconfigured (no 'MatchPillar' value) — usually..." +slug: /tests/MT.Zta.1201 +sidebar_class_name: hidden +--- + +# All populated buckets carry a non-empty Pillar + +| Severity | Source | +| --- | --- | +| Low | [`Test-MtZta.UserBuckets.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1) | + +## Description + +Every populated bucket must carry a non-empty `Pillar` value (Identity / Devices / Network / Data) so downstream reporting can route findings to the right pillar owner. A null `Pillar` typically means a CategoryMappings rule was misconfigured (no `MatchPillar` value) — usually a category that ended up in `Other`. + +The assertion aggregates across ALL populated buckets: every row in the matrix below must show a non-empty Pillar; the test fails only when at least one bucket has a missing Pillar. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1202.md b/website/docs/tests/maester/MT.Zta.1202.md new file mode 100644 index 000000000..bc7491458 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1202.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1202 - Across all buckets, Group sample size never exceeds pre-cap Count +description: "'Count' is the **pre-cap** total number of unique entities ZTA flagged for this category. 'Group' is the (capped) sample of up to 'MaxUsersPerCategory' entries. The sample size must never exceed the pre-cap total — a violation indicates a bucketing-logic bug in 'Group-MtZtaFla..." +slug: /tests/MT.Zta.1202 +sidebar_class_name: hidden +--- + +# Across all buckets, Group sample size never exceeds pre-cap Count + +| Severity | Source | +| --- | --- | +| Low | [`Test-MtZta.UserBuckets.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1) | + +## Description + +`Count` is the **pre-cap** total number of unique entities ZTA flagged for this category. `Group` is the (capped) sample of up to `MaxUsersPerCategory` entries. The sample size must never exceed the pre-cap total — a violation indicates a bucketing-logic bug in `Group-MtZtaFlaggedIdentity`. + +The matrix below lists every populated bucket and whether its `MaxUsersPerCategory` cap was applied (sample size < pre-cap total). The assertion fails only when at least one bucket's Group is larger than its Count. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1203.md b/website/docs/tests/maester/MT.Zta.1203.md new file mode 100644 index 000000000..897bb2f8f --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1203.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1203 - Every bucket entry has UPN, UserId, or test-level evidence +description: "Every entry in a ZTA-derived user bucket must carry at least one of: 'UserPrincipalName', 'UserId', or a non-empty 'Evidence' array. An entry with all three null/empty is unactionable — the operator can't pivot to Entra ID, a sign-in log, or even know which ZTA TestId surfaced..." +slug: /tests/MT.Zta.1203 +sidebar_class_name: hidden +--- + +# Every bucket entry has UPN, UserId, or test-level evidence + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.UserBuckets.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.UserBuckets.Tests.ps1) | + +## Description + +Every entry in a ZTA-derived user bucket must carry at least one of: `UserPrincipalName`, `UserId`, or a non-empty `Evidence` array. An entry with all three null/empty is unactionable — the operator can't pivot to Entra ID, a sign-in log, or even know which ZTA TestId surfaced it. This catches regressions in user-extraction (UPN/GUID regex) or DuckDB enrichment. + +The matrix below lists every populated bucket and the count of orphan entries (no UPN, no Id, no Evidence). The aggregate assertion fails only when any bucket has at least one orphan. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1301.md b/website/docs/tests/maester/MT.Zta.1301.md new file mode 100644 index 000000000..2272d61c0 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1301.md @@ -0,0 +1,20 @@ +--- +title: MT.Zta.1301 - ZTA context is populated for this run +description: "End-to-end smoke test that the orchestration script's 'Import-MtZtaResult' call succeeded and '$script:MtZtaContext' is visible from the test runtime. When this fails, the ZTA wiring is broken — most likely the resolver step set 'ZTA_RESULTS_REF' to an empty path, or the Get-..." +slug: /tests/MT.Zta.1301 +sidebar_class_name: hidden +--- + +# ZTA context is populated for this run + +| Severity | Source | +| --- | --- | +| High | [`Test-MtZta.SeverityOverlay.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1) | + +## Description + +End-to-end smoke test that the orchestration script's `Import-MtZtaResult` call succeeded and `$script:MtZtaContext` is visible from the test runtime. When this fails, the ZTA wiring is broken — most likely the resolver step set `ZTA_RESULTS_REF` to an empty path, or the Get-MtZta self-heal couldn't find a usable bundle on disk. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1302.md b/website/docs/tests/maester/MT.Zta.1302.md new file mode 100644 index 000000000..f4e8affb0 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1302.md @@ -0,0 +1,23 @@ +--- +title: MT.Zta.1302 - ZtaSettings is wired into the context +description: "Operator opted into ZTA-aware behaviour by adding a 'ZtaSettings' block to 'maester-config.json' AND the orchestration script forwarded it to 'Import-MtZtaResult' via the '-ZtaSettings' parameter (or Get-MtZta's self-heal re-read it from '$env:MAESTER_ZTA_CONFIG_PATH'). When ..." +slug: /tests/MT.Zta.1302 +sidebar_class_name: hidden +--- + +# ZtaSettings is wired into the context + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.SeverityOverlay.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1) | + +## Description + +Operator opted into ZTA-aware behaviour by adding a `ZtaSettings` block to `maester-config.json` AND the orchestration script forwarded it to `Import-MtZtaResult` via the `-ZtaSettings` parameter (or Get-MtZta's self-heal re-read it from `$env:MAESTER_ZTA_CONFIG_PATH`). When this is null, the data-driven and severity-overlay focus mechanisms (#3 and #4) silently degrade — the cmdlets exist but use vendor-neutral defaults. +## How to fix + +Add a `ZtaSettings` block to `maester-config.json` (see plan Section B). At minimum: `CategoryMappings` for the data-driven mechanism and `SeverityEscalationRules` for the severity overlay. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1303.md b/website/docs/tests/maester/MT.Zta.1303.md new file mode 100644 index 000000000..bba6f0f40 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1303.md @@ -0,0 +1,24 @@ +--- +title: MT.Zta.1303 - Each severity escalation rule has a To severity and at least one selector +description: "Every 'SeverityEscalationRule' in 'ZtaSettings' must specify both: - 'To' — the target severity (Medium / High / Critical), - One of 'EscalateMaesterTagged' (tag selector) or 'EscalateMaesterTestId' (id wildcard selector)." +slug: /tests/MT.Zta.1303 +sidebar_class_name: hidden +--- + +# Each severity escalation rule has a To severity and at least one selector + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.SeverityOverlay.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1) | + +## Description + +Every `SeverityEscalationRule` in `ZtaSettings` must specify both: +- `To` — the target severity (Medium / High / Critical), +- One of `EscalateMaesterTagged` (tag selector) or `EscalateMaesterTestId` (id wildcard selector). + +Without `To`, the rule has no destination. Without a selector, the rule matches no tests. Either case makes the rule a no-op and indicates a configuration mistake. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1304.md b/website/docs/tests/maester/MT.Zta.1304.md new file mode 100644 index 000000000..085ebbda1 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1304.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1304 - No escalation rule lowers severity (To is in {Medium,High,Critical}) +description: "The severity overlay is a one-way escalation — it should never **lower** a test's severity. Allowed 'To' values are limited to {Medium, High, Critical}. A rule with 'To: Low' or 'To: Info' indicates a misconfiguration that would silently downgrade findings." +slug: /tests/MT.Zta.1304 +sidebar_class_name: hidden +--- + +# No escalation rule lowers severity (To is in {Medium,High,Critical}) + +| Severity | Source | +| --- | --- | +| Medium | [`Test-MtZta.SeverityOverlay.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.SeverityOverlay.Tests.ps1) | + +## Description + +The severity overlay is a one-way escalation — it should never **lower** a test's severity. Allowed `To` values are limited to {Medium, High, Critical}. A rule with `To: Low` or `To: Info` indicates a misconfiguration that would silently downgrade findings. + +(Note: the actual ladder check happens at runtime in `Test-MtZtaSeverityHigher` inside `Update-MtSeverityFromZta` — this test catches the misconfiguration at the rule shape level so the operator gets feedback before the pipeline runs.) +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1305.md b/website/docs/tests/maester/MT.Zta.1305.md new file mode 100644 index 000000000..fe80ec128 --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1305.md @@ -0,0 +1,22 @@ +--- +title: MT.Zta.1305 - Severity overlay rule count + applied summary +description: "Smoke-tests the SeverityEscalationRules block by reporting how many rules exist and how many are wired with concrete selectors. This is mostly informational — failures of MT.Zta.1303 / 1304 already cover rule-shape correctness. This test exists to give the operator an at-a-gla..." +slug: /tests/MT.Zta.1305 +sidebar_class_name: hidden +--- + +# Severity overlay rule count + applied summary + +| Severity | Source | +| --- | --- | +| Low | [`Test-MtZta.OperatorDriftCheck.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1) | + +## Description + +Smoke-tests the SeverityEscalationRules block by reporting how many rules exist and how many are wired with concrete selectors. This is mostly informational — failures of MT.Zta.1303 / 1304 already cover rule-shape correctness. This test exists to give the operator an at-a-glance summary in the report tab. + +(Note: the actual escalation mutation runs inside `Update-MtSeverityFromZta` which is invoked from `Invoke-Maester`. PR-E does not yet wire that call from the customer pipeline — it lands once the upstream Maester PR adds the `-ZtaResultsPath` parameter natively.) +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/tests/maester/MT.Zta.1402.md b/website/docs/tests/maester/MT.Zta.1402.md new file mode 100644 index 000000000..11693cf7f --- /dev/null +++ b/website/docs/tests/maester/MT.Zta.1402.md @@ -0,0 +1,25 @@ +--- +title: MT.Zta.1402 - Get-MtZtaRecommendedTag produces a non-empty tag list +description: "Verifies that 'Get-MtZtaRecommendedTag' (focus mechanism #1) emits a non-empty '[string[]]' of Maester tags derived from the loaded ZTA findings. When this is empty even though ZTA has failed tests, either the CategoryMappings block is missing matching rules or PillarTagMap is..." +slug: /tests/MT.Zta.1402 +sidebar_class_name: hidden +--- + +# Get-MtZtaRecommendedTag produces a non-empty tag list + +| Severity | Source | +| --- | --- | +| Low | [`Test-MtZta.OperatorDriftCheck.Tests.ps1`](https://github.com/maester365/maester/blob/main/tests/Zta/Test-MtZta.OperatorDriftCheck.Tests.ps1) | + +## Description + +Verifies that `Get-MtZtaRecommendedTag` (focus mechanism #1) emits a non-empty `[string[]]` of Maester tags derived from the loaded ZTA findings. When this is empty even though ZTA has failed tests, either the CategoryMappings block is missing matching rules or PillarTagMap is empty. +## How to fix + +1. Confirm `ZtaSettings.CategoryMappings` covers the pillars that have failed tests (4 pillar-level rules + 2 cross-cuts is the recommended baseline). +2. Verify `ZtaSettings.PillarTagMap` lists the Maester-side tag aliases for each pillar. +3. Re-run with `WarningAction Continue` to surface the ">10% Other" coverage warning if many tests classify into Other. +## Learn more + +- [Zero Trust Assessment integration](/docs/zero-trust-assessment) +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) \ No newline at end of file diff --git a/website/docs/zero-trust-assessment.md b/website/docs/zero-trust-assessment.md new file mode 100644 index 000000000..7d45a0cde --- /dev/null +++ b/website/docs/zero-trust-assessment.md @@ -0,0 +1,349 @@ +--- +sidebar_label: Zero Trust Assessment +sidebar_position: 50 +title: Zero Trust Assessment integration +description: How Maester loads a ZTA result bundle and runs 38 ZTA-aware tests on top of it. +--- + +# Zero Trust Assessment integration + +Maester can load a **Zero Trust Assessment** (ZTA) result bundle and run 38 +`MT.Zta.*` tests on top of it. The integration is **opt-in via a single +parameter** — `Invoke-Maester -ZtaResultsPath <path-or-uri>` — and is +byte-identical to upstream behaviour when the parameter is absent. + +ZTA itself runs separately. It produces a comprehensive JSON + SQLite +(DuckDB) export of a tenant's posture across the four Zero Trust pillars +(Identity, Devices, Network, Data). Maester reads that captured bundle and +adds **gap-fill, compensating-control, and analytics tests** that ZTA itself +doesn't perform — for example: + +- *"ZTA flagged users without phish-resistant MFA. Does CA actually enforce + phish-resistant for a typical privileged sign-in right now?"* — answered + via a live CA What-If simulation. +- *"What percentage of recent sign-ins were not gated by ANY CA policy?"* — + answered via a streaming query over the `SignIn` table. +- *"Are any privileged accounts registered with phishable methods (SMS, + voice, email, Authenticator-push)?"* — answered via a `RoleAssignment ⨝ + UserRegistrationDetails` join. + +These are checks Maester core can't run because they need the ZTA evidence +to scope the question. They're checks ZTA can't run because it doesn't drive +live Graph (What-If) or apply a severity overlay. + +The two tools compose. + +## Architecture + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ ZeroTrustAssessment (separate run — workstation / pipeline) │ +│ └─► zt-export/ │ +│ ├─ ZeroTrustAssessmentReport.json (Tests[], TestPillar) │ +│ ├─ manifest.json (tenantId, runStartTime) │ +│ ├─ db/zt.db (DuckDB — optional Tier 2)│ +│ └─ <Table>/<Table>-N.json shards (JSON shadow — Tier 1) │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Invoke-Maester -ZtaResultsPath <path> │ +│ │ +│ 1. Import-MtZtaResult │ +│ - resolves source (local / blob URI / upkg://) │ +│ - loads manifest, freshness, ZtaSettings, GlobalSettings │ +│ - populates $script:MtZtaContext │ +│ │ +│ 2. Update-MtSeverityFromZta │ +│ - applies ZtaSettings.SeverityEscalationRules to TestSettings │ +│ - mutation happens BEFORE Pester discovery │ +│ │ +│ 3. Invoke-Pester │ +│ - the 38 MT.Zta.* tests under tests/Zta/ call Get-MtZta │ +│ - each test runs in Pester's Run phase (not Discovery) │ +│ - errors surface as Failed / Skipped rows, never crash │ +│ │ +│ 4. Build-MtZtaBundle │ +│ - compiles per-tenant analytics │ +│ - attached to $results.ZtaBundle for HTML / JSON / MD │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ MaesterReport.{html, json, md} │ +│ └─► ZTA tab renders bundle analytics (Tenant scale, Auth-method │ +│ posture, CA coverage, Applications/Devices/Privileged cards, │ +│ by-pillar chart) alongside the 38 MT.Zta.* test rows. │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Internal helpers + +Six private functions support the public surface. They are not exported and should not be called directly by test code: + +- `Read-MtZtaJsonExport` — Tier 1 reader; streams `<Table>/<Table>-N.json` shards from the bundle's `zt-export/` directory. Universal — no native binaries required. Always populated. +- `Read-MtZtaDatabase` — Tier 2 reader; opens `db/zt.db` read-only via `DuckDB.NET.Data`. Returns `$null` when the assemblies are not reachable so Tier 1 carries the load. +- `Initialize-MtZtaDuckDbAssembly` — loads `DuckDB.NET.Data` from the ZTA module's own `lib/` directory. Never falls back to Maester's `lib/` to preserve supply-chain isolation. +- `Resolve-MtZtaArtifact` — resolves a source string (local path, Azure Blob URI, or `upkg://` Universal Package reference) to a local bundle root directory, downloading and extracting as needed. +- `Test-MtZtaFreshness` — derives the bundle age from `manifest.runStartTime` → `Report.ExecutedAt` → `zt.db` file mtime (in priority order); clamps future timestamps to age 0. +- `Group-MtZtaFlaggedIdentity` — matches failed ZTA tests to `CategoryMappings` buckets via `Get-MtZtaCategoryForTest`; caps per-category user lists to `DataDrivenSettings.MaxUsersPerCategory`. + +## Prerequisites + +1. **A ZTA run output.** Install via + `Install-Module ZeroTrustAssessment -Scope CurrentUser` and run + `Invoke-ZeroTrustAssessment -OutputFolder <path>`. The folder you + feed Maester is the `zt-export/` subdirectory. +2. **Maester 2.2 or later** (this integration). Earlier versions don't have + `-ZtaResultsPath`. +3. **A live Graph connection** to the same tenant ZTA scanned — required by + the CA What-If tests (`MT.Zta.1130/1131/1132/1181`) and by + `Build-MtZtaBundle` for the live CA policies. Use `Connect-Maester` or + `Connect-MgGraph` with the standard Maester scope set. + +## Quick start + +```powershell +Connect-Maester +Invoke-Maester -ZtaResultsPath ./zta-bundle -Path ./tests +``` + +Three accepted source patterns for `-ZtaResultsPath`: + +| Pattern | Use case | +| ------------------------------------------------------------------ | ------------------------------------------------------------- | +| Local folder, `.tar.gz`, or `.zip` | Workstation runs; CI artifacts downloaded into the build dir | +| `https://<account>.blob.core.windows.net/...` | Cross-pipeline / cross-tenant via Azure Blob (SAS / WIF) | +| `upkg://<org>/<project>/<feed>/<name>@<ver>` | Azure Artifacts Universal Package versioned distribution | + +## The four focus mechanisms + +Custom tests under `tests/Zta/` use four mechanisms (often combined): + +1. **Tag-based selection** — `Get-MtZtaRecommendedTag` returns a tag list + derived from ZTA findings. Run `Invoke-Maester -Tag (Get-MtZtaRecommendedTag)` + to focus only on pillars ZTA flagged. +2. **Conditional `It`** — gates inside the test body. Example: `MT.Zta.1101` + skips when the Identity fail ratio is below `0.5` so guest-specific deep + dives don't fire on a healthy tenant. +3. **Data-driven `-ForEach`** — `MT.Zta.1200/1201/1202/1203` group flagged + identities by CategoryMappings bucket and assert quality dimensions + (Pillar present, Group cap respected, every entry actionable) per + bucket in a single test row each. +4. **Severity escalation** — `Update-MtSeverityFromZta` reads + `ZtaSettings.SeverityEscalationRules` and mutates `TestSettings[]` before + Pester discovery so a Medium test can be escalated to High when ZTA + evidence justifies (e.g. Identity pillar Failed ≥ 5). + +## Configuration + +Add a `ZtaSettings` block to your `maester-config.json` (alongside the +existing `GlobalSettings` and `TestSettings`): + +```jsonc +{ + "GlobalSettings": { + "EmergencyAccessAccounts": [ + "breakglass1@contoso.onmicrosoft.com", + "12345678-1234-1234-1234-123456789012", + { "userPrincipalName": "breakglass2@contoso.onmicrosoft.com", "displayName": "Tier-0 emergency #2" } + ] + }, + "ZtaSettings": { + "FreshnessDays": 14, + "ExpectedTenantId": null, + "FocusMechanisms": ["Tag", "Conditional", "DataDriven", "Severity"], + "PillarTagMap": { + "Identity": ["Identity", "EID", "MFA", "ConditionalAccess", "PIM"], + "Devices": ["Intune", "Device", "Compliance", "Defender"], + "Network": ["Network", "GlobalSecureAccess", "GSA"], + "Data": ["Exchange", "SharePoint", "Purview", "Sensitivity"] + }, + "CategoryMappings": [ + { "Category": "IdentityPosture", "MatchPillar": "Identity", "MaesterTagBoost": ["Identity","MFA"] }, + { "Category": "DevicePosture", "MatchPillar": "Devices", "MaesterTagBoost": ["Intune","Compliance"] }, + { "Category": "NetworkPosture", "MatchPillar": "Network", "MaesterTagBoost": ["Network","GSA"] }, + { "Category": "DataPosture", "MatchPillar": "Data", "MaesterTagBoost": ["Purview"] }, + { "Category": "PrivilegedAccess", "MatchPillar": "*", "MatchCategoryAny": ["Privileged access","Role management","Credential management"], "MaesterTagBoost": ["PIM"] }, + { "Category": "GuestUnconstrained", "MatchPillar": "Identity", "MatchCategoryAny": ["External collaboration","External Identities","Guest"], "MaesterTagBoost": ["Guest","B2B"] } + ], + "SeverityEscalationRules": [ + { "WhenPillarFailedAtLeast": 5, "Pillar": "Identity", "EscalateMaesterTagged": ["MFA","ConditionalAccess","PIM"], "From": "Medium", "To": "High" } + ], + "DataDrivenSettings": { "MaxUsersPerCategory": 50, "GroupSimilar": true }, + "Thresholds": { + "MT.Zta.1001": 30, "MT.Zta.1002": 0.5, "MT.Zta.1003": 10, + "MT.Zta.1004": 20, "MT.Zta.1005": 15, "MT.Zta.1006": 15, + "MT.Zta.1102": 25, "MT.Zta.1104": 25, "MT.Zta.1133": 0, + "MT.Zta.1140": 10, "MT.Zta.1140.NoMfa": 0, "MT.Zta.1140.Phishable": 5, + "MT.Zta.1141": 1, "MT.Zta.1142": 1, "MT.Zta.1150": 5, "MT.Zta.1170": 25 + } + } +} +``` + +`Get-MtMaesterConfig` is tenant-aware — drop a tenant-specific override at +`maester-config.<TenantId>.json` next to the default and it merges +automatically when Maester sees a matching `$env:AZURE_TENANT_ID` / +`Get-MgContext`. + +## Test catalogue + +Eleven files under `tests/Zta/`, 38 distinct test definitions. All severities +ride on Pester tags (`Severity:Low/Medium/High/Critical`). + +### Pattern A — Always-on pillar posture + +| ID | Title (excerpt) | Severity | Threshold key (default) | +| --- | --- | --- | --- | +| MT.Zta.1001 | Identity pillar fail count below warn threshold | High | `MT.Zta.1001` = 30 | +| MT.Zta.1002 | Identity fail ratio stays below 0.5 | High | `MT.Zta.1002` = 0.5 | +| MT.Zta.1003 | PrivilegedAccess bucket size below bar | High | `MT.Zta.1003` = 10 | +| MT.Zta.1004 | Devices pillar fail count | High | `MT.Zta.1004` = 20 | +| MT.Zta.1005 | Network pillar fail count | Medium | `MT.Zta.1005` = 15 | +| MT.Zta.1006 | Data pillar fail count | Medium | `MT.Zta.1006` = 15 | +| MT.Zta.1101 | Identity fail ratio high enough to deep-dive guests | Medium | gate at 0.5 | +| MT.Zta.1104 | Stale sign-in user count < threshold | High | `MT.Zta.1104` = 25 | +| MT.Zta.1107 | Zero permanent non-break-glass Global Administrator | Critical | n/a (boolean) | + +### Pattern B — Gap-fill / compensating control + +| ID | Trigger condition (ZTA) | Compensating control verified | +| --- | --- | --- | +| MT.Zta.1110 | `24543` / `24548` Failed | iOS App Protection covers unmanaged devices + assignment target valid | +| MT.Zta.1111 | `24547` / `24545` Failed | Android APP — same shape as 1110 | +| MT.Zta.1112 | `24547` / `24543` Failed | APP enforces `dataBackupBlocked=true` + `appActionIfDeviceComplianceRequired ∈ {wipe, block}` | +| MT.Zta.1130 | `21784` / `21801` Failed | CA What-If: typical user → grant requires MFA | +| MT.Zta.1131 | `21782` / `21783` Failed | CA What-If: privileged user → at least one in-scope auth strength is fully phish-resistant | +| MT.Zta.1132 | Identity pillar Failed ≥ 5 | CA What-If: legacy auth client → grant blocks | +| MT.Zta.1133 | always (gate: total sign-ins ≥ 100) | `SignIn.conditionalAccessStatus = 'notApplied'` rate stays below threshold (default 0%) | +| MT.Zta.1140 | `21801` / `21784` Failed | Members with no-MFA / phishable-only methods within sub-thresholds | +| MT.Zta.1141 | `21801` Failed AND 1140 has hits | At least *N* WHfB uplift candidates exist (else skip — needs strategic intervention) | +| MT.Zta.1142 | `21804` / `21784` Failed | At least *N* phishable-method users have a mobile device for Authenticator rollout (else skip) | +| MT.Zta.1143 | `21782` / `21804` Failed | Privileged accounts on phishable methods count | +| MT.Zta.1150 | `21858` / `21874` Failed | Inactive guests with active credentials within threshold | +| MT.Zta.1160 | `21992` / `21772` Failed | App credentials split by lifetime: never-expiring (Critical) + >1y (Medium) within budget | +| MT.Zta.1170 | Identity pillar Failed ≥ 5 | Stale non-privileged users count | +| MT.Zta.1180 | Devices pillar Failed ≥ 5 | Top compliance failure reasons enumerated for triage | +| MT.Zta.1181 | `24824` Failed | CA What-If: typical user on non-compliant device → grant blocks | + +### Pattern C — Meta / operator + +| ID | Purpose | +| --- | --- | +| MT.Zta.1010 | Bundle freshness within tolerance (warn-but-proceed) | +| MT.Zta.1102 | GuestUnconstrained bucket size below threshold | +| MT.Zta.1103 | Every GuestUnconstrained bucket entry has UPN/UserId/evidence | +| MT.Zta.1200 | Bucket family is populated (sentinel) | +| MT.Zta.1201 | Every populated bucket has a Pillar value | +| MT.Zta.1202 | Every bucket's Group sample size ≤ pre-cap Count | +| MT.Zta.1203 | Every bucket entry has UPN, UserId, or Evidence | +| MT.Zta.1301 | ZTA context is populated for this run | +| MT.Zta.1302 | `ZtaSettings` wired into context | +| MT.Zta.1303 | Each escalation rule has To severity and a selector | +| MT.Zta.1304 | No escalation rule lowers severity | +| MT.Zta.1305 | Severity overlay rule count + applied summary | +| MT.Zta.1402 | `Get-MtZtaRecommendedTag` produces a non-empty list | + +## Pass / Fail / Skip semantics + +Critical to triage — don't read Skipped as Passed-by-omission. + +- **Passed** — the assertion held against actual data. +- **Failed** — the assertion did not hold; there is a real finding. +- **Skipped** — the test was not applicable for this tenant's current state: + ZTA didn't flag the trigger (gap-fill N/A), too little data for + statistical relevance, no eligible sample subject, or the ZTA context + wasn't loaded. + +The HTML report's right-hand sheet shows the `SkippedReason` text. A future +run with different ZTA findings may unskip a previously-skipped gap-fill. + +## Joint reading with Maester core tests + +Six ZTA tests have direct Maester core counterparts. Read them together to +avoid false comfort: + +- **`MT.Zta.1107` + `MT.1032` / `CIS.M365.1.1.3` / `CISA.MS.AAD.7.1`** — + GA count being acceptable doesn't mean GAs aren't permanent. +- **`MT.Zta.1140` + `CISA.MS.AAD.3.1` / `EIDSCA.AF*`** — phish-resistant CA + policy existing doesn't mean users have registered phish-resistant methods. +- **`MT.Zta.1131` + `CISA.MS.AAD.3.6`** — policy state ≠ enforced state; + What-If is the proof. +- **`MT.Zta.1133` + `MT.1003-1011` / `CISA.MS.AAD.1.1`** — policy-existence + tests don't tell you whether sign-ins are actually covered. +- **`MT.Zta.1160` + `MT.1057` / `MT.1024.applicationCredentialExpiry`** — + long-lived secrets warn-band vs. strict "no secrets at all" target. +- **`MT.Zta.1143` + `MT.Zta.1131` + `CISA.MS.AAD.3.6`** — registration + inventory vs. live enforcement; both must align for end-to-end protection. + +Each test's `## Related Maester core tests` section in the report walks +through the outcome matrix. + +## Tier 1 / Tier 2 readers + +`Import-MtZtaResult` exposes two readers via `Get-MtZta -Section Reader`: + +- **Tier 1 (`Read-MtZtaJsonExport`)** — streams `<Table>/<Table>-N.json` + shards. Universal — works on any PS host, air-gapped, Cloud Shell, no + native binaries needed. **Always populated.** +- **Tier 2 (`Read-MtZtaDatabase`)** — opens `db/zt.db` read-only via + `DuckDB.NET.Data` when the assemblies are reachable (probes: AppDomain → + ZTA module's `lib/` → Maester's own `lib/`). When unreachable, returns + `$null` and Tier 1 carries the load. No test today requires Tier 2; it's + reserved for future joins / window-functions on multi-million-row tenants. + +Both tiers expose the same surface: `Tables`, `GetRows`, `GetRow`, `Query`, +`BuildIndex`, `Dispose`. + +## Freshness + +`Import-MtZtaResult -ZtaFreshnessDays <int>` (default 14) controls the +freshness threshold. Older bundles still load (warn-but-proceed); the +context's `IsStale` flag rides the context so tests can decide what to do. +`MT.Zta.1010` is the warn-band gate test. + +Timestamp source priority: `manifest.runStartTime` → `Report.ExecutedAt` → +`zt.db` file mtime. + +## Cross-tenant safety + +Pass `-ExpectedTenantId <guid>` to `Invoke-Maester` or +`Import-MtZtaResult`. The bundle's `manifest.tenantId` must match exactly +or the load aborts before any test runs. Pair with +`ZtaSettings.ExpectedTenantId` in config for the same effect set in code. + +## Public cmdlet reference + +All eight public cmdlets exported by the ZTA integration module: + +| Cmdlet | Purpose | +| --- | --- | +| [`Import-MtZtaResult`](commands/Import-MtZtaResult.mdx) | Loads a ZTA result bundle (local / Blob / Universal Package) into `$script:MtZtaContext` | +| [`Get-MtZta`](commands/Get-MtZta.mdx) | Accessor for the loaded ZTA context; returns sections (Tests, Summary, FlaggedUsers, Reader, …) | +| [`Build-MtZtaBundle`](commands/Build-MtZtaBundle.mdx) | Compiles per-tenant analytics hashtable for injection into the Maester HTML/JSON report ZTA tab | +| [`Get-MtZtaRecommendedTag`](commands/Get-MtZtaRecommendedTag.mdx) | Derives a Pester `-Tag` list from ZTA failures so only relevant tests run | +| [`Get-MtZtaThreshold`](commands/Get-MtZtaThreshold.mdx) | Returns a per-test numeric threshold from `ZtaSettings.Thresholds` with a built-in default fallback | +| [`Update-MtSeverityFromZta`](commands/Update-MtSeverityFromZta.mdx) | Mutates a `TestSettings[]` array per `SeverityEscalationRules` before Pester discovery | +| [`Get-MtZtaAuthMethodSet`](commands/Get-MtZtaAuthMethodSet.mdx) | Returns the canonical PhishResistant / Phishable / SingleFactor method classification used by MFA-uplift tests | +| [`Test-MtZtaIsEmergencyAccess`](commands/Test-MtZtaIsEmergencyAccess.mdx) | Returns `$true` when a principalId or UPN matches the operator's declared break-glass list | + +## Environment variables (Pester hand-off) + +Two environment variables bridge the gap between the orchestrator's runspace and Pester's +child runspace. On ADO clean agents the sub-runspace reset never fires and these are never +read; on local PowerShell sessions with pre-loaded modules, Pester can spawn a child runspace +that resets `$script:` scope to null, making them load-bearing for cross-platform parity. + +| Variable | Set by | Read by | Purpose | +| --- | --- | --- | --- | +| `$env:ZTA_RESULTS_REF` | `Invoke-Maester` after `Import-MtZtaResult` succeeds | `Get-MtZta` (self-heal path) | Holds the resolved local path of the ZTA bundle root so `Get-MtZta` can call `Import-MtZtaResult` again when it finds `$script:MtZtaContext` null in a Pester child runspace | +| `$env:MAESTER_ZTA_CONFIG_PATH` | `Invoke-Maester` after config is resolved | `Get-MtZta` (self-heal path) | Holds the resolved path of `maester-config.json` so the self-heal can re-read `ZtaSettings` and `GlobalSettings` — ensuring `CategoryMappings`, `SeverityEscalationRules`, and `EmergencyAccessAccounts` survive a sub-runspace reset | + +## See also + +- [`Invoke-Maester`](commands/Invoke-Maester.mdx) — primary entry, accepts `-ZtaResultsPath` +- [`Import-MtZtaResult`](commands/Import-MtZtaResult.mdx) — direct API +- [`Get-MtZta`](commands/Get-MtZta.mdx) — accessor for the loaded context +- [`Build-MtZtaBundle`](commands/Build-MtZtaBundle.mdx) — analytics bundle +- [Zero Trust Assessment project](https://microsoft.github.io/zerotrustassessment/) — upstream tool