tests/Test-Assessment.41204.ps1
|
<#
.SYNOPSIS Checks interactive and total retention against the security baseline on every Sentinel-onboarded Log Analytics workspace. .NOTES Test ID: 41204 Workshop Task: SECOPS_096 Pillar: SecOps Category: Security information and event management Required API: Azure Resource Manager (management.azure.com) #> function Test-Assessment-41204 { [ZtTest( Category = 'Security information and event management', ImplementationCost = 'Low', MinimumLicense = ('Consumption-based: Microsoft Sentinel'), Pillar = 'SecOps', RiskLevel = 'Medium', Service = ('Azure'), SfiPillar = 'Monitor and detect cyberthreats', TenantType = ('Workforce'), TestId = 41204, Title = 'Interactive and long-term retention duration meets the security baseline on every Microsoft Sentinel workspace', UserImpact = 'Low' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking retention settings in Microsoft Sentinel workspaces' # Q1 + Q2 + onboarding check via shared helper. # Returns 'Forbidden' on ARG 401/403 (Investigate). # Returns $null on unexpected ARG failure (Investigate). # Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip). # Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip). $allWorkspaces = Get-SentinelWorkspaceData -Activity $activity if ($null -eq $allWorkspaces) { $params = @{ TestId = '41204' Title = 'Interactive and long-term retention duration meets the security baseline on every Microsoft Sentinel workspace' Status = $false Result = '⚠️ Azure Resource Graph returned an unexpected error while querying subscriptions or Log Analytics workspaces. This is likely a transient issue, please re-run the assessment.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } if ($allWorkspaces -eq 'Forbidden') { $params = @{ TestId = '41204' Title = 'Interactive and long-term retention duration meets the security baseline on every Microsoft Sentinel workspace' Status = $false Result = '⚠️ Azure Resource Graph returned insufficient permissions when querying subscriptions or workspaces. Ensure you have at least Reader access to the Azure subscriptions being tested.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } if ($allWorkspaces -eq 'NoSubscriptions') { Write-PSFMessage 'No enabled subscriptions found — skipping Microsoft Sentinel retention check.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable return } if ($allWorkspaces -eq 'NoWorkspaces') { Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Microsoft Sentinel retention check.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable return } $checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError }) $forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError }) $onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded }) $unresolvedWorkspaces = @($checkableWorkspaces | Where-Object { $_.OnboardingError }) if ($onboardedWorkspaces.Count -eq 0) { if ($forbiddenWorkspaces.Count -gt 0 -or $unresolvedWorkspaces.Count -gt 0) { $params = @{ TestId = '41204' Title = 'Interactive and long-term retention duration meets the security baseline on every Microsoft Sentinel workspace' Status = $false Result = '⚠️ A workspace-discovery or onboarding call returned an authorization failure, throttling, service error, or malformed response, so no Sentinel-onboarded workspace could be evaluated.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params } else { Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Microsoft Sentinel retention check.' -Tag Test -Level VeryVerbose Add-ZtTestResultDetail -SkippedBecause NotApplicable } return } $retentionByWorkspace = @{} foreach ($workspace in $onboardedWorkspaces) { $workspaceRetention = $null $tables = $null $workspaceQueryError = $false $tablesQueryError = $false # Q1: Retrieve workspace-level interactive retention. Write-ZtProgress -Activity $activity -Status "Fetching workspace retention for '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'" try { $workspaceRetention = Invoke-ZtAzureRequest -Path "$($workspace.WorkspaceId)?api-version=2023-09-01" -ErrorAction Stop } catch { $workspaceQueryError = $true Write-PSFMessage "Error querying retention for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning } # Q2: Retrieve table-level interactive and total retention. Write-ZtProgress -Activity $activity -Status "Fetching table retention for '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'" try { $tables = @(Invoke-ZtAzureRequest -Path "$($workspace.WorkspaceId)/tables?api-version=2026-03-01" -ErrorAction Stop) } catch { $tablesQueryError = $true Write-PSFMessage "Error querying table retention for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning } $retentionByWorkspace[$workspace.WorkspaceId] = [PSCustomObject]@{ WorkspaceResponse = $workspaceRetention Tables = $tables WorkspaceQueryError = $workspaceQueryError TablesQueryError = $tablesQueryError } } #endregion Data Collection #region Assessment Logic $securityRelevantTables = @( 'SecurityEvent', 'SigninLogs', 'AuditLogs', 'AADNonInteractiveUserSignInLogs', 'AADServicePrincipalSignInLogs', 'OfficeActivity', 'SecurityAlert', 'SecurityIncident', 'CommonSecurityLog', 'Syslog', 'WindowsFirewall', 'DeviceLogonEvents', 'IdentityLogonEvents', 'EmailEvents', 'CloudAppEvents' ) $workspaceResults = @() $tableResults = @() foreach ($workspace in $onboardedWorkspaces) { $collectedData = $retentionByWorkspace[$workspace.WorkspaceId] $workspaceRetentionDays = $null $workspaceCriterion = 'Investigate' $workspaceReason = '' $presentTableCount = 0 if ($collectedData.WorkspaceQueryError) { $workspaceReason = 'The workspace retention request failed.' } elseif ($null -eq $collectedData.WorkspaceResponse.properties.retentionInDays) { $workspaceReason = 'The workspace response did not contain retentionInDays.' } else { try { $workspaceRetentionDays = [int]$collectedData.WorkspaceResponse.properties.retentionInDays $workspaceCriterion = if ($workspaceRetentionDays -ge 90) { 'Pass' } else { 'Fail' } } catch { $workspaceReason = 'The workspace retentionInDays value was not an integer.' } } if ($collectedData.TablesQueryError) { foreach ($tableName in $securityRelevantTables) { $tableResults += [PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId TableName = $tableName RetentionInDays = $null TotalRetentionDays = $null ArchiveDays = $null RowStatus = 'Investigate' StatusDetails = 'The tables retention request failed.' } } } else { $tablesByName = @{} foreach ($table in $collectedData.Tables) { if ($table.name) { $tablesByName[[string]$table.name] = $table } } $presentTableCount = @($securityRelevantTables | Where-Object { $tablesByName.ContainsKey($_) }).Count foreach ($tableName in $securityRelevantTables) { if (-not $tablesByName.ContainsKey($tableName)) { $tableResults += [PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId TableName = $tableName RetentionInDays = $null TotalRetentionDays = $null ArchiveDays = $null RowStatus = 'N/A' StatusDetails = 'Not present/not ingested.' } continue } $table = $tablesByName[$tableName] $tableRetentionDays = $null $totalRetentionDays = $null $archiveDays = $null $tableStatus = 'Investigate' $tableDetails = '' if ($null -eq $table.properties.retentionInDays -or $null -eq $table.properties.totalRetentionInDays) { $tableDetails = 'A required retention field was missing.' } else { try { $tableRetentionDays = [int]$table.properties.retentionInDays $totalRetentionDays = [int]$table.properties.totalRetentionInDays $archiveDays = if ($null -ne $table.properties.archiveRetentionInDays) { [int]$table.properties.archiveRetentionInDays } else { $totalRetentionDays - $tableRetentionDays } $tableStatus = if ($totalRetentionDays -ge 365) { 'Pass' } else { 'Fail' } $tableDetails = if ($tableStatus -eq 'Pass') { 'Total retention meets the 365-day threshold.' } else { 'Total retention is below the 365-day threshold.' } } catch { $tableDetails = 'A required retention field was malformed.' } } $tableResults += [PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId TableName = $tableName RetentionInDays = $tableRetentionDays TotalRetentionDays = $totalRetentionDays ArchiveDays = $archiveDays RowStatus = $tableStatus StatusDetails = $tableDetails } } } $workspaceTableResults = @($tableResults | Where-Object { $_.WorkspaceId -eq $workspace.WorkspaceId }) $presentTableResults = @($workspaceTableResults | Where-Object { $_.RowStatus -ne 'N/A' }) $workspaceStatus = 'Pass' if ($workspaceCriterion -eq 'Fail' -or @($presentTableResults | Where-Object { $_.RowStatus -eq 'Fail' }).Count -gt 0) { $workspaceStatus = 'Fail' $workspaceReason = 'The workspace default or a present table is below its retention threshold.' } elseif ($workspaceCriterion -eq 'Investigate' -or $collectedData.TablesQueryError -or @($presentTableResults | Where-Object { $_.RowStatus -eq 'Investigate' }).Count -gt 0) { $workspaceStatus = 'Investigate' if (-not $workspaceReason) { $workspaceReason = 'A required query or retention value could not be evaluated.' } } elseif ($presentTableResults.Count -eq 0) { $workspaceStatus = 'Investigate' $workspaceReason = 'None of the security-relevant tables is present to evaluate.' } else { $workspaceReason = 'The workspace default and all present tables meet their retention thresholds.' } $workspaceResults += [PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId RetentionInDays = $workspaceRetentionDays WorkspaceCriterionMet = $workspaceCriterion -eq 'Pass' PresentTableCount = $presentTableCount RowStatus = $workspaceStatus StatusDetails = $workspaceReason } } foreach ($workspace in @($forbiddenWorkspaces) + @($unresolvedWorkspaces)) { $workspaceResults += [PSCustomObject]@{ SubscriptionName = $workspace.SubscriptionName SubscriptionId = $workspace.SubscriptionId WorkspaceName = $workspace.WorkspaceName WorkspaceId = $workspace.WorkspaceId RetentionInDays = $null WorkspaceCriterionMet = $false PresentTableCount = 0 RowStatus = 'Investigate' StatusDetails = 'The Sentinel onboarding state could not be determined.' } } $failedItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Fail' }) $investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' }) $passed = $failedItems.Count -eq 0 -and $investigateItems.Count -eq 0 $customStatus = $null if ($failedItems.Count -gt 0) { $testResultMarkdown = "❌ The workspace default analytics retention is below 90 days, or a present security-relevant table has less than 365 days of total retention.`n`n%TestResult%" } elseif ($investigateItems.Count -gt 0) { $customStatus = 'Investigate' $testResultMarkdown = "⚠️ A workspace-discovery, onboarding, Q1, or Q2 call returned an authorization failure, throttling, service error, or malformed response; or a Sentinel-onboarded workspace exists but none of the security-relevant tables is present to evaluate.`n`n%TestResult%" } else { $testResultMarkdown = "✅ The workspace default analytics retention is at least 90 days, and every present security-relevant table has at least 365 days of total retention.`n`n%TestResult%" } #endregion Assessment Logic #region Report Generation $azContext = Get-AzContext $portalHost = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' } $portalSentinelLink = "$portalHost/#browse/microsoft.securityinsightsarg%2Fsentinel" $workspaceTitle = 'Retention summary per Microsoft Sentinel workspace' $tableTitle = 'Retention details for security-relevant tables' $formatTemplate = @' ## [{0}]({1}) | Subscription | Workspace | Workspace analytics retention | Workspace criterion met | Present allow-listed tables | Workspace status | Reason | | :----------- | :-------- | ----------------------------: | :---------------------- | --------------------------: | :--------------- | :----- | {2} ## {3} | Subscription | Workspace | Table | Analytics retention | Total retention | Archive retention | Status | Reason | | :----------- | :-------- | :---- | ------------------: | --------------: | ----------------: | :----- | :----- | {4} '@ $workspaceRows = '' foreach ($result in $workspaceResults | Sort-Object SubscriptionName, WorkspaceName) { $subLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)" $workspaceLink = "$portalHost/#resource$($result.WorkspaceId)" $subMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)" $workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($workspaceLink)" $retentionMd = if ($null -eq $result.RetentionInDays) { '—' } else { "$($result.RetentionInDays) days" } $criterionMd = if ($null -eq $result.RetentionInDays) { '—' } elseif ($result.WorkspaceCriterionMet) { '✅ Yes' } else { '❌ No' } $statusDisplay = switch ($result.RowStatus) { 'Pass' { '✅ Pass' } 'Fail' { '❌ Fail' } 'Investigate' { '⚠️ Investigate' } } $workspaceRows += "| $subMd | $workspaceMd | $retentionMd | $criterionMd | $($result.PresentTableCount) | $statusDisplay | $($result.StatusDetails) |`n" } $tableRows = '' $maxDisplay = 10 $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2; 'N/A' = 3 } $displayResults = @($tableResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName, TableName) $hasMoreItems = $displayResults.Count -gt $maxDisplay if ($hasMoreItems) { $displayResults = @($displayResults | Select-Object -First $maxDisplay) } foreach ($result in $displayResults) { $subLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)" $workspaceLink = "$portalHost/#resource$($result.WorkspaceId)" $subMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)" $workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($workspaceLink)" $interactiveMd = if ($null -eq $result.RetentionInDays) { '—' } else { "$($result.RetentionInDays) days" } $totalMd = if ($null -eq $result.TotalRetentionDays) { '—' } else { "$($result.TotalRetentionDays) days" } $archiveMd = if ($null -eq $result.ArchiveDays) { '—' } else { "$($result.ArchiveDays) days" } $statusDisplay = switch ($result.RowStatus) { 'Pass' { '✅ Pass' } 'Fail' { '❌ Fail' } 'N/A' { 'N/A' } 'Investigate' { '⚠️ Investigate' } } $tableRows += "| $subMd | $workspaceMd | $($result.TableName) | $interactiveMd | $totalMd | $archiveMd | $statusDisplay | $($result.StatusDetails) |`n" } if ($hasMoreItems) { $remainingCount = $tableResults.Count - $maxDisplay $tableRows += "| … | … | $remainingCount more of $($tableResults.Count) total | … | … | … | … | [View all in Microsoft Sentinel]($portalSentinelLink) |`n" } if (-not $tableRows) { $tableRows = '| No confirmed Sentinel-onboarded workspaces were available for table evaluation. | — | — | — | — | — | — | — |' } $mdInfo = $formatTemplate -f $workspaceTitle, $portalSentinelLink, $workspaceRows, $tableTitle, $tableRows $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation $params = @{ TestId = '41204' Title = 'Interactive and long-term retention duration meets the security baseline on every Microsoft Sentinel workspace' Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |