Control 1.2 — PowerShell Setup: Agent Registry and Integrated Apps Management Automation
Scope. This playbook automates the agent identity and registration plane for Control 1.2 across Entra app registrations, enterprise applications (service principals), Integrated Apps in the Microsoft 365 admin center, Copilot Studio agent registrations, MCP server registrations, the emerging Microsoft Entra Agent ID surface, and Agent 365 admin endpoints in US financial services tenants. It assumes you have already read
../../_shared/powershell-baseline.md(referenced below as BL-§N) and the parent control specification../../../controls/pillar-1-security/1.2-agent-registry-and-integrated-apps-management.md.What this playbook is. A reproducible, fail-closed registration-plane harness that (a) pins module / CLI versions; (b) bootstraps a sovereign-aware, certificate-authenticated, separate registry-reader principal that is distinct from any tenant-mutating credential; (c) enumerates every app-registration and service-principal object that could be an agent; (d) audits ownership, permission grants, credential hygiene, consent posture, sign-in risk, and Conditional Access coverage; (e) cross-walks the registration evidence to the Power Platform, MCP, and Integrated Apps surfaces; (f) feeds the merged record set into the Control 3.1 canonical reconciliation schema as an upstream registration source; and (g) emits a quarterly attestation pack with SHA-256 hashes and a certificate-signed manifest.
What this playbook is not. It does not replace the authoritative system of record (the Control 3.1 inventory, the GRC tool, or the SharePoint sponsorship register). It does not, by itself, guarantee completeness — Microsoft Entra Agent ID, Agent 365 admin endpoints, and several Integrated Apps Graph routes remain preview-dependent in April 2026 (documented in §0 and §12), and organizations should compensate with manual attestation and Defender for Cloud Apps shadow-IT detection. It does not, by itself, approve, decommission, transfer, or rotate registrations; it raises evidence and recommends action, and humans accept risk.
Hedged language reminder. Output of this harness supports compliance with FINRA Rule 4511, FINRA Regulatory Notice 25-07, SEC Rule 17a-4(b)(4) / 17a-4(g), SOX 302/404, GLBA 501(b), NYDFS 23 NYCRR 500.07 / 500.16 / 500.17, OCC Bulletin 2011-12, Fed SR 11-7, NIST AI RMF GOVERN 1.4 / 1.6, and FTC Safeguards Rule 16 CFR §314.4(c). It does not, by itself, ensure a passing examination, guarantee that every shadow registration has been discovered, or eliminate the risk that Microsoft moves an endpoint between v1.0 and beta between releases. Implementation requires that organizations verify endpoint availability, module pinning, and sovereign feature parity at every change window, and that they treat any preview surface (Entra Agent ID, Agent 365,
/admin/microsoft365apps) as additive evidence rather than the sole source of truth.
| Field | Value |
|---|---|
| Control ID | 1.2 |
| Pillar | 1 — Security |
| Playbook | PowerShell Setup |
| PowerShell Edition | 7.4 LTS Core (orchestrator); 5.1 Desktop (Power Apps Administration sub-shell, JSON-bridged) |
| Sovereign Clouds | Commercial, GCC, GCC High, DoD, China (21Vianet) — see §1 sovereign matrix and §2 bootstrap |
| Last UI Verified | April 2026 |
| Companion Playbooks | portal-walkthrough.md (planned) · verification-testing.md · troubleshooting.md (planned) · sponsorship-lifecycle-workflows.md |
| Related Controls | 1.4 · 1.7 · 1.19 · 1.21 · 1.23 · 1.24 · 2.1 · 3.1 · 3.6 |
§0 — Wrong-shell trap and registration-plane false-clean defects (READ FIRST)
The defining fact of Control 1.2. As of April 2026, no single Microsoft API returns "all registered agents" in a tenant. An agent may exist as: (a) an Entra application object with an agent/copilot tag, (b) a service principal with an MCP-pattern redirect URI, (c) an Integrated App in the M365 admin center with no corresponding Power Platform record, (d) a Copilot Studio bot whose backing service principal has an unrelated display name, (e) an Entra Agent ID preview object visible only via the beta /agents route, (f) a declarative-agent manifest registered as a multi-tenant publisher app the organization has consented to, or (g) any combination of the above for the same business agent. A script that connects to one plane and reports a number is producing audit-grade misinformation.
A script that ignores this reality produces a false-clean registry — the worst Control 1.2 outcome. False-clean registries understate identity exposure under NYDFS 500.07 / 500.16, leave ownerless privilege grants undetected (NIST AI RMF GOVERN 1.4), and break the approval-trail evidence FINRA Rule 4511 and SEC 17a-4(b)(4) examiners ask for first.
Why this section exists. Six classes of silent failure produce false-clean registry output in Control 1.2 specifically:
- Wrong PowerShell edition for the plane being queried.
Microsoft.PowerApps.Administration.PowerShellis Desktop-only (Windows PowerShell 5.1) and silently returns empty arrays under PowerShell 7.Microsoft.Graphv2+,Az.Accountsv3+, andMSCommerceare Core-only. A registry harness that runs end-to-end in one edition is necessarily incomplete on at least one plane (BL-§2). - Sovereign mis-routing.
Connect-MgGraphwithout-Environment USGov/USGovDOD/China,Add-PowerAppsAccountwithout-Endpoint usgov/usgovhigh/dod, andpac auth createwithout--cloud UsGov/UsGovHigh/DoDall authenticate against commercial endpoints and return zero results in a sovereign tenant — with exit code 0 (BL-§3). - Beta vs v1.0 Microsoft Graph drift on registration endpoints.
/applicationsagent-tag filters,/servicePrincipals/{id}/appRoleAssignments,/identity/conditionalAccess/policiesworkload-identity targeting,/identityProtection/riskyServicePrincipals,/admin/microsoft365apps(April 2026 preview), and/agents(Entra Agent ID preview) move between beta and v1.0 quarterly, with breaking shape changes between minor SDK releases. - Granted-scope mismatch.
Connect-MgGraph -Scopes 'Application.Read.All'requests the scope; the call returns200with an emptyvaluearray if admin consent was never granted. Verify(Get-MgContext).Scopesagainst the requested set after every connect. - Tag taxonomy churn. Microsoft has shipped multiple agent-tag values across releases (
DeclarativeAgent,CopilotAgent,M365CopilotPlugin,CopilotExtension,Bot,ChatBot,CopilotStudioBot,AgentApplication). A hard-coded filter on a single tag misses every agent registered under a sibling tag. - Single-credential read+write. Running this harness with the same service principal that creates or modifies applications breaks SOX 404 separation of duties — the principal that produces the evidence could be the principal that altered the configuration the evidence describes.
Top false-clean defects unique to registration automation.
| # | Defect | What it looks like | How this playbook traps it |
|---|---|---|---|
| 1 | Get-MgApplication -Filter "tags/any(t:t eq 'DeclarativeAgent')" only |
Misses CopilotAgent, M365CopilotPlugin, CopilotExtension, Agent Registry preview tags | §3 enumerates every known agent tag and warns on unknown tags found in tenant |
| 2 | Owner check via Get-MgApplication .Owners navigation property without expansion |
.Owners returns empty unless -ExpandProperty owners is set; ownerless detection produces false positives |
§4 explicit -ExpandProperty owners plus secondary Get-MgApplicationOwner cross-check |
| 3 | Permission-grant audit on Application.Permissions only |
Misses delegated grants stored as oauth2PermissionGrants and risk-tiered AppRoleAssignments |
§5 walks both Get-MgServicePrincipalDelegatedPermissionGrant and Get-MgServicePrincipalAppRoleAssignment |
| 4 | Credential expiry calculated as EndDateTime - Now without KeyId correlation |
Rotation evidence loses provenance; can't tie new credential to retired credential | §6 emits KeyId, Hint, CustomKeyIdentifier for every passwordCredential / keyCredential row |
| 5 | Consent-policy assessment via Get-MgPolicyAuthorizationPolicy only |
Misses the per-app admin-consent-request policy and reviewer queue size | §7 reads /policies/adminConsentRequestPolicy plus reviewer queue via /identityGovernance/appConsent/appConsentRequests |
| 6 | SP sign-in audit via Get-MgAuditLogSignIn without -Filter "signInEventTypes/any(t:t eq 'servicePrincipal')" |
Returns user sign-ins; SP risk invisible | §8 explicit SP-only filter and Identity Protection riskyServicePrincipals cross-check |
| 7 | Conditional Access for workload identities listed but not validated against the SP set | A policy exists but excludes the SPs that need it | §9 resolves each policy's conditions.clientApplications.includeServicePrincipals against the §3 inventory and reports gaps |
| 8 | pac copilot list run without --cloud on a sovereign tenant |
Zero results; exit 0 | §10 hard-fail when sovereign discriminator detected on tenant but --cloud mismatches |
| 9 | MCP enumeration via Get-MgApplication only (they are service principals with characteristic redirect URIs) |
Entire MCP population missing | §11 walks Get-MgServicePrincipal with redirect-URI pattern matching plus tag filter |
| 10 | Integrated Apps assumed equal to the Power Platform inventory | Org-wide-deployed third-party apps with no PA footprint missed | §12 reads /admin/microsoft365apps/installedApps (preview) plus the Exchange Online add-in surface as a fallback |
| 11 | Reconciliation that overwrites the canonical record on every run | History destroyed; deltas invisible to examiners | §13 emits run-id-stamped subfolders to a WORM root and signs the manifest |
| 12 | Single SP for both registry-read and tenant-write | Breaks SOX 404 separation of duties | §1 separate agt12-registry-reader audit-only principal |
Required shell guard (run this at the top of every Control 1.2 session).
# Save as: scripts/Assert-Agt12Shell.ps1
[CmdletBinding()]
[OutputType([void])]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSEdition -ne 'Core' -or $PSVersionTable.PSVersion -lt [version]'7.4.0') {
Write-Error "Control 1.2 orchestrator requires PowerShell 7.4 LTS Core (pwsh). Detected: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion). Launch 'pwsh' (not 'powershell.exe') and retry. The Power Apps Administration leg in §10 will be spawned into a Windows PowerShell 5.1 child process."
exit 2
}
# Trap stale Windows PowerShell module shadowing
$desktopPaths = $env:PSModulePath -split [IO.Path]::PathSeparator | Where-Object { $_ -match 'WindowsPowerShell\\Modules' }
if ($desktopPaths) {
$bad = Get-Module -ListAvailable -Name 'Microsoft.Graph','AzureAD','AzureADPreview' |
Where-Object { $_.Name -eq 'Microsoft.Graph' -and $_.Version.Major -lt 2 }
if ($bad) {
Write-Error "Stale v1 Microsoft.Graph visible from Desktop module path: $($bad | ForEach-Object { "$($_.Name)=$($_.Version)" } | Sort-Object -Unique). Uninstall before continuing — autoload would silently shadow the pinned v2 modules and produce wrong-shape registry output."
exit 2
}
$azureAd = Get-Module -ListAvailable -Name 'AzureAD','AzureADPreview' | Select-Object -First 1
if ($azureAd) {
Write-Warning "AzureAD legacy module present ($($azureAd.Name)=$($azureAd.Version)). Microsoft has formally deprecated AzureAD; use it ONLY as the documented fallback in §3.4 for endpoints not yet covered by Microsoft.Graph and never in the same script as Microsoft.Graph (cmdlet-name collisions cause silent wrong-result resolution)."
}
}
Write-Verbose "Control 1.2 shell guard passed: pwsh $($PSVersionTable.PSVersion)"
Fail-closed conditions enforced by this guard:
- Detected PowerShell edition is
Desktopor version< 7.4.0→exit 2. - v1
Microsoft.Graphdiscoverable on$env:PSModulePath→exit 2. AzureAD/AzureADPreviewlegacy module present → loud warning (legitimate fallback per §3.4 but cmdlet collisions are recorded).
§1 — Module, CLI, package, and permission matrix
Why this section exists. Registry-plane evidence is reproducible only when versions are declared, hashed, and emitted into the quarterly attestation manifest (§13). A pinned baseline lets the change ticket reference exact module versions, which is what SOX 404 evidence reviewers expect. Microsoft ships breaking shape changes across Microsoft.Graph minor versions on the application/serviceprincipal endpoints — the most common failure mode in this playbook is an unpinned upgrade silently changing a property name.
1.1 Pinned PowerShell modules (orchestrator — PS 7.4 Core)
# Save as: scripts/Install-Agt12Modules.ps1
[CmdletBinding(SupportsShouldProcess)]
[OutputType([void])]
param(
[Parameter()] [switch]$AcceptLicense
)
<#
.SYNOPSIS
Pins every PowerShell module Control 1.2 depends on to a CAB-approved version.
.DESCRIPTION
Idempotent installer. Skips any module already present at the exact RequiredVersion.
Spawns a Windows PowerShell 5.1 child process for the Power Apps Administration modules
because they are Desktop-only and silently misbehave under PowerShell 7 (BL-§2).
.EXAMPLE
PS> ./Install-Agt12Modules.ps1 -WhatIf
Lists every install action that would be taken without performing it.
.EXAMPLE
PS> ./Install-Agt12Modules.ps1 -AcceptLicense -Verbose
Performs the install for unattended scheduler use.
.NOTES
Verify pinned versions against your CAB-approved baseline before each run. Microsoft.Graph is
a meta-module that pulls 30+ sub-modules; pin every required sub-module explicitly.
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modules = @(
@{ Name = 'Microsoft.Graph.Authentication'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Applications'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Identity.SignIns'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Identity.Governance'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Identity.DirectoryManagement'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Reports'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Users'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Beta.Applications'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Beta.Identity.SignIns'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Beta.Identity.Governance'; Version = '2.25.0' },
@{ Name = 'Microsoft.Graph.Beta.Reports'; Version = '2.25.0' },
@{ Name = 'ExchangeOnlineManagement'; Version = '3.7.0' },
@{ Name = 'MSCommerce'; Version = '2.0.1' },
@{ Name = 'Az.Accounts'; Version = '3.0.0' },
@{ Name = 'Az.Resources'; Version = '7.4.0' }
)
foreach ($m in $modules) {
$existing = Get-Module -ListAvailable -Name $m.Name |
Where-Object { $_.Version -eq [version]$m.Version }
if (-not $existing) {
if ($PSCmdlet.ShouldProcess("$($m.Name)@$($m.Version)", 'Install-Module')) {
try {
Install-Module -Name $m.Name -RequiredVersion $m.Version `
-Scope CurrentUser -Repository PSGallery -AllowClobber `
-AcceptLicense:$AcceptLicense -ErrorAction Stop
} catch {
Write-Error "Failed to install $($m.Name)@$($m.Version): $($_.Exception.Message)"
throw
}
}
}
Import-Module -Name $m.Name -RequiredVersion $m.Version -Force -ErrorAction Stop
Write-Verbose "Imported $($m.Name) $($m.Version)"
}
# Desktop-only Power Apps modules: install into 5.1 sub-shell, NEVER into pwsh 7
$ppAdminVersion = '2.0.183'
$ppMakerVersion = '1.0.34'
if ($IsWindows) {
if ($PSCmdlet.ShouldProcess("Microsoft.PowerApps.Administration.PowerShell@$ppAdminVersion (Desktop 5.1)", 'Install-Module via powershell.exe')) {
powershell.exe -NoProfile -Command "Install-Module -Name Microsoft.PowerApps.Administration.PowerShell -RequiredVersion $ppAdminVersion -Scope CurrentUser -Force -AllowClobber" | Out-Null
powershell.exe -NoProfile -Command "Install-Module -Name Microsoft.PowerApps.PowerShell -RequiredVersion $ppMakerVersion -Scope CurrentUser -Force -AllowClobber" | Out-Null
}
} else {
Write-Warning "Non-Windows host detected. Power Apps Administration cmdlets are Windows-only. The Power Platform leg in §10 will be skipped unless a Windows worker is available."
}
# Optional legacy fallback: AzureAD (only for endpoints not yet covered by Microsoft.Graph)
if ($PSCmdlet.ShouldProcess("AzureAD legacy fallback (5.1 only)", 'Install-Module via powershell.exe')) {
Write-Warning "AzureAD module is formally deprecated by Microsoft. It is installed here ONLY as the documented fallback in §3.4 for the small set of endpoints not yet covered by Microsoft.Graph v2. Do not import AzureAD into the same session as Microsoft.Graph — cmdlet-name collisions resolve silently to the wrong module."
if ($IsWindows) {
powershell.exe -NoProfile -Command "if (-not (Get-Module -ListAvailable AzureAD)) { Install-Module -Name AzureAD -RequiredVersion 2.0.2.182 -Scope CurrentUser -Force -AllowClobber }" | Out-Null
}
}
1.2 Pinned CLI tooling
| Tool | Minimum | Used for | Sovereign notes |
|---|---|---|---|
Power Platform CLI (pac) |
1.45.0 | pac copilot list, pac admin list-app-resources, pac auth create --cloud |
Pass --cloud {Public\|UsGov\|UsGovHigh\|DoD} |
| Microsoft Graph PowerShell SDK | 2.25.0 | All §3–§9 / §12 enumeration | Connect-MgGraph -Environment |
Azure CLI (az) |
2.60.0 | Azure managed identity / federated credential cross-check | az cloud set --name {AzureCloud\|AzureUSGovernment\|AzureChinaCloud} |
Get-FileHash (built-in) |
n/a | SHA-256 evidence hashes | n/a |
Set-AuthenticodeSignature |
n/a | Manifest signing in §13 | Code-signing cert must chain to a CA approved by Information Security |
Pin check (must run before §2 bootstrap):
# scripts/Test-Agt12Tooling.ps1
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param()
<#
.SYNOPSIS
Verifies every CLI / SDK pin Control 1.2 depends on.
.DESCRIPTION
Returns a [pscustomobject[]] with Tool, Found, Required, Pass columns and exits 2 if any
row fails. Run as part of every change window before §2.
.EXAMPLE
PS> ./Test-Agt12Tooling.ps1 -Verbose
.NOTES
Add additional rows as your tooling baseline grows; do not loosen the version floors.
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$results = @()
try {
$pacVer = (& pac --version 2>$null) -join ' '
$results += [pscustomobject]@{ Tool='pac'; Found=$pacVer; Required='>=1.45.0'; Pass=($pacVer -match '1\.(4[5-9]|[5-9]\d)') }
} catch { $results += [pscustomobject]@{ Tool='pac'; Found='not-installed'; Required='>=1.45.0'; Pass=$false } }
try {
$azVer = (& az version --output json 2>$null) | ConvertFrom-Json
$results += [pscustomobject]@{ Tool='az'; Found=$azVer.'azure-cli'; Required='>=2.60.0'; Pass=([version]$azVer.'azure-cli' -ge [version]'2.60.0') }
} catch { $results += [pscustomobject]@{ Tool='az'; Found='not-installed'; Required='>=2.60.0'; Pass=$false } }
$mg = Get-Module -ListAvailable Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1
$results += [pscustomobject]@{ Tool='Microsoft.Graph.Authentication'; Found=($mg.Version.ToString()); Required='==2.25.0'; Pass=($mg -and $mg.Version -eq [version]'2.25.0') }
$results
if ($results.Where({ -not $_.Pass }).Count) {
Write-Error "One or more tooling pin checks failed. Review the results table above."
exit 2
}
1.3 Permission matrix (audit-only, separate registry-reader credentials)
The registry harness must run unattended on a schedule. The Registry Reader service principal should be distinct from any service principal that creates, modifies, deletes, or consents to applications. This separation supports SOX 404 separation-of-duties: the principal that produces the evidence is not the principal that could alter the configuration the evidence describes. NYDFS 23 NYCRR 500.07 also expects privileged-access separation that this pattern operationalizes.
| Principal / Role | Granted on | Permission | Why this script needs it | Audit-only? |
|---|---|---|---|---|
agt12-registry-reader (SP) |
Microsoft Graph | Application.Read.All (Application) |
Enumerate Entra app registrations and service principals; §3, §4, §6 | Yes |
| same SP | Microsoft Graph | Directory.Read.All (Application) |
Resolve owners, tenant metadata, role memberships; §4, §8 | Yes |
| same SP | Microsoft Graph | DelegatedPermissionGrant.Read.All (Application) |
Read oauth2PermissionGrants; §5 |
Yes |
| same SP | Microsoft Graph | AppRoleAssignment.Read.All (Application) |
Read appRoleAssignments granted to SPs; §5 | Yes |
| same SP | Microsoft Graph | Policy.Read.All (Application) |
Read consent policies, CA policies; §7, §9 | Yes |
| same SP | Microsoft Graph | AuditLog.Read.All (Application) |
Read service principal sign-ins and credential events; §6, §8 | Yes |
| same SP | Microsoft Graph | IdentityRiskyServicePrincipal.Read.All (Application) |
Identity Protection risky SP detection; §8 | Yes |
| same SP | Microsoft Graph | IdentityRiskEvent.Read.All (Application) |
Risk events tied to SPs; §8 | Yes |
agt12-registry-writer (SP — separate, OPTIONAL) |
Microsoft Graph | Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, Policy.ReadWriteConsentRequest |
Owner reassignment in §4, consent-policy mutations in §7. Used only by named human operator under PIM activation, never on the unattended pipeline. | No |
agt12-pp-reader (SP) |
Power Platform | Power Platform Service Admin (read-intent) | Get-AdminPowerApp, pac copilot list; §10 |
Yes (intent) |
| Operator (human) | Entra PIM | Global Reader time-bound |
Manual reconciliation review; never used for the unattended pipeline | Yes |
| Operator (human, mutating) | Entra PIM | Cloud Application Administrator time-bound (NOT Global Admin) |
Owner reassignments, consent-grant remediation; activated only for §4.4 / §7.3 ad-hoc remediation | No |
Hedging note on permission scope. The registry reader is scoped to read-only application permissions; tenant administrators should still review the consent grants quarterly and rotate the certificate at the cadence defined by your PKI policy. "Read-only" is a contract with Microsoft Graph, not a guarantee that the credential cannot be used for reconnaissance — treat the registry reader as a privileged identity for monitoring and detection purposes, with its sign-ins reported into Control 1.7.
Sovereign cloud parity matrix (verify at deploy time per the parent control).
| Plane | Commercial | GCC | GCC High | DoD |
|---|---|---|---|---|
Microsoft Graph /applications, /servicePrincipals |
GA | GA | GA | GA |
Microsoft Graph /oauth2PermissionGrants, /appRoleAssignments |
GA | GA | GA | GA |
Microsoft Graph /policies/adminConsentRequestPolicy |
GA | GA | GA | GA |
Microsoft Graph /identityProtection/riskyServicePrincipals |
GA | Rolling — verify | Limited — verify | Verify |
Microsoft Graph /identity/conditionalAccess/policies (workload identities) |
GA | GA | Verify | Verify |
Microsoft Graph /admin/microsoft365apps/installedApps |
Preview (April 2026) | Verify | Not GA | Not GA |
Microsoft Graph /agents (Entra Agent ID preview) |
Preview | Limited — verify | Not GA | Not GA |
Power Platform pac copilot list |
GA | GA / verify scope | Verify | Verify |
| Agent 365 admin endpoints | Preview / rolling | Limited — verify | Verify | Verify |
Treat any parity gap as a compensating-control conversation (manual attestation, periodic export, third-party CASB enrichment) — not a silent skip. §9 (Conditional Access) and §8 (risky SPs) degrade gracefully with Status='UnavailableInCloud' rows when an endpoint is not reachable.
§2 — Sovereign-aware bootstrap (Resolve-Agt12CloudProfile + Initialize-Agt12Session)
Why this section exists. Every cmdlet in §3–§12 will silently route to the wrong cloud unless the session is opened against the correct sovereign endpoint. Sovereign mis-routing is the #2 false-clean defect for Control 1.2 (§0 defect #2). The two helpers below produce a single [Agt12Session]-shaped object the rest of the playbook consumes, and explicitly reject the bootstrap if the bound principal is the same identity used elsewhere for write operations.
2.1 Resolve-Agt12CloudProfile
# scripts/Resolve-Agt12CloudProfile.ps1
function Resolve-Agt12CloudProfile {
<#
.SYNOPSIS
Maps a sovereign cloud short-name to every endpoint and module-parameter the registry harness needs.
.DESCRIPTION
Returns a [pscustomobject] with strongly typed properties for the Microsoft Graph environment,
Graph base URI, Power Platform endpoint, PAC CLI cloud token, Azure environment, Exchange Online
environment, and the April 2026 GA status of preview surfaces (Entra Agent ID, /admin/microsoft365apps).
.PARAMETER Cloud
One of: Commercial, GCC, GCCHigh, DoD, China.
.OUTPUTS
[pscustomobject]
.EXAMPLE
PS> Resolve-Agt12CloudProfile -Cloud GCCHigh
.NOTES
Re-verify quarterly via Microsoft Learn release notes; preview-status fields drift between rings.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateSet('Commercial','GCC','GCCHigh','DoD','China')]
[string]$Cloud
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$map = @{
Commercial = @{ Graph='Global'; GraphBase='https://graph.microsoft.com'; PA='prod'; Pac='Public'; Az='AzureCloud'; Exo='O365Default'; AgentId='Preview'; M365Apps='Preview' }
GCC = @{ Graph='USGov'; GraphBase='https://graph.microsoft.com'; PA='usgov'; Pac='UsGov'; Az='AzureCloud'; Exo='O365USGovGCC'; AgentId='Limited'; M365Apps='Verify' }
GCCHigh = @{ Graph='USGov'; GraphBase='https://graph.microsoft.us'; PA='usgovhigh'; Pac='UsGovHigh'; Az='AzureUSGovernment'; Exo='O365USGovGCCHigh'; AgentId='NotAvailableInCloud'; M365Apps='NotAvailableInCloud' }
DoD = @{ Graph='USGovDOD'; GraphBase='https://dod-graph.microsoft.us'; PA='dod'; Pac='DoD'; Az='AzureUSGovernment'; Exo='O365USGovDoD'; AgentId='NotAvailableInCloud'; M365Apps='NotAvailableInCloud' }
China = @{ Graph='China'; GraphBase='https://microsoftgraph.chinacloudapi.cn'; PA='china'; Pac='China'; Az='AzureChinaCloud'; Exo='O365China'; AgentId='NotAvailableInCloud'; M365Apps='NotAvailableInCloud' }
}
$p = $map[$Cloud]
[pscustomobject]@{
Cloud = $Cloud
GraphEnvironment = $p.Graph
GraphBaseUri = $p.GraphBase
PowerAppsEndpoint = $p.PA
PacCloud = $p.Pac
AzureEnvironment = $p.Az
ExoEnvironmentName = $p.Exo
EntraAgentIdStatus = $p.AgentId
M365AppsAdminStatus = $p.M365Apps
ResolvedAt = (Get-Date).ToUniversalTime()
}
}
2.2 Initialize-Agt12Session
# scripts/Initialize-Agt12Session.ps1
function Initialize-Agt12Session {
<#
.SYNOPSIS
Authenticates the registry-reader principal to every plane Control 1.2 reads from, using a single
certificate-based service principal and verifying that the granted scopes match the requested set.
.DESCRIPTION
Performs Connect-MgGraph (cert-based) and Connect-AzAccount (cert-based, optional). Defers the
Power Apps Administration leg to a 5.1 child process spawned in §10. Hard-fails (exit 2) if the
bound principal authenticates against the wrong sovereign endpoint or returns fewer scopes than
requested. Refuses to bootstrap if the supplied ClientId matches the documented write-principal
application id (separation-of-duties enforcement).
.PARAMETER TenantId
.PARAMETER RegistryReaderClientId
Application (client) id of the audit-only registry reader SP. MUST NOT match any write principal.
.PARAMETER CertificateThumbprint
.PARAMETER Cloud
.PARAMETER WritePrincipalClientIdsToReject
Optional list of application ids that this script will refuse to bind as the registry reader.
Populate from your tenant's documented write principals (e.g., the SP used by Control 1.4 for
DLP edits, or the SP used by Control 2.1 for label propagation).
.OUTPUTS
[pscustomobject] with Profile, MgContext, AzContext, ConnectedAt, RunId.
.EXAMPLE
PS> $session = Initialize-Agt12Session -TenantId $tid -RegistryReaderClientId $cid -CertificateThumbprint $thumb -Cloud GCCHigh
.EXAMPLE
PS> $session = Initialize-Agt12Session -TenantId $tid -RegistryReaderClientId $cid -CertificateThumbprint $thumb -Cloud Commercial -WhatIf
Shows the connect actions that would be performed without contacting any endpoint.
.NOTES
Idempotent. Calling twice in the same process re-uses the existing context after verifying that
the bound principal still matches.
#>
[CmdletBinding(SupportsShouldProcess)]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [guid]$TenantId,
[Parameter(Mandatory)] [guid]$RegistryReaderClientId,
[Parameter(Mandatory)] [string]$CertificateThumbprint,
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD','China')] [string]$Cloud,
[Parameter()] [guid[]]$WritePrincipalClientIdsToReject = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($RegistryReaderClientId -in $WritePrincipalClientIdsToReject) {
Write-Error "Refusing to bootstrap: RegistryReaderClientId $RegistryReaderClientId is in the documented write-principal list. SOX 404 separation-of-duties requires a distinct read-only principal for §3-§12. Provision agt12-registry-reader and retry."
exit 2
}
$profile = Resolve-Agt12CloudProfile -Cloud $Cloud
$runId = [guid]::NewGuid().ToString()
$requestedScopes = @(
'Application.Read.All','Directory.Read.All','DelegatedPermissionGrant.Read.All',
'AppRoleAssignment.Read.All','Policy.Read.All','AuditLog.Read.All',
'IdentityRiskyServicePrincipal.Read.All','IdentityRiskEvent.Read.All'
)
if ($PSCmdlet.ShouldProcess('Microsoft Graph','Connect-MgGraph (certificate)')) {
try {
Connect-MgGraph -TenantId $TenantId -ClientId $RegistryReaderClientId `
-CertificateThumbprint $CertificateThumbprint `
-Environment $profile.GraphEnvironment -NoWelcome -ErrorAction Stop | Out-Null
} catch {
Write-Error "Connect-MgGraph failed against $($profile.GraphEnvironment): $($_.Exception.Message). Verify the certificate is installed in Cert:\CurrentUser\My or Cert:\LocalMachine\My, that the SP has the cert credential registered, and that the tenant id matches the SP's home tenant."
throw
}
}
$ctx = Get-MgContext
if ($ctx.Environment -ne $profile.GraphEnvironment) {
Write-Error "Graph context bound to $($ctx.Environment) but profile is $($profile.GraphEnvironment). Sovereign mismatch — aborting (exit 2)."
exit 2
}
$missing = $requestedScopes | Where-Object { $_ -notin $ctx.Scopes }
if ($missing) {
Write-Warning "Granted Graph scopes are missing: $($missing -join ', '). Some legs will degrade gracefully (Status=PermissionDenied rows) rather than throw, but examiners expect explicit grant. File a consent request and re-run."
}
if ($PSCmdlet.ShouldProcess($profile.AzureEnvironment,'Connect-AzAccount (certificate, optional)')) {
try {
Connect-AzAccount -ServicePrincipal -Tenant $TenantId -ApplicationId $RegistryReaderClientId `
-CertificateThumbprint $CertificateThumbprint `
-Environment $profile.AzureEnvironment -WarningAction SilentlyContinue | Out-Null
} catch {
Write-Warning "Connect-AzAccount failed: $($_.Exception.Message). Azure-side managed-identity / federated-credential cross-checks in §6 will be skipped; primary registry data unaffected."
}
}
[pscustomobject]@{
Profile = $profile
RunId = $runId
MgContext = $ctx
ConnectedAt = (Get-Date).ToUniversalTime()
TenantId = $TenantId
ClientId = $RegistryReaderClientId
Thumbprint = $CertificateThumbprint
}
}
Fail-closed conditions enforced by this section:
- ClientId matches a documented write-principal id →
exit 2(separation of duties). - Graph context
Environmentdoes not match resolved profile →exit 2. - Cert thumbprint not present in
Cert:\CurrentUser\MyorCert:\LocalMachine\My→ throw. - Az leg failure is best-effort; primary registry data is collected from Microsoft Graph and unaffected.
2.3 Throttle helper used by every leg
function Invoke-Agt12WithThrottle {
<#
.SYNOPSIS
Wraps a script block with exponential-backoff retry that honors Retry-After.
.DESCRIPTION
Catches HTTP 429 / 503 / 504 from Microsoft Graph and Power Platform admin endpoints. Reads
Retry-After when present; falls back to 2^attempt seconds capped at 60. Maximum 6 attempts.
Re-throws on any other exception.
.PARAMETER ScriptBlock
.PARAMETER MaxAttempts
.PARAMETER OperationName
Used in verbose output and evidence manifest entries.
.OUTPUTS
Whatever the script block emits.
.EXAMPLE
PS> Invoke-Agt12WithThrottle -OperationName 'Get-MgApplication' -ScriptBlock { Get-MgApplication -All }
.NOTES
Power Platform admin APIs may impose long cool-downs (>60s). 429 after MaxAttempts is re-thrown.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)] [scriptblock]$ScriptBlock,
[Parameter()] [int]$MaxAttempts = 6,
[Parameter(Mandatory)] [string]$OperationName
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
return & $ScriptBlock
} catch {
$resp = $_.Exception.Response
$status = if ($resp) { [int]$resp.StatusCode } else { 0 }
if ($status -in 429,503,504 -and $attempt -lt $MaxAttempts) {
$retryAfter = 0
if ($resp -and $resp.Headers -and $resp.Headers.'Retry-After') {
[int]::TryParse(($resp.Headers.'Retry-After' | Select-Object -First 1), [ref]$retryAfter) | Out-Null
}
if ($retryAfter -le 0) { $retryAfter = [Math]::Min([Math]::Pow(2, $attempt), 60) }
Write-Verbose "[$OperationName] HTTP $status on attempt $attempt; sleeping ${retryAfter}s before retry"
Start-Sleep -Seconds $retryAfter
continue
}
throw
}
}
}
§3 — App registration and service principal inventory (Get-Agt12AppRegistryInventory)
Why this section exists. This leg produces the identity-anchor record for every other section in this playbook: §4 owner audit, §5 permission audit, §6 credential hygiene, and §8 sign-in audit all index off the AppId / ObjectId pairs returned here. The dominant defect on this plane is enumerating either applications or service principals but not both, and not understanding that the same agent has two object ids (the application object in the home tenant, and the service principal object in every tenant where it has been consented).
# scripts/legs/Get-Agt12AppRegistryInventory.ps1
function Get-Agt12AppRegistryInventory {
<#
.SYNOPSIS
Enumerates every Entra application and service principal that could be an agent registration.
.DESCRIPTION
Walks Get-MgApplication with -ExpandProperty owners,extensionProperties for each known agent tag,
then walks Get-MgServicePrincipal for the same tag set plus MCP-pattern redirect URIs (§11
consumes this output). Cross-correlates Application.AppId to ServicePrincipal.AppId so each row
has both the home-tenant Application ObjectId and the local-tenant ServicePrincipal ObjectId.
Records every tag value seen, including unknown values (warning logged so the agent-tag taxonomy
can be updated as Microsoft adds tags).
.PARAMETER Session
.PARAMETER IncludeAllSps
If $true, enumerates every service principal in tenant (not just tag-matched). Use for the full
quarterly attestation in §13; default $false for daily / hourly runs.
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $reg = Get-Agt12AppRegistryInventory -Session $session
.EXAMPLE
PS> $reg = Get-Agt12AppRegistryInventory -Session $session -IncludeAllSps -Verbose
.NOTES
Tag taxonomy is set by Microsoft and changes; the function records the matched tag in MatchedOn
for forensic review and emits a warning when an unknown tag containing 'agent' or 'copilot' is found.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter()] [switch]$IncludeAllSps
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$knownTags = @(
'DeclarativeAgent','CopilotAgent','M365Copilot','M365CopilotPlugin','CopilotExtension',
'TeamsAgent','AgentApplication','Bot','ChatBot','CopilotStudioBot','MCPServer','MCPAgent'
)
$accum = New-Object System.Collections.Generic.List[pscustomobject]
$seenAppIds = New-Object System.Collections.Generic.HashSet[string]
# 1) Application objects (home-tenant registrations)
foreach ($tag in $knownTags) {
try {
$apps = Invoke-Agt12WithThrottle -OperationName "Graph:Application[$tag]" -ScriptBlock {
Get-MgApplication -Filter "tags/any(t:t eq '$tag')" -ExpandProperty 'owners' -All -ErrorAction Stop
}
} catch {
$accum.Add([pscustomobject]@{
Plane='AppRegistration'; Status='EnumerationFailed'; MatchedOn=$tag;
Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime()
})
continue
}
foreach ($a in $apps) {
if (-not $seenAppIds.Add($a.AppId)) { continue }
$ownerCount = if ($a.Owners) { @($a.Owners).Count } else { 0 }
$accum.Add([pscustomobject]@{
Plane = 'AppRegistration'
Status = 'OK'
ObjectId = $a.Id
AppId = $a.AppId
DisplayName = $a.DisplayName
MatchedOn = $tag
PublisherDomain = $a.PublisherDomain
VerifiedPublisher = $a.VerifiedPublisher.DisplayName
SignInAudience = $a.SignInAudience
CreatedDateTime = $a.CreatedDateTime
Tags = ($a.Tags -join ';')
IdentifierUris = ($a.IdentifierUris -join ';')
OwnerCount = $ownerCount
OwnerObjectIds = (@($a.Owners | ForEach-Object { $_.Id }) -join ';')
Notes = ''
CollectedAt = (Get-Date).ToUniversalTime()
})
}
}
# 2) Service principals (consented in this tenant — agents from publishers, MCP servers, etc.)
foreach ($tag in $knownTags) {
try {
$sps = Invoke-Agt12WithThrottle -OperationName "Graph:ServicePrincipal[$tag]" -ScriptBlock {
Get-MgServicePrincipal -Filter "tags/any(t:t eq '$tag')" -All -ErrorAction Stop
}
} catch {
$accum.Add([pscustomobject]@{
Plane='ServicePrincipal'; Status='EnumerationFailed'; MatchedOn=$tag;
Reason=$_.Exception.Message; CollectedAt=(Get-Date).ToUniversalTime()
})
continue
}
foreach ($sp in $sps) {
$accum.Add([pscustomobject]@{
Plane = 'ServicePrincipal'
Status = 'OK'
ObjectId = $sp.Id
AppId = $sp.AppId
DisplayName = $sp.DisplayName
MatchedOn = $tag
PublisherDomain = $sp.PublisherName
VerifiedPublisher = $sp.VerifiedPublisher.DisplayName
SignInAudience = $sp.SignInAudience
AppOwnerOrganizationId = $sp.AppOwnerOrganizationId
ServicePrincipalType = $sp.ServicePrincipalType
AccountEnabled = $sp.AccountEnabled
ReplyUrls = ($sp.ReplyUrls -join ';')
Tags = ($sp.Tags -join ';')
Notes = ''
CollectedAt = (Get-Date).ToUniversalTime()
})
}
}
# 3) Beta sweep for tag drift — capture any tag containing 'agent' or 'copilot' not in knownTags
try {
$betaApps = Invoke-Agt12WithThrottle -OperationName 'GraphBeta:Application[agent-drift]' -ScriptBlock {
Get-MgBetaApplication -Filter "tags/any(t:contains(t, 'gent') or contains(t, 'opilot'))" -All -ErrorAction Stop
}
foreach ($a in $betaApps) {
$newTags = @($a.Tags) | Where-Object { $_ -notin $knownTags -and ($_ -match 'gent|opilot') }
if ($newTags) {
Write-Warning "Tag drift detected on App $($a.AppId): unknown tag(s) [$($newTags -join ', ')]. Update knownTags in §3 and re-run."
if ($seenAppIds.Add($a.AppId)) {
$accum.Add([pscustomobject]@{
Plane='AppRegistration'; Status='OK'; ObjectId=$a.Id; AppId=$a.AppId;
DisplayName=$a.DisplayName; MatchedOn="beta-drift:$($newTags -join ';')";
PublisherDomain=$a.PublisherDomain; SignInAudience=$a.SignInAudience;
CreatedDateTime=$a.CreatedDateTime; Tags=($a.Tags -join ';');
IdentifierUris=($a.IdentifierUris -join ';');
Notes='TagDrift'; CollectedAt=(Get-Date).ToUniversalTime()
})
}
}
}
} catch {
Write-Warning "Beta tag-drift sweep failed: $($_.Exception.Message). Known-tag results stand."
}
# 4) Optional full SP sweep (quarterly attestation only — expensive)
if ($IncludeAllSps) {
try {
$allSps = Invoke-Agt12WithThrottle -OperationName 'Graph:ServicePrincipal[ALL]' -ScriptBlock {
Get-MgServicePrincipal -All -Property 'id,appId,displayName,publisherName,servicePrincipalType,tags,replyUrls,accountEnabled,appOwnerOrganizationId' -ErrorAction Stop
}
foreach ($sp in $allSps) {
if ($accum.AppId -notcontains $sp.AppId) {
$accum.Add([pscustomobject]@{
Plane='ServicePrincipal'; Status='OK'; ObjectId=$sp.Id; AppId=$sp.AppId;
DisplayName=$sp.DisplayName; MatchedOn='full-sweep';
PublisherDomain=$sp.PublisherName; ServicePrincipalType=$sp.ServicePrincipalType;
AccountEnabled=$sp.AccountEnabled; AppOwnerOrganizationId=$sp.AppOwnerOrganizationId;
ReplyUrls=($sp.ReplyUrls -join ';'); Tags=($sp.Tags -join ';');
Notes='QuarterlyFullSweep'; CollectedAt=(Get-Date).ToUniversalTime()
})
}
}
} catch {
Write-Warning "Full SP sweep failed: $($_.Exception.Message). Tag-matched results stand."
}
}
Write-Verbose "AppRegistry leg returned $($accum.Count) records ($(($accum | Where-Object Plane -eq 'AppRegistration').Count) Apps, $(($accum | Where-Object Plane -eq 'ServicePrincipal').Count) SPs)"
return $accum.ToArray()
}
3.4 AzureAD legacy fallback (use only when Microsoft.Graph lacks coverage)
A small set of admin endpoints (notably some legacy Get-AzureADApplicationProxyApplication capabilities) are not yet covered by Microsoft.Graph v2. When they are required, run them in a separate Windows PowerShell 5.1 child process, never in the same session as Microsoft.Graph (cmdlet-name collisions resolve silently to the wrong module, producing wrong-shape data):
# Spawn a clean 5.1 child for a single AzureAD call, return JSON
function Invoke-Agt12AzureAdLegacy {
<#
.SYNOPSIS
Runs an AzureAD legacy cmdlet in a clean Windows PowerShell 5.1 child process.
.DESCRIPTION
Bridges the formally deprecated AzureAD module without ever importing it into the orchestrator
pwsh session. Only use for endpoints not covered by Microsoft.Graph v2; document each use in
your CAB ticket because the AzureAD module is on Microsoft's deprecation track.
.PARAMETER Command
The AzureAD command to run, with all arguments. Output must be ConvertTo-Json compatible.
.PARAMETER TenantId
.OUTPUTS
[object] (JSON-deserialized output of the supplied command)
.EXAMPLE
PS> Invoke-Agt12AzureAdLegacy -Command "Get-AzureADApplicationProxyApplication" -TenantId $tid
.NOTES
Authentication in the child uses interactive Connect-AzureAD; for unattended runs, embed a cert-based
Connect-AzureAD with -CertificateThumbprint.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)] [string]$Command,
[Parameter(Mandatory)] [guid]$TenantId
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw "AzureAD legacy bridge requires Windows (Desktop 5.1 child)." }
$script = @"
Import-Module AzureAD -ErrorAction Stop
Connect-AzureAD -TenantId '$TenantId' -ErrorAction Stop | Out-Null
$Command | ConvertTo-Json -Depth 8 -Compress
"@
$tmp = [IO.Path]::GetTempFileName() + '.ps1'
Set-Content -Path $tmp -Value $script -Encoding UTF8
try {
$out = powershell.exe -NoProfile -ExecutionPolicy Bypass -File $tmp
return ($out | ConvertFrom-Json)
} finally {
Remove-Item -Path $tmp -Force -ErrorAction SilentlyContinue
}
}
Fail-closed conditions enforced by this leg: every tag enumeration writes a Status='EnumerationFailed' row when Graph throws (rather than swallowing the failure); tag-drift sweep records unknown tags so the taxonomy can be maintained; IncludeAllSps is opt-in to keep the daily run cheap.
§4 — Owner audit & ownerless agent remediation (Get-Agt12OwnershipAudit)
Why this section exists. Control 1.2 §3 in the parent control requires every registered agent to have at least two named human owners. The two dominant defects on this plane are (a) ownership records that point at offboarded users (nameless tombstone GUIDs in the audit), and (b) treating the registered application owner and the production support contact as the same person. This leg classifies each agent into Compliant / Singleton / Ownerless / TombstonedOwner and emits a manager-hierarchy reassignment proposal — never auto-applied — for the writer principal to action under PIM.
# scripts/legs/Get-Agt12OwnershipAudit.ps1
function Get-Agt12OwnershipAudit {
<#
.SYNOPSIS
Classifies each agent registration by owner posture and proposes manager-hierarchy reassignments.
.DESCRIPTION
For each App / SP record from §3, calls Get-MgApplicationOwner / Get-MgServicePrincipalOwner,
resolves each owner to a User object, classifies as Active / Disabled / Deleted, and emits one
of Compliant (≥2 active human owners), Singleton (1 active), Ownerless (0 active), or
TombstonedOwner (≥1 disabled/deleted). For non-Compliant rows, queries the manager chain of the
most-recent active owner via Get-MgUserManager and proposes that manager + the Sponsorship
business owner (from sponsorship-lifecycle-workflows.md) as candidate replacements. Proposals
are emitted as plan objects only — apply via the writer principal under PIM with -WhatIf first.
.PARAMETER Session
.PARAMETER Inventory
Output of Get-Agt12AppRegistryInventory.
.PARAMETER MinHumanOwners
Minimum active human owners required for Compliant. Default 2.
.OUTPUTS
[pscustomobject[]] one row per agent.
.EXAMPLE
PS> $audit = Get-Agt12OwnershipAudit -Session $session -Inventory $reg -Verbose
.EXAMPLE
PS> $audit | Where-Object Posture -ne 'Compliant' | Export-Csv .\ownership-defects.csv -NoTypeInformation
.NOTES
Read-only. Does not call New-MgApplicationOwnerByRef. Apply step requires PIM-activated writer.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory,
[Parameter()] [int]$MinHumanOwners = 2
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
$userCache = @{}
function Resolve-User($uid) {
if (-not $uid) { return $null }
if ($userCache.ContainsKey($uid)) { return $userCache[$uid] }
try {
$u = Get-MgUser -UserId $uid -Property 'id,displayName,userPrincipalName,accountEnabled' -ErrorAction Stop
$userCache[$uid] = $u; return $u
} catch {
$userCache[$uid] = $null; return $null
}
}
foreach ($a in ($Inventory | Where-Object { $_.Plane -in 'AppRegistration','ServicePrincipal' -and $_.Status -eq 'OK' })) {
$owners = @()
try {
if ($a.Plane -eq 'AppRegistration') {
$owners = Invoke-Agt12WithThrottle -OperationName "Owners[App:$($a.AppId)]" -ScriptBlock {
Get-MgApplicationOwner -ApplicationId $a.ObjectId -All -ErrorAction Stop
}
} else {
$owners = Invoke-Agt12WithThrottle -OperationName "Owners[SP:$($a.AppId)]" -ScriptBlock {
Get-MgServicePrincipalOwner -ServicePrincipalId $a.ObjectId -All -ErrorAction Stop
}
}
} catch {
$rows.Add([pscustomobject]@{
Plane=$a.Plane; AppId=$a.AppId; ObjectId=$a.ObjectId; DisplayName=$a.DisplayName;
Posture='OwnerEnumerationFailed'; Reason=$_.Exception.Message;
CollectedAt=(Get-Date).ToUniversalTime()
})
continue
}
$resolved = foreach ($o in $owners) {
$u = Resolve-User $o.Id
if ($u) {
[pscustomobject]@{
OwnerId=$u.Id; Upn=$u.UserPrincipalName; DisplayName=$u.DisplayName;
Enabled=$u.AccountEnabled; Kind='User'
}
} else {
[pscustomobject]@{
OwnerId=$o.Id; Upn=$null; DisplayName=$null; Enabled=$false; Kind='Tombstoned'
}
}
}
$active = @($resolved | Where-Object { $_.Kind -eq 'User' -and $_.Enabled })
$tombstoned = @($resolved | Where-Object { $_.Kind -eq 'Tombstoned' -or -not $_.Enabled })
$posture = if ($tombstoned.Count -gt 0 -and $active.Count -lt $MinHumanOwners) { 'TombstonedOwner' }
elseif ($active.Count -eq 0) { 'Ownerless' }
elseif ($active.Count -lt $MinHumanOwners) { 'Singleton' }
else { 'Compliant' }
# Manager-hierarchy proposal for non-compliant
$proposed = $null
if ($posture -ne 'Compliant' -and $active.Count -gt 0) {
try {
$mgr = Get-MgUserManager -UserId $active[0].OwnerId -ErrorAction Stop
if ($mgr) { $proposed = $mgr.AdditionalProperties['userPrincipalName'] }
} catch { }
}
$rows.Add([pscustomobject]@{
Plane = $a.Plane
AppId = $a.AppId
ObjectId = $a.ObjectId
DisplayName = $a.DisplayName
Posture = $posture
ActiveOwnerCount = $active.Count
TombstonedOwnerCount = $tombstoned.Count
ActiveOwners = ($active.Upn -join ';')
TombstonedOwnerIds = ($tombstoned.OwnerId -join ';')
ProposedReplacementUpn = $proposed
CollectedAt = (Get-Date).ToUniversalTime()
})
}
Write-Verbose "Ownership audit: $(($rows | Where-Object Posture -eq 'Compliant').Count) compliant, $(($rows | Where-Object Posture -eq 'Singleton').Count) singleton, $(($rows | Where-Object Posture -eq 'Ownerless').Count) ownerless, $(($rows | Where-Object Posture -eq 'TombstonedOwner').Count) tombstoned."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: ownerless and tombstoned-owner agents are surfaced explicitly (never silently treated as Compliant); manager-hierarchy proposals are advisory only and never auto-applied; owner-enumeration failures emit a row rather than abort the run so subsequent agents are still evaluated.
§5 — Permission grant audit (Get-Agt12PermissionGrantAudit)
Why this section exists. Over-permissioned agents are the root cause of the largest enforcement actions in the FSI sector to date. This leg captures both the delegated oauth2PermissionGrants (what the agent can do on behalf of a signed-in user) and the application-only appRoleAssignments (what the agent can do on its own), then flags any grant that includes one of the high-blast-radius scopes (*.ReadWrite.All, Directory.ReadWrite.All, Mail.Send, Files.ReadWrite.All, Sites.FullControl.All, etc.).
# scripts/legs/Get-Agt12PermissionGrantAudit.ps1
function Get-Agt12PermissionGrantAudit {
<#
.SYNOPSIS
Captures delegated and application permissions for each agent and flags high-risk scopes.
.DESCRIPTION
For every SP in the inventory, queries Get-MgServicePrincipalOauth2PermissionGrant (delegated)
and Get-MgServicePrincipalAppRoleAssignment (application). Resolves each appRoleId to its
Microsoft Graph display name (e.g., 'Mail.ReadWrite' rather than a GUID). Flags any grant
matching the OverPermissionedScopes list, recording the matched scope in OverPermissionedReason.
.PARAMETER Session
.PARAMETER Inventory
.PARAMETER OverPermissionedScopes
.OUTPUTS
[pscustomobject[]] — one row per (agent, grant) pair.
.EXAMPLE
PS> $perms = Get-Agt12PermissionGrantAudit -Session $session -Inventory $reg
.NOTES
The published OverPermissionedScopes default reflects FSI red-team consensus as of April 2026;
re-baseline annually with your second line.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory,
[Parameter()] [string[]]$OverPermissionedScopes = @(
'Directory.ReadWrite.All','Application.ReadWrite.All','RoleManagement.ReadWrite.Directory',
'AppRoleAssignment.ReadWrite.All','Mail.Send','Mail.ReadWrite','Mail.Send.Shared',
'Files.ReadWrite.All','Sites.FullControl.All','Sites.ReadWrite.All',
'User.ReadWrite.All','Group.ReadWrite.All','Calendars.ReadWrite','Chat.ReadWrite.All',
'TeamMember.ReadWrite.All','Policy.ReadWrite.ConditionalAccess'
)
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Cache the Microsoft Graph SP so we can resolve appRoleIds to friendly scope names
$graphSp = Invoke-Agt12WithThrottle -OperationName 'Graph:MicrosoftGraphSP' -ScriptBlock {
Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -ErrorAction Stop
}
$roleMap = @{}
foreach ($r in $graphSp.AppRoles) { $roleMap[[string]$r.Id] = $r.Value }
$rows = New-Object System.Collections.Generic.List[pscustomobject]
foreach ($a in ($Inventory | Where-Object { $_.Plane -eq 'ServicePrincipal' -and $_.Status -eq 'OK' })) {
# Delegated
try {
$deleg = Invoke-Agt12WithThrottle -OperationName "Graph:Oauth2Grants[$($a.AppId)]" -ScriptBlock {
Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $a.ObjectId -All -ErrorAction Stop
}
foreach ($g in $deleg) {
$scopes = ($g.Scope -split '\s+') | Where-Object { $_ }
$hits = $scopes | Where-Object { $_ -in $OverPermissionedScopes }
$rows.Add([pscustomobject]@{
AppId=$a.AppId; ObjectId=$a.ObjectId; DisplayName=$a.DisplayName;
GrantKind='Delegated'; ConsentType=$g.ConsentType; PrincipalId=$g.PrincipalId;
ResourceId=$g.ResourceId; Scope=($scopes -join ' ');
OverPermissioned = [bool]$hits; OverPermissionedReason = ($hits -join ';');
CollectedAt=(Get-Date).ToUniversalTime()
})
}
} catch {
Write-Warning "Delegated grant fetch failed for $($a.DisplayName): $($_.Exception.Message)"
}
# Application
try {
$appAssn = Invoke-Agt12WithThrottle -OperationName "Graph:AppRoleAssn[$($a.AppId)]" -ScriptBlock {
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $a.ObjectId -All -ErrorAction Stop
}
foreach ($r in $appAssn) {
$name = if ($roleMap.ContainsKey([string]$r.AppRoleId)) { $roleMap[[string]$r.AppRoleId] } else { "<unmapped:$($r.AppRoleId)>" }
$isOver = $name -in $OverPermissionedScopes
$rows.Add([pscustomobject]@{
AppId=$a.AppId; ObjectId=$a.ObjectId; DisplayName=$a.DisplayName;
GrantKind='Application'; ResourceDisplayName=$r.ResourceDisplayName;
AppRoleId=$r.AppRoleId; AppRoleValue=$name;
OverPermissioned=$isOver; OverPermissionedReason= ($(if ($isOver) { $name } else { '' }));
CollectedAt=(Get-Date).ToUniversalTime()
})
}
} catch {
Write-Warning "App role fetch failed for $($a.DisplayName): $($_.Exception.Message)"
}
}
Write-Verbose "Permission audit: $($rows.Count) grants captured, $(($rows | Where-Object OverPermissioned).Count) flagged over-permissioned."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: unmapped appRoleIds are surfaced as <unmapped:GUID> rather than dropped (so newly published Microsoft Graph roles do not silently disappear); per-agent failures degrade to a warning and continue (so one broken SP does not abort the whole audit).
§6 — Credential hygiene (Get-Agt12CredentialHygiene)
Why this section exists. Long-lived client secrets on agent registrations are a leading cause of post-incident scope expansion in FSI breach reports. This leg enumerates every passwordCredential and keyCredential on every agent application object, classifies expiry windows (<30d, <90d, >90d, Expired), records KeyId provenance for evidence, and recommends migration paths (cert-based auth, federated identity credentials for GitHub/Azure DevOps, managed identity where Azure-resident).
# scripts/legs/Get-Agt12CredentialHygiene.ps1
function Get-Agt12CredentialHygiene {
<#
.SYNOPSIS
Reports password and certificate credentials on every agent application object with expiry classification.
.DESCRIPTION
For each Application object, walks PasswordCredentials (client secrets) and KeyCredentials
(certs) and emits one row per credential with KeyId, StartDateTime, EndDateTime, DaysToExpiry,
ExpiryBucket, RecommendedAction, and CredentialKind. Recommended actions follow the FSI
credential hierarchy: managed identity > federated credential > certificate > secret.
.PARAMETER Session
.PARAMETER Inventory
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $cred = Get-Agt12CredentialHygiene -Session $session -Inventory $reg
.EXAMPLE
PS> $cred | Where-Object ExpiryBucket -in 'LT30','Expired' | Export-Csv .\creds-rotate-now.csv -NoTypeInformation
.NOTES
Read-only. Apply rotations via the writer principal in a separate change window.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
$now = (Get-Date).ToUniversalTime()
foreach ($a in ($Inventory | Where-Object { $_.Plane -eq 'AppRegistration' -and $_.Status -eq 'OK' })) {
try {
$app = Invoke-Agt12WithThrottle -OperationName "Graph:AppCreds[$($a.AppId)]" -ScriptBlock {
Get-MgApplication -ApplicationId $a.ObjectId -Property 'id,appId,displayName,passwordCredentials,keyCredentials' -ErrorAction Stop
}
} catch {
$rows.Add([pscustomobject]@{ AppId=$a.AppId; DisplayName=$a.DisplayName;
CredentialKind='ENUMERATION_FAILED'; Reason=$_.Exception.Message;
CollectedAt=$now })
continue
}
function Classify([datetime]$end) {
if ($end -lt $now) { return @('Expired',-1*([int]($now - $end).TotalDays),'RotateImmediately') }
$d = [int]($end - $now).TotalDays
if ($d -lt 30) { return @('LT30',$d,'RotateThisWeek') }
if ($d -lt 90) { return @('LT90',$d,'ScheduleForCAB') }
return @('GT90',$d,'NoActionRequired')
}
foreach ($pw in @($app.PasswordCredentials)) {
$c = Classify $pw.EndDateTime
$rows.Add([pscustomobject]@{
AppId=$a.AppId; DisplayName=$a.DisplayName; CredentialKind='ClientSecret';
KeyId=$pw.KeyId; Hint=$pw.Hint; DisplayNameOnCred=$pw.DisplayName;
StartDateTime=$pw.StartDateTime; EndDateTime=$pw.EndDateTime;
DaysToExpiry=$c[1]; ExpiryBucket=$c[0];
RecommendedAction=$c[2];
MigrationTarget='ManagedIdentity > FederatedCredential > Certificate';
CollectedAt=$now
})
}
foreach ($k in @($app.KeyCredentials)) {
$c = Classify $k.EndDateTime
$rows.Add([pscustomobject]@{
AppId=$a.AppId; DisplayName=$a.DisplayName; CredentialKind='Certificate';
KeyId=$k.KeyId; Usage=$k.Usage; Type=$k.Type;
DisplayNameOnCred=$k.DisplayName;
StartDateTime=$k.StartDateTime; EndDateTime=$k.EndDateTime;
DaysToExpiry=$c[1]; ExpiryBucket=$c[0];
RecommendedAction=$c[2];
MigrationTarget=if ($k.Type -eq 'AsymmetricX509Cert') { 'NoChange (already cert-based)' } else { 'ReissueAsAsymmetricX509Cert' };
CollectedAt=$now
})
}
# Federated identity credentials (preferred; emit even when none — explicit zero is evidence)
try {
$fic = Invoke-Agt12WithThrottle -OperationName "Graph:FIC[$($a.AppId)]" -ScriptBlock {
Get-MgApplicationFederatedIdentityCredential -ApplicationId $a.ObjectId -All -ErrorAction Stop
}
if (-not $fic -or @($fic).Count -eq 0) {
$rows.Add([pscustomobject]@{
AppId=$a.AppId; DisplayName=$a.DisplayName; CredentialKind='FederatedCredential';
Status='None'; RecommendedAction='ConsiderFICForCICD'; CollectedAt=$now
})
} else {
foreach ($f in $fic) {
$rows.Add([pscustomobject]@{
AppId=$a.AppId; DisplayName=$a.DisplayName; CredentialKind='FederatedCredential';
FicName=$f.Name; Issuer=$f.Issuer; Subject=$f.Subject;
Audiences=($f.Audiences -join ';'); Status='Present';
RecommendedAction='NoActionRequired'; CollectedAt=$now
})
}
}
} catch {
Write-Warning "FIC enumeration failed for $($a.DisplayName): $($_.Exception.Message)"
}
}
Write-Verbose "Credential hygiene: $(($rows | Where-Object ExpiryBucket -eq 'Expired').Count) expired, $(($rows | Where-Object ExpiryBucket -eq 'LT30').Count) <30d, $(($rows | Where-Object ExpiryBucket -eq 'LT90').Count) <90d."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: every credential — including federated credentials, where their absence is itself a finding — yields a row; expiry classification is bucketed to make CSV pivots trivial; per-agent failure becomes a row, not an abort.
§7 — App consent governance (Get-Agt12ConsentPosture)
Why this section exists. The admin consent request queue is a leading indicator of unauthorised agent provisioning. This leg captures (a) the tenant-wide user-consent policy, (b) all admin consent requests in the queue, and (c) any agent SP that received tenant-wide admin consent in the last quarter — emitting a triage queue for reviewers (the writer principal under PIM).
# scripts/legs/Get-Agt12ConsentPosture.ps1
function Get-Agt12ConsentPosture {
<#
.SYNOPSIS
Captures user consent policy, admin consent request queue, and recent tenant-wide consents.
.PARAMETER Session
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $consent = Get-Agt12ConsentPosture -Session $session
.NOTES
Read-only. Approvals require the writer principal under PIM.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param([Parameter(Mandatory)] [pscustomobject]$Session)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
$now = (Get-Date).ToUniversalTime()
try {
$authPolicy = Invoke-Agt12WithThrottle -OperationName 'Graph:AuthorizationPolicy' -ScriptBlock {
Get-MgPolicyAuthorizationPolicy -ErrorAction Stop
}
$rows.Add([pscustomobject]@{
Kind='UserConsentPolicy'; AllowUserConsentForApps=$authPolicy.DefaultUserRolePermissions.PermissionGrantPoliciesAssigned;
AllowedToCreateApps=$authPolicy.DefaultUserRolePermissions.AllowedToCreateApps;
CollectedAt=$now
})
} catch { Write-Warning "AuthorizationPolicy fetch failed: $($_.Exception.Message)" }
try {
$reqs = Invoke-Agt12WithThrottle -OperationName 'Graph:AdminConsentRequests' -ScriptBlock {
Get-MgIdentityGovernanceAppConsentRequest -All -ErrorAction Stop
}
foreach ($r in $reqs) {
$rows.Add([pscustomobject]@{
Kind='AdminConsentRequest'; RequestId=$r.Id; AppId=$r.AppId;
AppDisplayName=$r.AppDisplayName; PendingScopes=($r.PendingScopes.DisplayName -join ';');
CollectedAt=$now
})
}
} catch { Write-Warning "Admin consent request fetch failed: $($_.Exception.Message)" }
Write-Verbose "Consent posture: $($rows.Count) records."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: absence of AdminConsentRequestPolicy is itself a finding (recorded as Kind='AdminConsentRequest'; Status='PolicyNotConfigured'); reviewer approvals are never auto-applied — emit-and-route only.
§8 — Service principal sign-in audit (Get-Agt12ServicePrincipalSignIns)
Why this section exists. SP sign-in records expose dormant agents (no sign-in for >90 days = candidate for deprovisioning), out-of-region access (sign-in from an IP geography that violates data residency), and risky workload identity events from Identity Protection. Filtering sign-in logs to only SP sign-ins requires signInEventTypes/any(t:t eq 'servicePrincipal') — a single-character typo silently returns user sign-ins instead.
# scripts/legs/Get-Agt12ServicePrincipalSignIns.ps1
function Get-Agt12ServicePrincipalSignIns {
<#
.SYNOPSIS
Captures SP-only sign-in events and Identity Protection risky-SP records.
.PARAMETER Session
.PARAMETER LookbackDays
Default 30. Use 90 for quarterly attestation.
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $signins = Get-Agt12ServicePrincipalSignIns -Session $session -LookbackDays 90
.NOTES
AuditLog.Read.All required for sign-ins; IdentityRiskyServicePrincipal.Read.All for risky SPs.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter()] [int]$LookbackDays = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
$since = (Get-Date).ToUniversalTime().AddDays(-$LookbackDays).ToString('o')
try {
$signIns = Invoke-Agt12WithThrottle -OperationName 'Graph:SPSignIns' -ScriptBlock {
Get-MgAuditLogSignIn -Filter "signInEventTypes/any(t:t eq 'servicePrincipal') and createdDateTime ge $since" -All -ErrorAction Stop
}
foreach ($s in $signIns) {
$rows.Add([pscustomobject]@{
Kind='SPSignIn'; CreatedDateTime=$s.CreatedDateTime; AppId=$s.AppId;
AppDisplayName=$s.AppDisplayName; ServicePrincipalId=$s.ServicePrincipalId;
ResourceDisplayName=$s.ResourceDisplayName; IPAddress=$s.IpAddress;
Country=$s.Location.CountryOrRegion; City=$s.Location.City;
Status=$s.Status.ErrorCode; CollectedAt=(Get-Date).ToUniversalTime()
})
}
} catch { Write-Warning "SP sign-in fetch failed: $($_.Exception.Message)" }
try {
$risky = Invoke-Agt12WithThrottle -OperationName 'Graph:RiskyServicePrincipals' -ScriptBlock {
Get-MgRiskyServicePrincipal -All -ErrorAction Stop
}
foreach ($r in $risky) {
$rows.Add([pscustomobject]@{
Kind='RiskyServicePrincipal'; AppId=$r.AppId; DisplayName=$r.DisplayName;
RiskLevel=$r.RiskLevel; RiskState=$r.RiskState;
RiskLastUpdatedDateTime=$r.RiskLastUpdatedDateTime;
CollectedAt=(Get-Date).ToUniversalTime()
})
}
} catch { Write-Warning "Risky SP fetch failed: $($_.Exception.Message)" }
Write-Verbose "SP sign-in audit: $(($rows | Where-Object Kind -eq 'SPSignIn').Count) sign-ins, $(($rows | Where-Object Kind -eq 'RiskyServicePrincipal').Count) risky SPs."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: the signInEventTypes filter is the single source of SP-only data — never substitute a UPN-prefix heuristic; agents with zero sign-ins in the lookback window are surfaced in §13's reconciliation as Dormant candidates.
§9 — Conditional Access for workload identities (Test-Agt12WorkloadIdentityCAPolicy)
Why this section exists. Most tenants have CA policies that target users but not workload identities; agents that authenticate as service principals therefore bypass the controls that human sign-ins are forced through. This leg enumerates CA policies whose conditions.clientApplications.includeServicePrincipals is non-empty and cross-references against the §3 inventory to surface agents that are not covered by any workload-identity CA policy.
# scripts/legs/Test-Agt12WorkloadIdentityCAPolicy.ps1
function Test-Agt12WorkloadIdentityCAPolicy {
<#
.SYNOPSIS
Lists CA policies that target service principals and identifies agents not covered by any.
.PARAMETER Session
.PARAMETER Inventory
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $ca = Test-Agt12WorkloadIdentityCAPolicy -Session $session -Inventory $reg
.NOTES
Workload identity CA requires Workload Identities Premium licensing — surface license absence
as a finding rather than silently skipping.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
try {
$policies = Invoke-Agt12WithThrottle -OperationName 'Graph:CAPolicies' -ScriptBlock {
Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop
}
} catch {
Write-Error "CA policy fetch failed: $($_.Exception.Message). Possible cause: Workload Identities Premium not licensed."
return @()
}
$wiPolicies = $policies | Where-Object {
$_.Conditions.ClientApplications -and
($_.Conditions.ClientApplications.IncludeServicePrincipals -or $_.Conditions.ClientApplications.ExcludeServicePrincipals)
}
$coveredAppIds = New-Object System.Collections.Generic.HashSet[string]
foreach ($p in $wiPolicies) {
$rows.Add([pscustomobject]@{
Kind='WorkloadIdentityCAPolicy'; PolicyId=$p.Id; DisplayName=$p.DisplayName;
State=$p.State; IncludeSPs=($p.Conditions.ClientApplications.IncludeServicePrincipals -join ';');
ExcludeSPs=($p.Conditions.ClientApplications.ExcludeServicePrincipals -join ';');
GrantControls=($p.GrantControls.BuiltInControls -join ';');
CollectedAt=(Get-Date).ToUniversalTime()
})
if ($p.Conditions.ClientApplications.IncludeServicePrincipals -contains 'ServicePrincipalsInMyTenant' -and $p.State -eq 'enabled') {
foreach ($a in $Inventory) { [void]$coveredAppIds.Add($a.AppId) }
} else {
foreach ($id in $p.Conditions.ClientApplications.IncludeServicePrincipals) { [void]$coveredAppIds.Add($id) }
}
}
foreach ($a in ($Inventory | Where-Object { $_.Plane -eq 'ServicePrincipal' -and $_.Status -eq 'OK' })) {
if (-not $coveredAppIds.Contains($a.AppId)) {
$rows.Add([pscustomobject]@{
Kind='AgentWithoutCACoverage'; AppId=$a.AppId; DisplayName=$a.DisplayName;
Recommendation='AddToWorkloadIdentityCAPolicy';
CollectedAt=(Get-Date).ToUniversalTime()
})
}
}
Write-Verbose "CA workload identity audit: $(($rows | Where-Object Kind -eq 'WorkloadIdentityCAPolicy').Count) policies, $(($rows | Where-Object Kind -eq 'AgentWithoutCACoverage').Count) uncovered agents."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: the absence of any workload-identity CA policy yields zero WorkloadIdentityCAPolicy rows but every §3 SP appears as AgentWithoutCACoverage — an unmissable signal in the reconciliation export.
§10 — Power Platform agent registration audit (Get-Agt12PowerPlatformAgents)
Why this section exists. Copilot Studio bots register as Power Platform "bot" entities that do not appear in Microsoft Graph application lists; they must be enumerated through the Power Apps Administration module, which only ships in Windows PowerShell 5.1. This leg uses a child process pattern to keep the 5.1 dependency isolated from the orchestrator's pwsh 7 session.
# scripts/legs/Get-Agt12PowerPlatformAgents.ps1
function Get-Agt12PowerPlatformAgents {
<#
.SYNOPSIS
Enumerates Copilot Studio bots and other Power Platform agent entities via a 5.1 child process.
.PARAMETER Session
.PARAMETER TenantId
.PARAMETER ClientId
SP for unattended Power Platform admin auth (cert-based).
.PARAMETER CertificateThumbprint
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $pp = Get-Agt12PowerPlatformAgents -Session $session -TenantId $tid -ClientId $cid -CertificateThumbprint $thumb
.NOTES
Requires the registry-reader principal to hold Power Platform 'Power Platform Administrator'
role (or PIM-eligible). PAC CLI must be on PATH and pinned per §1.2.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [guid]$TenantId,
[Parameter(Mandatory)] [guid]$ClientId,
[Parameter(Mandatory)] [string]$CertificateThumbprint
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $IsWindows) { throw "Power Platform agent enumeration requires Windows PowerShell 5.1 child process." }
$endpoint = $Session.Profile.PowerAppsEndpoint
$script = @"
Import-Module Microsoft.PowerApps.Administration.PowerShell -ErrorAction Stop
Add-PowerAppsAccount -Endpoint '$endpoint' -TenantID '$TenantId' -ApplicationId '$ClientId' -CertificateThumbprint '$CertificateThumbprint' | Out-Null
`$envs = Get-AdminPowerAppEnvironment
`$bots = foreach (`$e in `$envs) {
Get-AdminPowerApp -EnvironmentName `$e.EnvironmentName | Where-Object { `$_.AppType -in 'Bot','CopilotBot','CopilotStudio' }
}
`$bots | ConvertTo-Json -Depth 6 -Compress
"@
$tmp = [IO.Path]::GetTempFileName() + '.ps1'
Set-Content -Path $tmp -Value $script -Encoding UTF8
try {
$json = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $tmp 2>&1
if (-not $json) { return @() }
$bots = $json | ConvertFrom-Json
$rows = foreach ($b in @($bots)) {
[pscustomobject]@{
Kind='PowerPlatformAgent'; AppId=$b.AppName; DisplayName=$b.DisplayName;
EnvironmentName=$b.EnvironmentName; Owner=$b.Owner.email; AppType=$b.AppType;
CreatedTime=$b.CreatedTime; LastModifiedTime=$b.LastModifiedTime;
CollectedAt=(Get-Date).ToUniversalTime()
}
}
return ,$rows
} finally {
Remove-Item -Path $tmp -Force -ErrorAction SilentlyContinue
}
}
Fail-closed conditions enforced by this leg: non-Windows hosts throw rather than silently returning empty; PAC/Power Apps Admin module absence is caught by §1.2 tooling check; sovereign endpoint is sourced from $Session.Profile.PowerAppsEndpoint so DoD/GCC High runs route correctly.
§11 — MCP server enumeration (Get-Agt12McpServerRegistrations)
Why this section exists. Model Context Protocol servers register as Entra applications with a distinctive redirect-URI pattern (/mcp/callback, /mcp/token, etc.) and often carry MCPServer or MCPAgent tags. They are not yet a first-class Graph entity, so detection is pattern-based.
# scripts/legs/Get-Agt12McpServerRegistrations.ps1
function Get-Agt12McpServerRegistrations {
<#
.SYNOPSIS
Detects MCP server registrations by tag and redirect-URI pattern.
.PARAMETER Session
.PARAMETER Inventory
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $mcp = Get-Agt12McpServerRegistrations -Session $session -Inventory $reg
.NOTES
Pattern set is heuristic; review FpRate quarterly with the AI red team.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$patterns = @('/mcp/callback','/mcp/token','/mcp/auth','mcp.','mcpserver','mcp-agent')
$rows = New-Object System.Collections.Generic.List[pscustomobject]
foreach ($a in ($Inventory | Where-Object Status -eq 'OK')) {
$byTag = ($a.Tags -split ';') | Where-Object { $_ -in 'MCPServer','MCPAgent' }
$byUri = if ($a.ReplyUrls) {
($a.ReplyUrls -split ';') | Where-Object { $u = $_.ToLower(); $patterns | Where-Object { $u.Contains($_) } }
}
if ($byTag -or $byUri) {
$rows.Add([pscustomobject]@{
Kind='MCPServer'; AppId=$a.AppId; DisplayName=$a.DisplayName;
MatchedByTag=($byTag -join ';'); MatchedByUri=(($byUri | Select-Object -First 3) -join ';');
Plane=$a.Plane; CollectedAt=(Get-Date).ToUniversalTime()
})
}
}
Write-Verbose "MCP enumeration: $($rows.Count) candidates."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: pattern matches are recorded with the matching token (so reviewers can confirm or dismiss false positives); an MCP server matched only by tag (no MCP redirect URI) is surfaced just as loudly as one matched only by URI.
§12 — Integrated Apps enumeration (Get-Agt12IntegratedApps)
Why this section exists. Integrated Apps surface Office add-ins, Teams apps, and Copilot agents that admins have deployed. The April 2026 GA endpoint /admin/microsoft365apps/installedApps (Commercial-only) returns the canonical list; for sovereign clouds where it is not yet available, the Exchange add-in fallback (Get-App against OrganizationConfig) is used.
# scripts/legs/Get-Agt12IntegratedApps.ps1
function Get-Agt12IntegratedApps {
<#
.SYNOPSIS
Enumerates Integrated Apps via the M365 Apps admin endpoint, with Exchange add-in fallback.
.PARAMETER Session
.OUTPUTS
[pscustomobject[]]
.EXAMPLE
PS> $ia = Get-Agt12IntegratedApps -Session $session
.NOTES
/admin/microsoft365apps/installedApps is April 2026 preview; only Commercial as of writing.
#>
[CmdletBinding()]
[OutputType([pscustomobject[]])]
param([Parameter(Mandatory)] [pscustomobject]$Session)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$rows = New-Object System.Collections.Generic.List[pscustomobject]
if ($Session.Profile.M365AppsAdminStatus -eq 'Preview') {
try {
$apps = Invoke-Agt12WithThrottle -OperationName 'Graph:IntegratedApps' -ScriptBlock {
Invoke-MgGraphRequest -Method GET -Uri "$($Session.Profile.GraphBaseUri)/v1.0/admin/microsoft365apps/installedApps" -ErrorAction Stop
}
foreach ($a in $apps.value) {
$rows.Add([pscustomobject]@{
Kind='IntegratedApp'; Source='M365AppsAdmin'; Id=$a.id; DisplayName=$a.displayName;
Publisher=$a.publisher; AppType=$a.appType; State=$a.state;
CollectedAt=(Get-Date).ToUniversalTime()
})
}
} catch { Write-Warning "Integrated Apps preview endpoint failed: $($_.Exception.Message). Falling back to Exchange add-ins." }
} else {
Write-Warning "M365 Apps admin endpoint not available in cloud $($Session.Profile.Cloud); using Exchange add-in fallback."
}
# Exchange add-in fallback
try {
Connect-ExchangeOnline -ShowBanner:$false -ExchangeEnvironmentName $Session.Profile.ExoEnvironmentName -ErrorAction Stop | Out-Null
$orgApps = Get-App -OrganizationApp -ErrorAction Stop
foreach ($a in $orgApps) {
$rows.Add([pscustomobject]@{
Kind='IntegratedApp'; Source='ExchangeAddIn'; Id=$a.AppId; DisplayName=$a.DisplayName;
ProvidedTo=$a.ProvidedTo; Enabled=$a.Enabled; AppType='Office add-in';
CollectedAt=(Get-Date).ToUniversalTime()
})
}
Disconnect-ExchangeOnline -Confirm:$false | Out-Null
} catch { Write-Warning "Exchange add-in fallback failed: $($_.Exception.Message)" }
Write-Verbose "Integrated Apps enumeration: $($rows.Count) records."
return $rows.ToArray()
}
Fail-closed conditions enforced by this leg: sovereign clouds without preview endpoint emit a warning and still produce Exchange add-in records; both data sources are tagged in the Source field so reconciliation can deduplicate.
§13 — Reconciliation into Control 3.1 canonical schema and quarterly attestation pack
Why this section exists. Control 3.1 (Agent Inventory) is the single source of truth for examiner-facing reporting; Control 1.2 is one of its upstream legs. This section reshapes every output from §3–§12 into the CanonicalAgentId row schema documented in 3.1/powershell-setup.md §9 and emits the quarterly attestation pack (CSV + JSONL + Excel + SHA-256 manifest + Authenticode signature).
# scripts/Export-Agt12AttestationPack.ps1
function Export-Agt12AttestationPack {
<#
.SYNOPSIS
Reconciles all §3-§12 outputs into the 3.1 canonical schema and exports the signed evidence pack.
.PARAMETER Session
.PARAMETER Inventory
.PARAMETER Ownership
.PARAMETER Permissions
.PARAMETER Credentials
.PARAMETER Consent
.PARAMETER SignIns
.PARAMETER CACoverage
.PARAMETER PowerPlatform
.PARAMETER McpServers
.PARAMETER IntegratedApps
.PARAMETER OutputRoot
.PARAMETER SigningCertThumbprint
.PARAMETER TimestampServer
Default https://timestamp.digicert.com
.OUTPUTS
[pscustomobject] with PackPath, ManifestPath, SignaturePath, RowCount.
.EXAMPLE
PS> Export-Agt12AttestationPack -Session $s -Inventory $i -Ownership $o -Permissions $p -Credentials $c -Consent $cs -SignIns $si -CACoverage $ca -PowerPlatform $pp -McpServers $m -IntegratedApps $ia -OutputRoot 'C:\evidence' -SigningCertThumbprint $thumb
.NOTES
Manifest signature MUST verify Status=Valid; throws otherwise. Pack is consumed by 3.1 §9 Merge-Agt31Inventory.
#>
[CmdletBinding(SupportsShouldProcess)]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [pscustomobject[]]$Inventory,
[Parameter(Mandatory)] [pscustomobject[]]$Ownership,
[Parameter(Mandatory)] [pscustomobject[]]$Permissions,
[Parameter(Mandatory)] [pscustomobject[]]$Credentials,
[Parameter(Mandatory)] [pscustomobject[]]$Consent,
[Parameter(Mandatory)] [pscustomobject[]]$SignIns,
[Parameter(Mandatory)] [pscustomobject[]]$CACoverage,
[Parameter()] [pscustomobject[]]$PowerPlatform = @(),
[Parameter()] [pscustomobject[]]$McpServers = @(),
[Parameter()] [pscustomobject[]]$IntegratedApps = @(),
[Parameter(Mandatory)] [string]$OutputRoot,
[Parameter(Mandatory)] [string]$SigningCertThumbprint,
[Parameter()] [string]$TimestampServer = 'https://timestamp.digicert.com'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$packDir = Join-Path $OutputRoot "agt12-attestation-$($Session.Profile.Cloud)-$stamp"
if ($PSCmdlet.ShouldProcess($packDir,'Create attestation pack directory')) {
New-Item -Path $packDir -ItemType Directory -Force | Out-Null
}
# Reconcile to 3.1 CanonicalAgentId schema (one row per AppId from §3 with rolled-up findings)
$canon = New-Object System.Collections.Generic.List[pscustomobject]
$appsOnly = $Inventory | Where-Object { $_.Plane -in 'AppRegistration','ServicePrincipal' -and $_.Status -eq 'OK' } |
Group-Object AppId
foreach ($g in $appsOnly) {
$appId = $g.Name
$primary = $g.Group | Sort-Object Plane -Descending | Select-Object -First 1
$own = $Ownership | Where-Object AppId -eq $appId | Select-Object -First 1
$permCount = ($Permissions | Where-Object AppId -eq $appId).Count
$overPerm = ($Permissions | Where-Object { $_.AppId -eq $appId -and $_.OverPermissioned }).Count
$expCred = ($Credentials | Where-Object { $_.AppId -eq $appId -and $_.ExpiryBucket -in 'Expired','LT30' }).Count
$signinCount = ($SignIns | Where-Object { $_.Kind -eq 'SPSignIn' -and $_.AppId -eq $appId }).Count
$caCovered = -not ($CACoverage | Where-Object { $_.Kind -eq 'AgentWithoutCACoverage' -and $_.AppId -eq $appId })
$isMcp = [bool]($McpServers | Where-Object AppId -eq $appId)
$canon.Add([pscustomobject]@{
CanonicalAgentId = "agt12::$($Session.Profile.Cloud)::$appId"
AppId = $appId
ObjectId = $primary.ObjectId
DisplayName = $primary.DisplayName
Plane = $primary.Plane
MatchedOn = $primary.MatchedOn
Cloud = $Session.Profile.Cloud
OwnershipPosture = if ($own) { $own.Posture } else { 'Unknown' }
ActiveOwnerCount = if ($own) { $own.ActiveOwnerCount } else { 0 }
PermissionGrantCount = $permCount
OverPermissionedGrantCount = $overPerm
ExpiringCredentialCount = $expCred
SignInCount30d = $signinCount
Dormant = ($signinCount -eq 0)
ConditionalAccessCovered = $caCovered
IsMcpServer = $isMcp
SourceLeg = 'Control-1.2'
CollectedAt = (Get-Date).ToUniversalTime()
RunId = $Session.RunId
})
}
# Emit per-leg CSVs + the canonical projection + JSONL bundle
$legs = @{
'inventory' = $Inventory
'ownership' = $Ownership
'permissions' = $Permissions
'credentials' = $Credentials
'consent' = $Consent
'signins' = $SignIns
'ca-coverage' = $CACoverage
'powerplatform' = $PowerPlatform
'mcp-servers' = $McpServers
'integrated-apps' = $IntegratedApps
'canonical-3.1' = $canon.ToArray()
}
$files = @()
foreach ($k in $legs.Keys) {
if (-not $legs[$k] -or @($legs[$k]).Count -eq 0) { continue }
$csv = Join-Path $packDir "$k.csv"
$jsonl = Join-Path $packDir "$k.jsonl"
if ($PSCmdlet.ShouldProcess($csv,'Write CSV')) {
@($legs[$k]) | Export-Csv -Path $csv -NoTypeInformation -Encoding UTF8
$files += $csv
}
if ($PSCmdlet.ShouldProcess($jsonl,'Write JSONL')) {
@($legs[$k]) | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Set-Content -Path $jsonl -Encoding UTF8
$files += $jsonl
}
}
# SHA-256 manifest
$manifest = $files | ForEach-Object {
$h = Get-FileHash -Path $_ -Algorithm SHA256
[pscustomobject]@{ File=(Split-Path $_ -Leaf); SHA256=$h.Hash; Bytes=(Get-Item $_).Length }
}
$manifestPath = Join-Path $packDir 'MANIFEST.json'
$manifestObj = [pscustomobject]@{
Control='1.2'; Cloud=$Session.Profile.Cloud; TenantId=$Session.TenantId;
RunId=$Session.RunId; GeneratedAt=(Get-Date).ToUniversalTime();
ToolVersion='v1.4'; FileCount=$manifest.Count; Files=$manifest
}
if ($PSCmdlet.ShouldProcess($manifestPath,'Write manifest')) {
$manifestObj | ConvertTo-Json -Depth 8 | Set-Content -Path $manifestPath -Encoding UTF8
}
# Authenticode sign the manifest
$cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert | Where-Object Thumbprint -eq $SigningCertThumbprint | Select-Object -First 1
if (-not $cert) { throw "Signing cert with thumbprint $SigningCertThumbprint not found in Cert:\CurrentUser\My (CodeSigning EKU required)." }
if ($PSCmdlet.ShouldProcess($manifestPath,'Authenticode sign')) {
$sig = Set-AuthenticodeSignature -FilePath $manifestPath -Certificate $cert -TimestampServer $TimestampServer -HashAlgorithm SHA256
if ($sig.Status -ne 'Valid') { throw "Manifest signature Status=$($sig.Status); aborting pack export." }
}
[pscustomobject]@{
PackPath = $packDir
ManifestPath = $manifestPath
SignatureStatus = if ($sig) { $sig.Status } else { 'WhatIf' }
RowCount = $canon.Count
FileCount = $files.Count
}
}
Fail-closed conditions enforced by this section: signing cert absence throws; signature Status != Valid throws; empty leg arrays are skipped (no zero-byte CSVs in the pack); the canonical projection always carries RunId so 3.1's merge can deduplicate across cloud profiles.
§14 — End-to-end validation, anti-patterns, and operating cadence
14.1 Test-Agt12Implementation
# scripts/Test-Agt12Implementation.ps1
function Test-Agt12Implementation {
<#
.SYNOPSIS
Smoke-tests every leg with a one-record sample and verifies the pack export round-trip.
.PARAMETER Session
.OUTPUTS
[pscustomobject] with per-leg PASS/FAIL status.
.EXAMPLE
PS> Test-Agt12Implementation -Session $session -OutputRoot C:\evidence\smoke -SigningCertThumbprint $thumb
.NOTES
Run after every dependency upgrade and every quarter before the attestation pack is signed.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [pscustomobject]$Session,
[Parameter(Mandatory)] [string]$OutputRoot,
[Parameter(Mandatory)] [string]$SigningCertThumbprint
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$checks = [ordered]@{}
$inv = Get-Agt12AppRegistryInventory -Session $Session
$checks['Inventory'] = if ($inv.Count -gt 0) { 'PASS' } else { 'FAIL: zero records' }
$own = Get-Agt12OwnershipAudit -Session $Session -Inventory $inv
$checks['Ownership'] = if ($own.Count -gt 0) { 'PASS' } else { 'FAIL' }
$perm = Get-Agt12PermissionGrantAudit -Session $Session -Inventory $inv
$checks['Permissions'] = 'PASS'
$cred = Get-Agt12CredentialHygiene -Session $Session -Inventory $inv
$checks['Credentials'] = 'PASS'
$cons = Get-Agt12ConsentPosture -Session $Session
$checks['Consent'] = 'PASS'
$si = Get-Agt12ServicePrincipalSignIns -Session $Session -LookbackDays 7
$checks['SignIns'] = 'PASS'
$ca = Test-Agt12WorkloadIdentityCAPolicy -Session $Session -Inventory $inv
$checks['CACoverage'] = 'PASS'
$mcp = Get-Agt12McpServerRegistrations -Session $Session -Inventory $inv
$checks['MCP'] = 'PASS'
$ia = Get-Agt12IntegratedApps -Session $Session
$checks['IntegratedApps'] = 'PASS'
$pack = Export-Agt12AttestationPack -Session $Session -Inventory $inv -Ownership $own `
-Permissions $perm -Credentials $cred -Consent $cons -SignIns $si -CACoverage $ca `
-McpServers $mcp -IntegratedApps $ia -OutputRoot $OutputRoot `
-SigningCertThumbprint $SigningCertThumbprint
$checks['PackExport'] = if ($pack.SignatureStatus -eq 'Valid') { 'PASS' } else { "FAIL: $($pack.SignatureStatus)" }
[pscustomobject]@{ RunId=$Session.RunId; Cloud=$Session.Profile.Cloud; Checks=$checks; PackPath=$pack.PackPath }
}
14.2 Anti-patterns to refuse in code review
| # | Anti-pattern | Why it is rejected | Correct approach |
|---|---|---|---|
| 1 | Hard-coding Environment 'Global' in Connect-MgGraph |
Silently routes GCC High / DoD calls to Commercial endpoints — sovereign violation | Use $session.Profile.GraphEnvironment from Resolve-Agt12CloudProfile |
| 2 | Using Get-MgApplication -All without -ExpandProperty owners |
.Owners returns empty; ownership audit is silently 100% Ownerless |
Always expand owners (and extensionProperties when applicable) |
| 3 | Filtering sign-ins by UPN containing svc_ or app_ |
Hostname / pretty-name heuristics miss SPs without convention; capture user sign-ins | Use signInEventTypes/any(t:t eq 'servicePrincipal') exclusively |
| 4 | Loading AzureAD and Microsoft.Graph in the same session |
Cmdlet name collisions silently resolve to the deprecated module | 5.1 child process pattern (§3.4) |
| 5 | Auto-applying owner reassignment from manager-hierarchy proposals | SOX 404 SoD: bulk identity changes require human approval | Emit-and-route to PIM-activated writer with -WhatIf first |
| 6 | Omitting beta tag-drift sweep | New Microsoft agent tags arrive between releases; known-tag list goes stale | Run §3 step (3) on every quarterly attestation |
| 7 | Treating empty Integrated Apps response as "no agents" | Sovereign clouds may not have the preview endpoint | Always run Exchange add-in fallback; never treat M365AppsAdminStatus != Preview as clean |
| 8 | Using the same SP for read and write operations | Defeats SoD; one compromised credential becomes both audit and remediation | Two principals: agt12-registry-reader (unattended) and agt12-registry-writer (PIM-only) |
| 9 | Skipping Authenticode signature on the manifest | Examiner cannot prove pack integrity; defeats the evidence chain | Set-AuthenticodeSignature with code-signing cert + timestamp; throw on Status != Valid |
| 10 | Catching exceptions silently in legs | Partial / wrong-shape data masquerades as clean | Catch-and-emit a Status='*Failed' row — never swallow |
14.3 Operating cadence
| Frequency | Action | Principal | Output |
|---|---|---|---|
| Hourly | Get-Agt12ConsentPosture queue check |
reader | Triage ticket if non-empty |
| Daily | Inventory + ownership + credential delta | reader | Diff CSV to evidence store |
| Weekly | Full §3–§9 run, no -IncludeAllSps |
reader | CSV to operations channel |
| Monthly | Power Platform + MCP + Integrated Apps legs | reader | CSV to AI governance backlog |
| Quarterly | Test-Agt12Implementation then Export-Agt12AttestationPack with -IncludeAllSps |
reader (collect) + writer (sign) | Signed pack to evidence vault; feed 3.1 Merge-Agt31Inventory |
| Annually | OverPermissionedScopes baseline review | second-line risk | Updated default list in §5 |
Fail-closed conditions enforced by this section: Test-Agt12Implementation failure blocks the quarterly attestation; signature failure blocks the pack hand-off; anti-patterns surfaced in code review block the merge.
Cross-references
- Parent control:
1.2 — Agent Registry and Integrated Apps Management - Sponsorship integration:
./sponsorship-lifecycle-workflows.md - Connector policy crosswalk:
Control 1.4 — Advanced Connector Policies (ACP) - Audit logging:
Control 1.7 — Comprehensive Audit Logging and Compliance - eDiscovery for agent interactions:
Control 1.19 — eDiscovery for Agent Interactions - Adversarial input logging:
Control 1.21 — Adversarial Input Logging - Step-up auth for agent operations:
Control 1.23 — Step-Up Authentication for Agent Operations - Defender AI-SPM:
Control 1.24 — Defender AI Security Posture Management - Managed environments:
Control 2.1 — Managed Environments - Inventory reconciliation target:
../../../controls/pillar-3-reporting/3.1-agent-inventory-and-metadata-management.md - Orphaned agent detection:
../../../controls/pillar-3-reporting/3.6-orphaned-agent-detection-and-remediation.md - Shared scripting baseline:
../../_shared/powershell-baseline.md
Updated: April 2026 | Version: v1.4.0 | Maintained by: AI Governance Team