Skip to content

Commit 3639452

Browse files
committed
SecOps - 41217 - Security tables are provisioned on the appropriate Log Analytics storage plan (Analytics, Basic, or Auxiliary)
1 parent 8da89bb commit 3639452

2 files changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Log Analytics tables can be provisioned on three plans, each with a different ingest cost, query cost, and detection capability: Analytics (full KQL, real-time analytics rule support, UEBA, scheduled rules; highest ingest cost), Basic (KQL with limitations, no scheduled analytics rules, search jobs over 8-day interactive window; mid ingest cost), and Auxiliary (very low ingest cost, no scheduled rules, queryable only via search jobs and Summary Rules over 30-day interactive window). Putting a high-fidelity, low-volume detection source like SigninLogs, SecurityAlert, or IdentityLogonEvents on Auxiliary breaks every analytics rule and incident-creation workflow that depends on it (the rules cannot run against Auxiliary tables) — meaning attacker-relevant signals such as impossible-travel sign-ins, DCSync, or token-replay silently stop generating Sentinel incidents even though the data is technically retained. Conversely, putting high-volume verbose telemetry like CommonSecurityLog, Syslog, or DeviceNetworkEvents on Analytics costs roughly 4–10× more per GB than Basic/Auxiliary while delivering little marginal detection value because most of those events are used in retrospective hunting rather than scheduled rules. The plan choice is therefore a security-and-cost decision: detection-critical tables must remain on Analytics, and high-volume tables that are only hunted retrospectively are candidates for Basic or Auxiliary. This check verifies the plan assignment of the security-relevant tables in each Sentinel workspace.
2+
3+
**Remediation action**
4+
5+
- [Set a table's log data plan](https://learn.microsoft.com/azure/azure-monitor/logs/logs-table-plans)
6+
- [Switch a table's plan from the Azure portal](https://learn.microsoft.com/azure/azure-monitor/logs/logs-table-plans#change-the-table-plan)
7+
8+
<!--- Results --->
9+
%TestResult%
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
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

Comments
 (0)