|
| 1 | +<# |
| 2 | +.SYNOPSIS |
| 3 | + Checks that security tables in each Sentinel workspace are provisioned on the |
| 4 | + appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary). |
| 5 | +
|
| 6 | +.DESCRIPTION |
| 7 | + Detection-critical tables such as SigninLogs and SecurityAlert must remain on |
| 8 | + the Analytics plan so that scheduled analytics rules and UEBA can run against |
| 9 | + them. High-volume verbose tables like CommonSecurityLog and Syslog may be moved |
| 10 | + to Basic or Auxiliary to reduce ingest costs without losing scheduled-rule coverage. |
| 11 | + This test flags detection-critical tables on the wrong plan (Fail) and |
| 12 | + high-volume tables left on Analytics when cheaper plans are viable (Investigate). |
| 13 | +
|
| 14 | +.NOTES |
| 15 | + Test ID: 41217 |
| 16 | + Workshop Task: SECOPS_096 |
| 17 | + Category: Security information and event management |
| 18 | + Pillar: SecOps |
| 19 | + Required API: Azure Resource Manager (management.azure.com) |
| 20 | +#> |
| 21 | + |
| 22 | +function Test-Assessment-41217 { |
| 23 | + [ZtTest( |
| 24 | + Category = 'Security information and event management', |
| 25 | + ImplementationCost = 'Low', |
| 26 | + MinimumLicense = ('Consumption-based: Microsoft Sentinel'), |
| 27 | + Pillar = 'SecOps', |
| 28 | + RiskLevel = 'Medium', |
| 29 | + Service = ('Azure'), |
| 30 | + SfiPillar = 'Monitor and detect cyberthreats', |
| 31 | + TenantType = ('Workforce'), |
| 32 | + TestId = 41217, |
| 33 | + Title = 'Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)', |
| 34 | + UserImpact = 'Low' |
| 35 | + )] |
| 36 | + [CmdletBinding()] |
| 37 | + param() |
| 38 | + |
| 39 | + #region Data Collection |
| 40 | + |
| 41 | + Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose |
| 42 | + $activity = 'Checking Log Analytics table plan assignments in Sentinel workspaces' |
| 43 | + |
| 44 | + # Q1 + Q2 + onboarding check via shared helper. |
| 45 | + # Returns 'Forbidden' on ARG 401/403 (Investigate). |
| 46 | + # Returns $null on unexpected ARG failure (Investigate). |
| 47 | + # Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip). |
| 48 | + # Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip). |
| 49 | + $allWorkspaces = Get-SentinelWorkspaceData -Activity $activity |
| 50 | + |
| 51 | + if ($null -eq $allWorkspaces) { |
| 52 | + $params = @{ |
| 53 | + TestId = '41217' |
| 54 | + Title = 'Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)' |
| 55 | + Status = $false |
| 56 | + 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.' |
| 57 | + CustomStatus = 'Investigate' |
| 58 | + } |
| 59 | + Add-ZtTestResultDetail @params |
| 60 | + return |
| 61 | + } |
| 62 | + |
| 63 | + if ($allWorkspaces -eq 'Forbidden') { |
| 64 | + $params = @{ |
| 65 | + TestId = '41217' |
| 66 | + Title = 'Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)' |
| 67 | + Status = $false |
| 68 | + 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.' |
| 69 | + CustomStatus = 'Investigate' |
| 70 | + } |
| 71 | + Add-ZtTestResultDetail @params |
| 72 | + return |
| 73 | + } |
| 74 | + |
| 75 | + if ($allWorkspaces -eq 'NoSubscriptions') { |
| 76 | + Write-PSFMessage 'No enabled subscriptions found — skipping Sentinel table-plan check.' -Tag Test -Level VeryVerbose |
| 77 | + Add-ZtTestResultDetail -SkippedBecause NotApplicable |
| 78 | + return |
| 79 | + } |
| 80 | + |
| 81 | + if ($allWorkspaces -eq 'NoWorkspaces') { |
| 82 | + Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Sentinel table-plan check.' -Tag Test -Level VeryVerbose |
| 83 | + Add-ZtTestResultDetail -SkippedBecause NotApplicable |
| 84 | + return |
| 85 | + } |
| 86 | + |
| 87 | + $checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError }) |
| 88 | + $forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError }) |
| 89 | + $onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded }) |
| 90 | + |
| 91 | + if ($onboardedWorkspaces.Count -eq 0) { |
| 92 | + if ($forbiddenWorkspaces.Count -gt 0) { |
| 93 | + $params = @{ |
| 94 | + TestId = '41217' |
| 95 | + Title = 'Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)' |
| 96 | + Status = $false |
| 97 | + Result = '⚠️ One or more Log Analytics workspaces returned insufficient permissions when checking Sentinel onboarding state. No Sentinel-onboarded workspace was confirmed among accessible workspaces — the overall state cannot be determined. Ensure Microsoft Sentinel Reader is granted on all workspaces and re-run the assessment.' |
| 98 | + CustomStatus = 'Investigate' |
| 99 | + } |
| 100 | + Add-ZtTestResultDetail @params |
| 101 | + } |
| 102 | + else { |
| 103 | + Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel table-plan check.' -Tag Test -Level VeryVerbose |
| 104 | + Add-ZtTestResultDetail -SkippedBecause NotApplicable |
| 105 | + } |
| 106 | + return |
| 107 | + } |
| 108 | + |
| 109 | + # Q3 (spec): For each Sentinel-onboarded workspace, list every table and read its plan. |
| 110 | + $rawTablesByWorkspace = @{} |
| 111 | + |
| 112 | + foreach ($workspace in $onboardedWorkspaces) { |
| 113 | + Write-ZtProgress -Activity $activity -Status "Reading table plans for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)" |
| 114 | + $tablesPath = "$($workspace.WorkspaceId)/tables?api-version=2026-03-01" |
| 115 | + |
| 116 | + try { |
| 117 | + $rawTablesByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $tablesPath -ErrorAction Stop) |
| 118 | + } |
| 119 | + catch { |
| 120 | + $rawTablesByWorkspace[$workspace.WorkspaceId] = $null |
| 121 | + Write-PSFMessage "Error reading table plans for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + #endregion Data Collection |
| 126 | + |
| 127 | + #region Assessment Logic |
| 128 | + |
| 129 | + # Detection-critical tables: must be on Analytics for analytics rules and UEBA to run. |
| 130 | + $detectionCriticalTables = @( |
| 131 | + 'SigninLogs', 'AuditLogs', 'AADNonInteractiveUserSignInLogs', |
| 132 | + 'AADServicePrincipalSignInLogs', 'OfficeActivity', 'SecurityAlert', |
| 133 | + 'SecurityIncident', 'IdentityLogonEvents', 'EmailEvents', |
| 134 | + 'CloudAppEvents', 'SecurityEvent' |
| 135 | + ) |
| 136 | + |
| 137 | + # High-volume verbose tables: any plan is acceptable; Analytics is flagged as cost-optimization. |
| 138 | + $highVolumeTables = @( |
| 139 | + 'CommonSecurityLog', 'Syslog', 'WindowsFirewall', 'DeviceNetworkEvents', |
| 140 | + 'AzureDiagnostics', 'AzureMetrics' |
| 141 | + ) |
| 142 | + |
| 143 | + $workspaceResults = [System.Collections.Generic.List[object]]::new() |
| 144 | + $allTableRows = [System.Collections.Generic.List[object]]::new() |
| 145 | + |
| 146 | + foreach ($workspace in $onboardedWorkspaces) { |
| 147 | + $rawTables = $rawTablesByWorkspace[$workspace.WorkspaceId] |
| 148 | + $wsHasApiError = $null -eq $rawTables |
| 149 | + |
| 150 | + if (-not $wsHasApiError) { |
| 151 | + foreach ($table in $rawTables) { |
| 152 | + $tableName = $table.name |
| 153 | + $actualPlan = $table.properties.plan |
| 154 | + |
| 155 | + if ($tableName -in $detectionCriticalTables) { |
| 156 | + $classification = 'Detection-critical' |
| 157 | + $expectedPlan = 'Analytics' |
| 158 | + $rowStatus = if ($actualPlan -eq 'Analytics') { 'Pass' } else { 'Fail' } |
| 159 | + } |
| 160 | + elseif ($tableName -in $highVolumeTables) { |
| 161 | + $classification = 'High-volume' |
| 162 | + $expectedPlan = 'Basic or Auxiliary' |
| 163 | + $rowStatus = if ($actualPlan -eq 'Analytics') { 'Investigate' } else { 'Pass' } |
| 164 | + } |
| 165 | + else { |
| 166 | + continue # Not in either list — not evaluated. |
| 167 | + } |
| 168 | + |
| 169 | + [void]$allTableRows.Add([PSCustomObject]@{ |
| 170 | + SubscriptionName = $workspace.SubscriptionName |
| 171 | + SubscriptionId = $workspace.SubscriptionId |
| 172 | + WorkspaceName = $workspace.WorkspaceName |
| 173 | + WorkspaceId = $workspace.WorkspaceId |
| 174 | + ResourceGroup = $workspace.ResourceGroup |
| 175 | + TableName = $tableName |
| 176 | + Classification = $classification |
| 177 | + ExpectedPlan = $expectedPlan |
| 178 | + ActualPlan = $actualPlan |
| 179 | + RowStatus = $rowStatus |
| 180 | + }) |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + # Workspace-level aggregation per spec: Fail > Investigate > Pass. |
| 185 | + # Q3 API error is always Investigate regardless of individual table results. |
| 186 | + $wsFail = @($allTableRows | Where-Object { $_.WorkspaceId -eq $workspace.WorkspaceId -and $_.RowStatus -eq 'Fail' }) |
| 187 | + $wsInvestigate = @($allTableRows | Where-Object { $_.WorkspaceId -eq $workspace.WorkspaceId -and $_.RowStatus -eq 'Investigate' }) |
| 188 | + |
| 189 | + $wsStatus = if ($wsHasApiError) { |
| 190 | + 'Investigate' |
| 191 | + } |
| 192 | + elseif ($wsFail.Count -gt 0) { |
| 193 | + 'Fail' |
| 194 | + } |
| 195 | + elseif ($wsInvestigate.Count -gt 0) { |
| 196 | + 'Investigate' |
| 197 | + } |
| 198 | + else { |
| 199 | + 'Pass' |
| 200 | + } |
| 201 | + |
| 202 | + [void]$workspaceResults.Add([PSCustomObject]@{ |
| 203 | + SubscriptionName = $workspace.SubscriptionName |
| 204 | + SubscriptionId = $workspace.SubscriptionId |
| 205 | + WorkspaceName = $workspace.WorkspaceName |
| 206 | + WorkspaceId = $workspace.WorkspaceId |
| 207 | + ResourceGroup = $workspace.ResourceGroup |
| 208 | + ApiError = $wsHasApiError |
| 209 | + RowStatus = $wsStatus |
| 210 | + }) |
| 211 | + } |
| 212 | + |
| 213 | + $workspaceResults = @($workspaceResults) |
| 214 | + $allTableRows = @($allTableRows) |
| 215 | + |
| 216 | + # Tenant-level roll-up: Fail > Investigate > Pass. |
| 217 | + $tenantFailWs = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Fail' }) |
| 218 | + $tenantInvestigateWs = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' }) |
| 219 | + |
| 220 | + $passed = $tenantFailWs.Count -eq 0 -and $tenantInvestigateWs.Count -eq 0 |
| 221 | + $customStatus = $null |
| 222 | + |
| 223 | + if ($tenantFailWs.Count -gt 0) { |
| 224 | + $testResultMarkdown = "❌ One or more detection-critical tables are on Basic or Auxiliary, which prevents scheduled analytics rules from running against them.`n`n%TestResult%" |
| 225 | + } |
| 226 | + elseif ($tenantInvestigateWs.Count -gt 0) { |
| 227 | + $customStatus = 'Investigate' |
| 228 | + $apiErrorWorkspaces = @($workspaceResults | Where-Object { $_.ApiError }) |
| 229 | + $costOptimizeWorkspaces = @($workspaceResults | Where-Object { -not $_.ApiError -and $_.RowStatus -eq 'Investigate' }) |
| 230 | + if ($apiErrorWorkspaces.Count -gt 0 -and $costOptimizeWorkspaces.Count -gt 0) { |
| 231 | + # Both Q3 API errors and high-volume cost-optimization Investigate conditions coexist. |
| 232 | + $testResultMarkdown = "⚠️ A high-volume table is on Analytics where Basic or Auxiliary may significantly reduce ingest cost without losing required detection capability. Additionally, table plan data could not be read from one or more workspaces — verify Log Analytics Reader access and re-run the assessment.`n`n%TestResult%" |
| 233 | + } |
| 234 | + elseif ($apiErrorWorkspaces.Count -gt 0) { |
| 235 | + $testResultMarkdown = "⚠️ Table plan data could not be read from one or more Sentinel workspaces. Verify Log Analytics Reader access and re-run the assessment.`n`n%TestResult%" |
| 236 | + } |
| 237 | + else { |
| 238 | + $testResultMarkdown = "⚠️ A high-volume table is on Analytics where Basic or Auxiliary may significantly reduce ingest cost without losing required detection capability.`n`n%TestResult%" |
| 239 | + } |
| 240 | + } |
| 241 | + else { |
| 242 | + $testResultMarkdown = "✅ All detection-critical security tables are on the Analytics plan in the Sentinel workspace.`n`n%TestResult%" |
| 243 | + } |
| 244 | + |
| 245 | + #endregion Assessment Logic |
| 246 | + |
| 247 | + #region Report Generation |
| 248 | + |
| 249 | + $azContext = Get-AzContext -ErrorAction SilentlyContinue |
| 250 | + $portalHost = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' } |
| 251 | + $portalSentinelLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/microsoft.securityinsightsarg%2Fsentinel" |
| 252 | + $title41217 = 'Security tables per Sentinel workspace' |
| 253 | + |
| 254 | + # Workspace summary table — satisfies "list every evaluated workspace with its status". |
| 255 | + $wsFormatTemplate = @' |
| 256 | +
|
| 257 | +
|
| 258 | +## [{0}]({1}) |
| 259 | +
|
| 260 | +| Subscription | Workspace | Status | |
| 261 | +| :----------- | :-------- | :----- | |
| 262 | +{2} |
| 263 | +'@ |
| 264 | + |
| 265 | + $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 } |
| 266 | + $wsSortedResults = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName) |
| 267 | + |
| 268 | + $wsTableRows = '' |
| 269 | + foreach ($ws in $wsSortedResults) { |
| 270 | + $subLink = "$portalHost/#resource/subscriptions/$($ws.SubscriptionId)" |
| 271 | + $wsTablesLink = "$portalHost/#resource$($ws.WorkspaceId)/tables" |
| 272 | + $subMd = "[$(Get-SafeMarkdown $ws.SubscriptionName)]($subLink)" |
| 273 | + $wsMd = "[$(Get-SafeMarkdown $ws.WorkspaceName)]($wsTablesLink)" |
| 274 | + $wsStatusDisplay = switch ($ws.RowStatus) { |
| 275 | + 'Pass' { '✅ Pass' } |
| 276 | + 'Fail' { '❌ Fail' } |
| 277 | + 'Investigate' { '⚠️ Investigate' } |
| 278 | + } |
| 279 | + $wsTableRows += "| $subMd | $wsMd | $wsStatusDisplay |`n" |
| 280 | + } |
| 281 | + |
| 282 | + $wsSection = $wsFormatTemplate -f $title41217, $portalSentinelLink, $wsTableRows |
| 283 | + |
| 284 | + # Per-table detail table — one row per evaluated (classified + present) table. |
| 285 | + $detailFormatTemplate = @' |
| 286 | +
|
| 287 | +
|
| 288 | +## [Table plan details]({1}) |
| 289 | +
|
| 290 | +| Subscription | Workspace | Table | Classification | Expected plan | Actual plan | Status | |
| 291 | +| :----------- | :-------- | :---- | :------------- | :------------ | :---------- | :----- | |
| 292 | +{0} |
| 293 | +'@ |
| 294 | + |
| 295 | + $maxDisplay = 25 |
| 296 | + # Sort: worst status first within each workspace, then by classification (critical first), then name. |
| 297 | + $classOrder = @{ 'Detection-critical' = 0; 'High-volume' = 1 } |
| 298 | + $sortedRows = @($allTableRows | Sort-Object { |
| 299 | + $statusPriority[$_.RowStatus] |
| 300 | + }, SubscriptionName, WorkspaceName, { $classOrder[$_.Classification] }, TableName) |
| 301 | + |
| 302 | + $hasMoreItems = $sortedRows.Count -gt $maxDisplay |
| 303 | + $displayRows = if ($hasMoreItems) { @($sortedRows | Select-Object -First $maxDisplay) } else { $sortedRows } |
| 304 | + |
| 305 | + $tableDetailRows = '' |
| 306 | + foreach ($row in $displayRows) { |
| 307 | + $subLink = "$portalHost/#resource/subscriptions/$($row.SubscriptionId)" |
| 308 | + $wsTablesLink = "$portalHost/#resource$($row.WorkspaceId)/tables" |
| 309 | + $subMd = "[$(Get-SafeMarkdown $row.SubscriptionName)]($subLink)" |
| 310 | + $wsMd = "[$(Get-SafeMarkdown $row.WorkspaceName)]($wsTablesLink)" |
| 311 | + $rowStatusDisplay = switch ($row.RowStatus) { |
| 312 | + 'Pass' { '✅ Pass' } |
| 313 | + 'Fail' { '❌ Fail' } |
| 314 | + 'Investigate' { '⚠️ Investigate' } |
| 315 | + } |
| 316 | + $tableDetailRows += "| $subMd | $wsMd | $($row.TableName) | $($row.Classification) | $($row.ExpectedPlan) | $($row.ActualPlan) | $rowStatusDisplay |`n" |
| 317 | + } |
| 318 | + |
| 319 | + if ($hasMoreItems) { |
| 320 | + $remainingCount = $sortedRows.Count - $maxDisplay |
| 321 | + $tableDetailRows += "`n... and $remainingCount more. [View all tables in Microsoft Sentinel]($portalSentinelLink)`n" |
| 322 | + } |
| 323 | + |
| 324 | + if ($allTableRows.Count -eq 0) { |
| 325 | + $detailSection = "`n`nNo classified security tables were found in any evaluated workspace." |
| 326 | + } |
| 327 | + else { |
| 328 | + $detailSection = $detailFormatTemplate -f $tableDetailRows, $portalSentinelLink |
| 329 | + } |
| 330 | + |
| 331 | + $mdInfo = $wsSection + $detailSection |
| 332 | + $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo |
| 333 | + |
| 334 | + #endregion Report Generation |
| 335 | + |
| 336 | + $params = @{ |
| 337 | + TestId = '41217' |
| 338 | + Title = 'Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)' |
| 339 | + Status = $passed |
| 340 | + Result = $testResultMarkdown |
| 341 | + } |
| 342 | + if ($customStatus) { |
| 343 | + $params.CustomStatus = $customStatus |
| 344 | + } |
| 345 | + |
| 346 | + Add-ZtTestResultDetail @params |
| 347 | +} |
0 commit comments