packs/zero-trust-assessment/helpers/Add-MtZtaObjectFact.ps1
|
function Add-MtZtaObjectFact { <# .SYNOPSIS Adds facts from the Zero Trust Assessment's tenant export to the objects on Maester's Affected objects page. .DESCRIPTION The assessment exports the tenant's applications, service principals, users, devices, directory roles, agent identities and Intune configuration policies into zt-export, next to its report, as pages of Graph JSON. This reads those pages and records what they say about each object with Add-MtAffectedObjectFact: an app's permissions, credentials and last sign-in, a user's roles, MFA registration and last sign-in, and so on. The facts appear on the objects that the run lists, whichever check named them. It runs in the pack's instance source, after the assessment and before a temporary output folder is deleted. Nothing is added when the export is not there (a report file copied on its own), when the ZeroTrustAssessment.ObjectFacts setting is false, or on a Maester version without Add-MtAffectedObjectFact. A problem reading the export is a warning, never a failed run. This is the only code in Maester that reads the export. When the assessment's tests become native Maester tests, delete this file, the Helpers entry in maester-pack.json and its call in Get-MtZtaInstance. #> [CmdletBinding()] [OutputType([void])] param( # The report file, the assessment's output folder, or its zt-export folder. [Parameter(Mandatory)] [string] $ReportPath, # The assessment's report, read by Read-MtZtaReport, for its tenant id and when it ran. [Parameter(Mandatory)] $Report ) if (-not (Get-Command -Name Add-MtAffectedObjectFact -ErrorAction Ignore)) { return } if (-not [bool](Get-MtSetting -Name 'ZeroTrustAssessment.ObjectFacts' -Default $true)) { return } # Facts are only kept while the run collects affected objects: skip reading the export otherwise. if (-not (Get-MtSession).IncludeAffectedObjects) { return } $folder = if (Test-Path -LiteralPath $ReportPath -PathType Container) { $ReportPath } else { Split-Path -Path $ReportPath -Parent } $root = $null if ($folder) { $root = @($folder, (Join-Path $folder 'zt-export')) | Where-Object { (Test-Path -LiteralPath (Join-Path $_ 'ServicePrincipal') -PathType Container) -or (Test-Path -LiteralPath (Join-Path $_ 'User') -PathType Container) } | Select-Object -First 1 } if (-not $root) { Write-Verbose "No zt-export folder next to $ReportPath, so the Zero Trust Assessment adds no facts to the affected objects." return } try { $records = @(ConvertTo-MtZtaObjectFactRecord -ExportPath $root -Report $Report) if ($records.Count -eq 0) { return } $asOf = ConvertTo-MtZtaDate $Report.ExecutedAt $parameters = @{ Source = 'Zero Trust Assessment' } if ($asOf) { $parameters.AsOf = $asOf } $records | Add-MtAffectedObjectFact @parameters Write-Verbose "The Zero Trust Assessment export added facts about $($records.Count) object(s)." } catch { Write-Warning "The Zero Trust Assessment export could not be read, so its facts are not on the Affected objects page: $($_.Exception.Message)" } } function ConvertTo-MtZtaDate { <# .SYNOPSIS Returns a value of the export as a UTC date, or $null. #> [CmdletBinding()] [OutputType([datetime])] param([Parameter(Position = 0)] $Value) if ($null -eq $Value) { return $null } if ($Value -is [datetime]) { # ConvertFrom-Json reads ISO dates already; an unspecified kind is UTC, as the export writes it. if ($Value.Kind -eq [System.DateTimeKind]::Unspecified) { return [datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc) } return $Value.ToUniversalTime() } if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime } $parsed = [datetime]::MinValue $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal if ([datetime]::TryParse([string]$Value, [cultureinfo]::InvariantCulture, $styles, [ref]$parsed)) { return $parsed } $null } function Read-MtZtaExportEntity { <# .SYNOPSIS Returns the objects of one entity of the assessment's export, read page by page. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string] $ExportPath, [Parameter(Mandatory)] [string] $Name ) $entityPath = Join-Path $ExportPath $Name if (-not (Test-Path -LiteralPath $entityPath -PathType Container)) { return } # Pages are <Name>-0.json, <Name>-1.json...; <Name>-model.json describes the table, not the data. foreach ($file in @(Get-ChildItem -LiteralPath $entityPath -File -Filter "$Name-*.json" | Where-Object { $_.BaseName -match '-\d+$' } | Sort-Object { [int]($_.BaseName -replace '^.*-', '') })) { $page = Get-Content -LiteralPath $file.FullName -Raw -Encoding utf8 | ConvertFrom-Json -Depth 30 if ($null -eq $page) { continue } if ($page.PSObject.Properties['value']) { foreach ($item in @($page.value)) { if ($null -ne $item) { $item } } } else { $page } } } function ConvertTo-MtZtaObjectFactRecord { <# .SYNOPSIS Builds one record of facts per object in the assessment's export: System, Type, Id and Fact. .DESCRIPTION Tones follow Add-MtAffectedObjectFact: Risk for a path to tenant takeover or a privileged account without MFA, Warning for something to review (unused for 90 days, no owner or sponsor, a client secret, an unassigned policy), Good for a reassuring sign, Info for the rest. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $ExportPath, [Parameter(Mandatory)] $Report ) $asOf = ConvertTo-MtZtaDate $Report.ExecutedAt if (-not $asOf) { $asOf = [datetime]::UtcNow } $tenantId = [string]$Report.TenantId $staleDays = 90 $comparer = [System.StringComparer]::OrdinalIgnoreCase # The first-party Microsoft tenants that publish Microsoft's own apps. $microsoftTenants = [System.Collections.Generic.HashSet[string]]::new([string[]]@( 'f8cdef31-a31e-4b4a-93e4-5f571e91255a', '72f988bf-86f1-41af-91ab-2d7cd011db47', '33e01921-4d64-4f8c-a055-5bdaffd5e33d' ), $comparer) # Graph permissions with a direct or indirect path to Global Administrator: the Tier 0 list that MT.1050 and # MT.1051 use (https://github.com/emiliensocchi/azure-tiering). $tierZero = [System.Collections.Generic.HashSet[string]]::new([string[]]@( 'AdministrativeUnit.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'Application.ReadWrite.All', 'Application.ReadWrite.OwnedBy', 'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementRBAC.ReadWrite.All', 'Directory.ReadWrite.All', 'Domain.ReadWrite.All', 'EntitlementManagement.ReadWrite.All', 'Group.ReadWrite.All', 'GroupMember.ReadWrite.All', 'Organization.ReadWrite.All', 'Policy.ReadWrite.AuthenticationMethod', 'Policy.ReadWrite.ConditionalAccess', 'Policy.ReadWrite.PermissionGrant', 'PrivilegedAccess.ReadWrite.AzureADGroup', 'PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup', 'PrivilegedEligibilitySchedule.ReadWrite.AzureADGroup', 'RoleAssignmentSchedule.ReadWrite.Directory', 'RoleEligibilitySchedule.ReadWrite.Directory', 'RoleManagement.ReadWrite.Directory', 'RoleManagementPolicy.ReadWrite.AzureADGroup', 'RoleManagementPolicy.ReadWrite.Directory', 'User-PasswordProfile.ReadWrite.All', 'User.DeleteRestore.All', 'User.EnableDisableAccount.All', 'User.ReadWrite.All', 'UserAuthenticationMethod.ReadWrite.All' ), $comparer) $graphAppId = '00000003-0000-0000-c000-000000000000' $formatDate = { param([datetime]$Date) $Date.ToString('yyyy-MM-dd', [cultureinfo]::InvariantCulture) } $daysBefore = { param([datetime]$Date) [int][math]::Floor(($asOf - $Date).TotalDays) } $describeWhen = { param([datetime]$Date) $days = & $daysBefore $Date if ($days -le 0) { return "$(& $formatDate $Date) (the day of the assessment)" } if ($days -eq 1) { return "$(& $formatDate $Date) (1 day before the assessment)" } "$(& $formatDate $Date) ($days days before the assessment)" } $join = { param([string[]]$Items, [int]$Max = 6) $all = @($Items | Where-Object { $_ }) if ($all.Count -le $Max) { return $all -join ', ' } (($all | Select-Object -First $Max) -join ', ') + ", and $($all.Count - $Max) more" } # Key and Raw follow the shared fact keys (Add-MtAffectedObjectFact), so code can read a fact without its text. $newFact = { param($Name, $Value, $Tone = 'Info', [string] $Key, $Raw) $fact = [pscustomobject]@{ Name = $Name; Value = $Value; Tone = $Tone } if ($Key) { $fact | Add-Member -NotePropertyName Key -NotePropertyValue $Key # -NotePropertyValue keeps a list a list. $fact | Add-Member -NotePropertyName Raw -NotePropertyValue $Raw } $fact } $daysFact = { param($Date, [bool] $Stale) if ($Date) { & $newFact 'Last sign-in' (& $describeWhen $Date) $(if ($Stale) { 'Warning' } else { 'Info' }) 'signIn.daysSince' ([int](& $daysBefore $Date)) } else { & $newFact 'Last sign-in' 'None recorded' 'Warning' 'signIn.daysSince' $null } } $credentialFacts = { param($SecretList, $CertificateList) foreach ($kind in @(@{ Name = 'Client secrets'; Items = @($SecretList | Where-Object { $_ }) }, @{ Name = 'Certificates'; Items = @($CertificateList | Where-Object { $_ }) })) { if ($kind.Items.Count -eq 0) { continue } $ends = @($kind.Items | ForEach-Object { ConvertTo-MtZtaDate $_.endDateTime }) $active = @($ends | Where-Object { $_ -and $_ -ge $asOf } | Sort-Object) $expired = @($ends | Where-Object { $_ -and $_ -lt $asOf }).Count $parts = [System.Collections.Generic.List[string]]::new() if ($active.Count -gt 0) { $parts.Add("$($active.Count) active, the next expires on $(& $formatDate $active[0])") } if ($expired -gt 0) { $parts.Add("$expired expired") } $tone = if ($kind.Name -eq 'Client secrets' -and $active.Count -gt 0) { 'Warning' } elseif ($active.Count -gt 0 -and ($active[0] - $asOf).TotalDays -le 30) { 'Warning' } else { 'Info' } # Keyed on the secrets when there are any, otherwise on the certificates, so an app has one app.credentialExpired. $key = if ($kind.Name -eq 'Client secrets' -or @($SecretList | Where-Object { $_ }).Count -eq 0) { 'app.credentialExpired' } else { $null } $anyExpired = @(@($SecretList) + @($CertificateList) | Where-Object { $_ } | ForEach-Object { ConvertTo-MtZtaDate $_.endDateTime } | Where-Object { $_ -and $_ -lt $asOf }).Count -gt 0 & $newFact $kind.Name ($parts -join '; ') $tone $key $anyExpired } } # --- Directory roles: principal id -> 'Role (how)' --------------------------------------------------------- $roleDefinitions = [System.Collections.Generic.Dictionary[string, object]]::new($comparer) foreach ($definition in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleDefinition')) { $roleDefinitions[[string]$definition.id] = $definition if ($definition.templateId) { $roleDefinitions[[string]$definition.templateId] = $definition } } $rolesByPrincipal = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[object]]]::new($comparer) $addRole = { param($PrincipalId, $RoleDefinitionId, [string]$How, $Scope) if (-not $PrincipalId -or -not $RoleDefinitionId) { return } $definition = $null $null = $roleDefinitions.TryGetValue([string]$RoleDefinitionId, [ref]$definition) $name = if ($definition) { [string]$definition.displayName } else { [string]$RoleDefinitionId } if ($Scope -and [string]$Scope -ne '/') { $How = "$How, scoped" } $list = $null if (-not $rolesByPrincipal.TryGetValue([string]$PrincipalId, [ref]$list)) { $list = [System.Collections.Generic.List[object]]::new() $rolesByPrincipal[[string]$PrincipalId] = $list } $label = "$name ($How)" if (-not ($list | Where-Object Label -EQ $label)) { $list.Add([pscustomobject]@{ Label = $label; Role = $name; Privileged = [bool]($definition -and $definition.isPrivileged); Permanent = $How -like 'permanent*' }) } } # With PIM the schedule instances say how a role is held; without it only the assignments are exported. $activeInstances = @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleAssignmentScheduleInstance') if ($activeInstances.Count -gt 0) { foreach ($instance in $activeInstances) { $how = if ([string]$instance.assignmentType -eq 'Activated') { 'activated' } else { 'permanent' } & $addRole $instance.principalId $instance.roleDefinitionId $how $instance.directoryScopeId } foreach ($member in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleAssignmentScheduleInstanceGroup')) { & $addRole $member.id $member.roleDefinitionId 'permanent, through a group' $null } } else { foreach ($assignment in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleAssignment')) { & $addRole $assignment.principalId $assignment.roleDefinitionId 'permanent' $assignment.directoryScopeId } foreach ($member in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleAssignmentGroup')) { & $addRole $member.id $member.roleDefinitionId 'permanent, through a group' $null } } foreach ($instance in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleEligibilityScheduleInstance')) { & $addRole $instance.principalId $instance.roleDefinitionId 'eligible' $instance.directoryScopeId } foreach ($member in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'RoleEligibilityScheduleInstanceGroup')) { & $addRole $member.id $member.roleDefinitionId 'eligible, through a group' $null } $roleFact = { param($PrincipalId, [switch] $User) $roles = $null if (-not $rolesByPrincipal.TryGetValue([string]$PrincipalId, [ref]$roles)) { return } $tone = if (@($roles | Where-Object { $_.Privileged -and $_.Permanent }).Count -gt 0) { 'Risk' } else { 'Info' } $text = & $join @($roles | Sort-Object { -not $_.Privileged }, Label | ForEach-Object Label) if ($User) { # user.privilegedRoles: the privileged roles the user holds in any way, permanent, activated or eligible. & $newFact 'Entra roles' $text $tone 'user.privilegedRoles' ([string[]]@($roles | Where-Object Privileged | ForEach-Object Role | Select-Object -Unique)) } else { & $newFact 'Entra roles' $text $tone } } # --- Service principals: names of the resources and permissions they grant ------------------------------- $servicePrincipals = @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'ServicePrincipal') $resourceName = [System.Collections.Generic.Dictionary[string, string]]::new($comparer) $appRoleName = [System.Collections.Generic.Dictionary[string, string]]::new($comparer) $servicePrincipalById = [System.Collections.Generic.Dictionary[string, object]]::new($comparer) $graphResourceIds = [System.Collections.Generic.HashSet[string]]::new($comparer) foreach ($sp in $servicePrincipals) { $resourceName[[string]$sp.id] = [string]$sp.displayName $servicePrincipalById[[string]$sp.id] = $sp if ([string]$sp.appId -eq $graphAppId) { $null = $graphResourceIds.Add([string]$sp.id) } foreach ($role in @($sp.appRoles)) { if ($role -and $role.id) { $appRoleName["$($sp.id)|$($role.id)"] = [string]$role.value } } } $lastSignIn = [System.Collections.Generic.Dictionary[string, object]]::new($comparer) $hasSignInActivity = $false foreach ($activity in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'ServicePrincipalSignIn')) { $hasSignInActivity = $true if ($activity.appId) { $lastSignIn[[string]$activity.appId] = ConvertTo-MtZtaDate $activity.lastSignInActivity.lastSignInDateTime } } # Facts about what an app can do, shared by its enterprise application and its app registration. $appFacts = { param($Sp) $isMicrosoft = $Sp.appOwnerOrganizationId -and $microsoftTenants.Contains([string]$Sp.appOwnerOrganizationId) $byResource = [ordered]@{} $risky = [System.Collections.Generic.List[string]]::new() foreach ($assignment in @($Sp.appRoleAssignments)) { if (-not $assignment) { continue } $name = $null if (-not $appRoleName.TryGetValue("$($assignment.resourceId)|$($assignment.appRoleId)", [ref]$name) -or -not $name) { continue } $resource = $null $null = $resourceName.TryGetValue([string]$assignment.resourceId, [ref]$resource) if (-not $resource) { $resource = 'Another app' } if (-not $byResource.Contains($resource)) { $byResource[$resource] = [System.Collections.Generic.List[string]]::new() } if (-not $byResource[$resource].Contains($name)) { $byResource[$resource].Add($name) } if ($graphResourceIds.Contains([string]$assignment.resourceId) -and $tierZero.Contains($name)) { $risky.Add($name) } } if ($byResource.Count -gt 0) { $text = (@($byResource.Keys | ForEach-Object { "$($_): $(& $join @($byResource[$_]) 8)" }) -join '; ') if ($risky.Count -gt 0) { $lead = if ($risky.Count -eq 1) { "$($risky[0]) has a path to Global Administrator" } else { "$(& $join @($risky) 4) have a path to Global Administrator" } & $newFact 'Application permissions' "$lead. $text" 'Risk' 'app.highPrivilegePermissions' ([string[]]@($risky)) } else { & $newFact 'Application permissions' $text 'Info' 'app.highPrivilegePermissions' ([string[]]@()) } } # Microsoft's own apps are consented for everyone when the tenant is created, so only other apps are listed. $tenantWide = @(if (-not $isMicrosoft) { $Sp.oauth2PermissionGrants | Where-Object { $_ -and [string]$_.consentType -eq 'AllPrincipals' } }) if ($tenantWide.Count -gt 0) { $scopes = [System.Collections.Generic.List[string]]::new() $write = $false foreach ($grant in $tenantWide) { $resource = $null $null = $resourceName.TryGetValue([string]$grant.resourceId, [ref]$resource) $names = @(([string]$grant.scope) -split '\s+' | Where-Object { $_ -and $_ -notin 'openid', 'profile', 'offline_access', 'email' }) if ($names | Where-Object { $_ -match '\.ReadWrite|\.Send$|FullControl|\.Manage' }) { $write = $true } if ($names.Count -gt 0) { $scopes.Add("$(if ($resource) { $resource } else { 'Another app' }): $(& $join $names 8)") } } if ($scopes.Count -gt 0) { & $newFact 'Consented for all users' ($scopes -join '; ') $(if ($write) { 'Warning' } else { 'Info' }) } } $userGrants = @($Sp.oauth2PermissionGrants | Where-Object { $_ -and [string]$_.consentType -eq 'Principal' } | ForEach-Object principalId | Select-Object -Unique).Count if ($userGrants -gt 0) { & $newFact 'Consented by users' $(if ($userGrants -eq 1) { '1 user' } else { "$userGrants users" }) } if ($hasSignInActivity -and $Sp.appId) { $last = $null $null = $lastSignIn.TryGetValue([string]$Sp.appId, [ref]$last) if ($last) { & $daysFact $last ((& $daysBefore $last) -gt $staleDays -and -not $isMicrosoft) } elseif (-not $isMicrosoft) { & $daysFact $null $false } } } $records = [System.Collections.Generic.List[object]]::new() $add = { param([string]$System, [string]$Type, $Id, $Facts) $list = @($Facts | Where-Object { $_ }) if ($Id -and $list.Count -gt 0) { $records.Add([pscustomobject]@{ System = $System; Type = $Type; Id = [string]$Id; Fact = $list }) } } # --- Enterprise applications (and agent identities, which are service principals) ------------------------- $appFactsByAppId = [System.Collections.Generic.Dictionary[string, object[]]]::new($comparer) foreach ($sp in $servicePrincipals) { $facts = [System.Collections.Generic.List[object]]::new() $owner = [string]$sp.appOwnerOrganizationId $publisher = if ([string]$sp.servicePrincipalType -eq 'ManagedIdentity') { 'Managed identity' } elseif ($owner -and $owner -eq $tenantId) { 'This tenant' } elseif ($owner -and $microsoftTenants.Contains($owner)) { 'Microsoft' } elseif ($sp.publisherName) { "Another organization ($($sp.publisherName))" } elseif ($owner) { 'Another organization' } if ($publisher) { $facts.Add((& $newFact 'Publisher' $publisher)) } if ($sp.accountEnabled -eq $false) { $facts.Add((& $newFact 'Sign-in' 'Disabled')) } $shared = @(& $appFacts $sp) foreach ($fact in $shared) { $facts.Add($fact) } if ($sp.appId) { $appFactsByAppId[[string]$sp.appId] = $shared } $sso = [string]$sp.preferredSingleSignOnMode if ($sso -and $sso -ne 'notSupported') { $facts.Add((& $newFact 'Single sign-on' $sso.ToUpperInvariant().Replace('PASSWORD', 'Password-based'))) } foreach ($fact in @(& $credentialFacts $sp.passwordCredentials $sp.keyCredentials)) { $facts.Add($fact) } $role = & $roleFact $sp.id if ($role) { $facts.Add($role) } & $add 'EntraID' 'ServicePrincipal' $sp.id $facts } # --- App registrations: keyed by appId, as their portal link is ------------------------------------------ $audience = @{ AzureADMyOrg = 'This organization only' AzureADMultipleOrgs = 'Any Microsoft Entra organization' AzureADandPersonalMicrosoftAccount = 'Any Microsoft Entra organization and personal Microsoft accounts' PersonalMicrosoftAccount = 'Personal Microsoft accounts only' } foreach ($app in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'Application')) { $facts = [System.Collections.Generic.List[object]]::new() $who = [string]$app.signInAudience if ($who) { $facts.Add((& $newFact 'Who can sign in' $(if ($audience.ContainsKey($who)) { $audience[$who] } else { $who }))) } $owners = @($app.owners | Where-Object { $_ }) $facts.Add((& $newFact 'Owners' $(if ($owners.Count -eq 0) { 'None' } else { [string]$owners.Count }) $(if ($owners.Count -eq 0) { 'Warning' } else { 'Info' }) 'app.ownerCount' ([int]$owners.Count))) foreach ($fact in @(& $credentialFacts $app.passwordCredentials $app.keyCredentials)) { $facts.Add($fact) } $shared = $null if ($app.appId -and $appFactsByAppId.TryGetValue([string]$app.appId, [ref]$shared)) { foreach ($fact in $shared) { $facts.Add($fact) } } & $add 'EntraID' 'AppRegistration' $app.appId $facts } # --- Users ---------------------------------------------------------------------------------------------- $registration = [System.Collections.Generic.Dictionary[string, object]]::new($comparer) foreach ($detail in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'UserRegistrationDetails')) { if ($detail.id) { $registration[[string]$detail.id] = $detail } } foreach ($user in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'User')) { $facts = [System.Collections.Generic.List[object]]::new() $account = @( if ([string]$user.userType -eq 'Guest') { 'Guest' } else { 'Member' } if ($user.onPremisesSyncEnabled -eq $true) { 'synced from on-premises' } if ($user.accountEnabled -eq $false) { 'disabled' } ) -join ', ' $facts.Add((& $newFact 'Account' $account)) $role = & $roleFact $user.id -User if ($role) { $facts.Add($role) } $detail = $null if ($registration.TryGetValue([string]$user.id, [ref]$detail)) { $privileged = [bool]$detail.isAdmin -or ($role -and $role.Tone -eq 'Risk') if ($detail.isMfaRegistered -eq $true) { $methods = @($detail.methodsRegistered | Where-Object { $_ }) $facts.Add((& $newFact 'MFA' $(if ($methods.Count -gt 0) { "Registered: $(& $join $methods)" } else { 'Registered' }) 'Good' 'user.mfaRegistered' $true)) } elseif ($user.accountEnabled -ne $false) { $facts.Add((& $newFact 'MFA' 'Not registered' $(if ($privileged) { 'Risk' } else { 'Warning' }) 'user.mfaRegistered' $false)) } if ($detail.isPasswordlessCapable -eq $true) { $facts.Add((& $newFact 'Passwordless' 'Can sign in without a password' 'Good' 'user.passwordless' $true)) } elseif ($detail.isPasswordlessCapable -eq $false) { $facts.Add((& $newFact 'Passwordless' 'Not set up' 'Info' 'user.passwordless' $false)) } } $activity = $user.signInActivity if ($activity) { $dates = @( ConvertTo-MtZtaDate $activity.lastSuccessfulSignInDateTime ConvertTo-MtZtaDate $activity.lastSignInDateTime ConvertTo-MtZtaDate $activity.lastNonInteractiveSignInDateTime ) | Where-Object { $_ } | Sort-Object -Descending if ($dates.Count -gt 0) { $facts.Add((& $daysFact $dates[0] ((& $daysBefore $dates[0]) -gt $staleDays -and $user.accountEnabled -ne $false))) } elseif ($user.accountEnabled -ne $false) { $facts.Add((& $daysFact $null $false)) } } & $add 'EntraID' 'User' $user.id $facts # A check that names a user only by UPN ([upn]() links) gets a record keyed by the UPN when the run never read that user. if ($user.userPrincipalName) { & $add 'EntraID' 'User' $user.userPrincipalName $facts } } # --- Devices -------------------------------------------------------------------------------------------- $joinType = @{ AzureAd = 'Microsoft Entra joined'; ServerAd = 'Microsoft Entra hybrid joined'; Workplace = 'Microsoft Entra registered' } foreach ($device in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'Device')) { $facts = [System.Collections.Generic.List[object]]::new() $os = (@($device.operatingSystem, $device.operatingSystemVersion) | Where-Object { $_ }) -join ' ' if ($os) { $facts.Add((& $newFact 'Operating system' $os 'Info' 'device.os' ([string]$device.operatingSystem))) } $trust = [string]$device.trustType if ($trust) { $facts.Add((& $newFact 'Join type' $(if ($joinType.ContainsKey($trust)) { $joinType[$trust] } else { $trust }) 'Info' 'device.joinType' $trust)) } # A registered (personal) device is often unmanaged by design; a joined one should be managed. $joined = $trust -in 'AzureAd', 'ServerAd' $managed = $device.isManaged -eq $true $facts.Add((& $newFact 'Management' $(if ($managed) { 'Managed' } else { 'Not managed' }) $(if (-not $managed -and $joined) { 'Warning' } else { 'Info' }) 'device.managed' $managed)) if ($managed -or $null -ne $device.isCompliant) { $compliant = $device.isCompliant -eq $true $facts.Add((& $newFact 'Compliance' $(if ($compliant) { 'Compliant' } else { 'Not compliant' }) $(if ($compliant) { 'Good' } else { 'Warning' }) 'device.compliant' $compliant)) } if ($device.accountEnabled -eq $false) { $facts.Add((& $newFact 'Status' 'Disabled')) } $last = ConvertTo-MtZtaDate $device.approximateLastSignInDateTime if ($last) { $facts.Add((& $daysFact $last ((& $daysBefore $last) -gt $staleDays -and $device.accountEnabled -ne $false))) } & $add 'EntraID' 'Device' $device.id $facts } # --- Agent identities and their blueprints ----------------------------------------------------------------- $blueprints = @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'AgentIdentityBlueprint') $blueprintName = [System.Collections.Generic.Dictionary[string, string]]::new($comparer) foreach ($blueprint in $blueprints) { if ($blueprint.appId) { $blueprintName[[string]$blueprint.appId] = [string]$blueprint.displayName } if ($blueprint.id) { $blueprintName[[string]$blueprint.id] = [string]$blueprint.displayName } } $agentsPerBlueprint = @{} foreach ($agent in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'AgentIdentity')) { $facts = [System.Collections.Generic.List[object]]::new() $name = $null if ($agent.agentIdentityBlueprintId) { $key = [string]$agent.agentIdentityBlueprintId $agentsPerBlueprint[$key] = 1 + [int]$agentsPerBlueprint[$key] if ($blueprintName.TryGetValue($key, [ref]$name) -and $name) { $facts.Add((& $newFact 'Blueprint' $name)) } } $sponsors = @($agent.sponsors | Where-Object { $_ }).Count $facts.Add((& $newFact 'Sponsors' $(if ($sponsors -eq 0) { 'None' } else { [string]$sponsors }) $(if ($sponsors -eq 0) { 'Warning' } else { 'Info' }))) if ($agent.accountEnabled -eq $false) { $facts.Add((& $newFact 'Sign-in' 'Disabled')) } $sp = $null if ($servicePrincipalById.TryGetValue([string]$agent.id, [ref]$sp)) { foreach ($fact in @(& $appFacts $sp)) { $facts.Add($fact) } } $role = & $roleFact $agent.id if ($role) { $facts.Add($role) } & $add 'EntraID' 'AgentIdentity' $agent.id $facts } foreach ($blueprint in $blueprints) { $facts = [System.Collections.Generic.List[object]]::new() $owners = @($blueprint.owners | Where-Object { $_ }).Count $facts.Add((& $newFact 'Owners' $(if ($owners -eq 0) { 'None' } else { [string]$owners }) $(if ($owners -eq 0) { 'Warning' } else { 'Info' }) 'app.ownerCount' ([int]$owners))) $sponsors = @($blueprint.sponsors | Where-Object { $_ }).Count $facts.Add((& $newFact 'Sponsors' $(if ($sponsors -eq 0) { 'None' } else { [string]$sponsors }) $(if ($sponsors -eq 0) { 'Warning' } else { 'Info' }))) $count = [int]$agentsPerBlueprint[[string]$blueprint.appId] $facts.Add((& $newFact 'Agent identities' $(if ($count -eq 1) { '1' } else { [string]$count }))) foreach ($fact in @(& $credentialFacts $blueprint.passwordCredentials $blueprint.keyCredentials)) { $facts.Add($fact) } & $add 'EntraID' 'AgentIdentityBlueprint' $blueprint.appId $facts } foreach ($principal in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'AgentIdentityBlueprintPrincipal')) { $facts = [System.Collections.Generic.List[object]]::new() $name = $null if ($principal.appId -and $blueprintName.TryGetValue([string]$principal.appId, [ref]$name) -and $name) { $facts.Add((& $newFact 'Blueprint' $name)) } if ($principal.accountEnabled -eq $false) { $facts.Add((& $newFact 'Sign-in' 'Disabled')) } $sp = $null if ($servicePrincipalById.TryGetValue([string]$principal.id, [ref]$sp)) { foreach ($fact in @(& $appFacts $sp)) { $facts.Add($fact) } } $role = & $roleFact $principal.id if ($role) { $facts.Add($role) } & $add 'EntraID' 'AgentIdentityBlueprintPrincipal' $principal.id $facts } # --- Intune configuration policies --------------------------------------------------------------------------- foreach ($policy in @(Read-MtZtaExportEntity -ExportPath $ExportPath -Name 'ConfigurationPolicy')) { $facts = [System.Collections.Generic.List[object]]::new() $assignments = @($policy.assignments | Where-Object { $_ }).Count $facts.Add((& $newFact 'Assignments' $(if ($assignments -eq 0) { 'Not assigned' } else { [string]$assignments }) $(if ($assignments -eq 0) { 'Warning' } else { 'Info' }))) $platform = (@($policy.platforms, $(if ($policy.technologies) { "($($policy.technologies -replace ',', ', '))" })) | Where-Object { $_ }) -join ' ' if ($platform) { $facts.Add((& $newFact 'Platform' $platform)) } if ($null -ne $policy.settingCount) { $facts.Add((& $newFact 'Settings' ([string]$policy.settingCount))) } $modified = ConvertTo-MtZtaDate $policy.lastModifiedDateTime if ($modified) { $facts.Add((& $newFact 'Last changed' (& $describeWhen $modified))) } & $add 'Intune' 'ConfigurationPolicy' $policy.id $facts } $records.ToArray() } |