Skip to content

Control 2.13: Plugin and Graph Connector Security — PowerShell Setup

Automation scripts for managing plugin and Graph connector security.

Prerequisites

  • PowerShell 7.x
  • Microsoft Graph PowerShell SDK for Scripts 1–3
  • Exchange Online PowerShell module (ExchangeOnlineManagement) for Script 4
  • Microsoft Graph read permissions for applications, external connections, and policies
  • The View-Only Audit Logs or Audit Logs role in Exchange Online for Search-UnifiedAuditLog
  • Appropriate Microsoft Entra and Microsoft 365 administrator roles

Install the required modules once:

Install-Module Microsoft.Graph -Scope CurrentUser
Install-Module ExchangeOnlineManagement -Scope CurrentUser

Scripts 1–3 connect to Microsoft Graph with their required scopes. Before running Script 4, connect to Exchange Online and verify that unified audit log ingestion is enabled:

Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -ShowBanner:$false
Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled

Scripts

Script 1: Candidate Plugin and App OAuth Audit

# Audit delegated OAuth grants for candidate plugin and agent service principals.
# Reconcile this best-effort result with the Agent Registry and Agent Tools inventory.
Import-Module Microsoft.Graph.Applications
Connect-MgGraph -Scopes "Application.Read.All","Directory.Read.All"

$apps = Get-MgServicePrincipal -All -Property "id,displayName,appId,tags,oauth2PermissionScopes,appRoles"
$copilotApps = $apps | Where-Object {
    $_.Tags -contains "CopilotExtension" -or $_.DisplayName -match "Copilot|Plugin|Agent"
}

$appReport = @()
foreach ($app in $copilotApps) {
    $grants = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $app.Id -ErrorAction SilentlyContinue
    $scopes = @(
        $grants.Scope -split " " |
            Where-Object { $_ } |
            Sort-Object -Unique
    )
    $appReport += [PSCustomObject]@{
        Name         = $app.DisplayName
        AppId        = $app.AppId
        PermCount    = $scopes.Count
        Permissions  = ($scopes -join "; ")
    }
}

Write-Host "Copilot-related apps: $($appReport.Count)"
$appReport | Format-Table Name, PermCount -AutoSize
$appReport | Export-Csv "PluginPermissions_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation

Script 2: Graph Connector Security Audit

# Audit Graph connector configurations and access controls
Import-Module Microsoft.Graph.Search
Connect-MgGraph -Scopes "ExternalConnection.Read.All"

$connectors = Get-MgExternalConnection -All
$connReport = @()

foreach ($conn in $connectors) {
    $connReport += [PSCustomObject]@{
        Name        = $conn.Name
        Id          = $conn.Id
        State       = $conn.State
        Description = $conn.Description
    }
}

Write-Host "=== Graph Connector Security Audit ==="
Write-Host "Active connectors: $($connReport.Count)"
$connReport | Format-Table Name, State -AutoSize
$connReport | Export-Csv "ConnectorSecurity_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation

This script inventories Microsoft Graph external connections. Verify each connection's access permissions separately under Microsoft 365 Admin Center > Copilot > Connectors > Your Connections; the Graph inventory is not proof that source ACLs are configured correctly.

# Verify user self-consent is disabled and the admin consent workflow is enabled
Import-Module Microsoft.Graph.Identity.SignIns
Connect-MgGraph -Scopes "Policy.Read.All"

$authorizationPolicy = Get-MgPolicyAuthorizationPolicy
$assignedPolicies = @(
    $authorizationPolicy.DefaultUserRolePermissions.PermissionGrantPoliciesAssigned
)
$selfConsentPolicies = @(
    $assignedPolicies |
        Where-Object { $_ -like "managePermissionGrantsForSelf.*" }
)
$ownedResourcePolicies = @(
    $assignedPolicies |
        Where-Object { $_ -like "managePermissionGrantsForOwnedResource.*" }
)
$adminConsentPolicy = Get-MgPolicyAdminConsentRequestPolicy

$result = [PSCustomObject]@{
    UserConsentDisabled        = ($selfConsentPolicies.Count -eq 0)
    AdminConsentWorkflowEnabled = $adminConsentPolicy.IsEnabled
    SelfConsentPolicies         = ($selfConsentPolicies -join "; ")
    OwnedResourcePolicies       = ($ownedResourcePolicies -join "; ")
}

$result | Format-List

if (-not $result.UserConsentDisabled) {
    Write-Warning "User consent remains enabled by a managePermissionGrantsForSelf policy."
}
if (-not $result.AdminConsentWorkflowEnabled) {
    Write-Warning "The admin consent request workflow is disabled."
}

AllowedToCreateApps controls whether users can register applications; it does not report whether users can grant OAuth consent. User consent is disabled only when no managePermissionGrantsForSelf.* policy is assigned to the default user role. Existing grants must be reviewed and revoked separately.

Script 4: Microsoft 365 Copilot Plugin Usage Evidence

Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -ShowBanner:$false

# Populate exact values from the approved agent and plugin inventory.
# Microsoft documents values such as Copilot.MicrosoftCopilot.BizChat and
# Copilot.Studio.<app-id>; do not add Copilot.Security.SecurityCopilot.
$approvedAppIdentities = @(
    # "Copilot.MicrosoftCopilot.BizChat"
    # "Copilot.Studio.<approved-app-id>"
)
$approvedPluginIds = @(
    # "<approved-plugin-id>"
)

if ($approvedAppIdentities.Count -eq 0 -or $approvedPluginIds.Count -eq 0) {
    throw "Populate the approved AppIdentity and plugin ID allow-lists before collecting evidence."
}

function Get-PagedCopilotAuditRecord {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [datetime]$StartDate,

        [Parameter(Mandatory)]
        [datetime]$EndDate,

        [timespan]$SegmentDuration = [timespan]::FromHours(1)
    )

    if ($EndDate -le $StartDate) {
        throw "EndDate must be later than StartDate."
    }
    if ($SegmentDuration -le [timespan]::Zero) {
        throw "SegmentDuration must be greater than zero."
    }

    $records = [System.Collections.Generic.List[object]]::new()
    $seenRecordIds = [System.Collections.Generic.HashSet[string]]::new(
        [System.StringComparer]::OrdinalIgnoreCase
    )

    for ($segmentStart = $StartDate; $segmentStart -lt $EndDate; $segmentStart = $segmentEnd) {
        $segmentEnd = $segmentStart.Add($SegmentDuration)
        if ($segmentEnd -gt $EndDate) {
            $segmentEnd = $EndDate
        }

        $sessionId = "CopilotPluginAudit_$([guid]::NewGuid())"
        $pageNumber = 0
        $previousResultIndex = -1L
        $segmentResultCount = 0
        $segmentExhausted = $false

        do {
            $pageNumber++
            if ($pageNumber -gt 100) {
                throw "Paging safety limit reached for $segmentStart to $segmentEnd."
            }

            try {
                $page = @(
                    Search-UnifiedAuditLog `
                        -StartDate $segmentStart `
                        -EndDate $segmentEnd `
                        -RecordType CopilotInteraction `
                        -Operations "CopilotInteraction" `
                        -SessionId $sessionId `
                        -SessionCommand ReturnLargeSet `
                        -ResultSize 5000 `
                        -ErrorAction Stop
                )
            }
            catch {
                throw (
                    "Search-UnifiedAuditLog failed for segment $segmentStart to " +
                    "$segmentEnd on page $pageNumber (SessionId $sessionId). " +
                    "No evidence from this run should be treated as complete. " +
                    "Underlying error: $($_.Exception.Message)"
                )
            }

            if ($page.Count -eq 0) {
                $segmentExhausted = $true
                break
            }

            $segmentResultCount += $page.Count
            $newRecordCount = 0

            foreach ($record in $page) {
                $auditData = $record.AuditData | ConvertFrom-Json
                $recordId = [string]$auditData.Id
                if ([string]::IsNullOrWhiteSpace($recordId)) {
                    throw "An audit record is missing AuditData.Id; safe deduplication is not possible."
                }

                if ($seenRecordIds.Add($recordId)) {
                    $records.Add(
                        [PSCustomObject]@{
                            Record    = $record
                            AuditData = $auditData
                        }
                    )
                    $newRecordCount++
                }
            }

            $lastResult = $page[-1]
            $hasResultIndex = $null -ne $lastResult.ResultIndex -and
                -not [string]::IsNullOrWhiteSpace([string]$lastResult.ResultIndex)
            $hasResultCount = $null -ne $lastResult.ResultCount -and
                -not [string]::IsNullOrWhiteSpace([string]$lastResult.ResultCount)

            if ($hasResultIndex) {
                $resultIndex = [long]$lastResult.ResultIndex
                if ($resultIndex -le $previousResultIndex -and $newRecordCount -eq 0) {
                    throw "Audit paging made no progress for $segmentStart to $segmentEnd."
                }
                $previousResultIndex = $resultIndex
            }

            $moreRecordsProperty = $lastResult.AuditSearchRequestMetadata.PSObject.Properties[
                "moreRecordsAvailable"
            ]
            if ($null -ne $moreRecordsProperty) {
                $segmentExhausted = -not [System.Convert]::ToBoolean(
                    $moreRecordsProperty.Value
                )
            }
            elseif ($hasResultIndex -and $hasResultCount) {
                $segmentExhausted = (
                    [long]$lastResult.ResultIndex -eq [long]$lastResult.ResultCount
                )
            }

            if (
                $segmentResultCount -ge 50000 -or
                ($hasResultCount -and [long]$lastResult.ResultCount -ge 50000)
            ) {
                throw (
                    "The $segmentStart to $segmentEnd segment reached the Exchange Online " +
                    "50,000-record session limit. Reduce SegmentDuration and rerun; this " +
                    "result cannot be represented as complete."
                )
            }

            if (-not $segmentExhausted -and $newRecordCount -eq 0) {
                throw "Audit paging returned only duplicate records before reporting exhaustion."
            }
        }
        while (-not $segmentExhausted)
    }

    return $records
}

$endDate = (Get-Date).ToUniversalTime()
$startDate = $endDate.AddDays(-30)
$recordEnvelopes = @(
    Get-PagedCopilotAuditRecord -StartDate $startDate -EndDate $endDate
)

$pluginUsageRows = @(
    foreach ($envelope in $recordEnvelopes) {
        $record = $envelope.Record
        $auditData = $envelope.AuditData
        $copilotEventData = $auditData.CopilotEventData

        foreach ($plugin in @($copilotEventData.AISystemPlugin)) {
            if ($null -eq $plugin) {
                continue
            }

            $rejectionReasons = @()
            if ($auditData.Workload -ne "Copilot") {
                $rejectionReasons += "Unexpected workload"
            }
            if (([string]$auditData.AppIdentity) -like "Copilot.Security.*") {
                $rejectionReasons += "Security Copilot is out of scope"
            }
            elseif ($approvedAppIdentities -notcontains [string]$auditData.AppIdentity) {
                $rejectionReasons += "AppIdentity is not approved"
            }
            if ($approvedPluginIds -notcontains [string]$plugin.ID) {
                $rejectionReasons += "Plugin ID is not approved"
            }

            [PSCustomObject]@{
                EvidenceStatus  = if ($rejectionReasons.Count -eq 0) { "Accepted" } else { "Rejected" }
                RejectionReason = $rejectionReasons -join "; "
                CreationDate    = $record.CreationDate
                UserId           = $record.UserIds -join "; "
                Workload         = $auditData.Workload
                AppIdentity      = $auditData.AppIdentity
                AppHost          = $copilotEventData.AppHost
                AgentId          = $auditData.AgentId
                AgentName        = $auditData.AgentName
                PluginName       = $plugin.Name
                PluginId         = $plugin.ID
                PluginVersion    = $plugin.Version
            }
        }
    }
)

$m365PluginUsage = @($pluginUsageRows | Where-Object EvidenceStatus -eq "Accepted")
$rejectedPluginUsage = @($pluginUsageRows | Where-Object EvidenceStatus -eq "Rejected")

$m365PluginUsage |
    Export-Csv "M365CopilotPluginUsage_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
$rejectedPluginUsage |
    Export-Csv "RejectedCopilotPluginUsage_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation

if ($rejectedPluginUsage.Count -gt 0) {
    Write-Warning "$($rejectedPluginUsage.Count) plugin record(s) were excluded from M365 evidence; review the rejected export."
}

The Microsoft audit schema places AISystemPlugin and AppHost inside AuditData.CopilotEventData; the script keeps root-level fields such as Workload and AppIdentity separate. The Microsoft audit catalog lists EnablePlugin for both Microsoft 365 Copilot administration and Security Copilot platform management, so this script does not use that overloaded operation. CopilotInteraction also spans multiple Microsoft Copilot products, and Workload = Copilot is not a sufficient product boundary. Microsoft's audit examples show distinct AppIdentity values for Microsoft Copilot and Security Copilot, and Microsoft directs administrators to export records and filter that property offline. This script additionally requires an exact CopilotEventData.AISystemPlugin.ID match. Unknown identities and Copilot.Security.SecurityCopilot therefore fail closed into a separate rejected export instead of entering the Microsoft 365 Copilot evidence set.

Search-UnifiedAuditLog returns at most 5,000 records per call. Script 4 reuses a stable SessionId with SessionCommand ReturnLargeSet until each one-hour segment is exhausted, and deduplicates the immutable audit record ID across page and segment boundaries. Exchange Online limits a ReturnLargeSet session to 50,000 records. If any segment reaches that limit, the script stops instead of claiming a complete result; reduce SegmentDuration (for example, to 15 minutes) and rerun. For high-volume, recurring ingestion, use the Office 365 Management Activity API rather than treating this cmdlet as a guaranteed bulk-export interface.

Scheduled Tasks

Task Frequency Purpose
Plugin Permission Audit Monthly Reconcile portal inventory and delegated OAuth scope
Connector Security Audit Monthly Verify connector configurations
Consent Policy Check Quarterly Verify self-consent is disabled and request workflow is active
M365 Copilot Plugin Usage Audit Monthly Filter interaction telemetry by approved application and plugin IDs

Microsoft Guidance

Next Steps