|
| 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