Skip to content

Commit 71200d6

Browse files
authored
SecOps - 41116 - Threat hunting against Email and Collaboration tables in Microsoft 365 Defender Advanced Hunting is operational (#1425)
2 parents b017427 + a10bd37 commit 71200d6

3 files changed

Lines changed: 442 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
Describe 'Test-Assessment-41116' {
2+
BeforeAll {
3+
$srcRoot = Join-Path $PSScriptRoot '../../src/powershell'
4+
5+
if (-not (Get-Command Write-PSFMessage -ErrorAction SilentlyContinue)) {
6+
function global:Write-PSFMessage {}
7+
}
8+
if (-not (Get-Command Write-ZtProgress -ErrorAction SilentlyContinue)) {
9+
function global:Write-ZtProgress {}
10+
}
11+
if (-not (Get-Command Invoke-ZtGraphRequest -ErrorAction SilentlyContinue)) {
12+
function global:Invoke-ZtGraphRequest {
13+
[CmdletBinding()]
14+
param($RelativeUri, $ApiVersion, $Method, $Body)
15+
}
16+
}
17+
if (-not (Get-Command Get-ZtHttpStatusCode -ErrorAction SilentlyContinue)) {
18+
function global:Get-ZtHttpStatusCode { param($ErrorRecord) return $null }
19+
}
20+
if (-not (Get-Command Get-SafeMarkdown -ErrorAction SilentlyContinue)) {
21+
function global:Get-SafeMarkdown { param($Text) return $Text }
22+
}
23+
if (-not (Get-Command Add-ZtTestResultDetail -ErrorAction SilentlyContinue)) {
24+
function global:Add-ZtTestResultDetail {
25+
param(
26+
[string] $Description, [bool] $Status, [string] $Result,
27+
[Object[]] $GraphObjects,[string] $GraphObjectType,
28+
[string] $TestId, [string] $Title, [string] $SkippedBecause,
29+
[string] $UserImpact, [string] $Risk, [string] $ImplementationCost,
30+
[string[]] $AppliesTo, [string[]] $Tag, [string] $CustomStatus,
31+
[string[]] $NotConnectedService, [string] $Pillar, [string] $Category
32+
)
33+
}
34+
}
35+
36+
$classPath = Join-Path $srcRoot 'classes/ZtTest.ps1'
37+
if (-not ('ZtTest' -as [type])) {
38+
. $classPath
39+
}
40+
41+
. (Join-Path $srcRoot 'tests/Test-Assessment.41116.ps1')
42+
43+
function global:New-TestGraphError {
44+
param(
45+
[Parameter(Mandatory)]
46+
[string] $Message,
47+
48+
[string] $ResponseBody
49+
)
50+
51+
$errorRecord = [System.Management.Automation.ErrorRecord]::new(
52+
[System.Exception]::new($Message),
53+
'GraphRequestFailed',
54+
[System.Management.Automation.ErrorCategory]::InvalidOperation,
55+
$null
56+
)
57+
if ($ResponseBody) {
58+
$errorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($ResponseBody)
59+
}
60+
$errorRecord
61+
}
62+
}
63+
64+
BeforeEach {
65+
Mock Write-PSFMessage {}
66+
Mock Write-ZtProgress {}
67+
Mock Get-SafeMarkdown { param($Text) return $Text }
68+
Mock Get-ZtHttpStatusCode { return $null }
69+
70+
$script:capturedRequestBody = $null
71+
$script:capturedStatus = $null
72+
$script:capturedResult = $null
73+
$script:capturedCustomStatus = $null
74+
$script:capturedSkippedBecause = $null
75+
76+
Mock Add-ZtTestResultDetail {
77+
param($TestId, $Title, $Status, $Result, $CustomStatus, $SkippedBecause)
78+
$script:capturedStatus = $Status
79+
$script:capturedResult = $Result
80+
$script:capturedCustomStatus = $CustomStatus
81+
$script:capturedSkippedBecause = $SkippedBecause
82+
}
83+
}
84+
85+
Context 'When the advanced hunting query succeeds' {
86+
It 'Should send the expected Graph POST request and pass for a positive count' {
87+
Mock Invoke-ZtGraphRequest {
88+
param($Body)
89+
$script:capturedRequestBody = $Body
90+
[PSCustomObject]@{
91+
results = @([PSCustomObject]@{ Count = 42L })
92+
}
93+
}
94+
95+
Test-Assessment-41116
96+
97+
Should -Invoke Invoke-ZtGraphRequest -Times 1 -Exactly -ParameterFilter {
98+
$RelativeUri -eq 'security/runHuntingQuery' -and
99+
$ApiVersion -eq 'beta' -and
100+
$Method -eq 'POST'
101+
}
102+
$requestBody = $script:capturedRequestBody | ConvertFrom-Json
103+
$requestBody.Query | Should -Be 'EmailEvents | where Timestamp > ago(1d) | summarize Count=count()'
104+
$requestBody.Timespan | Should -Be 'P1D'
105+
$script:capturedStatus | Should -BeTrue
106+
$script:capturedCustomStatus | Should -BeNullOrEmpty
107+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
108+
$script:capturedResult | Should -Match 'data is queryable from automation'
109+
$script:capturedResult | Should -Match '\| /beta/security/runHuntingQuery \| 200 \|'
110+
$script:capturedResult | Should -Match '\| 42 \| Pass \|'
111+
}
112+
113+
It 'Should investigate when the count is zero' {
114+
Mock Invoke-ZtGraphRequest {
115+
[PSCustomObject]@{
116+
results = @([PSCustomObject]@{ Count = 0L })
117+
}
118+
}
119+
120+
Test-Assessment-41116
121+
122+
$script:capturedStatus | Should -BeFalse
123+
$script:capturedCustomStatus | Should -Be 'Investigate'
124+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
125+
$script:capturedResult | Should -Match 'zero recent EmailEvents'
126+
$script:capturedResult | Should -Match '\| 0 \| Investigate \|'
127+
}
128+
129+
It 'Should preserve a count above the 32-bit integer limit' {
130+
Mock Invoke-ZtGraphRequest {
131+
[PSCustomObject]@{
132+
results = @([PSCustomObject]@{ Count = 3000000000L })
133+
}
134+
}
135+
136+
Test-Assessment-41116
137+
138+
$script:capturedStatus | Should -BeTrue
139+
$script:capturedCustomStatus | Should -BeNullOrEmpty
140+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
141+
$script:capturedResult | Should -Match '\| 3000000000 \| Pass \|'
142+
}
143+
144+
It 'Should investigate when the results array is empty' {
145+
Mock Invoke-ZtGraphRequest {
146+
[PSCustomObject]@{ results = @() }
147+
}
148+
149+
Test-Assessment-41116
150+
151+
$script:capturedStatus | Should -BeFalse
152+
$script:capturedCustomStatus | Should -Be 'Investigate'
153+
$script:capturedResult | Should -Match 'No results were returned'
154+
}
155+
156+
It 'Should investigate when the count cannot be parsed' {
157+
Mock Invoke-ZtGraphRequest {
158+
[PSCustomObject]@{
159+
results = @([PSCustomObject]@{ Count = 'not-a-number' })
160+
}
161+
}
162+
163+
{ Test-Assessment-41116 } | Should -Not -Throw
164+
$script:capturedStatus | Should -BeFalse
165+
$script:capturedCustomStatus | Should -Be 'Investigate'
166+
$script:capturedResult | Should -Match 'could not be parsed'
167+
}
168+
}
169+
170+
Context 'When Microsoft Graph returns an error' {
171+
It 'Should skip a license-related HTTP 403 response' {
172+
$script:queryError = New-TestGraphError `
173+
-Message 'Forbidden' `
174+
-ResponseBody '{"error":{"code":"Forbidden","message":"The tenant is not licensed for Defender for Office 365 Plan 2."}}'
175+
Mock Get-ZtHttpStatusCode { return 403 }
176+
Mock Invoke-ZtGraphRequest {
177+
$PSCmdlet.ThrowTerminatingError($script:queryError)
178+
}
179+
180+
Test-Assessment-41116
181+
182+
$script:capturedStatus | Should -BeFalse
183+
$script:capturedCustomStatus | Should -BeNullOrEmpty
184+
$script:capturedSkippedBecause | Should -Be 'NotApplicable'
185+
$script:capturedResult | Should -Match 'does not have the required Microsoft Defender for Office 365 Plan 2'
186+
$script:capturedResult | Should -Match '\| 403 \| Forbidden \|'
187+
}
188+
189+
It 'Should investigate a permission-related HTTP 403 response' {
190+
$script:queryError = New-TestGraphError `
191+
-Message 'Forbidden' `
192+
-ResponseBody '{"error":{"code":"Forbidden","message":"Access denied."}}'
193+
Mock Get-ZtHttpStatusCode { return 403 }
194+
Mock Invoke-ZtGraphRequest {
195+
$PSCmdlet.ThrowTerminatingError($script:queryError)
196+
}
197+
198+
Test-Assessment-41116
199+
200+
$script:capturedStatus | Should -BeFalse
201+
$script:capturedCustomStatus | Should -Be 'Investigate'
202+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
203+
$script:capturedResult | Should -Match 'ThreatHunting.Read.All'
204+
$script:capturedResult | Should -Match 'Global Reader'
205+
$script:capturedResult | Should -Match 'Security Administrator'
206+
$script:capturedResult | Should -Match 'Defender XDR Unified RBAC'
207+
}
208+
209+
It 'Should fail when HTTP 400 reports an unknown EmailEvents table' {
210+
$script:queryError = New-TestGraphError `
211+
-Message 'Bad Request' `
212+
-ResponseBody '{"error":{"code":"BadRequest","message":"Failed to resolve table expression named EmailEvents."}}'
213+
Mock Get-ZtHttpStatusCode { return 400 }
214+
Mock Invoke-ZtGraphRequest {
215+
$PSCmdlet.ThrowTerminatingError($script:queryError)
216+
}
217+
218+
Test-Assessment-41116
219+
220+
$script:capturedStatus | Should -BeFalse
221+
$script:capturedCustomStatus | Should -BeNullOrEmpty
222+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
223+
$script:capturedResult | Should -Match 'schema is unavailable'
224+
$script:capturedResult | Should -Match '\| Fail \|'
225+
}
226+
227+
It 'Should investigate an HTTP 429 throttling response' {
228+
$script:queryError = New-TestGraphError -Message 'Too Many Requests'
229+
Mock Get-ZtHttpStatusCode { return 429 }
230+
Mock Invoke-ZtGraphRequest {
231+
$PSCmdlet.ThrowTerminatingError($script:queryError)
232+
}
233+
234+
Test-Assessment-41116
235+
236+
$script:capturedStatus | Should -BeFalse
237+
$script:capturedCustomStatus | Should -Be 'Investigate'
238+
$script:capturedResult | Should -Match 'quota was exceeded'
239+
}
240+
241+
It 'Should investigate an unexpected Graph failure' {
242+
$script:queryError = New-TestGraphError -Message 'Service Unavailable'
243+
Mock Get-ZtHttpStatusCode { return 503 }
244+
Mock Invoke-ZtGraphRequest {
245+
$PSCmdlet.ThrowTerminatingError($script:queryError)
246+
}
247+
248+
Test-Assessment-41116
249+
250+
$script:capturedStatus | Should -BeFalse
251+
$script:capturedCustomStatus | Should -Be 'Investigate'
252+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
253+
$script:capturedResult | Should -Match 'unexpected error'
254+
}
255+
256+
It 'Should investigate an unparseable error response' {
257+
$script:queryError = New-TestGraphError -Message 'Forbidden' -ResponseBody 'not-json'
258+
Mock Get-ZtHttpStatusCode { return 403 }
259+
Mock Invoke-ZtGraphRequest {
260+
$PSCmdlet.ThrowTerminatingError($script:queryError)
261+
}
262+
263+
{ Test-Assessment-41116 } | Should -Not -Throw
264+
$script:capturedStatus | Should -BeFalse
265+
$script:capturedCustomStatus | Should -Be 'Investigate'
266+
$script:capturedSkippedBecause | Should -BeNullOrEmpty
267+
$script:capturedResult | Should -Match 'ThreatHunting.Read.All'
268+
}
269+
}
270+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- markdownlint-disable MD041 -->
2+
Advanced hunting in Microsoft Defender XDR lets security teams query event and entity data across email and collaboration telemetry using Kusto Query Language. Email and Collaboration tables such as `EmailEvents`, `EmailAttachmentInfo`, `EmailUrlInfo`, `EmailPostDeliveryEvents`, and `UrlClickEvents` help analysts find suspicious messages, attachments, links, clicks, and post-delivery actions that may not already be tied to an alert. Threat actors can use new sender patterns, weaponized links, or delivered attachments before alert rules fully identify the campaign. Hunting gives the SOC a way to look across initial access, credential access, lateral movement, and impact signals and connect them to incidents. This check only proves the API and table are available and returning data; it does not prove that skilled analysts are writing effective hunts or that hunts run on a useful cadence.
3+
4+
## Remediation action
5+
6+
- [Run a hunting query - Microsoft Graph](https://learn.microsoft.com/en-us/graph/api/security-security-runhuntingquery?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci)
7+
- [Advanced hunting schema tables](https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-schema-tables?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci)
8+
- [Hunt for threats across devices, emails, apps, and identities](https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-query-emails-devices?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci)
9+
10+
<!--- Results --->
11+
%TestResult%

0 commit comments

Comments
 (0)