Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<account>.blob.core.windows.net/...` (SAS, WIF, or `-Identity`)
3. **Azure Artifacts Universal Package** β€” `upkg://<org>/<project>/<feed>/<name>@<ver>`

### 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
Comment on lines +95 to +102

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.
Expand Down
214 changes: 214 additions & 0 deletions build/Update-MtZtaTestDocs.ps1
Original file line number Diff line number Diff line change
@@ -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: <title>...'` 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
---

Comment on lines +168 to +170
# $($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)
16 changes: 11 additions & 5 deletions powershell/Maester.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,17 @@

# 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',
'Get-MailAuthenticationRecord', 'Get-MtAdminPortalUrl', 'Get-MtAuthenticationMethodPolicyConfig',
'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',
Expand Down Expand Up @@ -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',
Expand All @@ -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.
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions powershell/internal/Get-MtMaesterConfig.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 @{}
Expand Down
Loading