Skip to content

Commit 252f8dc

Browse files
authored
SecOps - 41212 - Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks (#1484)
2 parents 86b8624 + b4fdff7 commit 252f8dc

2 files changed

Lines changed: 288 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Hunting in Sentinel is the proactive, hypothesis-driven counterpart to rule-based detection: SOC analysts run KQL queries against ingested telemetry to find threat actor behavior that no analytics rule has yet been written to catch — emerging TTPs, novel persistence mechanisms, low-and-slow command-and-control, insider data staging, supply chain compromise, and post-compromise reconnaissance. Sentinel exposes hunting through three documented surfaces: saved hunting queries (Microsoft.SecurityInsights/huntingQueries legacy resource and the modern hunts API, Microsoft.SecurityInsights/hunts), bookmarks that pin investigation findings (Microsoft.SecurityInsights/bookmarks) for case-tracking and incident attachment, and Notebooks (Jupyter / Azure ML) for advanced investigation workflows. Without operationalized hunting, the SOC operates in a purely reactive posture and only finds what its current rule corpus is configured to find; investigations cannot be tracked or shared across analysts, and the institutional knowledge of "we have hunted this hypothesis and ruled it out" or "we have found this anomaly and need to keep investigating" is lost when the analyst's browser tab closes. The check confirms at least one saved hunting query or bookmark exists in the workspace, indicating hunting is being practiced rather than aspirational.
2+
3+
**Remediation action**
4+
5+
- [Hunt for threats with Microsoft Sentinel](https://learn.microsoft.com/azure/sentinel/hunting)
6+
- [Use bookmarks to save interesting information while hunting](https://learn.microsoft.com/azure/sentinel/bookmarks)
7+
- [Use Jupyter Notebook to hunt for security threats](https://learn.microsoft.com/azure/sentinel/notebooks)
8+
9+
<!--- Results --->
10+
%TestResult%
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
<#
2+
.SYNOPSIS
3+
Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks
4+
#>
5+
function Test-Assessment-41212 {
6+
[ZtTest(
7+
Category = 'Security information and event management',
8+
ImplementationCost = 'Medium',
9+
Service = ('Azure'),
10+
MinimumLicense = ('Consumption-based: Microsoft Sentinel'),
11+
Pillar = 'SecOps',
12+
RiskLevel = 'Medium',
13+
SfiPillar = 'Accelerate response and remediation',
14+
TenantType = ('Workforce'),
15+
TestId = 41212,
16+
Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks',
17+
UserImpact = 'Low'
18+
)]
19+
[CmdletBinding()]
20+
param()
21+
22+
#region Data Collection
23+
24+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
25+
$activity = 'Checking Sentinel hunting queries and bookmarks'
26+
27+
# Q1 + Q2 + onboarding check via shared helper.
28+
# Returns 'Forbidden' on ARG 401/403 (Investigate).
29+
# Returns $null on unexpected ARG failure (Investigate).
30+
# Returns 'NoSubscriptions' when no enabled subscriptions are accessible (Skip).
31+
# Returns 'NoWorkspaces' when no Log Analytics workspaces exist in scope (Skip).
32+
$allWorkspaces = Get-SentinelWorkspaceData -Activity $activity
33+
34+
if ($null -eq $allWorkspaces) {
35+
$params = @{
36+
TestId = '41212'
37+
Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
38+
Status = $false
39+
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.'
40+
CustomStatus = 'Investigate'
41+
}
42+
Add-ZtTestResultDetail @params
43+
return
44+
}
45+
46+
if ($allWorkspaces -eq 'Forbidden') {
47+
$params = @{
48+
TestId = '41212'
49+
Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
50+
Status = $false
51+
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.'
52+
CustomStatus = 'Investigate'
53+
}
54+
Add-ZtTestResultDetail @params
55+
return
56+
}
57+
58+
if ($allWorkspaces -eq 'NoSubscriptions') {
59+
Write-PSFMessage 'No enabled subscriptions found — skipping Sentinel hunting check.' -Tag Test -Level VeryVerbose
60+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
61+
return
62+
}
63+
64+
if ($allWorkspaces -eq 'NoWorkspaces') {
65+
Write-PSFMessage 'No Log Analytics workspaces found across accessible subscriptions — skipping Sentinel hunting check.' -Tag Test -Level VeryVerbose
66+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
67+
return
68+
}
69+
70+
$checkableWorkspaces = @($allWorkspaces | Where-Object { -not $_.PermissionError })
71+
$forbiddenWorkspaces = @($allWorkspaces | Where-Object { $_.PermissionError })
72+
$onboardingErrorWorkspaces = @($allWorkspaces | Where-Object { $_.OnboardingError })
73+
$onboardedWorkspaces = @($checkableWorkspaces | Where-Object { $_.SentinelOnboarded })
74+
75+
if ($onboardedWorkspaces.Count -eq 0) {
76+
if ($forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0) {
77+
# Auth errors or onboarding-state failures mean we cannot confirm whether those workspaces have Sentinel onboarded;
78+
# we cannot rule out a passing workspace exists among the inaccessible ones.
79+
$params = @{
80+
TestId = '41212'
81+
Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
82+
Status = $false
83+
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.'
84+
CustomStatus = 'Investigate'
85+
}
86+
Add-ZtTestResultDetail @params
87+
}
88+
else {
89+
# Spec: no Sentinel-onboarded workspaces with full visibility — Skipped.
90+
Write-PSFMessage 'No Sentinel-onboarded workspaces found — skipping Sentinel hunting check.' -Tag Test -Level VeryVerbose
91+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
92+
}
93+
return
94+
}
95+
96+
# Q1 (spec): List saved searches; filter to those with category "Hunting Queries".
97+
$rawSavedSearchesByWorkspace = @{}
98+
99+
foreach ($workspace in $onboardedWorkspaces) {
100+
Write-ZtProgress -Activity $activity -Status "Fetching saved hunting queries for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
101+
$savedSearchesPath = "$($workspace.WorkspaceId)/savedSearches?api-version=2026-03-01"
102+
103+
try {
104+
$rawSavedSearchesByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $savedSearchesPath -ErrorAction Stop)
105+
}
106+
catch {
107+
$rawSavedSearchesByWorkspace[$workspace.WorkspaceId] = $null
108+
Write-PSFMessage "Error querying saved hunting queries for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
109+
}
110+
}
111+
112+
# Q2 (spec): List bookmarks for each Sentinel-onboarded workspace.
113+
$rawBookmarksByWorkspace = @{}
114+
115+
foreach ($workspace in $onboardedWorkspaces) {
116+
Write-ZtProgress -Activity $activity -Status "Fetching bookmarks for $($workspace.WorkspaceName) in $($workspace.SubscriptionName)"
117+
$bookmarksPath = "$($workspace.WorkspaceId)/providers/Microsoft.SecurityInsights/bookmarks?api-version=2025-09-01"
118+
119+
try {
120+
$rawBookmarksByWorkspace[$workspace.WorkspaceId] = @(Invoke-ZtAzureRequest -Path $bookmarksPath -ErrorAction Stop)
121+
}
122+
catch {
123+
$rawBookmarksByWorkspace[$workspace.WorkspaceId] = $null
124+
Write-PSFMessage "Error querying bookmarks for workspace '$($workspace.WorkspaceName)' in subscription '$($workspace.SubscriptionName)': $_" -Tag Test -Level Warning
125+
}
126+
}
127+
128+
#endregion Data Collection
129+
130+
#region Assessment Logic
131+
132+
$workspaceResults = foreach ($workspace in $onboardedWorkspaces) {
133+
$rawSavedSearches = $rawSavedSearchesByWorkspace[$workspace.WorkspaceId]
134+
$rawBookmarks = $rawBookmarksByWorkspace[$workspace.WorkspaceId]
135+
136+
$q1Error = $null -eq $rawSavedSearches
137+
$q2Error = $null -eq $rawBookmarks
138+
139+
$huntingQueryCount = $null
140+
$bookmarkCount = $null
141+
$recentBookmarkName = $null
142+
$recentBookmarkBy = $null
143+
144+
if (-not $q1Error) {
145+
# savedSearches of category "Hunting Queries" are the persisted hunting surface.
146+
$huntingSearches = @($rawSavedSearches | Where-Object { $_.properties.category -eq 'Hunting Queries' })
147+
$huntingQueryCount = $huntingSearches.Count
148+
}
149+
150+
if (-not $q2Error) {
151+
$bookmarkCount = $rawBookmarks.Count
152+
153+
if ($bookmarkCount -gt 0) {
154+
# Surface the most recently created bookmark for the display table.
155+
$recentBookmark = $rawBookmarks | Sort-Object { $_.properties.created } -Descending | Select-Object -First 1
156+
$recentBookmarkName = $recentBookmark.properties.displayName
157+
$recentBookmarkBy = if ($recentBookmark.properties.createdBy.name) {
158+
$recentBookmark.properties.createdBy.name
159+
} elseif ($recentBookmark.properties.createdBy.email) {
160+
$recentBookmark.properties.createdBy.email
161+
} else { $null }
162+
}
163+
}
164+
165+
# Spec evaluation order — first matching rule wins:
166+
# Rule 1: Pass if Q1 succeeded with count >= 1 OR Q2 succeeded with count >= 1.
167+
# A confirmed positive from either surface is authoritative even if the other errored.
168+
# Rule 2: Investigate if either query errored (absence cannot be confirmed while a query fails).
169+
# Rule 3: Fail if both succeeded and both counts are zero.
170+
$rowStatus = if ((-not $q1Error -and $huntingQueryCount -ge 1) -or (-not $q2Error -and $bookmarkCount -ge 1)) {
171+
'Pass'
172+
} elseif ($q1Error -or $q2Error) {
173+
'Investigate'
174+
} else {
175+
'Fail'
176+
}
177+
178+
[PSCustomObject]@{
179+
SubscriptionName = $workspace.SubscriptionName
180+
SubscriptionId = $workspace.SubscriptionId
181+
WorkspaceName = $workspace.WorkspaceName
182+
ResourceGroup = $workspace.ResourceGroup
183+
WorkspaceId = $workspace.WorkspaceId
184+
HuntingQueryCount = $huntingQueryCount
185+
BookmarkCount = $bookmarkCount
186+
RecentBookmarkName = $recentBookmarkName
187+
RecentBookmarkBy = $recentBookmarkBy
188+
RowStatus = $rowStatus
189+
}
190+
}
191+
$workspaceResults = @($workspaceResults)
192+
193+
$passedItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Pass' })
194+
$investigateItems = @($workspaceResults | Where-Object { $_.RowStatus -eq 'Investigate' })
195+
196+
$passed = $passedItems.Count -gt 0
197+
$customStatus = $null
198+
199+
if (-not $passed -and ($investigateItems.Count -gt 0 -or $forbiddenWorkspaces.Count -gt 0 -or $onboardingErrorWorkspaces.Count -gt 0)) {
200+
$customStatus = 'Investigate'
201+
$testResultMarkdown = "⚠️ Hunting capability could not be confirmed — one or more workspaces had insufficient permissions on the Sentinel onboarding check, or the saved hunting queries or bookmarks API returned an unexpected response. Re-run after verifying Microsoft Sentinel Reader access on each affected workspace.`n`n%TestResult%"
202+
}
203+
elseif ($passed) {
204+
$testResultMarkdown = "✅ Hunting capability is operationalized in the Sentinel workspace.`n`n%TestResult%"
205+
}
206+
else {
207+
$testResultMarkdown = "❌ No saved hunting queries or bookmarks exist in the Sentinel workspace.`n`n%TestResult%"
208+
}
209+
210+
#endregion Assessment Logic
211+
212+
#region Report Generation
213+
214+
$azContext = Get-AzContext -ErrorAction SilentlyContinue
215+
$portalHost = if ($azContext -and $azContext.Environment.Name -eq 'AzureUSGovernment') { 'https://portal.azure.us' } else { 'https://portal.azure.com' }
216+
$portalSentinelLink = "$portalHost/#view/HubsExtension/BrowseResource/resourceType/microsoft.securityinsightsarg%2Fsentinel"
217+
$tableTitle = 'Hunting queries and bookmarks per workspace'
218+
219+
$formatTemplate = @'
220+
221+
222+
### [{0}]({1})
223+
224+
| Subscription | Workspace | Hunting queries | Bookmarks | Recent bookmark | Created by | Status |
225+
| :----------- | :-------- | --------------: | --------: | :-------------- | :--------- | :----- |
226+
{2}
227+
'@
228+
229+
$tableRows = ''
230+
$maxDisplay = 10
231+
$statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
232+
$displayResults = @($workspaceResults | Sort-Object { $statusPriority[$_.RowStatus] }, SubscriptionName, WorkspaceName)
233+
$hasMoreItems = $false
234+
if ($workspaceResults.Count -gt $maxDisplay) {
235+
$displayResults = @($displayResults | Select-Object -First $maxDisplay)
236+
$hasMoreItems = $true
237+
}
238+
239+
foreach ($result in $displayResults) {
240+
$subLink = "$portalHost/#resource/subscriptions/$($result.SubscriptionId)"
241+
$sentinelId = "/subscriptions/$($result.SubscriptionId)/resourcegroups/$($result.ResourceGroup)/providers/microsoft.securityinsightsarg/sentinel/$($result.WorkspaceName)"
242+
$huntingLink = "$portalHost/#view/Microsoft_Azure_Security_Insights/MainMenuBlade/~/Hunting/id/$($sentinelId -replace '/', '%2F')"
243+
$subMd = "[$(Get-SafeMarkdown $result.SubscriptionName)]($subLink)"
244+
$workspaceMd = "[$(Get-SafeMarkdown $result.WorkspaceName)]($huntingLink)"
245+
$huntingCountMd = if ($null -eq $result.HuntingQueryCount) { '' } else { $result.HuntingQueryCount }
246+
$bookmarkCountMd = if ($null -eq $result.BookmarkCount) { '' } else { $result.BookmarkCount }
247+
$recentNameMd = if ($result.RecentBookmarkName) { Get-SafeMarkdown -Text $result.RecentBookmarkName } else { '' }
248+
$recentByMd = if ($result.RecentBookmarkBy) { Get-SafeMarkdown -Text $result.RecentBookmarkBy } else { '' }
249+
$statusDisplay = switch ($result.RowStatus) {
250+
'Pass' { '✅ Pass' }
251+
'Fail' { '❌ Fail' }
252+
'Investigate' { '⚠️ Investigate' }
253+
}
254+
$tableRows += "| $subMd | $workspaceMd | $huntingCountMd | $bookmarkCountMd | $recentNameMd | $recentByMd | $statusDisplay |`n"
255+
}
256+
257+
if ($hasMoreItems) {
258+
$remainingCount = $workspaceResults.Count - $maxDisplay
259+
$tableRows += "`n... and $remainingCount more. [View all in Microsoft Sentinel]($portalSentinelLink)`n"
260+
}
261+
262+
$mdInfo = $formatTemplate -f $tableTitle, $portalSentinelLink, $tableRows
263+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
264+
265+
#endregion Report Generation
266+
267+
$params = @{
268+
TestId = '41212'
269+
Title = 'Hunting capabilities are operationalized in Microsoft Sentinel via saved hunting queries or bookmarks'
270+
Status = $passed
271+
Result = $testResultMarkdown
272+
}
273+
if ($customStatus) {
274+
$params.CustomStatus = $customStatus
275+
}
276+
277+
Add-ZtTestResultDetail @params
278+
}

0 commit comments

Comments
 (0)