Skip to content

Commit d636cd8

Browse files
committed
SecOps - 41219 - Microsoft Graph identity and device APIs are queryable so analysts (with Security Copilot) can review identities and devices during investigation
1 parent 480e41e commit d636cd8

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Identities and devices are the two pivot points of nearly every modern intrusion: a threat actor first compromises a user, enumerates Entra group memberships and device ownership for Discovery, and then chains lateral hops across managed devices for Lateral Movement before reaching a high-value target. Microsoft Security Copilot's Microsoft Entra and Microsoft Intune plugins surface, in natural language, the same identity and device context that analysts would otherwise gather by hand from Microsoft Graph — sign-in risk, role assignments, group membership, device compliance, OS version, and last sync — and condense it into the incident workspace. The plugins are only as useful as the underlying Graph data planes; if the Entra users, devices, or Intune managedDevices endpoints are unreachable for the assessment principal, or if Security Copilot itself is not provisioned, analysts revert to manual cross-portal navigation, mean-time-to-respond (MTTR) increases, and the threat actor's window for Collection and Exfiltration widens.
2+
3+
**Remediation action**
4+
5+
- [Get started with Security Copilot](https://learn.microsoft.com/copilot/security/get-started-security-copilot)
6+
- [Manage plugins in Microsoft Security Copilot](https://learn.microsoft.com/copilot/security/manage-plugins)
7+
8+
<!--- Results --->
9+
%TestResult%
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
<#
2+
.SYNOPSIS
3+
Microsoft Graph identity and device APIs are queryable so analysts (with Security Copilot) can review identities and devices during investigation.
4+
5+
.NOTES
6+
Test ID: 41219
7+
Workshop Task: SECOPS_129
8+
Pillar: SecOps
9+
Category: AI for security
10+
Required Graph permissions: Device.Read.All (Q1), User.Read.All (Q2),
11+
DeviceManagementManagedDevices.Read.All (Q3), LicenseAssignment.Read.All (Q5)
12+
Required Azure role: Reader (or equivalent) on subscriptions hosting Security Copilot capacity (Q4)
13+
#>
14+
15+
function Test-Assessment-41219 {
16+
[ZtTest(
17+
Category = 'AI for security',
18+
ImplementationCost = 'Low',
19+
MinimumLicense = ('Consumption-based: Microsoft Security Copilot'),
20+
Pillar = 'SecOps',
21+
RiskLevel = 'Medium',
22+
Service = ('Azure', 'Graph'),
23+
SfiPillar = 'Accelerate response and remediation',
24+
TenantType = ('Workforce'),
25+
TestId = 41219,
26+
Title = 'Microsoft Graph identity and device APIs are queryable so analysts (with Security Copilot) can review identities and devices during investigation',
27+
UserImpact = 'Low'
28+
)]
29+
[CmdletBinding()]
30+
param()
31+
32+
#region Data Collection
33+
34+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
35+
$activity = 'Checking Microsoft Graph identity and device data plane reachability for Security Copilot'
36+
37+
# Q5: Determine Intune licensing from subscribedSkus before evaluating Q3.
38+
# Required permission: LicenseAssignment.Read.All (least-privilege).
39+
# INTUNE_A (Plan 1) and INTUNE_P2 (Plan 2, additive to Plan 1) both indicate Intune is licensed.
40+
Write-ZtProgress -Activity $activity -Status 'Checking Intune licensing via subscribedSkus (Q5)'
41+
$intuneServicePlanNames = @('INTUNE_A', 'INTUNE_P2')
42+
$intuneLicensed = $false
43+
$decidingPlanName = $null
44+
$subscribedSkusCount = 0
45+
try {
46+
$subscribedSkus = @(Invoke-ZtGraphRequest -RelativeUri 'subscribedSkus' -Select 'skuPartNumber,servicePlans,capabilityStatus' -ApiVersion beta -ErrorAction Stop)
47+
$subscribedSkusCount = $subscribedSkus.Count
48+
$decidingPlan = $subscribedSkus |
49+
Where-Object { $_.capabilityStatus -eq 'Enabled' } |
50+
ForEach-Object { $_.servicePlans } |
51+
Where-Object { $_.servicePlanName -in $intuneServicePlanNames -and $_.provisioningStatus -eq 'Success' } |
52+
Select-Object -First 1
53+
$intuneLicensed = $null -ne $decidingPlan
54+
if ($decidingPlan) { $decidingPlanName = $decidingPlan.servicePlanName }
55+
}
56+
catch {
57+
Write-PSFMessage "Q5 subscribedSkus query failed: $_" -Tag Test -Level Warning
58+
$params = @{
59+
TestId = '41219'
60+
Title = 'Microsoft Graph identity and device APIs are queryable so analysts (with Security Copilot) can review identities and devices during investigation'
61+
# More specific than the generic spec Investigate message; names the required permission for actionability.
62+
Status = $false
63+
Result = "⚠️ Intune licensing could not be determined — ``GET /subscribedSkus`` returned an error. Verify the assessment principal has ``LicenseAssignment.Read.All`` permission and re-run."
64+
CustomStatus = 'Investigate'
65+
}
66+
Add-ZtTestResultDetail @params
67+
return
68+
}
69+
70+
# Q1: Verify the Microsoft Entra device data plane is reachable.
71+
Write-ZtProgress -Activity $activity -Status 'Verifying Entra device data plane (Q1)'
72+
$q1Devices = $null
73+
$q1Error = $null
74+
$q1HttpStatus = $null
75+
try {
76+
$q1Devices = @(Invoke-ZtGraphRequest -RelativeUri 'devices' -Select 'id,displayName,operatingSystem,trustType,isCompliant,isManaged' -Top 1 -ApiVersion beta -ErrorAction Stop)
77+
}
78+
catch {
79+
$q1Error = $_
80+
$q1HttpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
81+
Write-PSFMessage "Q1 Entra devices query failed: $_" -Tag Test -Level Warning
82+
}
83+
84+
# Q2: Verify the Microsoft Entra users data plane is reachable.
85+
Write-ZtProgress -Activity $activity -Status 'Verifying Entra users data plane (Q2)'
86+
$q2Users = $null
87+
$q2Error = $null
88+
$q2HttpStatus = $null
89+
try {
90+
$q2Users = @(Invoke-ZtGraphRequest -RelativeUri 'users' -Select 'id,userPrincipalName,accountEnabled,assignedLicenses' -Top 1 -ApiVersion beta -ErrorAction Stop)
91+
}
92+
catch {
93+
$q2Error = $_
94+
$q2HttpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
95+
Write-PSFMessage "Q2 Entra users query failed: $_" -Tag Test -Level Warning
96+
}
97+
98+
# Q3: Verify the Intune managed device data plane (excluded as N/A when Intune is not licensed per Q5).
99+
$q3Devices = $null
100+
$q3Error = $null
101+
$q3HttpStatus = $null
102+
$q3Excluded = -not $intuneLicensed
103+
if (-not $q3Excluded) {
104+
Write-ZtProgress -Activity $activity -Status 'Verifying Intune managed device data plane (Q3)'
105+
try {
106+
$q3Devices = @(Invoke-ZtGraphRequest -RelativeUri 'deviceManagement/managedDevices' -Select 'id,deviceName,operatingSystem,complianceState,lastSyncDateTime' -Top 1 -ApiVersion beta -ErrorAction Stop)
107+
}
108+
catch {
109+
$q3Error = $_
110+
$q3HttpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
111+
Write-PSFMessage "Q3 Intune managed devices query failed: $_" -Tag Test -Level Warning
112+
}
113+
}
114+
115+
# Q4: Verify Security Copilot capacity via Azure Resource Graph.
116+
# Required Azure role: Reader (or equivalent read access) on the subscriptions hosting the capacity.
117+
Write-ZtProgress -Activity $activity -Status 'Verifying Security Copilot capacity via Azure Resource Graph (Q4)'
118+
$argQuery = @"
119+
resources
120+
| where type =~ 'microsoft.securitycopilot/capacities'
121+
| join kind=leftouter (
122+
resourcecontainers
123+
| where type =~ 'microsoft.resources/subscriptions'
124+
| where properties.state =~ 'Enabled'
125+
| project subscriptionId, subscriptionName = name
126+
) on subscriptionId
127+
| project id, name, location, resourceGroup, subscriptionId, subscriptionName, provisioningState = tostring(properties.provisioningState)
128+
"@
129+
$capacities = @()
130+
$q4Error = $null
131+
try {
132+
$capacities = @(Invoke-ZtAzureResourceGraphRequest -Query $argQuery)
133+
Write-PSFMessage "Q4 ARG returned $($capacities.Count) Security Copilot capacity resource(s)" -Tag Test -Level VeryVerbose
134+
}
135+
catch {
136+
$q4Error = $_
137+
Write-PSFMessage "Q4 ARG query failed: $($q4Error.Exception.Message)" -Tag Test -Level Warning
138+
}
139+
140+
#endregion Data Collection
141+
142+
#region Assessment Logic
143+
144+
# Classify each Graph data plane as Reachable, Investigate, or N/A (Q3 excluded when Intune not licensed).
145+
$q1Status = if ($null -ne $q1Error) { 'Investigate' } else { 'Reachable' }
146+
$q2Status = if ($null -ne $q2Error) { 'Investigate' } else { 'Reachable' }
147+
$q3Status = if ($q3Excluded) { 'N/A' } elseif ($null -ne $q3Error) { 'Investigate' } else { 'Reachable' }
148+
149+
# Classify Q4: Succeeded only when at least one capacity has provisioningState == Succeeded.
150+
$q4HttpStatus = $null
151+
$succeededCaps = @()
152+
if ($null -ne $q4Error) {
153+
# Invoke-ZtAzureResourceGraphRequest throws "Azure REST request failed with status <code>: ..."
154+
if ($q4Error.Exception.Message -match 'with status (\d+):') {
155+
$q4HttpStatus = [int]$Matches[1]
156+
}
157+
}
158+
else {
159+
$succeededCaps = @($capacities | Where-Object { $_.provisioningState -eq 'Succeeded' })
160+
}
161+
$q4Status = if ($null -ne $q4Error) { 'Investigate' } elseif ($succeededCaps.Count -gt 0) { 'Succeeded' } else { 'Investigate' }
162+
163+
# Pass: Q1 AND Q2 reachable AND (Q3 reachable OR Q3 excluded) AND Q4 Succeeded.
164+
$entraReachable = ($q1Status -eq 'Reachable') -and ($q2Status -eq 'Reachable')
165+
$intuneOk = ($q3Status -eq 'Reachable') -or ($q3Status -eq 'N/A')
166+
$capacityReady = ($q4Status -eq 'Succeeded')
167+
$passed = $entraReachable -and $intuneOk -and $capacityReady
168+
$customStatus = if ($passed) { $null } else { 'Investigate' }
169+
170+
if ($passed) {
171+
$testResultMarkdown = "✅ Microsoft Entra users and devices are reachable, the Intune managed-devices plane is reachable (or excluded as not licensed), and a Security Copilot capacity is provisioned — so the prerequisites for AI-assisted identity and device review are in place for the assessment principal.`n`n%TestResult%"
172+
}
173+
else {
174+
$testResultMarkdown = "⚠️ A data plane returned an authorization failure (401/403), throttling, a service error, or a malformed response; or no Security Copilot capacity was returned — an eligible Microsoft 365 E5/E7 tenant may have an inclusion-path capacity that Azure Resource Graph does not surface, so verify enablement in the Security Copilot portal; or the returned capacity is not in a ``Succeeded`` provisioning state.`n`n%TestResult%"
175+
}
176+
177+
#endregion Assessment Logic
178+
179+
#region Report Generation
180+
181+
# Build the data plane summary table (Q1, Q2, Q3, Q5).
182+
$q1Icon = if ($q1Status -eq 'Reachable') { '✅ Reachable' } elseif ($null -ne $q1HttpStatus) { "⚠️ HTTP $q1HttpStatus" } else { '⚠️ Investigate' }
183+
$q1Count = if ($null -eq $q1Error) { $q1Devices.Count } else { '' }
184+
$q1Props = if ($null -eq $q1Error -and $q1Devices.Count -gt 0) {
185+
$d = $q1Devices[0]
186+
"trustType=$($d.trustType), isCompliant=$($d.isCompliant), isManaged=$($d.isManaged)"
187+
} elseif ($null -ne $q1HttpStatus -and $q1HttpStatus -in @(401, 403)) {
188+
"HTTP $q1HttpStatus — verify Device.Read.All is granted"
189+
} else { '' }
190+
191+
$q2Icon = if ($q2Status -eq 'Reachable') { '✅ Reachable' } elseif ($null -ne $q2HttpStatus) { "⚠️ HTTP $q2HttpStatus" } else { '⚠️ Investigate' }
192+
$q2Count = if ($null -eq $q2Error) { $q2Users.Count } else { '' }
193+
$q2Props = if ($null -eq $q2Error -and $q2Users.Count -gt 0) {
194+
$u = $q2Users[0]
195+
"accountEnabled=$($u.accountEnabled), assignedLicenses=$($u.assignedLicenses.Count)"
196+
} elseif ($null -ne $q2HttpStatus -and $q2HttpStatus -in @(401, 403)) {
197+
"HTTP $q2HttpStatus — verify User.Read.All is granted"
198+
} else { '' }
199+
200+
$q3Icon = switch ($q3Status) {
201+
'Reachable' { '✅ Reachable' }
202+
'N/A' { '— N/A' }
203+
default { if ($null -ne $q3HttpStatus) { "⚠️ HTTP $q3HttpStatus" } else { '⚠️ Investigate' } }
204+
}
205+
$q3Count = if ($q3Excluded) { '' } elseif ($null -eq $q3Error) { $q3Devices.Count } else { '' }
206+
$q3Props = if ($q3Excluded) {
207+
'N/A (Intune not licensed) — excluded from evaluation'
208+
} elseif ($null -eq $q3Error -and $q3Devices.Count -gt 0) {
209+
"complianceState=$($q3Devices[0].complianceState)"
210+
} elseif ($null -ne $q3HttpStatus -and $q3HttpStatus -in @(401, 403)) {
211+
"HTTP $q3HttpStatus — verify DeviceManagementManagedDevices.Read.All is granted"
212+
} else { '' }
213+
214+
# Q5 row: licensing decision and deciding service-plan identifier.
215+
$q5LicenseIcon = if ($intuneLicensed) { '✅ Licensed' } else { '❌ Not licensed' }
216+
$q5PlanDisplay = if ($decidingPlanName) { $decidingPlanName } else { '' }
217+
218+
$planeRows = @"
219+
| Entra devices (Q1) | $q1Icon | $q1Count | $q1Props |
220+
| Entra users (Q2) | $q2Icon | $q2Count | $q2Props |
221+
| Intune managed devices (Q3) | $q3Icon | $q3Count | $q3Props |
222+
| Intune licensing probe (Q5) | $q5LicenseIcon | $subscribedSkusCount | Deciding plan: $q5PlanDisplay |
223+
"@
224+
225+
$dataPlaneTemplate = @'
226+
227+
## Data plane and licensing status
228+
229+
| Data plane | Status | Row count | Sample properties / notes |
230+
| :--------- | :----- | --------: | :------------------------ |
231+
{0}
232+
'@
233+
$mdInfo = $dataPlaneTemplate -f $planeRows
234+
235+
# Q4 capacity section (pattern from Test-Assessment.41215).
236+
$copilotCapacitiesLink = 'https://portal.azure.com/#browse/microsoft.securitycopilot%2Fcapacities'
237+
if ($null -ne $q4Error) {
238+
$q4ErrText = if ($q4HttpStatus -in @(401, 403)) {
239+
"Azure Resource Graph returned an authorization error (HTTP $q4HttpStatus). Verify the assessment principal has Azure Reader (or equivalent) access on the relevant subscriptions."
240+
} else {
241+
"Azure Resource Graph returned an error: $($q4Error.Exception.Message)"
242+
}
243+
$mdInfo += "`n⚠️ $q4ErrText`n"
244+
}
245+
elseif ($capacities.Count -gt 0) {
246+
$capacityRows = ''
247+
foreach ($item in $capacities | Sort-Object name) {
248+
$nameLink = "[$(Get-SafeMarkdown $item.name)](https://portal.azure.com/#resource$($item.id))"
249+
$subDisplay = if (-not [string]::IsNullOrWhiteSpace($item.subscriptionName)) { Get-SafeMarkdown $item.subscriptionName } else { $item.subscriptionId }
250+
$stateIcon = switch ($item.provisioningState) {
251+
'Succeeded' { '✅ Succeeded' }
252+
{ [string]::IsNullOrEmpty($_) } { '⚠️ Unknown' }
253+
default { "⚠️ $($item.provisioningState)" }
254+
}
255+
$capacityRows += "| $nameLink | $(Get-SafeMarkdown $item.resourceGroup) | $($item.location) | $subDisplay | $stateIcon |`n"
256+
}
257+
258+
$capacityTemplate = @'
259+
260+
## [Security Copilot capacities]({0})
261+
262+
| Name | Resource group | Location | Subscription | Provisioning state |
263+
| :--- | :------------- | :------- | :----------- | :----------------- |
264+
{1}
265+
'@
266+
$mdInfo += $capacityTemplate -f $copilotCapacitiesLink, $capacityRows
267+
}
268+
else {
269+
$mdInfo += "`n⚠️ No Security Copilot capacity resources were found via Azure Resource Graph. An eligible Microsoft 365 E5/E7 tenant may have an inclusion-path capacity that is not visible to ARM — verify enablement in the [Security Copilot portal]($copilotCapacitiesLink).`n"
270+
}
271+
272+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
273+
274+
#endregion Report Generation
275+
276+
$params = @{
277+
TestId = '41219'
278+
Title = 'Microsoft Graph identity and device APIs are queryable so analysts (with Security Copilot) can review identities and devices during investigation'
279+
Status = $passed
280+
Result = $testResultMarkdown
281+
}
282+
if ($null -ne $customStatus) {
283+
$params.CustomStatus = $customStatus
284+
}
285+
Add-ZtTestResultDetail @params
286+
}

0 commit comments

Comments
 (0)