Skip to content

Commit 29c7a83

Browse files
authored
SecOps - 41208 - At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities (#1415)
2 parents 3f4c5ee + b7e76e2 commit 29c7a83

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Watchlists are named, KQL-queryable lookup tables in Microsoft Sentinel that hold customer-specific context — privileged user lists, terminated employee lists, VIP executives, allow-listed corporate IP ranges, known service-account inventories, third-party vendor identities, asset criticality tiers, IOCs from incident retrospectives — and are joined into analytics rules and hunting queries to convert generic detections into context-aware ones. Without watchlists, every detection is evaluated against the same baseline regardless of who or what is involved: a sign-in anomaly on a tier-0 service account is treated as equivalent to one on a contractor's mailbox, and a session from a vendor-managed jump host is treated as equivalent to one from an arbitrary internet IP. The detection consequence is that high-value-asset (HVA) targeting by threat actors — credential reuse against domain-admin accounts, Golden Ticket forgery against tier-0, data staging from finance-and-HR mailboxes — does not get the priority treatment it deserves and competes with low-value noise in the analyst queue, increasing dwell time. Watchlists are also the documented mechanism for terminated-user monitoring (detect a re-enabled account belonging to a former employee accessing resources, an indicator of Cloud Accounts persistence). The check confirms at least one watchlist exists; mature deployments maintain a handful of curated watchlists keyed to the customer's HVA inventory.
2+
3+
**Remediation action**
4+
5+
- [Use watchlists in Microsoft Sentinel](https://learn.microsoft.com/azure/sentinel/watchlists)
6+
- [Create watchlists in Microsoft Sentinel](https://learn.microsoft.com/azure/sentinel/watchlists-create)
7+
- [Use watchlists in analytics rules and hunting queries](https://learn.microsoft.com/azure/sentinel/watchlists-queries)
8+
9+
<!--- Results --->
10+
%TestResult%
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
<#
2+
.SYNOPSIS
3+
Checks that at least one watchlist is configured in Microsoft Sentinel for
4+
correlation against high-value entities.
5+
6+
.NOTES
7+
Test ID: 41208
8+
Workshop Task: SECOPS_102
9+
Pillar: SecOps
10+
Category: Security information and event management
11+
Required API: Azure Resource Manager (management.azure.com)
12+
#>
13+
function Test-Assessment-41208 {
14+
[ZtTest(
15+
Category = 'Security information and event management',
16+
ImplementationCost = 'Low',
17+
MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
18+
Pillar = 'SecOps',
19+
RiskLevel = 'Low',
20+
Service = ('Azure'),
21+
SfiPillar = 'Monitor and detect cyberthreats',
22+
TenantType = ('Workforce'),
23+
TestId = 41208,
24+
Title = 'At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities',
25+
UserImpact = 'Low'
26+
)]
27+
[CmdletBinding()]
28+
param()
29+
30+
#region Data Collection
31+
32+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
33+
$activity = 'Checking watchlists configured in Microsoft Sentinel workspaces'
34+
35+
# Q1 + Q2 + onboarding check via shared helper.
36+
# Returns 'Forbidden' on ARG 401/403 (Investigate).
37+
# Returns $null on unexpected ARG failure (Investigate).
38+
# Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip).
39+
# Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip).
40+
$allWorkspaces = Get-SentinelWorkspaceData -Activity $activity
41+
42+
if ($null -eq $allWorkspaces) {
43+
$params = @{
44+
TestId = '41208'
45+
Title = 'At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities'
46+
Status = $false
47+
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.'
48+
CustomStatus = 'Investigate'
49+
}
50+
Add-ZtTestResultDetail @params
51+
return
52+
}
53+
54+
if ($allWorkspaces -eq 'Forbidden') {
55+
$params = @{
56+
TestId = '41208'
57+
Title = 'At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities'
58+
Status = $false
59+
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.'
60+
CustomStatus = 'Investigate'
61+
}
62+
Add-ZtTestResultDetail @params
63+
return
64+
}
65+
66+
if ($allWorkspaces -eq 'NoSubscriptions') {
67+
Write-PSFMessage 'No enabled subscriptions found — skipping Sentinel watchlists check.' -Tag Test -Level VeryVerbose
68+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
69+
return
70+
}
71+
72+
if ($allWorkspaces -eq 'NoWorkspaces') {
73+
Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Sentinel watchlists check.' -Tag Test -Level VeryVerbose
74+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
75+
return
76+
}
77+
78+
$checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError })
79+
$forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError })
80+
$onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded })
81+
82+
if ($onboardedWorkspaces.Count -eq 0) {
83+
if ($forbiddenWorkspaces.Count -gt 0) {
84+
# Auth errors mean we cannot confirm whether those workspaces have Sentinel onboarded;
85+
# a passing workspace may exist among the inaccessible ones.
86+
$params = @{
87+
TestId = '41208'
88+
Title = 'At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities'
89+
Status = $false
90+
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.'
91+
CustomStatus = 'Investigate'
92+
}
93+
Add-ZtTestResultDetail @params
94+
}
95+
else {
96+
# Spec: no Sentinel-onboarded workspaces with full visibility — Skipped.
97+
Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel watchlists check.' -Tag Test -Level VeryVerbose
98+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
99+
}
100+
return
101+
}
102+
103+
# Q1 (spec): Enumerate all watchlists for each Sentinel-onboarded workspace.
104+
# Invoke-ZtAzureRequest paginates automatically (Paginate=$true for GET) and unwraps .value.
105+
$watchlistsByWorkspace = @{}
106+
107+
foreach ($workspace in $onboardedWorkspaces) {
108+
Write-ZtProgress -Activity $activity -Status "Fetching watchlists for workspace '$($workspace.WorkspaceName)' in '$($workspace.SubscriptionName)'"
109+
$watchlistsPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/watchlists?api-version=2024-09-01"
110+
111+
try {
112+
$watchlistsByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $watchlistsPath -ErrorAction Stop)
113+
}
114+
catch {
115+
$watchlistsByWorkspace[$workspace.WorkspaceId] = $null
116+
Write-PSFMessage "Error querying watchlists for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
117+
}
118+
}
119+
120+
#endregion Data Collection
121+
122+
#region Assessment Logic
123+
124+
$workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
125+
$rawWatchlists = $watchlistsByWorkspace[$workspace.WorkspaceId]
126+
127+
$activeWatchlists = @()
128+
$succeededWatchlists = @()
129+
$rowStatus = 'Fail'
130+
131+
if ($null -eq $rawWatchlists) {
132+
# API error for this workspace — cannot determine watchlist state.
133+
$rowStatus = 'Investigate'
134+
}
135+
else {
136+
# Exclude deleted watchlists per spec.
137+
$activeWatchlists = @($rawWatchlists | Where-Object { $_.properties.isDeleted -ne $true })
138+
$succeededWatchlists = @($activeWatchlists | Where-Object { $_.properties.provisioningState -eq 'Succeeded' })
139+
140+
$rowStatus = if ($succeededWatchlists.Count -ge 1) {
141+
'Pass'
142+
}
143+
elseif ($activeWatchlists.Count -gt 0) {
144+
# Watchlists exist (Failed, New, Uploading, Canceled, or Deleting) but none have provisioningState = Succeeded.
145+
'Investigate'
146+
}
147+
else {
148+
# No active (non-deleted) watchlists found.
149+
'Fail'
150+
}
151+
}
152+
153+
[PSCustomObject]@{
154+
SubscriptionName = $workspace.SubscriptionName
155+
SubscriptionId = $workspace.SubscriptionId
156+
WorkspaceName = $workspace.WorkspaceName
157+
ResourceGroup = $workspace.ResourceGroup
158+
WorkspaceId = $workspace.WorkspaceId
159+
TotalWatchlists = if ($null -eq $rawWatchlists) { $null } else { $activeWatchlists.Count }
160+
ActiveWatchlists = $activeWatchlists # kept for per-watchlist report rendering
161+
RowStatus = $rowStatus
162+
}
163+
}
164+
$workspaceResults = @($workspaceResults)
165+
166+
$passedItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Pass' })
167+
$investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })
168+
169+
$passed = $passedItems.Count -gt 0
170+
$customStatus = $null
171+
172+
if (-not $passed -and ($investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0)) {
173+
$customStatus = 'Investigate'
174+
$testResultMarkdown = "⚠️ The watchlists API returned an unexpected response, or all watchlists report a non-successful provisioningState.`n`n%TestResult%"
175+
}
176+
elseif ($passed) {
177+
$testResultMarkdown = "✅ At least one watchlist is configured in the Sentinel workspace.`n`n%TestResult%"
178+
}
179+
else {
180+
$testResultMarkdown = "❌ No watchlists are configured in the Sentinel workspace.`n`n%TestResult%"
181+
}
182+
183+
#endregion Assessment Logic
184+
185+
#region Report Generation
186+
187+
$azContext = Get-AzContext -ErrorAction SilentlyContinue
188+
$portalHost = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' }
189+
$portalSentinelLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/microsoft.securityinsightsarg%2Fsentinel"
190+
$tableTitle = 'Watchlists per Sentinel workspace'
191+
192+
$formatTemplate = @'
193+
194+
195+
## [{0}]({1})
196+
197+
| Subscription | Workspace | Watchlist count | Watchlist names | Watchlist aliases | Providers | Provisioning states | Status |
198+
| :----------- | :-------- | :-------------- | :-------------- | :---------------- | :-------- | :------------------ | :----- |
199+
{2}
200+
'@
201+
202+
$tableRows = ''
203+
$maxDisplay = 10
204+
$statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
205+
$displayResults = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)
206+
$hasMoreItems = $false
207+
if ($workspaceResults.Count -gt $maxDisplay) {
208+
$displayResults = @($displayResults | Select-Object -First $maxDisplay)
209+
$hasMoreItems = $true
210+
}
211+
212+
foreach ($result in $displayResults) {
213+
$subLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)"
214+
$sentinelId = "/subscriptions/$($result.SubscriptionId)/resourcegroups/$($result.ResourceGroup)/providers/microsoft.securityinsightsarg/sentinel/$($result.WorkspaceName)"
215+
$watchlistsLink = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/Watchlists/id/$($sentinelId -replace '/', '%2F')"
216+
$subMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
217+
$workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($watchlistsLink)"
218+
$statusDisplay = switch ($result.RowStatus) {
219+
'Pass' { '✅ Pass' }
220+
'Fail' { '❌ Fail' }
221+
'Investigate' { '⚠️ Investigate' }
222+
}
223+
224+
if ($result.ActiveWatchlists.Count -gt 0) {
225+
$namesMd = ($result.ActiveWatchlists | ForEach-Object { Get-SafeMarkdown $_.properties.displayName }) -join ', '
226+
$aliasesMd = ($result.ActiveWatchlists | ForEach-Object { Get-SafeMarkdown $_.properties.watchlistAlias }) -join ', '
227+
$provsMd = ($result.ActiveWatchlists | ForEach-Object { Get-SafeMarkdown $_.properties.provider }) -join ', '
228+
$statesMd = ($result.ActiveWatchlists | ForEach-Object {
229+
if ($_.properties.provisioningState -eq 'Succeeded') { '✅ Succeeded' } else { "⚠️ $($_.properties.provisioningState)" }
230+
}) -join ', '
231+
$tableRows += "| $subMd | $workspaceMd | $($result.TotalWatchlists) | $namesMd | $aliasesMd | $provsMd | $statesMd | $statusDisplay |`n"
232+
}
233+
else {
234+
# No active watchlists (Fail) or API error (Investigate) — one placeholder row so the workspace appears in the table
235+
$countMd = if ($null -eq $result.TotalWatchlists) { '' } else { $result.TotalWatchlists }
236+
$tableRows += "| $subMd | $workspaceMd | $countMd | — | — | — | — | $statusDisplay |`n"
237+
}
238+
}
239+
240+
if ($hasMoreItems) {
241+
$remainingCount = $workspaceResults.Count - $maxDisplay
242+
$tableRows += "`n... and $remainingCount more. [View all in Microsoft Sentinel]($portalSentinelLink)`n"
243+
}
244+
245+
$mdInfo = $formatTemplate -f $tableTitle, $portalSentinelLink, $tableRows
246+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
247+
248+
#endregion Report Generation
249+
250+
$params = @{
251+
TestId = '41208'
252+
Title = 'At least one watchlist is configured in Microsoft Sentinel for correlation against high-value entities'
253+
Status = $passed
254+
Result = $testResultMarkdown
255+
}
256+
if ($customStatus) {
257+
$params.CustomStatus = $customStatus
258+
}
259+
260+
Add-ZtTestResultDetail @params
261+
}

0 commit comments

Comments
 (0)