Private/Evaluate.ps1

$script:AgentIdGraphAppId = '00000003-0000-0000-c000-000000000000'
$script:AgentIdSeverityRank = @{ Critical = 4; High = 3; Medium = 2; Low = 1; Info = 0 }
$script:AgentIdWellKnownResources = @{
    '00000003-0000-0000-c000-000000000000' = 'Microsoft Graph'
    '00000002-0000-0ff1-ce00-000000000000' = 'Office 365 Exchange Online'
    '00000003-0000-0ff1-ce00-000000000000' = 'Office 365 SharePoint Online'
}

function New-AgentIdContext {
    param(
        [Parameter(Mandatory)]$Snapshot,
        [Parameter(Mandatory)][datetimeoffset]$AsOf,
        [Parameter(Mandatory)][hashtable]$Options
    )
    $ctx = @{
        Snapshot                  = $Snapshot
        AsOf                      = $AsOf
        Options                   = $Options
        Blocked                   = $script:AgentIdBlockedPermissions
        Sensitive                 = $script:AgentIdSensitivePermissions
        AccessRows                = @(Get-AgentIdAccessRow -Snapshot $Snapshot)
        BlueprintByAppId          = @{}
        BlueprintPrincipalByAppId = @{}
        AgentById                 = @{}
        AgentsByBlueprintAppId    = @{}
        ResourceByAppId           = @{}
        SignInByAppId             = @{}
    }
    foreach ($b in @($Snapshot.Blueprints)) { if ($b) { $ctx.BlueprintByAppId[$b.appId] = $b } }
    foreach ($p in @($Snapshot.BlueprintPrincipals)) { if ($p) { $ctx.BlueprintPrincipalByAppId[$p.appId] = $p } }
    foreach ($a in @($Snapshot.AgentIdentities)) {
        if (-not $a) { continue }
        $ctx.AgentById[$a.id] = $a
        $key = "$($a.agentIdentityBlueprintId)"
        if (-not $ctx.AgentsByBlueprintAppId.ContainsKey($key)) { $ctx.AgentsByBlueprintAppId[$key] = [System.Collections.Generic.List[object]]::new() }
        $ctx.AgentsByBlueprintAppId[$key].Add($a)
    }
    foreach ($r in @($Snapshot.Resources)) { if ($r -and $r.appId) { $ctx.ResourceByAppId[$r.appId] = $r } }
    foreach ($s in @($Snapshot.SignInActivity)) { if ($s -and $s.appId) { $ctx.SignInByAppId[$s.appId] = $s } }
    return $ctx
}

function New-AgentIdFinding {
    param(
        [Parameter(Mandatory)]$Rule,
        [Parameter(Mandatory)][string]$ObjectType,
        [string]$ObjectId,
        [string]$ObjectName,
        [Parameter(Mandatory)][string]$Detail,
        [ValidateSet('Critical', 'High', 'Medium', 'Low', 'Info')][string]$Severity
    )
    [pscustomobject]@{
        PSTypeName  = 'AgentIdAudit.Finding'
        RuleId      = $Rule.Id
        Severity    = if ($Severity) { $Severity } else { $Rule.Severity }
        Category    = $Rule.Category
        Title       = $Rule.Title
        ObjectType  = $ObjectType
        ObjectName  = $ObjectName
        ObjectId    = $ObjectId
        Detail      = $Detail
        Remediation = $Rule.Remediation
        Reference   = $Rule.Reference
    }
}

function Get-AgentIdSeverityRank {
    param([string]$Severity)
    if ($script:AgentIdSeverityRank.ContainsKey("$Severity")) { return $script:AgentIdSeverityRank[$Severity] }
    return -1
}

function Get-AgentIdActiveCredential {
    param([Parameter(Mandatory)]$Ctx, $Credentials)
    foreach ($c in @($Credentials)) {
        if (-not $c) { continue }
        $end = ConvertTo-AgentIdDate $c.endDateTime
        if ($null -eq $end -or $end -gt $Ctx.AsOf) { $c }
    }
}

function Get-AgentIdExpiredCredential {
    param([Parameter(Mandatory)]$Ctx, $Credentials)
    foreach ($c in @($Credentials)) {
        if (-not $c) { continue }
        $end = ConvertTo-AgentIdDate $c.endDateTime
        if ($null -ne $end -and $end -le $Ctx.AsOf) { $c }
    }
}

function Format-AgentIdDate {
    param($Value)
    $d = ConvertTo-AgentIdDate $Value
    if ($null -eq $d) { return 'unknown' }
    return $d.UtcDateTime.ToString('yyyy-MM-dd')
}

function Format-AgentIdCredential {
    param($Credential)
    $name = if ($Credential.displayName) { "'$($Credential.displayName)'" } else { "key $($Credential.keyId)" }
    $kind = if ($Credential.kind -eq 'Secret') { 'secret' } else { 'certificate' }
    return "$name ($kind)"
}

function Test-AgentIdCoverageUsable {
    param([Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$Key)
    $entry = $Snapshot.Coverage.$Key
    return ($null -ne $entry -and $entry.Status -in 'Ok', 'Partial')
}

function Get-AgentIdBlueprintAgentText {
    # Describes who a blueprint credential or permission affects, without overstating what we know.
    param([Parameter(Mandatory)]$Ctx, [string]$BlueprintAppId)
    if (-not (Test-AgentIdCoverageUsable $Ctx.Snapshot 'AgentIdentities')) {
        return 'every agent identity created from this blueprint'
    }
    $count = 0
    if ($Ctx.AgentsByBlueprintAppId.ContainsKey("$BlueprintAppId")) { $count = $Ctx.AgentsByBlueprintAppId["$BlueprintAppId"].Count }
    switch ($count) {
        0 { return 'any agent identity created from this blueprint (none exist yet)' }
        1 { return 'the 1 agent identity created from this blueprint, and any created later' }
        default { return "the $count agent identities created from this blueprint, and any created later" }
    }
}

function Get-AgentIdResourceName {
    param([Parameter(Mandatory)]$Ctx, [string]$ResourceAppId)
    if ($Ctx.ResourceByAppId.ContainsKey("$ResourceAppId")) { return $Ctx.ResourceByAppId[$ResourceAppId].displayName }
    if ($script:AgentIdWellKnownResources.ContainsKey("$ResourceAppId")) { return $script:AgentIdWellKnownResources[$ResourceAppId] }
    return "resource $ResourceAppId"
}

function Test-AgentIdBlockedPermission {
    param([Parameter(Mandatory)]$Ctx, [Parameter(Mandatory)]$Row)
    if ($Row.ResourceAppId -ne $script:AgentIdGraphAppId) { return $false }
    $list = if ($Row.PermissionType -eq 'Application') { $Ctx.Blocked.Application } else { $Ctx.Blocked.Delegated }
    return (@($list) -contains $Row.Permission)
}

function Test-AgentIdSensitivePermission {
    param([Parameter(Mandatory)]$Ctx, [Parameter(Mandatory)][string]$PermissionType, [string]$ResourceAppId, [string]$Permission)
    if (-not $ResourceAppId) { return $false }
    $table = $Ctx.Sensitive[$PermissionType]
    if (-not $table -or -not $table.ContainsKey($ResourceAppId)) { return $false }
    return (@($table[$ResourceAppId]) -contains $Permission)
}

function Get-AgentIdEvaluatedAccessRow {
    # Rows the permission rules should judge. An agent's inherited rows are skipped when its blueprint
    # principal is in the snapshot, because the same access is reported once, on the blueprint principal,
    # rather than repeated for every agent created from it.
    param([Parameter(Mandatory)]$Ctx)
    foreach ($row in $Ctx.AccessRows) {
        if ($row.Source -eq 'Inherited' -and $Ctx.BlueprintPrincipalByAppId.ContainsKey("$($row.BlueprintAppId)")) { continue }
        $row
    }
}

function Get-AgentIdPrincipalLabel {
    param([Parameter(Mandatory)][string]$Kind)
    switch ($Kind) {
        'AgentIdentity' { 'Agent identity' }
        'BlueprintPrincipal' { 'Blueprint principal' }
        'AgentUser' { 'Agent user' }
        default { $Kind }
    }
}

function Format-AgentIdPermissionList {
    param([Parameter(Mandatory)]$Ctx, [Parameter(Mandatory)]$Rows)
    $parts = foreach ($r in $Rows) {
        $resource = if ($r.ResourceName) { $r.ResourceName } else { Get-AgentIdResourceName $Ctx $r.ResourceAppId }
        $suffix = if ($r.Source -eq 'Inherited') { ', inherited' } else { '' }
        "$($r.Permission) ($resource$suffix)"
    }
    return (@($parts) | Sort-Object -Unique) -join ', '
}

function Get-AgentIdAgentMissingSecurityAttribute {
    # For each agent identity, the given 'Set.Attribute' names it holds no value for. An agent whose attributes
    # could not be read ($null) is skipped: unknown is not the same as missing.
    param([Parameter(Mandatory)]$Ctx, [string[]]$Name)
    foreach ($agent in @($Ctx.Snapshot.AgentIdentities)) {
        if (-not $agent -or $null -eq $agent.securityAttributes) { continue }
        $missing = @($Name | Where-Object { $_ -notin @($agent.securityAttributes) })
        if ($missing) { [pscustomobject]@{ Agent = $agent; Missing = $missing } }
    }
}

function Invoke-AgentIdRuleEvaluation {
    param(
        [Parameter(Mandatory)]$Snapshot,
        [datetimeoffset]$AsOf,
        [Parameter(Mandatory)][hashtable]$Options,
        [string[]]$IncludeRuleId,
        [string[]]$ExcludeRuleId
    )
    if (-not $AsOf) {
        $collected = ConvertTo-AgentIdDate $Snapshot.CollectedAt
        $AsOf = if ($collected) { $collected } else { [datetimeoffset]::UtcNow }
    }
    $ctx = New-AgentIdContext -Snapshot $Snapshot -AsOf $AsOf -Options $Options
    $findings = [System.Collections.Generic.List[object]]::new()
    $notEvaluated = [System.Collections.Generic.List[object]]::new()

    foreach ($rule in $script:AgentIdRules) {
        if ($IncludeRuleId -and -not ($IncludeRuleId | Where-Object { $rule.Id -like $_ })) { continue }
        if ($ExcludeRuleId -and ($ExcludeRuleId | Where-Object { $rule.Id -like $_ })) { continue }
        # A rule that needs configuration (e.g. a list of attribute names) is left out entirely, not reported as
        # not evaluated, until it has been configured.
        if ($rule.ContainsKey('Applies') -and -not (& $rule.Applies $ctx)) { continue }

        $blocking = foreach ($key in $rule.Requires) {
            $entry = $Snapshot.Coverage.$key
            $status = if ($entry) { $entry.Status } else { 'NotCollected' }
            if ($status -notin 'Ok', 'Partial') {
                $text = "$key $status"
                if ($entry.Detail) { $text += " ($($entry.Detail))" }
                $text
            }
        }
        if ($blocking) {
            $notEvaluated.Add([pscustomobject]@{
                    PSTypeName = 'AgentIdAudit.NotEvaluated'
                    RuleId     = $rule.Id
                    Title      = $rule.Title
                    Reason     = ($blocking -join '; ')
                })
            continue
        }
        try {
            foreach ($f in @(& $rule.Evaluate $ctx $rule)) { if ($f) { $findings.Add($f) } }
        } catch {
            $notEvaluated.Add([pscustomobject]@{
                    PSTypeName = 'AgentIdAudit.NotEvaluated'
                    RuleId     = $rule.Id
                    Title      = $rule.Title
                    Reason     = "Rule failed: $($_.Exception.Message)"
                })
        }
    }

    $sorted = @($findings | Sort-Object -Property @{ Expression = { Get-AgentIdSeverityRank $_.Severity }; Descending = $true }, RuleId, ObjectName)
    return @{
        Findings     = $sorted
        NotEvaluated = @($notEvaluated)
        Context      = $ctx
    }
}