Public/Invoke-METTriage.ps1
|
function Invoke-METTriage { [CmdletBinding()] param( [Parameter()] [ValidateSet('MDO','EXO','Teams')] [string[]] $Category, [Parameter()] [string[]] $CheckId, [Parameter()] [string[]] $ExcludeCheckId, [Parameter()] [string] $DelegatedOrganization, [Parameter()] [switch] $PassThru, [Parameter()] [switch] $ListChecks, [Parameter()] [switch] $Detailed ) $checksRoot = Join-Path $PSScriptRoot '..' 'Checks' $checkFiles = Get-ChildItem -Path $checksRoot -Recurse -Filter 'MET-*.ps1' | Sort-Object Name if ($Category) { $checkFiles = $checkFiles | Where-Object { $Category -contains $_.Directory.Name } } if ($CheckId) { $checkFiles = $checkFiles | Where-Object { $id = ($_.BaseName -split '-')[0..1] -join '-' $CheckId -contains $id } } if ($ExcludeCheckId) { $checkFiles = $checkFiles | Where-Object { $id = ($_.BaseName -split '-')[0..1] -join '-' $ExcludeCheckId -notcontains $id } } if ($ListChecks) { return $checkFiles | ForEach-Object { $parts = $_.BaseName -split '-', 3 [PSCustomObject]@{ CheckId = "$($parts[0])-$($parts[1])" Category = $_.Directory.Name Script = $_.Name } } } # Pre-fetch shared context. Check scripts access $METContext via the # scriptblock wrapper below ($METContext injected as a named parameter). $METContext = @{ AcceptedDomains = @() GroupMembers = @{} # keyed by group identity; populated lazily by checks AllMailboxes = $null # populated lazily by MDO008; reused by any future coverage check } Write-Progress -Activity 'MET Triage' -Status 'Initializing - fetching accepted domains...' ` -PercentComplete 0 -Id 1 try { $METContext.AcceptedDomains = @(Get-AcceptedDomain -ErrorAction Stop) Write-Verbose "Pre-fetched $($METContext.AcceptedDomains.Count) accepted domain(s)" } catch { Write-Warning "Could not pre-fetch accepted domains: $_" } $results = [System.Collections.Generic.List[PSCustomObject]]::new() $totalChecks = @($checkFiles).Count $currentIndex = 0 foreach ($file in $checkFiles) { $currentIndex++ $checkIdDisplay = ($file.BaseName -split '-' | Select-Object -First 2) -join '-' Write-Progress -Activity 'MET Triage' -Status "$checkIdDisplay - $($file.BaseName)" ` -PercentComplete ([int]($currentIndex / $totalChecks * 100)) ` -CurrentOperation "Check $currentIndex of $totalChecks" -Id 1 Write-Verbose "Running check: $($file.BaseName)" # Run the check script inside a scriptblock so that: # 1. $METContext is injected as a local variable the script can read. # 2. `return` inside the check script exits only this scriptblock, # not Invoke-METTriage, avoiding the dot-source return-scope trap. # 3. Hashtable fields (e.g. GroupMembers) mutated by the check script # persist across checks because hashtables are reference types. $checkPath = $file.FullName try { $checkResults = & { param([hashtable] $METContext) . $checkPath } $METContext if ($checkResults) { foreach ($r in $checkResults) { if ($PassThru) { Write-Output $r } else { $results.Add($r) } } } } catch { $checkIdPart = ($file.BaseName -split '-' | Select-Object -First 2) -join '-' $errResult = [PSCustomObject]@{ CheckId = $checkIdPart Category = $file.Directory.Name Name = $file.BaseName Result = 'Fail' Severity = 'High' Score = $null AffectedObject = 'N/A' Finding = 'Check script failed to execute' Recommendation = '' ReferenceUrl = '' Timestamp = [datetime]::UtcNow Error = $_.ToString() } if ($PassThru) { Write-Output $errResult } else { $results.Add($errResult) } } } Write-Progress -Activity 'MET Triage' -Completed -Id 1 if ($PassThru) { return } if ($Detailed) { return $results.ToArray() } # Aggregate: collapse multiple per-policy / per-domain results for the same # CheckId into a single result, keeping per-item detail in the Finding text. # Use -Detailed to get the full per-object breakdown. $aggregated = [System.Collections.Generic.List[PSCustomObject]]::new() $groups = $results | Group-Object CheckId foreach ($group in $groups) { $items = @($group.Group) if ($items.Count -eq 1) { $aggregated.Add($items[0]) continue } $failItems = @($items | Where-Object Result -eq 'Fail') $warnItems = @($items | Where-Object Result -eq 'Warning') $errorItems = @($items | Where-Object { $_.Error }) if ($failItems.Count -eq 0 -and $warnItems.Count -eq 0 -and $errorItems.Count -eq 0) { # All pass / info / N/A - emit a single tidy summary result. Info-only # checks (e.g. MET-MDO014's healthy case) are summarised the same way # Pass results are, so no item is silently dropped. $first = $items[0] $passItems = @($items | Where-Object Result -eq 'Pass') $infoItems = @($items | Where-Object Result -eq 'Info') $noun = Get-METAggregationNoun -CheckId $first.CheckId $summaryItems = @($passItems) + @($infoItems) $summaryResult = if ($passItems.Count -gt 0) { 'Pass' } else { 'Info' } if ($summaryItems.Count -gt 0) { $findingLines = $summaryItems | ForEach-Object { "$($_.AffectedObject): $($_.Finding)" } $aggregated.Add((New-METCheckResult ` -CheckId $first.CheckId -Category $first.Category -Name $first.Name ` -Result $summaryResult -Severity $first.Severity ` -AffectedObject "All $($summaryItems.Count) $noun" ` -Finding ($findingLines -join "`n") ` -Recommendation $first.Recommendation ` -ReferenceUrl $first.ReferenceUrl)) } else { $aggregated.Add($items[0]) } continue } # Determine which items are noteworthy $badItems = if ($failItems.Count -gt 0) { $failItems } elseif ($warnItems.Count -gt 0) { $warnItems } else { $errorItems } $worstResult = if ($failItems.Count -gt 0) { 'Fail' } elseif ($warnItems.Count -gt 0) { 'Warning' } else { 'Fail' } $first = $items[0] $noun = Get-METAggregationNoun -CheckId $first.CheckId $findingLines = $badItems | ForEach-Object { "$($_.AffectedObject): $($_.Finding)" } $errorMessage = @($errorItems | ForEach-Object Error | Where-Object { $_ }) -join "`n" $aggregated.Add((New-METCheckResult ` -CheckId $first.CheckId -Category $first.Category -Name $first.Name ` -Result $worstResult -Severity $first.Severity ` -AffectedObject "$($badItems.Count) of $($items.Count) $noun" ` -Finding ($findingLines -join "`n") ` -Recommendation $first.Recommendation ` -ReferenceUrl $first.ReferenceUrl ` -ErrorMessage $errorMessage)) } return $aggregated.ToArray() } function Get-METAggregationNoun { [CmdletBinding()] param([Parameter(Mandatory)] [string] $CheckId) switch -Regex ($CheckId) { 'MET-EXO00[1-3]' { return 'domains' } 'MET-EXO004' { return 'quarantine policies' } 'MET-MDO014' { return 'groups' } default { return 'policies' } } } |