|
| 1 | +# Update-TagsDocumentation.ps1 |
| 2 | + |
| 3 | +<# |
| 4 | +.SYNOPSIS |
| 5 | + Updates the tags documentation file with an inventory of tags used in Maester tests. |
| 6 | +
|
| 7 | +.DESCRIPTION |
| 8 | + This script scans Maester's tests directory for tags used in Pester tests and generates a |
| 9 | + markdown documentation file. The document lists all tags along with their usage counts, grouped by categories. |
| 10 | +
|
| 11 | +.PARAMETER RepoRoot |
| 12 | + The path to the root of the repository. Defaults to the parent directory of the script location (i.e., the repository root). |
| 13 | +
|
| 14 | +.PARAMETER TestsPath |
| 15 | + The path to the Maester tests directory. Defaults to 'tests' within the repository root. |
| 16 | +
|
| 17 | +.PARAMETER TagsDocPath |
| 18 | + The path to the tags documentation file to update. Defaults to 'website/docs/tests/tags/readme.md' within the repository root. |
| 19 | +
|
| 20 | +.EXAMPLE |
| 21 | + .\Update-TagsDocumentation.ps1 |
| 22 | +
|
| 23 | + Updates the tags documentation file using default paths for the repository root, tests directory, and tags documentation file. |
| 24 | +
|
| 25 | +.EXAMPLE |
| 26 | + .\Update-TagsDocumentation.ps1 -RepoRoot 'C:\Maester' -TestsPath 'C:\Maester\tests' -TagsDocPath 'C:\Maester\website\docs\tests\tags\readme.md' |
| 27 | +
|
| 28 | + Updates the tags documentation file using specified paths for the repository root, tests directory, and tags documentation file. |
| 29 | +#> |
| 30 | +[CmdletBinding()] |
| 31 | +param( |
| 32 | + # The path to the root of the repository. |
| 33 | + [Parameter()] |
| 34 | + [ValidateScript( { Test-Path $_ } )] |
| 35 | + [string]$RepoRoot = (Split-Path -Path $PSScriptRoot), |
| 36 | + |
| 37 | + # The path to the Maester tests directory. Defaults to tests within the repository root. |
| 38 | + [Parameter()] |
| 39 | + [ValidateScript( { Test-Path $_ } )] |
| 40 | + [string]$TestsPath = (Join-Path -Path (Split-Path -Path $PSScriptRoot) -ChildPath 'tests'), |
| 41 | + |
| 42 | + # The path to the tags documentation file to update. Defaults to website/docs/tests/tags/readme.md within the repository root. |
| 43 | + [Parameter()] |
| 44 | + [ValidateScript( { Test-Path (Split-Path $_ -Parent) } )] |
| 45 | + [string]$TagsDocPath = (Join-Path -Path (Split-Path -Path $PSScriptRoot) -ChildPath 'website/docs/tests/tags/readme.md') |
| 46 | +) |
| 47 | + |
| 48 | +#region Get Tag Inventory |
| 49 | +# Dot-source the Get-MtTestInventory script (in lieu of importing the entire module). |
| 50 | +try { |
| 51 | + $InventoryScript = Join-Path $RepoRoot 'powershell/public/Get-MtTestInventory.ps1' |
| 52 | + . $InventoryScript |
| 53 | +} catch { |
| 54 | + throw "Failed to load Get-MtTestInventory.ps1 from $InventoryScript. $_" |
| 55 | +} |
| 56 | + |
| 57 | +# Get test and tag inventory |
| 58 | +$Inventory = Get-MtTestInventory -Path $TestsPath |
| 59 | + |
| 60 | +# Build list of tags counts for use as table rows. |
| 61 | +$TagCounts = [System.Collections.Generic.List[pscustomobject]]::new() |
| 62 | +foreach ($Item in $Inventory.GetEnumerator()) { |
| 63 | + $TagCounts.Add([pscustomobject]@{ |
| 64 | + Tag = $Item.Name |
| 65 | + Count = $Item.Value.Count |
| 66 | + }) |
| 67 | +} |
| 68 | + |
| 69 | +function Add-OrUpdateTag { |
| 70 | + <# |
| 71 | + .SYNOPSIS |
| 72 | + Adds a new tag and its count to the list, or updates the count if the tag already exists in the list. |
| 73 | + #> |
| 74 | + param( |
| 75 | + # The tag to add or update in the list. |
| 76 | + [string]$Tag, |
| 77 | + |
| 78 | + # The count to add to the tag's existing count (or set if new). |
| 79 | + [int]$Count |
| 80 | + ) |
| 81 | + |
| 82 | + # Check if the tag already exists in the TagCounts list. |
| 83 | + $Existing = $TagCounts | Where-Object { $_.Tag -eq $Tag } |
| 84 | + if ($Existing) { |
| 85 | + # Increment the count if it already exists in the list. |
| 86 | + foreach ($item in $Existing) { |
| 87 | + $item.Count += $Count |
| 88 | + } |
| 89 | + } else { |
| 90 | + # Add a new entry if the tag does not exist in the list. |
| 91 | + $TagCounts.Add([pscustomobject]@{ |
| 92 | + Tag = $Tag |
| 93 | + Count = $Count |
| 94 | + }) |
| 95 | + } |
| 96 | +} # end function Add-OrUpdateTag |
| 97 | + |
| 98 | +#region Manually Add Tags |
| 99 | +# Manually add and count tags from tests that fail discovery so counts remain accurate. |
| 100 | +# For example, 'MT.1059' will always fail discovery unless connected to an environment that has implemented MDI. |
| 101 | +Add-OrUpdateTag -Tag 'MT.1059' -Count 1 |
| 102 | +Add-OrUpdateTag -Tag 'MDI' -Count 1 |
| 103 | +#endregion Manually Add Tags |
| 104 | + |
| 105 | +# Define groups for categorizing tags in the documentation. |
| 106 | +$TagGroups = [ordered]@{ |
| 107 | + 'CIS' = { param($t) $t.Tag -match '^CIS(\.|\s|$)|L1|L2' } |
| 108 | + 'CISA' = { param($t) $t.Tag -match '^CISA(\.|$)' -or $t.Tag -match '^MS\.' } |
| 109 | + 'EIDSCA' = { param($t) $t.Tag -match '^EIDSCA(\.|$)' } |
| 110 | + 'ORCA' = { param($t) $t.Tag -match '^ORCA(\.|$)' } |
| 111 | + 'Maester' = { param($t) $t.Tag -match '^(MT\.|Maester)' } |
| 112 | + 'Ungrouped' = { param($t) $t.Tag -notmatch '^(CIS|L1|L2|CISA|MS\.|EIDSCA|ORCA|MT\.|Maester)' } |
| 113 | +} |
| 114 | +#endregion Get Tag Inventory |
| 115 | + |
| 116 | + |
| 117 | +function ConvertTo-MarkdownTable { |
| 118 | + <# |
| 119 | + .SYNOPSIS |
| 120 | + Converts a list of tags and their counts into a markdown-formatted table. |
| 121 | + #> |
| 122 | + param( |
| 123 | + [string] $TagCategoryTitle, |
| 124 | + [System.Collections.Generic.List[PSCustomObject]] $Items |
| 125 | + ) |
| 126 | + |
| 127 | + # Return null if there are no items to process. |
| 128 | + if (-not $Items -or $Items.Count -eq 0) { return $null } |
| 129 | + |
| 130 | + # Split items into multiple use (count > 1) and single use (count = 1) for the summary. |
| 131 | + $MultipleUse = $Items | Where-Object { $_.Count -gt 1 } | Sort-Object -Property Tag |
| 132 | + $SingleUse = $Items | Where-Object { $_.Count -eq 1 } | Sort-Object -Property Tag |
| 133 | + |
| 134 | + # Initialize a StringBuilder to construct the markdown table. |
| 135 | + $SB = [System.Text.StringBuilder]::new() |
| 136 | + [void]$SB.AppendLine("### $TagCategoryTitle") |
| 137 | + [void]$SB.AppendLine() |
| 138 | + |
| 139 | + # Only create table if there are tags used more than once. |
| 140 | + if ($MultipleUse) { |
| 141 | + [void]$SB.AppendLine('| Tag | Count |') |
| 142 | + [void]$SB.AppendLine('| :--- | ---: |') |
| 143 | + foreach ($i in $MultipleUse) { |
| 144 | + [void]$SB.AppendLine("| $($i.Tag) | $($i.Count) |") |
| 145 | + } |
| 146 | + [void]$SB.AppendLine() |
| 147 | + } |
| 148 | + |
| 149 | + # Add single-use tags as comma-separated list |
| 150 | + if ($SingleUse) { |
| 151 | + $singleTagList = ($SingleUse | ForEach-Object { $_.Tag }) -join ', ' |
| 152 | + [void]$SB.AppendLine("**Individual tags**: $singleTagList") |
| 153 | + #[void]$SB.AppendLine() |
| 154 | + } |
| 155 | + |
| 156 | + # Return the constructed markdown string. |
| 157 | + return $SB.ToString() |
| 158 | +} # end function ConvertTo-MarkdownTable |
| 159 | + |
| 160 | +# Build markdown sections for each tag group. |
| 161 | +$SectionBlocks = @() |
| 162 | +foreach ($Key in $TagGroups.Keys) { |
| 163 | + $matched = $TagCounts | Where-Object { & $TagGroups[$Key] $_ } |
| 164 | + if ($matched) { $SectionBlocks += ConvertTo-MarkdownTable -TagCategoryTitle $Key -Items ([System.Collections.Generic.List[PSCustomObject]]$matched) } |
| 165 | +} |
| 166 | + |
| 167 | +# Join the section blocks into a single markdown string. |
| 168 | +$SectionsText = ($SectionBlocks | Where-Object { $_ }) -join "`n" |
| 169 | + |
| 170 | +#region Create Static Markdown Content |
| 171 | +# Create the markdown front matter. |
| 172 | +$FrontMatter = @" |
| 173 | +--- |
| 174 | +id: overview |
| 175 | +title: Tags Overview |
| 176 | +sidebar_label: 🏷️ Tags |
| 177 | +description: Overview of the tags used to identify and group related tests. |
| 178 | +--- |
| 179 | +
|
| 180 | +"@ |
| 181 | + |
| 182 | +# Create the introductory text for the tags documentation. |
| 183 | +$Intro = @" |
| 184 | +## Tags Overview |
| 185 | +
|
| 186 | +Tags are used by Maester to identify and group related tests. They can also be used to select specific tests to run or exclude during test execution. This makes them very useful, but they can also get in the way if too many tags are created. Our goal is to minimize the "signal to noise" ratio when it comes to tags by focusing on a few key areas: |
| 187 | +
|
| 188 | +- **Test Suites**: We use standardized tag categories for test suites that align with well-known benchmarks and baselines. This helps users quickly identify tests that align with these widely recognized standards or with Maester's own suite of tests: |
| 189 | + - **CIS Benchmarks**: Tags prefixed with `CIS` (e.g., `CIS.M365.1.1`, `CIS.Azure.3.2`) |
| 190 | + - **CISA & Microsoft Baseline**: Tags prefixed with `CISA` or `MS` (e.g., `CISA.M365.Baseline`, `MS.Azure.Baseline`) |
| 191 | + - **EIDSCA**: Tags prefixed with `EIDSCA` (e.g., `EIDSCA.EntraID.2.1`) |
| 192 | + - **ORCA**: Tags prefixed with `ORCA` (e.g., `ORCA.Exchange.1.1`) |
| 193 | + - **Maester**: Tags prefixed with `Maester` or `MT` (e.g., `MT.1001`, `MT.1024`) |
| 194 | +
|
| 195 | +- **Product Areas**: Tags related to specific products and services that are being tested: |
| 196 | + - Azure |
| 197 | + - Defender XDR |
| 198 | + - Entra ID |
| 199 | + - Exchange |
| 200 | + - Microsoft 365 |
| 201 | + - SharePoint |
| 202 | + - Teams |
| 203 | +
|
| 204 | +- **Practices or Capabilities**: Tags that denote specific security practices or capabilities within the security domain, such as: |
| 205 | + - Authentication (May include related topics such as MFA, SSPR, etc.) |
| 206 | + - Conditional Access (CA) |
| 207 | + - Data Loss Prevention (DLP) |
| 208 | + - Extended Security Posture Management (XSPM) |
| 209 | + - Hybrid Identity |
| 210 | + - Privileged Access Management (PAM) |
| 211 | + - Privileged Identity Management (PIM) |
| 212 | +
|
| 213 | +### Recommendations for Tag Usage |
| 214 | +
|
| 215 | +Less is more! When creating or assigning tags to tests, consider the following best practices: |
| 216 | +
|
| 217 | +1. Assign one ``Test Suite`` tag per test to ensure clarity on which benchmark or baseline the test aligns with. This tag will usually go in the `Describe` block of a Pester test file. |
| 218 | +2. Assign a ``Product Area`` tag to indicate which products or services the test is most relevant to. Limit these to 1-3 tags per test to avoid over-tagging. |
| 219 | +3. Use ``Practice`` or ``Capability`` tags sparingly and only when they add significant value in categorizing the test. Avoid creating overly specific tags that may only apply to a single test. |
| 220 | +
|
| 221 | +## Tags Used |
| 222 | +
|
| 223 | +The tables below list every tag discovered via `Get-MtTestInventory`. |
| 224 | +
|
| 225 | +"@ |
| 226 | +#endregion Create Static Markdown Content |
| 227 | + |
| 228 | +# Combine all parts into the final markdown content and write to the documentation file. |
| 229 | +$Body = ($FrontMatter + $Intro + "`n" + $SectionsText) -join "`n" |
| 230 | + |
| 231 | +# Create the directory for the tags documentation file if it doesn't exist. |
| 232 | +if (-not (Test-Path -Path (Split-Path -Parent $TagsDocPath))) { |
| 233 | + New-Item -ItemType Directory -Path (Split-Path -Parent $TagsDocPath) -Force | Out-Null |
| 234 | +} |
| 235 | + |
| 236 | +# Write the final markdown content to the tags documentation file. |
| 237 | +try { |
| 238 | + Set-Content -LiteralPath $TagsDocPath -Value $Body -Encoding UTF8 |
| 239 | + Write-Host "Updated $TagsDocPath" |
| 240 | +} |
| 241 | +catch { |
| 242 | + Write-Error "Failed to write tags documentation to $TagsDocPath. $_" |
| 243 | +} |
0 commit comments