Public/Get-AgentIdInventory.ps1
|
function Get-AgentIdInventory { <# .SYNOPSIS Collects a read-only snapshot of the agent identities in the connected tenant. .DESCRIPTION Reads agent identity blueprints, blueprint principals, agent identities and agent users, together with their credentials (metadata only - never secret values), sponsors, owners, direct and inherited permission grants, directory role assignments, recent sign-in activity and Identity Protection risk. Every call is a GET. Anything that can't be read (missing permission, feature not available in the tenant) is recorded in the snapshot's Coverage section, and checks that depend on it are reported as not evaluated. The snapshot is plain data: save it with Export-AgentIdSnapshot and analyze it later, on another machine, with Get-AgentIdFinding or Export-AgentIdReport. .PARAMETER GraphVersion Microsoft Graph API version. The Agent ID APIs are documented under beta. Default: beta. .PARAMETER SkipSignInActivity Don't read sign-in activity (needs AuditLog.Read.All and a Microsoft Entra ID P1 or P2 license). .PARAMETER SkipRisk Don't read Identity Protection risky agents (needs IdentityRiskyAgent.Read.All). .PARAMETER IncludeSecurityAttributes Also read each agent identity's custom security attributes, so that AID-GOV-008 and AID-GOV-009 can check them. Needs CustomSecAttributeAssignment.Read.All and the Attribute Assignment Reader role (Connect-AgentIdAudit -IncludeSecurityAttributes requests the permission). Only the names of attributes that have a value are stored, not the values. .EXAMPLE Connect-AgentIdAudit $snapshot = Get-AgentIdInventory Export-AgentIdSnapshot -Snapshot $snapshot -Path .\contoso-agents.json #> [CmdletBinding()] [OutputType('AgentIdAudit.Snapshot')] param( [ValidateSet('beta', 'v1.0')][string]$GraphVersion = 'beta', [switch]$SkipSignInActivity, [switch]$SkipRisk, [switch]$IncludeSecurityAttributes ) Test-AgentIdConnection $script:AgentIdGraphVersion = $GraphVersion $cov = New-AgentIdCoverage $activity = 'Collecting agent identity inventory' # ---------------------------------------------------------------- Tenant $tenantId = $null; $tenantName = $null $r = Invoke-AgentIdStep $cov 'Tenant' { Invoke-AgentIdGraphRequest -All -Uri 'organization?$select=id,displayName' } if ($r.Ok -and $r.Value.Count) { $tenantId = $r.Value[0].id; $tenantName = $r.Value[0].displayName } if (-not $tenantId -and -not $script:AgentIdGraphHandler) { $tenantId = (Get-MgContext).TenantId } # ---------------------------------------------------------------- Blueprints Write-Progress -Activity $activity -Status 'Blueprints' $blueprintKeys = 'BlueprintFederatedCredentials', 'BlueprintInheritablePermissions', 'BlueprintSponsors', 'BlueprintOwners' $blueprints = @() $r = Invoke-AgentIdStep $cov 'Blueprints' { Invoke-AgentIdGraphRequest -All -Uri 'applications/microsoft.graph.agentIdentityBlueprint' } if ($r.Ok) { Initialize-AgentIdCoverage $cov $blueprintKeys $i = 0 $blueprints = @(foreach ($raw in $r.Value) { $i++ Write-Progress -Activity $activity -Status "Blueprint $i of $($r.Value.Count): $($raw.displayName)" -PercentComplete (100 * $i / [math]::Max(1, $r.Value.Count)) $id = $raw.id $fic = Invoke-AgentIdStep $cov 'BlueprintFederatedCredentials' { Invoke-AgentIdGraphRequest -All -Uri "applications/$id/federatedIdentityCredentials" } $inh = Invoke-AgentIdStep $cov 'BlueprintInheritablePermissions' { Invoke-AgentIdGraphRequest -All -Uri "applications/$id/microsoft.graph.agentIdentityBlueprint/inheritablePermissions" } $spn = Invoke-AgentIdStep $cov 'BlueprintSponsors' { Invoke-AgentIdGraphRequest -All -Uri "applications/$id/microsoft.graph.agentIdentityBlueprint/sponsors" } $own = Invoke-AgentIdStep $cov 'BlueprintOwners' { Invoke-AgentIdGraphRequest -All -Uri "applications/$id/owners" } [ordered]@{ id = $id appId = $raw.appId displayName = $raw.displayName createdDateTime = ConvertTo-AgentIdDateString $raw.createdDateTime signInAudience = $raw.signInAudience disabledByMicrosoftStatus = $raw.disabledByMicrosoftStatus identifierUris = @($raw.identifierUris) tags = @($raw.tags) managerApplications = @($raw.managerApplications | Where-Object { $_ }) passwordCredentials = @(ConvertTo-AgentIdCredential $raw.passwordCredentials -Kind Secret) keyCredentials = @(ConvertTo-AgentIdCredential $raw.keyCredentials -Kind Certificate) federatedIdentityCredentials = if ($fic.Ok) { ,@(ConvertTo-AgentIdFederatedCredential $fic.Value) } else { $null } inheritablePermissions = if ($inh.Ok) { ,@(ConvertTo-AgentIdInheritablePermission $inh.Value) } else { $null } sponsors = if ($spn.Ok) { ,@(ConvertTo-AgentIdDirectoryObjectRef $spn.Value) } else { $null } owners = if ($own.Ok) { ,@(ConvertTo-AgentIdDirectoryObjectRef $own.Value) } else { $null } } }) } else { foreach ($k in $blueprintKeys) { Add-AgentIdCoverage $cov $k Skipped 'Blueprints could not be read.' } } # ---------------------------------------------------------------- Blueprint principals Write-Progress -Activity $activity -Status 'Blueprint principals' $blueprintPrincipals = @() $r = Invoke-AgentIdStep $cov 'BlueprintPrincipals' { Invoke-AgentIdGraphRequest -All -Uri 'servicePrincipals/microsoft.graph.agentIdentityBlueprintPrincipal' } if ($r.Ok) { Initialize-AgentIdCoverage $cov 'BlueprintPrincipalGrants' $blueprintPrincipals = @(foreach ($raw in $r.Value) { $id = $raw.id $ara = Invoke-AgentIdStep $cov 'BlueprintPrincipalGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/appRoleAssignments" } $grt = Invoke-AgentIdStep $cov 'BlueprintPrincipalGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/oauth2PermissionGrants" } [ordered]@{ id = $id appId = $raw.appId displayName = $raw.displayName accountEnabled = $raw.accountEnabled createdDateTime = ConvertTo-AgentIdDateString $raw.createdDateTime appRoleAssignments = if ($ara.Ok) { ,@(ConvertTo-AgentIdAppRoleAssignment $ara.Value) } else { $null } oauth2PermissionGrants = if ($grt.Ok) { ,@(ConvertTo-AgentIdPermissionGrant $grt.Value) } else { $null } } }) } else { Add-AgentIdCoverage $cov 'BlueprintPrincipalGrants' Skipped 'Blueprint principals could not be read.' } # ---------------------------------------------------------------- Agent identities Write-Progress -Activity $activity -Status 'Agent identities' $agentKeys = 'AgentSponsors', 'AgentOwners', 'AgentGrants', 'AgentInheritedGrants' $agents = @() # Try to fetch sponsors in the same call. Any failure of the $expand variant - unsupported, or denied because # sponsors need more privilege than the list - falls back to the plain list, which must not be lost. $expanded = $null try { $expanded = @(Invoke-AgentIdGraphRequest -All -Uri 'servicePrincipals/microsoft.graph.agentIdentity?$expand=sponsors') } catch { Write-Verbose "Listing agent identities with sponsors expanded failed; retrying without. $($_.Exception.Message)" } if ($null -ne $expanded) { Add-AgentIdCoverage $cov 'AgentIdentities' Ok $r = @{ Ok = $true; Value = $expanded } } else { $r = Invoke-AgentIdStep $cov 'AgentIdentities' { Invoke-AgentIdGraphRequest -All -Uri 'servicePrincipals/microsoft.graph.agentIdentity' } } if ($r.Ok) { Initialize-AgentIdCoverage $cov $agentKeys if ($IncludeSecurityAttributes) { Initialize-AgentIdCoverage $cov 'AgentSecurityAttributes' } $i = 0 $agents = @(foreach ($raw in $r.Value) { $i++ Write-Progress -Activity $activity -Status "Agent identity $i of $($r.Value.Count): $($raw.displayName)" -PercentComplete (100 * $i / [math]::Max(1, $r.Value.Count)) $id = $raw.id if (Test-AgentIdProperty $raw 'sponsors') { Add-AgentIdCoverage $cov 'AgentSponsors' Ok $sponsors = @(ConvertTo-AgentIdDirectoryObjectRef $raw.sponsors) } else { # Microsoft documents this call as requiring AgentIdentity.ReadWrite.All; a read-only # connection may be denied, in which case sponsor checks are reported as not evaluated. $spn = Invoke-AgentIdStep $cov 'AgentSponsors' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/microsoft.graph.agentIdentity/sponsors?`$select=id,displayName,userPrincipalName,accountEnabled", "servicePrincipals/$id/microsoft.graph.agentIdentity/sponsors" } $sponsors = if ($spn.Ok) { ,@(ConvertTo-AgentIdDirectoryObjectRef $spn.Value) } else { $null } } $own = Invoke-AgentIdStep $cov 'AgentOwners' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/owners" } $ara = Invoke-AgentIdStep $cov 'AgentGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/appRoleAssignments" } $grt = Invoke-AgentIdStep $cov 'AgentGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/$id/oauth2PermissionGrants" } $iar = Invoke-AgentIdStep $cov 'AgentInheritedGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/microsoft.graph.agentIdentity/$id/inheritedAppRoleAssignments" } $igr = Invoke-AgentIdStep $cov 'AgentInheritedGrants' { Invoke-AgentIdGraphRequest -All -Uri "servicePrincipals/microsoft.graph.agentIdentity/$id/inheritedOauth2PermissionGrants" } # customSecurityAttributes is only returned when selected. A response without the property is # treated as a failure, not as "no attributes", so a silent omission can't produce false findings. $csa = $null if ($IncludeSecurityAttributes) { $csa = Invoke-AgentIdStep $cov 'AgentSecurityAttributes' { $response = Invoke-AgentIdGraphRequest -Uri "servicePrincipals/$($id)?`$select=id,customSecurityAttributes" if (-not (Test-AgentIdProperty $response 'customSecurityAttributes')) { throw 'The response did not include customSecurityAttributes.' } $response } } # appId is inherited from servicePrincipal; some responses have also carried 'agentAppId'. $appId = if ($raw.appId) { $raw.appId } else { $raw.agentAppId } [ordered]@{ id = $id appId = $appId displayName = $raw.displayName agentIdentityBlueprintId = $raw.agentIdentityBlueprintId accountEnabled = $raw.accountEnabled createdDateTime = ConvertTo-AgentIdDateString $raw.createdDateTime createdByAppId = $raw.createdByAppId disabledByMicrosoftStatus = $raw.disabledByMicrosoftStatus servicePrincipalType = $raw.servicePrincipalType tags = @($raw.tags) passwordCredentials = if (Test-AgentIdProperty $raw 'passwordCredentials') { ,@(ConvertTo-AgentIdCredential $raw.passwordCredentials -Kind Secret) } else { $null } keyCredentials = if (Test-AgentIdProperty $raw 'keyCredentials') { ,@(ConvertTo-AgentIdCredential $raw.keyCredentials -Kind Certificate) } else { $null } sponsors = $sponsors owners = if ($own.Ok) { ,@(ConvertTo-AgentIdDirectoryObjectRef $own.Value) } else { $null } securityAttributes = if ($csa -and $csa.Ok) { ,@(ConvertTo-AgentIdSecurityAttributeName $csa.Value[0].customSecurityAttributes) } else { $null } appRoleAssignments = if ($ara.Ok) { ,@(ConvertTo-AgentIdAppRoleAssignment $ara.Value) } else { $null } oauth2PermissionGrants = if ($grt.Ok) { ,@(ConvertTo-AgentIdPermissionGrant $grt.Value) } else { $null } inheritedAppRoleAssignments = if ($iar.Ok) { ,@(ConvertTo-AgentIdAppRoleAssignment $iar.Value) } else { $null } inheritedOauth2PermissionGrants = if ($igr.Ok) { ,@(ConvertTo-AgentIdPermissionGrant $igr.Value) } else { $null } } }) } else { foreach ($k in $agentKeys) { Add-AgentIdCoverage $cov $k Skipped 'Agent identities could not be read.' } if ($IncludeSecurityAttributes) { Add-AgentIdCoverage $cov 'AgentSecurityAttributes' Skipped 'Agent identities could not be read.' } } # ---------------------------------------------------------------- Agent users Write-Progress -Activity $activity -Status 'Agent users' $agentUsers = @() $r = Invoke-AgentIdStep $cov 'AgentUsers' { Invoke-AgentIdGraphRequest -All -Uri @( 'users/microsoft.graph.agentUser?$select=id,displayName,userPrincipalName,accountEnabled,createdDateTime,identityParentId' 'users/microsoft.graph.agentUser' 'users/microsoft.graph.AgentUser' ) } if ($r.Ok) { Initialize-AgentIdCoverage $cov 'AgentUserSponsors' $agentUsers = @(foreach ($raw in $r.Value) { $id = $raw.id $spn = Invoke-AgentIdStep $cov 'AgentUserSponsors' { Invoke-AgentIdGraphRequest -All -Uri "users/$id/sponsors?`$select=id,displayName,userPrincipalName,accountEnabled", "users/$id/sponsors" } [ordered]@{ id = $id displayName = $raw.displayName userPrincipalName = $raw.userPrincipalName accountEnabled = $raw.accountEnabled createdDateTime = ConvertTo-AgentIdDateString $raw.createdDateTime identityParentId = $raw.identityParentId sponsors = if ($spn.Ok) { ,@(ConvertTo-AgentIdDirectoryObjectRef $spn.Value) } else { $null } } }) } else { Add-AgentIdCoverage $cov 'AgentUserSponsors' Skipped 'Agent users could not be read.' } # ---------------------------------------------------------------- Directory roles Write-Progress -Activity $activity -Status 'Directory role assignments' $principalIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($o in @($agents) + @($agentUsers) + @($blueprintPrincipals)) { if ($o) { [void]$principalIds.Add($o.id) } } $roleAssignments = @() if ($principalIds.Count -eq 0) { Initialize-AgentIdCoverage $cov 'DirectoryRoles' } else { $r = Invoke-AgentIdStep $cov 'DirectoryRoles' { Invoke-AgentIdGraphRequest -All -Uri 'roleManagement/directory/roleAssignments?$expand=roleDefinition' } if ($r.Ok) { $roleAssignments = @(foreach ($ra in $r.Value) { if (-not $principalIds.Contains("$($ra.principalId)")) { continue } [ordered]@{ id = $ra.id principalId = $ra.principalId roleDefinitionId = $ra.roleDefinitionId roleName = $ra.roleDefinition.displayName roleTemplateId = $ra.roleDefinition.templateId isPrivileged = $ra.roleDefinition.isPrivileged isBuiltIn = $ra.roleDefinition.isBuiltIn directoryScopeId = $ra.directoryScopeId } }) } } # ---------------------------------------------------------------- Resources named in grants Write-Progress -Activity $activity -Status 'Resolving permission names' $resourceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($o in @($agents) + @($blueprintPrincipals)) { if (-not $o) { continue } foreach ($list in $o.appRoleAssignments, $o.oauth2PermissionGrants, $o.inheritedAppRoleAssignments, $o.inheritedOauth2PermissionGrants) { foreach ($item in @($list)) { if ($item -and $item.resourceId) { [void]$resourceIds.Add($item.resourceId) } } } } Initialize-AgentIdCoverage $cov 'Resources' $resources = @(foreach ($rid in $resourceIds) { $res = Invoke-AgentIdStep $cov 'Resources' { Invoke-AgentIdGraphRequest -Uri "servicePrincipals/$($rid)?`$select=id,appId,displayName,appRoles" } if ($res.Ok -and $res.Value.Count) { ConvertTo-AgentIdResource $res.Value[0] } }) # ---------------------------------------------------------------- Sign-in activity $signIns = @() if ($SkipSignInActivity) { Add-AgentIdCoverage $cov 'SignInActivity' Skipped 'Skipped with -SkipSignInActivity.' } else { Write-Progress -Activity $activity -Status 'Sign-in activity' Initialize-AgentIdCoverage $cov 'SignInActivity' $appIds = @(@($agents) | Where-Object { $_ -and $_.appId } | ForEach-Object { $_.appId } | Sort-Object -Unique) $signIns = @(foreach ($appId in $appIds) { $filter = [uri]::EscapeDataString("appId eq '$appId'") $s = Invoke-AgentIdStep $cov 'SignInActivity' { Invoke-AgentIdGraphRequest -All -Uri "reports/servicePrincipalSignInActivities?`$filter=$filter" } if (-not $s.Ok) { continue } # An entry is written for every successful lookup, so "no entry" means "unknown", while an entry with # a null date means "looked, and found no sign-in". $dates = foreach ($entry in $s.Value) { foreach ($prop in 'lastSignInActivity', 'applicationAuthenticationClientSignInActivity', 'delegatedClientSignInActivity', 'applicationAuthenticationResourceSignInActivity', 'delegatedResourceSignInActivity') { $d = ConvertTo-AgentIdDate $entry.$prop.lastSignInDateTime if ($d) { $d } } } $latest = $dates | Sort-Object -Descending | Select-Object -First 1 [ordered]@{ appId = $appId; lastSignInDateTime = if ($latest) { $latest.UtcDateTime.ToString('o') } else { $null } } }) } # ---------------------------------------------------------------- Identity Protection $risky = @() if ($SkipRisk) { Add-AgentIdCoverage $cov 'RiskyAgents' Skipped 'Skipped with -SkipRisk.' } else { Write-Progress -Activity $activity -Status 'Identity Protection' $r = Invoke-AgentIdStep $cov 'RiskyAgents' { Invoke-AgentIdGraphRequest -All -Uri 'identityProtection/riskyAgents' -Headers @{ Prefer = 'include-unknown-enum-members' } } if ($r.Ok) { $risky = @(foreach ($x in $r.Value) { [ordered]@{ id = $x.id agentDisplayName = $x.agentDisplayName identityType = $x.identityType blueprintId = $x.blueprintId riskLevel = $x.riskLevel riskState = $x.riskState riskDetail = $x.riskDetail riskLastModifiedDateTime = ConvertTo-AgentIdDateString $x.riskLastModifiedDateTime isEnabled = $x.isEnabled isDeleted = $x.isDeleted } }) } } Write-Progress -Activity $activity -Completed $snapshot = [ordered]@{ SchemaVersion = $script:AgentIdSchemaVersion Tool = 'AgentIdAudit' ToolVersion = $script:AgentIdModuleVersion GraphVersion = $GraphVersion TenantId = $tenantId TenantName = $tenantName CollectedAt = [datetime]::UtcNow.ToString('o') Coverage = Complete-AgentIdCoverage $cov Blueprints = $blueprints BlueprintPrincipals = $blueprintPrincipals AgentIdentities = $agents AgentUsers = $agentUsers DirectoryRoleAssignments = $roleAssignments Resources = $resources SignInActivity = $signIns RiskyAgents = $risky } # A JSON round trip gives a live snapshot exactly the same shape as one loaded with Import-AgentIdSnapshot, # so the rules only ever have to handle one form. return ConvertTo-AgentIdSnapshotObject ($snapshot | ConvertTo-Json -Depth 32 -Compress) } |