tests/Test-Assessment.41036.ps1
|
<#
.SYNOPSIS Attack Simulation Training is configured and a baseline simulation has been run in the last 12 months. .NOTES Test ID: 41036 Workshop Task: SECOPS-036 Pillar: SecOps Category: Email and collaboration security Required permission: AttackSimulation.Read.All Supported Clouds: Global only — the Attack Simulation Training Graph API is not available in national clouds (GCC High, DoD, or other sovereign environments). #> function Test-Assessment-41036 { [ZtTest( Category = 'Email and collaboration security', CompatibleLicense = ('THREAT_INTELLIGENCE'), ImplementationCost = 'Medium', Pillar = 'SecOps', RiskLevel = 'Medium', Service = ('Graph'), SfiPillar = 'Protect tenants and isolate production systems', TenantType = ('Workforce'), TestId = 41036, Title = 'Attack Simulation Training is configured and a baseline simulation has been run in the last 12 months', UserImpact = 'Medium' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking Attack Simulation Training configuration' $testTitle = 'Attack Simulation Training is configured and a baseline simulation has been run in the last 12 months' $now = (Get-Date).ToUniversalTime() $cutoffDate = $now.AddDays(-365) $allSimulations = @() $automations = @() $simulationsError = $null $automationsError = $null $cloudUnsupported = $false # Non-Global cloud: the Graph Attack Simulation API is Global-only. Per spec: unsupported # cloud → Investigate (API unreachable), not Skip. Skip is emitted only by the assessment # engine's Minimum License gate — the check body never emits it. if ((Get-MgContext).Environment -ne 'Global') { $cloudUnsupported = $true Write-PSFMessage 'The Attack Simulation Training Graph API is not available outside the Global cloud.' -Tag Test -Level VeryVerbose } else { # Q1: Simulations ordered newest-first. Server-side $filter on launchDateTime is # unreliable (confirmed: returns empty even for a qualifying simulation on the beta # endpoint). Fetch pages until the 395-day boundary covers the 365-day window plus # the maximum 30-day simulation duration. Write-ZtProgress -Activity $activity -Status 'Querying attack simulations' try { $simulationPage = Invoke-ZtGraphRequest -RelativeUri 'security/attackSimulation/simulations' -QueryParameters @{ '$orderby' = 'launchDateTime desc' } -Top 50 -ApiVersion beta -DisablePaging -ErrorAction Stop while ($simulationPage) { $pageSimulations = @($simulationPage.value) $allSimulations += $pageSimulations $pageBoundaryReached = $false if ($pageSimulations.Count -gt 0) { if ($pageSimulations[-1].launchDateTime) { try { $oldestLaunch = [datetime]$pageSimulations[-1].launchDateTime $pageBoundaryReached = $oldestLaunch -lt $cutoffDate.AddDays(-30) } catch { $pageBoundaryReached = $false } } } if ($pageBoundaryReached -or -not $simulationPage.'@odata.nextLink') { break } $nextPageUri = [uri]$simulationPage.'@odata.nextLink' $nextPageRelativeUri = $nextPageUri.AbsolutePath.TrimStart('/') -replace '^beta/', '' if ($nextPageUri.Query) { $nextPageRelativeUri += $nextPageUri.Query } $simulationPage = Invoke-ZtGraphRequest -RelativeUri $nextPageRelativeUri -ApiVersion beta -DisablePaging -ErrorAction Stop } } catch { $simulationsError = $_ Write-PSFMessage "Failed to query attack simulations: $_" -Tag Test -Level Warning } # Q2: Simulation automations — paged automatically via @odata.nextLink. Write-ZtProgress -Activity $activity -Status 'Querying simulation automations' try { $automations = @(Invoke-ZtGraphRequest -RelativeUri 'security/attackSimulation/simulationAutomations' -ApiVersion beta -ErrorAction Stop) } catch { $automationsError = $_ Write-PSFMessage "Failed to query simulation automations: $_" -Tag Test -Level Warning } } #endregion Data Collection #region Assessment Logic $passed = $false $customStatus = $null # Classify each simulation. # Baseline : status == 'succeeded' AND completionDateTime in [cutoffDate, now] (future completionDateTime excluded — running sims carry projected end dates). # Recent activity: launchDateTime OR completionDateTime in [cutoffDate, now] but not a baseline. # Out of window : neither date falls in the window. # A succeeded simulation with a missing/unparseable completionDateTime is undecidable for recency. $classifiedSims = [System.Collections.Generic.List[PSCustomObject]]::new() $hasSucceededMissingCompletion = $false $hasUnparseableLaunchDate = $false foreach ($sim in $allSimulations) { $launchInWindow = $false $completionInWindow = $false $launchParsed = $null $completionParsed = $null $launchValid = $false $completionValid = $false if ($sim.launchDateTime) { try { $launchParsed = [datetime]$sim.launchDateTime $launchValid = $true $launchInWindow = $launchParsed -ge $cutoffDate -and $launchParsed -le $now } catch { $hasUnparseableLaunchDate = $true Write-PSFMessage "Could not parse launchDateTime '$($sim.launchDateTime)' for simulation '$($sim.displayName)'." -Tag Test -Level Warning } } if ($sim.completionDateTime) { try { $completionParsed = [datetime]$sim.completionDateTime $completionValid = $true $completionInWindow = $completionParsed -ge $cutoffDate -and $completionParsed -le $now } catch { Write-PSFMessage "Could not parse completionDateTime '$($sim.completionDateTime)' for simulation '$($sim.displayName)'." -Tag Test -Level Warning } } if ($sim.status -eq 'succeeded' -and -not $completionValid) { $hasSucceededMissingCompletion = $true } $ztSignal = if ($sim.status -eq 'succeeded' -and $completionInWindow) { 'Baseline' } elseif ($launchInWindow -or $completionInWindow) { 'Recent activity' } else { 'Out of window' } $classifiedSims.Add([PSCustomObject]@{ Sim = $sim ZtSignal = $ztSignal LaunchDt = if ($launchValid) { $launchParsed } else { [datetime]::MinValue } CompletionDt = if ($completionValid) { $completionParsed } else { $null } LaunchValid = $launchValid CompletionValid = $completionValid }) } $hasBaseline = ($classifiedSims | Where-Object { $_.ZtSignal -eq 'Baseline' } | Measure-Object).Count -gt 0 $hasActivityInWindow = ($classifiedSims | Where-Object { $_.ZtSignal -ne 'Out of window' } | Measure-Object).Count -gt 0 $runningAutomationCount = ($automations | Where-Object { $_.status -eq 'running' } | Measure-Object).Count $hasRunningAutomation = $runningAutomationCount -gt 0 # Result summary (always emitted, including on error/unsupported cloud). $statusCounts = ($allSimulations | Group-Object status | ForEach-Object { "$($_.Name): $($_.Count)" }) -join ', ' $cutoffFormatted = Get-FormattedDate -DateString $cutoffDate.ToString('o') $summaryCounts = "Evaluated cutoff: **$cutoffFormatted**. Simulations: **$($allSimulations.Count)**$(if ($statusCounts) { " ($statusCounts)" }). Automations: **$($automations.Count)** ($runningAutomationCount running)." if ($cloudUnsupported) { $customStatus = 'Investigate' $reason = 'Unsupported cloud: the Attack Simulation Training Graph API is only available in the Global cloud; it cannot be evaluated for this tenant.' $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } elseif ($simulationsError -or $automationsError) { # Any query error (auth/permission, 404, throttling, or service error) # → Investigate immediately. Never Skip, never Fail for errors. $errorParts = @() if ($simulationsError) { $q1Status = Get-ZtHttpStatusCode -ErrorRecord $simulationsError $errorParts += "Q1 (simulations) — HTTP $q1Status" } if ($automationsError) { $q2Status = Get-ZtHttpStatusCode -ErrorRecord $automationsError $errorParts += "Q2 (automations) — HTTP $q2Status" } $customStatus = 'Investigate' $reason = "Query error: $($errorParts -join '; '). Ensure the assessment account has **AttackSimulation.Read.All** permission and re-run." $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } # Precedence: Pass > Investigate > Fail. elseif ($hasBaseline -and $hasRunningAutomation) { $passed = $true $testResultMarkdown = "✅ A baseline Attack Simulation Training campaign completed (**succeeded**) within the last 12 months and a simulation automation is configured and running.`n`n$summaryCounts`n`n%TestResult%" } elseif ($hasUnparseableLaunchDate) { $customStatus = 'Investigate' $reason = 'At least one simulation had an unparseable **launchDateTime** — recency could not be confirmed.' $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } elseif ($hasBaseline) { $customStatus = 'Investigate' $reason = "A baseline simulation succeeded in the last 12 months but no simulation automation is **running**." $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } elseif ($hasSucceededMissingCompletion) { $customStatus = 'Investigate' $reason = "A **succeeded** simulation was missing a usable **completionDateTime** — recency could not be confirmed." $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } elseif ($hasActivityInWindow) { $customStatus = 'Investigate' $observedStatuses = ($classifiedSims | Where-Object { $_.ZtSignal -ne 'Out of window' } | ForEach-Object { $_.Sim.status } | Select-Object -Unique | Sort-Object) -join ', ' $reason = "Simulations exist in the last 12 months but none reached **succeeded** in the window. Observed statuses: **$observedStatuses**." if ($hasSucceededMissingCompletion) { $reason += ' Additionally, a **succeeded** simulation was missing a usable **completionDateTime** — recency could not be confirmed.' } $testResultMarkdown = "⚠️ A completed baseline and a running automation could not both be confirmed. $reason Review [Attack simulation training](https://security.microsoft.com/attacksimulator).`n`n$summaryCounts`n`n%TestResult%" } else { $passed = $false $testResultMarkdown = "❌ No Attack Simulation Training simulation has run in the last 12 months; user susceptibility to phishing is unmeasured.`n`n$summaryCounts`n`n%TestResult%" } #endregion Assessment Logic #region Report Generation $portalUrl = 'https://security.microsoft.com/attacksimulator' $maxDisplay = 10 # Sort: baseline rows first (never truncated), then all remaining rows newest-first # (Recent activity and Out of window are not separately grouped — just sorted by launch date). $sortedClassified = @($classifiedSims | Sort-Object -Property @( @{ Expression = { if ($_.ZtSignal -eq 'Baseline') { 0 } else { 1 } }; Ascending = $true }, @{ Expression = 'LaunchDt'; Descending = $true } )) # Baseline rows are never truncated — decisive evidence takes precedence over the 10-row display # cap. When more than 10 baselines exist the output intentionally exceeds the stated limit. $baselineRows = @($sortedClassified | Where-Object { $_.ZtSignal -eq 'Baseline' }) $nonBaselineRows = @($sortedClassified | Where-Object { $_.ZtSignal -ne 'Baseline' }) $remainingSlots = [Math]::Max(0, $maxDisplay - $baselineRows.Count) $displayRows = $baselineRows + @($nonBaselineRows | Select-Object -First $remainingSlots) $totalCount = $sortedClassified.Count # Table 1 — Simulations if ($simulationsError -or $cloudUnsupported) { $simTableRows = "| — | — | Simulation data unavailable (query error) | — | — | — | — | — | — |`n" } elseif ($totalCount -eq 0) { $simTableRows = "| — | — | No simulations found in the last 365 days | — | — | — | — | — | — |`n" } else { $simTableRows = '' foreach ($entry in $displayRows) { $sim = $entry.Sim $ztSignalMd = Get-SafeMarkdown -Text $entry.ZtSignal $simStatusMd = Get-SafeMarkdown -Text $sim.status $nameMd = Get-SafeMarkdown -Text $sim.displayName $techniqueMd = Get-SafeMarkdown -Text $sim.attackTechnique $typeMd = Get-SafeMarkdown -Text $sim.attackType $platformMd = Get-SafeMarkdown -Text $sim.payloadDeliveryPlatform $launchDateMd = if ($entry.LaunchValid) { Get-FormattedDate -DateString $entry.LaunchDt.ToString('o') } else { '—' } $completeDateMd = if ($entry.CompletionValid) { Get-FormattedDate -DateString $entry.CompletionDt.ToString('o') } else { '—' } $automatedMd = if ($sim.isAutomated -eq $true) { 'Yes' } else { 'No' } $simTableRows += "| $ztSignalMd | $simStatusMd | $nameMd | $techniqueMd | $typeMd | $platformMd | $launchDateMd | $completeDateMd | $automatedMd |`n" } if ($totalCount -gt $maxDisplay) { $simTableRows += "| ... | ... | ... | ... | ... | ... | ... | ... | ... |`n" } } $preTableLines = if ($totalCount -gt $maxDisplay) { "Showing $($displayRows.Count) of $totalCount simulations (baseline always shown). [View all in Microsoft 365 Defender > Email & collaboration > Attack simulation training]($portalUrl)`n`n" } else { '' } # Table 2 — Simulation automations (always rendered) if ($automationsError -or $cloudUnsupported) { $autoTableRows = "| Automation data unavailable (query error) | — | — | — |`n" } elseif ($automations.Count -eq 0) { $autoTableRows = "| No simulation automation configured | — | — | — |`n" } else { $autoTableRows = '' foreach ($auto in $automations) { $autoNameMd = Get-SafeMarkdown -Text $auto.displayName $autoStatusMd = Get-SafeMarkdown -Text $auto.status $lastRunMd = '—' if ($auto.lastRunDateTime) { try { $lastRunMd = Get-FormattedDate -DateString ([datetime]$auto.lastRunDateTime).ToString('o') } catch { Write-PSFMessage "Could not parse lastRunDateTime '$($auto.lastRunDateTime)' for automation '$($auto.displayName)'." -Tag Test -Level Warning } } $nextRunMd = '—' if ($auto.nextRunDateTime) { try { $nextRunMd = Get-FormattedDate -DateString ([datetime]$auto.nextRunDateTime).ToString('o') } catch { Write-PSFMessage "Could not parse nextRunDateTime '$($auto.nextRunDateTime)' for automation '$($auto.displayName)'." -Tag Test -Level Warning } } $autoTableRows += "| $autoNameMd | $autoStatusMd | $lastRunMd | $nextRunMd |`n" } } $formatTemplate = @' {0} ## [Attack simulation training]({3}) ### Simulations | ZT signal | Simulation status | Display name | Attack technique | Attack type | Delivery platform | Launch date | Completion date | Automated | | :-------- | :----------------- | :----------- | :---------------- | :----------- | :----------------- | :----------- | :---------------- | :-------- | {1} ### Simulation automations | Automation name | Automation status | Last run | Next run | | :--------------- | :----------------- | :------- | :------- | {2} '@ $mdInfo = $formatTemplate -f $preTableLines, $simTableRows, $autoTableRows, $portalUrl $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation $params = @{ TestId = '41036' Title = $testTitle Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |