Private/Entra/Checks/Invoke-EntraAIAgentChecks.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
#
# Copilot Studio AI-agent governance dispatcher + checks. Collector:
# Get-PowerPlatformData -> $AuditData.AIAgents (the Dataverse `bots` table per Power
# Platform environment). A failed collection is Not Assessed; a tenant with no agents
# passes (nothing to govern). Field semantics mirror the Copilot Studio bots schema
# (AccessControlPolicy / UserAuthenticationType / AuthenticationTrigger /
# LastPublishedTime / LastModifiedTime / AuthorizedSecurityGroupIds).
function Invoke-EntraAIAgentChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'EntraAIAgentChecks'
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($check in $checkDefs.checks) {
        $funcName = "Test-Infiltration$($check.id -replace '-', '')"
        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            try {
                $finding = & $funcName -AuditData $AuditData -CheckDefinition $check
                if ($finding) { $findings.Add($finding) }
            } catch {
                Write-Warning "Check $($check.id) failed: $($_.Exception.Message)"
            }
        }
    }

    return @($findings)
}

# Shared Not-Assessed guard + agent-list resolver for the AI-agent checks.
function Get-AIAgentList {
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)
    $na = Get-NotAssessedFinding -CheckDefinition $CheckDefinition `
        -ErrorMap @($AuditData.Errors, $AuditData.AIAgents.Errors) `
        -SourceKey @('AIAgents', 'Agents') -Subject 'Copilot Studio agent (Dataverse) data'
    if ($na) { return @{ Na = $na } }
    return @{ Agents = @($AuditData.AIAgents.Agents) }
}

# ── AIAGENT-001: Restrict who can interact (no broad access) ──────────────
function Test-InfiltrationAIAGENT001 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $r = Get-AIAgentList -AuditData $AuditData -CheckDefinition $CheckDefinition
    if ($r.Na) { return $r.Na }
    $agents = $r.Agents
    if ($agents.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No Copilot Studio agents found (nothing to govern)' -Details @{ AgentCount = 0 }
    }

    $broad = @($agents | Where-Object { $_.AccessControlPolicy -in @('Any', 'Any multitenant') })
    $status = if ($broad.Count -eq 0) { 'PASS' } else { 'FAIL' }
    $cv = if ($status -eq 'PASS') {
        "All $($agents.Count) agents restrict who can interact"
    } else {
        "$($broad.Count) of $($agents.Count) agents allow broad access (Any / Any multitenant)"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{ AgentCount = $agents.Count; BroadAccessCount = $broad.Count; BroadAgents = @($broad | ForEach-Object { $_.AIAgentName }) }
}

# ── AIAGENT-002: Require authentication ──────────────────────────────────
function Test-InfiltrationAIAGENT002 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $r = Get-AIAgentList -AuditData $AuditData -CheckDefinition $CheckDefinition
    if ($r.Na) { return $r.Na }
    $agents = $r.Agents
    if ($agents.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No Copilot Studio agents found (nothing to govern)' -Details @{ AgentCount = 0 }
    }

    $noAuth = @($agents | Where-Object { $_.UserAuthenticationType -eq 'None' })
    $optionalAuth = @($agents | Where-Object { $_.UserAuthenticationType -ne 'None' -and $_.AuthenticationTrigger -eq 'As Needed' })

    if ($noAuth.Count -gt 0) {
        $status = 'FAIL'
        $cv = "$($noAuth.Count) of $($agents.Count) agents accept anonymous (unauthenticated) interaction"
    } elseif ($optionalAuth.Count -gt 0) {
        $status = 'WARN'
        $cv = "$($optionalAuth.Count) of $($agents.Count) agents make authentication optional (triggered As Needed)"
    } else {
        $status = 'PASS'
        $cv = "All $($agents.Count) agents require authentication"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{
            AgentCount        = $agents.Count
            NoAuthCount       = $noAuth.Count
            OptionalAuthCount = $optionalAuth.Count
            NoAuthAgents      = @($noAuth | ForEach-Object { $_.AIAgentName })
        }
}

# ── AIAGENT-003: Published agents not dormant ────────────────────────────
function Test-InfiltrationAIAGENT003 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $r = Get-AIAgentList -AuditData $AuditData -CheckDefinition $CheckDefinition
    if ($r.Na) { return $r.Na }
    $agents = $r.Agents
    # Only PUBLISHED agents can be dormant-but-exposed.
    $published = @($agents | Where-Object { $_.LastPublishedTime })
    if ($published.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No published Copilot Studio agents to review' -Details @{ PublishedCount = 0 }
    }

    $thresholdDays = 90
    $threshold = (Get-Date).AddDays(-$thresholdDays)
    $dormant = @($published | Where-Object {
        $dt = [datetime]::MinValue
        [datetime]::TryParse("$($_.LastModifiedTime)", [ref]$dt) -and $dt -lt $threshold
    })
    $status = if ($dormant.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "All $($published.Count) published agents modified within $thresholdDays days"
    } else {
        "$($dormant.Count) of $($published.Count) published agents are dormant (>$thresholdDays days since modification)"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{ PublishedCount = $published.Count; DormantCount = $dormant.Count; DormantAgents = @($dormant | ForEach-Object { $_.AIAgentName }) }
}

# ── AIAGENT-004: Access scoped to security groups ────────────────────────
function Test-InfiltrationAIAGENT004 {
    [CmdletBinding()]
    param([hashtable]$AuditData, [hashtable]$CheckDefinition)

    $r = Get-AIAgentList -AuditData $AuditData -CheckDefinition $CheckDefinition
    if ($r.Na) { return $r.Na }
    $agents = $r.Agents
    if ($agents.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No Copilot Studio agents found (nothing to govern)' -Details @{ AgentCount = 0 }
    }

    # Authenticated agents with no authorized security groups are reachable by any
    # authenticated user in the tenant.
    $authenticated = @($agents | Where-Object { $_.UserAuthenticationType -ne 'None' })
    $unscoped = @($authenticated | Where-Object { @($_.AuthorizedSecurityGroupIds | Where-Object { $_ }).Count -eq 0 })
    $status = if ($unscoped.Count -eq 0) { 'PASS' } else { 'WARN' }
    $cv = if ($status -eq 'PASS') {
        "All authenticated agents are scoped to security groups (or there are none)"
    } else {
        "$($unscoped.Count) of $($authenticated.Count) authenticated agents are open to all authenticated users (no security-group scope)"
    }
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status -CurrentValue $cv `
        -Details @{ AuthenticatedCount = $authenticated.Count; UnscopedCount = $unscoped.Count; UnscopedAgents = @($unscoped | ForEach-Object { $_.AIAgentName }) }
}