Control 3.4 — PowerShell Setup: Incident Reporting and Root Cause Analysis
Control under management:
3.4 — Incident Reporting and Root Cause AnalysisSister playbooks: Portal Walkthrough · Verification & Testing · Troubleshooting (forthcoming)
Shared baseline:
_shared/powershell-baseline.md— module pinning, sovereign endpoint matrix, mutation safety, evidence emission, SHA-256 manifest format. Read it once before running any helper here.Namespace. Every exported function in this playbook uses the literal name from the Control 3.4 portal walkthrough specification (e.g.,
New-Fsi-IncidentList,Start-Fsi-RegulatorTimer). Internal-only helpers use theFsi34prefix (e.g.,Assert-Fsi34ShellHost) so they cannot collide with peer-control automation (Agt36,Agt225,Agt226,Agt12).
Non-Substitution
The automation in this playbook supports — it does not replace — the elements of an FSI incident-response program that must be performed and attested by named human officers:
- The written incident-response program required by NYDFS 23 NYCRR 500.16 and FFIEC IT Examination Handbook (Information Security booklet).
- Registered-principal supervisory review of supervision-relevant incidents under FINRA Rule 3110 (cross-reference Control 2.12).
- Legal-hold issuance, custodian acknowledgment tracking, and preservation defensibility under Federal Rule of Civil Procedure 37(e) — the
New-Fsi-LegalHoldhelper provisions the eDiscovery case but does not replace counsel's hold memo, scope decisions, or ongoing supervisory obligations. - Regulator notification filings themselves (NYDFS Cybersecurity Portal submissions, SEC EDGAR 8-K Item 1.05 filings, OCC/FRB/FDIC notifications under 12 CFR Parts 53/225/304, FINRA 4530 e-filings, state Attorney General notifications). Helpers below open and time the workflow; the actual filing is performed by the General Counsel, Chief Compliance Officer, or designated principal in the relevant regulator portal.
- Books-and-records retention under SEC Rule 17a-4(f) and FINRA Rule 4511 — incident records must land in a WORM-locked Purview-retention or Azure Storage immutability-policy store; the helpers emit signed manifests but the immutability binding is the operator's responsibility (cross-reference Control 1.9).
- Disclosure Committee materiality determinations under SEC Rule 8-K Item 1.05 — the helper opens a meeting request; the determination of materiality is exclusively the Committee's decision under counsel's advice.
Tooling aids in meeting these obligations. It does not satisfy them.
Sovereign Cloud Availability
Several surfaces this playbook touches have no parity in US Government clouds (GCC / GCC High / DoD) at the time of this verification (April 2026):
- Microsoft Sentinel content hub solutions for AI-agent incident playbooks ship to Commercial first; sovereign builds typically lag 60–120 days. Operators must verify availability per workspace before relying on the deployers in §5.
- Insider Risk Management Graph beta parity (§6) is partial in GCC and unreleased in GCC High at this verification date —
Get-Fsi-IRMCasereturnsStatus='NotApplicable'withReason='SovereignParityUnavailable'and points to the compensating-control worksheet. - Service Health correlation (§8) emits a different schema in sovereign tenants; the helper detects the cloud profile and reads the appropriate beta endpoint.
The bootstrap helper in §2 detects sovereign tenants and routes each downstream call to the correct endpoint or returns a structured NotApplicable with a compensating-control reference. It does not silently emit Clean against a surface that does not exist. Refer to baseline §3 — Sovereign Cloud Endpoints.
Hedged-language reminder. Throughout this playbook, governance helpers support compliance with NYDFS 23 NYCRR 500.16 / 500.17, SEC Rule 8-K Item 1.05, the federal banking 36-hour rule (12 CFR Parts 53 / 225 / 304), Regulation S-P (2024 amendments), FINRA Rules 4530(a)/(d) and 4511, the FTC Safeguards Rule 30-day notification requirement, state breach-notification statutes, the CISA CIRCIA proposed/horizon rule, FFIEC IT Examination Handbook expectations, SOX §§302 / 404 ITGC, and SEC Rule 17a-3/17a-4 books-and-records requirements. They do not "ensure," "guarantee," or "eliminate risk." Implementation requires named owners, calibrated severity definitions, and quarterly tabletop validation. Organizations should verify per-tenant and per-cloud parity using the §13 self-test before relying on any output as regulatory evidence.
§0 — Wrong-shell trap and false-clean defects
Control 3.4 false negatives almost always stem from running the wrong shell, mixing module families, or treating an empty Sentinel response as evidence of "no incidents." Treat every "clean" report from a new responder workstation as suspect until the §13 self-test passes with the exact module versions and the exact tenant context that production uses.
0.1 — Wrong-shell trap
- Windows PowerShell 5.1 is not supported for the Graph + Az path.
Microsoft.Graphv2.25 andAz.SecurityInsightsv3.1 require PowerShell 7.4+. Running under 5.1 silently loads the v1.x legacyMicrosoft.Graphmodule if present and returns partial security-incident payloads — entire alert chains drop, and helpers downstream report "no anomalies." - Windows PowerShell 5.1 IS REQUIRED for the Power Platform suspension path (
Microsoft.PowerApps.Administration.PowerShell). The agent-suspension Logic Apps playbook in §5.2 invokes the Desktop-edition module via a 5.1 sidecar runspace; do not attempt to load that module in PS 7.4. - Integrated Script Environment (ISE) is not supported for interactive sovereign-cloud bootstrap — device-code flow UI is clipped at 80 columns. Use Windows Terminal +
pwsh.exefor the 7.4 path and a separatepowershell.exewindow for the 5.1 sidecar. - Azure Cloud Shell has no Power Platform module and no PnP.PowerShell v2 build path. Run this playbook from a privileged-access workstation (PAW) — never from Cloud Shell — for any tenant with regulator-notification timers running.
# Enforce before sourcing any Fsi34 helper from the 7.4 path.
function Assert-Fsi34ShellHost {
[CmdletBinding()]
param([switch]$AllowSidecarFiveOne)
if ($AllowSidecarFiveOne -and $PSVersionTable.PSEdition -eq 'Desktop' -and $PSVersionTable.PSVersion -ge [version]'5.1') {
return [pscustomobject]@{ Status='Clean'; Mode='Sidecar51'; PSVersion=$PSVersionTable.PSVersion.ToString(); Reason='' }
}
if ($PSVersionTable.PSEdition -ne 'Core') {
throw [System.InvalidOperationException]::new("Fsi34-WrongShell: PowerShell Core (7.4+) required for Graph/Az path; got $($PSVersionTable.PSEdition).")
}
if ($PSVersionTable.PSVersion -lt [version]'7.4') {
throw [System.InvalidOperationException]::new("Fsi34-WrongShell: PS 7.4+ required; got $($PSVersionTable.PSVersion).")
}
if ($Host.Name -eq 'Windows PowerShell ISE Host') {
throw [System.InvalidOperationException]::new("Fsi34-WrongShell: ISE not supported. Use Windows Terminal + pwsh.exe.")
}
[pscustomobject]@{ Status='Clean'; Mode='Pwsh74'; PSVersion=$PSVersionTable.PSVersion.ToString(); Reason='' }
}
0.2 — False-clean defect catalogue (Fsi34-specific)
| # | Defect | Symptom | Root cause | Mitigation in this playbook |
|---|---|---|---|---|
| 1 | Wrong PowerShell host on detection path | Get-Fsi-DefenderIncident returns @() against tenants with active incidents |
PS 5.1 loaded Microsoft.Graph 1.x alongside 2.25 |
Assert-Fsi34ShellHost (above) refuses to proceed |
| 2 | Sovereign tenant treated as commercial | Helpers run, return Clean, against a .us tenant where the underlying surface is GCC-only |
Connect-MgGraph defaulted to -Environment Global; Connect-AzAccount defaulted to AzureCloud |
Resolve-Fsi34CloudProfile (§2.2) — sovereign-aware routing |
| 3 | Read-only token used against mutation surface | Logic Apps deployer fails with 403, helper swallows and reports "deployer skipped" |
Caller used SecurityIncident.Read.All only when SecurityActions.ReadWrite.All was needed |
Test-Fsi34GraphScopes preflight (§1.4) refuses to dispatch mutation helpers without ReadWrite scope |
| 4 | Sentinel paged response truncated at 50 | Incident pull undercounts in busy tenants; missed open incidents past SLA | Operator forgot -MaxResults defaulted to 50 in Get-AzSentinelIncident |
Invoke-Fsi34SentinelPaged (§4.4) follows nextLink and asserts page count |
| 5 | Throttled ARM call returned empty body | Helper interprets HTTP 429 with empty JSON as "no incidents" | No retry/backoff wrapper around Az.SecurityInsights | Invoke-Fsi34Throttled (§4.5) honors Retry-After; raises Status='Error' after 3 retries |
| 6 | Legal-hold cmdlet GA-renamed mid-flight | Cmdlet not found, caught by broad try, swallowed |
Microsoft renamed *ComplianceCase* → *EdiscoveryCase* between Graph beta and v1.0 |
Get-Fsi34CmdletAvailability (§1.3) emits NotApplicable, not Clean |
| 7 | Empty result conflated with "no findings" | Helper returns $null; downstream evidence pack says "Clean" |
Helpers must distinguish Clean from NotApplicable from Error |
All helpers return [pscustomobject] with explicit Status enum |
| 8 | Cached delegated token from prior tenant | Helper enumerates the wrong tenant's incidents | Operator switched tenants but did not call Disconnect-MgGraph / Disconnect-AzAccount |
Initialize-Fsi34Session always disconnects first |
| 9 | NYDFS 24-hour ransom timer triggered without OFAC screening | Extortion payment authorized via incident automation before Treasury OFAC sanctions check | Timer helper provided notification but no OFAC gate | Start-Fsi-RegulatorTimer -Timer NYDFS24 (§9.3) emits a terminating sanctions checkpoint before any payment-authorization workflow can advance — see !!! danger in §9.3 |
| 10 | SEC 8-K materiality determination assumed automatic | Helper marked an incident "non-material" without Disclosure Committee meeting | Materiality determination delegated to a regex on incident severity, not to the Committee | New-Fsi-SentinelPlaybook-SEC8K-MaterialityCheck (§5.5) opens the Disclosure Committee meeting request and does not make a determination — the Committee does |
| 11 | RCA template populated but not signed | Closed incident has RootCause text but no analyst/manager/compliance signatures | Closure helper accepted text without signature manifest | Test-Fsi-Control34-EvidenceChain (§11.2) refuses to mark an incident-evidence record Clean without the three-signature SHA-256 manifest |
| 12 | Service Health outage masked as agent fault | Sev-Critical/High RCA filed against an internal owner when the actual cause was a Microsoft-side service outage | RCA author did not consult the M365 Service Health window for the incident interval | Test-Fsi-Control34-ServiceHealthChecked (§11.3) flags Anomaly if a Sev-Critical/High RCA closure lacks a Service Health correlation record |
| 13 | Tabletop output overwrites production incident list | Synthetic tabletop incident written to the same SharePoint list as real incidents, polluting metrics | Tabletop harness used the production list URL | Invoke-Fsi-TabletopExercise (§10) writes to a sibling list AI Agent Incidents - Tabletop and tags every row with IsSynthetic=true; production metrics queries exclude the sibling |
| 14 | State breach triage missed a residency | Customer notice sent for 49 states; one state attorney general filed enforcement | Per-state lookup keyed only on billing address, not on additional residency tags | New-Fsi-SentinelPlaybook-StateBreachLawTriage (§5.9) joins on multiple residency attributes and produces a per-state reconciliation manifest |
| 15 | MRM feedback loop dropped | Data-quality / model-risk incident closed without MRMTicketRef populated to Control 2.6 | Closure helper did not enforce the conditional cross-control link | Test-Fsi-Control34-MRMFeedback (§11.4) refuses to mark closure clean for AIAgentClass=DataQuality-ModelRisk without MRMTicketRef |
Every helper in this playbook returns one of five Status values — Clean, Anomaly, Pending, NotApplicable, Error — and every helper carries a non-empty Reason string when Status -ne 'Clean'. Helpers never return $null or @() as a clean signal. This is the core defect-#7 mitigation.
0.3 — Self-test before every production run
Every scheduled detection or evidence-bundling job must invoke Invoke-Fsi34SelfTest (§13.1) before emitting results to SIEM, the Disclosure Committee distribution, or the regulator-notification queue. A failed self-test emits an operational alert to the AI Governance Lead, the CISO, and the General Counsel and suppresses the affected batch until resolved — it does not emit a "clean" report.
§1 — Module pinning, Graph scopes, RBAC matrix, and preview gating
1.1 — Module version matrix
Pin exact minimum versions. Later minor versions are acceptable, but do not float to an unpinned -MinimumVersion — Az.SecurityInsights v3.2 has been observed to regress on Logic Apps-playbook association in sovereign clouds, and Microsoft.Graph.Security v2.26 changed the incidents/{id}/comments payload shape between minor builds.
$Fsi34ModuleMatrix = @(
@{ Name = 'Microsoft.Graph.Authentication'; Min = '2.25.0' ; Edition = 'Core' },
@{ Name = 'Microsoft.Graph.Security'; Min = '2.25.0' ; Edition = 'Core' },
@{ Name = 'Microsoft.Graph.SecurityEvents'; Min = '2.25.0' ; Edition = 'Core' },
@{ Name = 'Microsoft.Graph.Identity.SignIns'; Min = '2.25.0' ; Edition = 'Core' },
@{ Name = 'Microsoft.Graph.Beta.Security'; Min = '2.25.0' ; Edition = 'Core' },
@{ Name = 'Az.Accounts'; Min = '2.16.0' ; Edition = 'Core' },
@{ Name = 'Az.SecurityInsights'; Min = '3.1.0' ; Edition = 'Core' },
@{ Name = 'Az.OperationalInsights'; Min = '3.2.0' ; Edition = 'Core' },
@{ Name = 'Az.Monitor'; Min = '5.2.0' ; Edition = 'Core' },
@{ Name = 'Az.LogicApp'; Min = '1.6.0' ; Edition = 'Core' },
@{ Name = 'Az.Resources'; Min = '6.16.0' ; Edition = 'Core' },
@{ Name = 'PnP.PowerShell'; Min = '2.4.0' ; Edition = 'Core' },
@{ Name = 'ExchangeOnlineManagement'; Min = '3.5.0' ; Edition = 'Both' },
@{ Name = 'Microsoft.PowerApps.Administration.PowerShell'; Min = '2.0.175' ; Edition = 'Desktop' }
)
function Test-Fsi34ModuleMatrix {
<#
.SYNOPSIS
Validates the Control 3.4 module-pinning matrix is satisfied on the current host.
.DESCRIPTION
Verifies every required module is installed at or above the pinned minimum, and that
Desktop-only modules are only loaded into PS 5.1 sidecars. Returns the contract object.
.PARAMETER InstallMissing
When set, attempts to install any missing or outdated module from the configured repository.
.OUTPUTS
[pscustomobject] @{ Status; Modules; Reason } where Status is one of the five canonical values.
.NOTES
Control: 3.4 Incident Reporting and Root Cause Analysis
Last UI verified: April 2026
#>
[CmdletBinding()]
param([switch]$InstallMissing)
$rows = foreach ($m in $Fsi34ModuleMatrix) {
$isDesktopOnly = ($m.Edition -eq 'Desktop')
$hostMatchesEdition = $true
if ($isDesktopOnly -and $PSVersionTable.PSEdition -ne 'Desktop') {
$hostMatchesEdition = $false # Desktop module skipped on Core host; verify on sidecar
}
$installed = Get-Module -ListAvailable -Name $m.Name |
Sort-Object Version -Descending | Select-Object -First 1
$status = if (-not $hostMatchesEdition) { 'NotApplicable-WrongEdition' }
elseif (-not $installed) { 'Missing' }
elseif ($installed.Version -lt [version]$m.Min) { 'Outdated' }
else { 'OK' }
if (($status -in @('Missing','Outdated')) -and $InstallMissing -and $hostMatchesEdition) {
Install-Module -Name $m.Name -RequiredVersion $m.Min -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -AcceptLicense
$status = 'Installed'
}
[pscustomobject]@{
Module = $m.Name
Required = $m.Min
Edition = $m.Edition
Installed = if ($installed) { $installed.Version.ToString() } else { '' }
Status = $status
}
}
$bad = $rows | Where-Object { $_.Status -in @('Missing','Outdated') }
[pscustomobject]@{
Status = if ($bad) { 'Anomaly' } else { 'Clean' }
Modules = $rows
Reason = if ($bad) { "Missing or outdated modules: $($bad.Module -join ', ')" } else { '' }
}
}
1.2 — Microsoft Graph scope matrix
Two least-privilege profiles are defined — read-only investigation (most operators) and mutation (incident commander, legal-hold owner, Sentinel automation deployer). They are never combined into a single delegated token. Mutation operations require step-up via PIM activation (§1.4) and are bound to a documented ChangeTicketId.
| Operation | Graph scopes (delegated) | Graph scopes (app-only) |
|---|---|---|
| Incident detection / read-only investigation | SecurityIncident.Read.All, SecurityEvents.Read.All, InsiderRiskManagement.Read.All, AuditLog.Read.All, ServiceHealth.Read.All |
Same |
| Incident mutation (status, classification, comment) | SecurityIncident.ReadWrite.All, SecurityActions.ReadWrite.All |
SecurityIncident.ReadWrite.All (preferred), fall back only with documented justification |
| Legal hold (eDiscovery Premium) | eDiscovery.Read.All (read), eDiscovery.ReadWrite.All (mutation) |
Same — app-only requires Compliance Admin role on the service principal |
| IRM case helpers | InsiderRiskManagement.Read.All |
Beta-only; verify per-tenant availability via §1.3 |
| Service Health correlation | ServiceHealth.Read.All |
Same |
| Information Protection labels in evidence pack | InformationProtectionPolicy.Read |
Same |
$Fsi34Scopes = @{
Read = @(
'SecurityIncident.Read.All',
'SecurityEvents.Read.All',
'InsiderRiskManagement.Read.All',
'AuditLog.Read.All',
'ServiceHealth.Read.All',
'InformationProtectionPolicy.Read'
)
Mutate = @(
'SecurityIncident.ReadWrite.All',
'SecurityActions.ReadWrite.All'
)
LegalHold = @(
'eDiscovery.Read.All',
'eDiscovery.ReadWrite.All'
)
}
1.3 — Cmdlet availability — handling preview-to-GA renames
Between the November 2025 preview wave and the April 2026 verification window, Microsoft renamed several Graph SDK cmdlets across the Security and Compliance surfaces. To prevent defect #6 and #10 from §0.2, every helper that calls a Graph SDK or Az cmdlet first invokes Get-Fsi34CmdletAvailability:
function Get-Fsi34CmdletAvailability {
<#
.SYNOPSIS
Probes the local module surface for the named cmdlets and returns availability per cmdlet.
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [string[]]$CmdletName)
$rows = foreach ($name in $CmdletName) {
$cmd = Get-Command -Name $name -ErrorAction SilentlyContinue
[pscustomobject]@{
CmdletName = $name
Available = [bool]$cmd
Module = if ($cmd) { $cmd.ModuleName } else { '' }
Version = if ($cmd -and $cmd.Module) { $cmd.Module.Version.ToString() } else { '' }
}
}
$missing = $rows | Where-Object { -not $_.Available }
[pscustomobject]@{
Status = if ($missing) { 'NotApplicable' } else { 'Clean' }
Cmdlets = $rows
Reason = if ($missing) { "CmdletMissing: $($missing.CmdletName -join ', ')" } else { '' }
}
}
When a required cmdlet is unavailable, the calling helper emits Status='NotApplicable', a Reason="CmdletMissing:<name>", and a portal-export fallback URI in the evidence record — it never silently returns Clean.
1.4 — RBAC and PIM activation matrix
| Activity | Canonical role | PIM elevation? | Notes |
|---|---|---|---|
| Read incident inventory | Entra Global Reader + Sentinel Reader | No | Sufficient for §4 read helpers |
| Read IRM cases | Purview Insider Risk Investigator | No | Plus InsiderRiskManagement.Read.All scope |
| Mutate incidents (status/classify/comment) | Entra Security Admin | Yes — PIM-bound, 4h window | Requires ChangeTicketId |
| Deploy Sentinel Logic Apps playbooks | Logic Apps Contributor + Sentinel Contributor on the workspace RG | Yes — PIM-bound, 8h window | Requires ChangeTicketId and CAB approval |
| Suspend a Power Platform agent | Power Platform Admin | Yes — PIM-bound, 1h window | Mutation; cross-reference Control 1.11 workload-identity policy |
| Issue / mutate legal hold | Purview Compliance Admin + eDiscovery Manager | Yes — PIM-bound, 24h window | Counsel approval required separately |
File regulator notification (acknowledged in Stop-Fsi-RegulatorTimer) |
General Counsel + Chief Compliance Officer (named officers, not role-based) | N/A — not a tenant role | Acknowledgment hash captured |
| Sign quarterly evidence bundle | CISO + CCO + GC + AI Governance Lead | N/A | Four-signature requirement enforced by §12 |
Canonical role names: Entra Security Admin, Entra Global Reader, Purview Compliance Admin, Purview Insider Risk Investigator, Sentinel Contributor, Sentinel Reader, Sentinel Responder, Logic Apps Contributor, Power Platform Admin, Exchange Online Admin, AI Governance Lead, CISO, CCO, General Counsel. See docs/reference/role-catalog.md. Do not substitute synonyms (e.g., do not write "Compliance Administrator" — use "Purview Compliance Admin"; do not write "Security Administrator" — use "Entra Security Admin").
1.5 — Preview-surface gating preflight
A subset of Control 3.4 surfaces remain in public preview at this verification date — most notably the Graph beta informationProtection/threatAssessmentRequests feed used by the Service Health correlation helper, and the IRM-case-to-incident lineage join used by Get-Fsi-IRMCase. Operators must affirmatively opt in to preview surfaces through the -AllowPreviewSurfaces switch on Initialize-Fsi34Session. Without that switch, helpers that touch preview surfaces return Status='NotApplicable', Reason='PreviewSurfaceNotEnabled' rather than failing or, worse, returning a synthetic clean.
function Test-Fsi34PreviewGating {
<#
.SYNOPSIS
Returns the preview-gating decision recorded for this session.
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [bool]$AllowPreviewSurfaces)
[pscustomobject]@{
AllowPreviewSurfaces = $AllowPreviewSurfaces
IRMLineageJoin = if ($AllowPreviewSurfaces) { 'Reachable' } else { 'GatedOff' }
ThreatAssessmentFeed = if ($AllowPreviewSurfaces) { 'Reachable' } else { 'GatedOff' }
Status = if ($AllowPreviewSurfaces) { 'Clean' } else { 'NotApplicable' }
Reason = if ($AllowPreviewSurfaces) { '' } else { 'OperatorOptedOutOfPreview' }
}
}
§2 — Sovereign-aware bootstrap, connection helper, and Sentinel workspace resolution
The bootstrap helpers establish all four sessions this control needs (Microsoft Graph, Azure ARM, PnP SharePoint, Exchange Online), decide which sovereign environment to target, validate scopes against the live token, resolve the Sentinel workspace from a structured tag query, and emit a SessionContext object that every subsequent helper consumes. Three rules are non-negotiable:
- Sovereign tenants route through the sovereign endpoint matrix — never default to
Global/AzureCloud. - Every session is initialized with
Disconnect-MgGraph/Disconnect-AzAccountfirst so cached cross-tenant tokens cannot leak into the wrong evidence pack. - Throttling is wrapped at the bootstrap layer so callers in §4 onward never need to write their own retry loops.
2.1 — Resolve-Fsi34CloudProfile
function Resolve-Fsi34CloudProfile {
<#
.SYNOPSIS
Returns the canonical sovereign-cloud profile for the current run.
.PARAMETER Cloud
One of Commercial | GCC | GCCHigh | DoD. Defaults to Commercial.
.OUTPUTS
[pscustomobject] with GraphEnv, AzEnv, SentinelEnv, Status, Reason fields.
.NOTES
Control: 3.4. See _shared/powershell-baseline.md §3 for endpoint authority.
#>
[CmdletBinding()]
param(
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud = 'Commercial'
)
$map = @{
Commercial = @{ GraphEnv='Global'; AzEnv='AzureCloud'; Sentinel='Global' }
GCC = @{ GraphEnv='USGov'; AzEnv='AzureUSGovernment'; Sentinel='USGov' }
GCCHigh = @{ GraphEnv='USGovDoD'; AzEnv='AzureUSGovernment'; Sentinel='USGovHigh'}
DoD = @{ GraphEnv='USGovDoD'; AzEnv='AzureUSGovernment'; Sentinel='USGovDoD' }
}
$p = $map[$Cloud]
[pscustomobject]@{
Cloud = $Cloud
GraphEnv = $p.GraphEnv
AzEnv = $p.AzEnv
SentinelEnv = $p.Sentinel
Status = 'Clean'
Reason = ''
ResolvedAt = (Get-Date).ToUniversalTime().ToString('o')
}
}
2.2 — Initialize-Fsi34Session
function Initialize-Fsi34Session {
<#
.SYNOPSIS
Establishes Graph + Az + (optionally) PnP + EXO sessions for Control 3.4 helpers.
.DESCRIPTION
Disconnects any cached sessions first, then connects to Graph and Az with the
sovereign-aware environment, validates required scopes, and returns a
SessionContext object consumed by every helper in §4 onward.
.PARAMETER TenantId
Tenant GUID or verified-domain string.
.PARAMETER Cloud
Commercial | GCC | GCCHigh | DoD. Defaults to Commercial.
.PARAMETER Mode
Read | Mutate | LegalHold. Drives which scope set is requested. Mutate and LegalHold
require a documented ChangeTicketId.
.PARAMETER ChangeTicketId
Required when Mode is Mutate or LegalHold. Recorded in evidence.
.PARAMETER AllowPreviewSurfaces
Affirmative opt-in for preview surfaces (IRM lineage, threat assessment feed).
.OUTPUTS
[pscustomobject] SessionContext with Status, Cloud, TenantId, Mode, Scopes, Reason.
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium')]
param(
[Parameter(Mandatory)] [string]$TenantId,
[ValidateSet('Commercial','GCC','GCCHigh','DoD')]
[string]$Cloud = 'Commercial',
[ValidateSet('Read','Mutate','LegalHold')]
[string]$Mode = 'Read',
[string]$ChangeTicketId,
[switch]$AllowPreviewSurfaces
)
Assert-Fsi34ShellHost | Out-Null
if ($Mode -in @('Mutate','LegalHold') -and -not $ChangeTicketId) {
throw "Fsi34-MissingChangeTicket: Mode '$Mode' requires -ChangeTicketId for SOX 404 / OCC 2023-17 evidence."
}
$profile = Resolve-Fsi34CloudProfile -Cloud $Cloud
$previewDecision = Test-Fsi34PreviewGating -AllowPreviewSurfaces:$AllowPreviewSurfaces
# Always disconnect first to evict cached cross-tenant tokens (defect #8).
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {}
try { Disconnect-AzAccount -ErrorAction SilentlyContinue | Out-Null } catch {}
$scopes = switch ($Mode) {
'Read' { $Fsi34Scopes.Read }
'Mutate' { $Fsi34Scopes.Read + $Fsi34Scopes.Mutate }
'LegalHold' { $Fsi34Scopes.Read + $Fsi34Scopes.LegalHold }
}
if ($PSCmdlet.ShouldProcess($TenantId, "Connect-MgGraph + Connect-AzAccount in $($profile.GraphEnv)/$($profile.AzEnv)")) {
Connect-MgGraph -TenantId $TenantId -Environment $profile.GraphEnv -Scopes $scopes -NoWelcome | Out-Null
Connect-AzAccount -Tenant $TenantId -Environment $profile.AzEnv -WarningAction SilentlyContinue | Out-Null
}
$tokenScopes = (Get-MgContext).Scopes
$missing = $scopes | Where-Object { $_ -notin $tokenScopes -and $_ -notlike '*ServiceHealth*' }
$status = if ($missing) { 'Anomaly' } else { 'Clean' }
$reason = if ($missing) { "MissingScopes: $($missing -join ', ')" } else { '' }
[pscustomobject]@{
Status = $status
Reason = $reason
TenantId = $TenantId
Cloud = $Cloud
Mode = $Mode
ChangeTicketId = $ChangeTicketId
Scopes = $tokenScopes
Profile = $profile
PreviewDecision = $previewDecision
InitializedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
}
}
2.3 — Resolve-Fsi34SentinelWorkspace
The Sentinel workspace must be resolved from tags (not a hardcoded resource ID) so the orchestrator can re-deploy across environments without code edits. The expected tag set is governance=fsi-agentgov, control=3.4, cloud=<Commercial|GCC|GCCHigh|DoD>.
function Resolve-Fsi34SentinelWorkspace {
<#
.SYNOPSIS
Resolves the Sentinel workspace + resource group for the current cloud via Az.Resources tag query.
.OUTPUTS
[pscustomobject] @{ Status; SubscriptionId; ResourceGroup; WorkspaceName; WorkspaceId; Reason }
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [string]$Cloud)
$tagFilter = @{ governance='fsi-agentgov'; control='3.4'; cloud=$Cloud }
$candidates = Get-AzResource -ResourceType 'Microsoft.OperationalInsights/workspaces' -Tag $tagFilter -ErrorAction SilentlyContinue
if (-not $candidates) {
return [pscustomobject]@{
Status = 'Anomaly'
SubscriptionId = ''; ResourceGroup = ''; WorkspaceName = ''; WorkspaceId = ''
Reason = "NoTaggedSentinelWorkspaceFound for cloud=$Cloud (expected tag governance=fsi-agentgov + control=3.4)"
}
}
if ($candidates.Count -gt 1) {
return [pscustomobject]@{
Status = 'Anomaly'
SubscriptionId = ''; ResourceGroup = ''; WorkspaceName = ''; WorkspaceId = ''
Reason = "MultipleTaggedWorkspaces: $($candidates.Count); refusing to dispatch — operator must disambiguate."
}
}
$w = $candidates[0]
[pscustomobject]@{
Status = 'Clean'
SubscriptionId = $w.SubscriptionId
ResourceGroup = $w.ResourceGroupName
WorkspaceName = $w.Name
WorkspaceId = $w.ResourceId
Reason = ''
}
}
2.4 — Get-RunMetadata (signed transcript wrapper)
The signed-transcript wrapper is shared across the playbook and called from every mutation helper. It honors the baseline Start-Transcript pattern and signs the resulting transcript with a tenant-scoped certificate stored in Key Vault.
function Get-RunMetadata {
<#
.SYNOPSIS
Returns the canonical run-metadata block applied to every emitted artifact.
.NOTES
Control: 3.4. The signature value below is computed by the Key Vault step in §11.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$Helper,
[string]$ChangeTicketId
)
[pscustomobject]@{
control_id = '3.4'
helper = $Helper
run_id = [guid]::NewGuid().ToString()
run_utc = (Get-Date).ToUniversalTime().ToString('o')
tenant_id = $SessionContext.TenantId
cloud = $SessionContext.Cloud
mode = $SessionContext.Mode
change_ticket = $ChangeTicketId
operator_upn = (Get-MgContext).Account
playbook_version= 'v1.4'
schema_version = '1.0'
}
}
§3 — SharePoint IR list provisioner (New-Fsi-IncidentList)
The SharePoint list backing the incident-tracking system is provisioned via PnP.PowerShell v2. The list name is AI Agent Incidents, and a sibling list named AI Agent Incidents - Tabletop is provisioned alongside it for the §10 tabletop harness so synthetic exercise data never contaminates production metrics (defect #13).
The list schema is intentionally broader than the legacy SharePoint columns documented in the portal walkthrough — the additional columns (regulator-timer multi-select, MRM ticket reference, legal-hold reference, retention label) carry the cross-control state the orchestrator needs.
3.1 — Column specification
| Column | Type | Required | Notes |
|---|---|---|---|
IncidentId |
Text (single-line) | Yes | Format INC-YYYY-MMDD-NNN; uniqueness enforced by helper |
DeterminationTimestamp |
DateTime (UTC) | Yes | Anchor for every regulator-notification timer |
DeterminingPersonUPN |
Person | Yes | Named officer per NYDFS 500.17(a) "determined" trigger |
Severity |
Choice | Yes | Critical / High / Medium / Low |
AIAgentClass |
Choice | Yes | Security / Compliance / DataQuality-ModelRisk / Privacy / Identity-AgentID / Supervision / 3p-Vendor |
AffectedSystems |
Multi-line | No | Comma-separated system identifiers |
AffectedRecordCount |
Number | No | Records exposed (privacy / GLBA scope) |
AffectedDataType |
Choice multi | No | NPI / PCI / PHI / MNPI / PII-Resident / PII-NonResident |
AffectedCustomerCount |
Number | No | Distinct customer count for Reg S-P 30-day notice |
AffectedStateResidencies |
Choice multi | No | Two-letter state codes; drives §5.9 state breach triage |
ApplicableTimers |
Choice multi | Yes | NYDFS72 / NYDFS24 / SEC8K / Bank36 / RegSP30 / FTC30 / FINRA4530A / FINRA4530D / CISA72 / State / NCUA72 |
TimerOwners |
Person multi | Yes | Per-timer named owner (CCO, GC, CISO, Privacy Officer) |
ContainmentTimestamp |
DateTime | No | Populated when threat actor activity is contained |
EradicationTimestamp |
DateTime | No | Populated when root cause is removed from the environment |
RCATargetDate |
Date | Yes | 14 calendar days from DeterminationTimestamp for Critical/High |
RCAStatus |
Choice | Yes | NotStarted / Drafting / InReview / Approved / Filed |
MRMTicketRef |
URL | Conditional | Required when AIAgentClass = DataQuality-ModelRisk per Control 2.6 feedback loop |
LegalHoldRef |
URL | Conditional | Populated by New-Fsi-LegalHold (§7) |
EvidencePointers |
Multi-line | No | List of Azure Storage immutability blob URIs from Export-Fsi-IncidentEvidenceBundle |
RetentionLabel |
Choice | Yes | Drives Purview retention; default IncidentRecord-7yr for FINRA 4511 / 17a-4(f) |
ServiceHealthCorrelationRef |
URL | Conditional | Required for Sev-Critical/High closure per defect #12 |
IsSynthetic |
Yes/No | Yes | True only on the Tabletop sibling list |
3.2 — New-Fsi-IncidentList
function New-Fsi-IncidentList {
<#
.SYNOPSIS
Provisions the AI Agent Incidents list and the Tabletop sibling list with the full Control 3.4 schema.
.PARAMETER SiteUrl
SharePoint site URL (the AI Governance site).
.PARAMETER SessionContext
The object returned by Initialize-Fsi34Session.
.PARAMETER ChangeTicketId
Required mutation evidence reference.
.OUTPUTS
[pscustomobject] @{ Status; ProductionListId; TabletopListId; Reason }
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
Mutation helper — requires Mode='Mutate' on the session and a CAB-approved ChangeTicketId.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$SiteUrl,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$ProductionListName = 'AI Agent Incidents',
[string]$TabletopListName = 'AI Agent Incidents - Tabletop'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; ProductionListId=''; TabletopListId=''; Reason='WrongMode: New-Fsi-IncidentList requires Mode=Mutate.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-IncidentList' -ChangeTicketId $ChangeTicketId
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
$columnSpec = @(
@{ Name='IncidentId'; Type='Text'; Required=$true },
@{ Name='DeterminationTimestamp'; Type='DateTime'; Required=$true },
@{ Name='DeterminingPersonUPN'; Type='User'; Required=$true },
@{ Name='Severity'; Type='Choice'; Required=$true; Choices=@('Critical','High','Medium','Low') },
@{ Name='AIAgentClass'; Type='Choice'; Required=$true; Choices=@('Security','Compliance','DataQuality-ModelRisk','Privacy','Identity-AgentID','Supervision','3p-Vendor') },
@{ Name='AffectedSystems'; Type='Note' },
@{ Name='AffectedRecordCount'; Type='Number' },
@{ Name='AffectedDataType'; Type='MultiChoice'; Choices=@('NPI','PCI','PHI','MNPI','PII-Resident','PII-NonResident') },
@{ Name='AffectedCustomerCount'; Type='Number' },
@{ Name='AffectedStateResidencies'; Type='MultiChoice'; Choices=(0..49 | ForEach-Object { ('AL','AK','AZ','AR','CA','CO','CT','DE','FL','GA','HI','ID','IL','IN','IA','KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ','NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT','VA','WA','WV','WI','WY')[$_] }) },
@{ Name='ApplicableTimers'; Type='MultiChoice'; Required=$true; Choices=@('NYDFS72','NYDFS24','SEC8K','Bank36','RegSP30','FTC30','FINRA4530A','FINRA4530D','CISA72','State','NCUA72') },
@{ Name='TimerOwners'; Type='UserMulti'; Required=$true },
@{ Name='ContainmentTimestamp'; Type='DateTime' },
@{ Name='EradicationTimestamp'; Type='DateTime' },
@{ Name='RCATargetDate'; Type='DateTime'; Required=$true },
@{ Name='RCAStatus'; Type='Choice'; Required=$true; Choices=@('NotStarted','Drafting','InReview','Approved','Filed') },
@{ Name='MRMTicketRef'; Type='URL' },
@{ Name='LegalHoldRef'; Type='URL' },
@{ Name='EvidencePointers'; Type='Note' },
@{ Name='RetentionLabel'; Type='Choice'; Required=$true; Choices=@('IncidentRecord-7yr','IncidentRecord-6yr','IncidentRecord-3yr-Tabletop') },
@{ Name='ServiceHealthCorrelationRef';Type='URL' },
@{ Name='IsSynthetic'; Type='Boolean'; Required=$true }
)
function _provisionList {
param([string]$ListName, [bool]$IsTabletopFlag)
$existing = Get-PnPList -Identity $ListName -ErrorAction SilentlyContinue
if (-not $existing) {
if ($PSCmdlet.ShouldProcess($ListName, "Create SharePoint list with $($columnSpec.Count) columns")) {
$existing = New-PnPList -Title $ListName -Template GenericList -EnableVersioning -EnableContentTypes
}
}
foreach ($col in $columnSpec) {
$exists = Get-PnPField -List $ListName -Identity $col.Name -ErrorAction SilentlyContinue
if ($exists) { continue }
switch ($col.Type) {
'Text' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type Text -Required:([bool]$col.Required) | Out-Null }
'Note' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type Note | Out-Null }
'Number' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type Number | Out-Null }
'DateTime' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type DateTime -Required:([bool]$col.Required) | Out-Null }
'Boolean' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type Boolean -Required:([bool]$col.Required) | Out-Null }
'URL' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type URL | Out-Null }
'User' { Add-PnPField -List $ListName -DisplayName $col.Name -InternalName $col.Name -Type User -Required:([bool]$col.Required) | Out-Null }
'UserMulti' { Add-PnPFieldFromXml -List $ListName -FieldXml "<Field Type='UserMulti' DisplayName='$($col.Name)' Mult='TRUE' Required='$([string]$col.Required)' />" | Out-Null }
'Choice' { $cx = ($col.Choices | ForEach-Object { "<CHOICE>$_</CHOICE>" }) -join ''
Add-PnPFieldFromXml -List $ListName -FieldXml "<Field Type='Choice' DisplayName='$($col.Name)' Required='$([string]$col.Required)'><CHOICES>$cx</CHOICES></Field>" | Out-Null }
'MultiChoice' { $cx = ($col.Choices | ForEach-Object { "<CHOICE>$_</CHOICE>" }) -join ''
Add-PnPFieldFromXml -List $ListName -FieldXml "<Field Type='MultiChoice' DisplayName='$($col.Name)' Required='$([string]$col.Required)'><CHOICES>$cx</CHOICES></Field>" | Out-Null }
}
}
# Default IsSynthetic value
Set-PnPField -List $ListName -Identity 'IsSynthetic' -Values @{ DefaultValue = ([string]$IsTabletopFlag).ToLower() } | Out-Null
return (Get-PnPList -Identity $ListName).Id.ToString()
}
try {
$prodId = _provisionList -ListName $ProductionListName -IsTabletopFlag $false
$tabId = _provisionList -ListName $TabletopListName -IsTabletopFlag $true
return [pscustomobject]@{
Status = 'Clean'
ProductionListId = $prodId
TabletopListId = $tabId
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{
Status = 'Error'
ProductionListId = ''
TabletopListId = ''
Meta = $meta
Reason = "ProvisioningException: $($_.Exception.Message)"
}
} finally {
Disconnect-PnPOnline -ErrorAction SilentlyContinue
}
}
3.3 — Retention-label binding
The RetentionLabel choice column is informational; the actual Purview retention label binding must be applied through Purview Data Lifecycle Management with records-management enabled and a regulatory record designation so the label becomes immutable once applied. Cross-reference Control 1.9 — Data Retention and Deletion Policies. The labels expected by this playbook are:
| Label | Retention | Purpose |
|---|---|---|
IncidentRecord-7yr |
7 years from EradicationTimestamp |
FINRA 4511 / SEC 17a-4(f) for broker-dealer-affiliated incidents |
IncidentRecord-6yr |
6 years from EradicationTimestamp |
NYDFS 500.06 minimum; banking baseline |
IncidentRecord-3yr-Tabletop |
3 years | Tabletop exercise records (FFIEC IT Examination expectation) |
§4 — Defender XDR / Sentinel incident pull helpers
These read-only helpers pull a structured incident object from each detection plane (Defender XDR, Sentinel, IRM) and emit it in a canonical shape that the timer engine (§9), evidence bundler (§4.6), and orchestrator (§13) all consume. The canonical shape carries the incident's alert chain, the unified KQL hunting trail used during triage, the affected-entity list, and the Sev-Critical/High Service Health correlation reference.
4.1 — Get-Fsi-DefenderIncident
function Get-Fsi-DefenderIncident {
<#
.SYNOPSIS
Returns a structured Defender XDR incident with alert chain and affected entities.
.PARAMETER IncidentId
The Defender XDR incident GUID.
.PARAMETER SessionContext
The object returned by Initialize-Fsi34Session.
.OUTPUTS
[pscustomobject] @{ Status; Incident; Alerts; Entities; Reason }
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$IncidentId,
[Parameter(Mandatory)] $SessionContext
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Get-Fsi-DefenderIncident'
$availability = Get-Fsi34CmdletAvailability -CmdletName @('Get-MgSecurityIncident','Get-MgSecurityAlertV2')
if ($availability.Status -ne 'Clean') {
return [pscustomobject]@{ Status='NotApplicable'; Incident=$null; Alerts=@(); Entities=@(); Meta=$meta; Reason=$availability.Reason }
}
try {
$inc = Invoke-Fsi34Throttled -ScriptBlock { Get-MgSecurityIncident -IncidentId $using:IncidentId -ErrorAction Stop }
if (-not $inc) {
return [pscustomobject]@{ Status='Anomaly'; Incident=$null; Alerts=@(); Entities=@(); Meta=$meta; Reason="IncidentNotFound: $IncidentId" }
}
$alerts = @()
if ($inc.AlertIds) {
$alerts = $inc.AlertIds | ForEach-Object {
Invoke-Fsi34Throttled -ScriptBlock { Get-MgSecurityAlertV2 -AlertId $using:_ -ErrorAction SilentlyContinue }
} | Where-Object { $_ }
}
$entities = @()
foreach ($a in $alerts) {
if ($a.Evidence) { $entities += $a.Evidence }
}
return [pscustomobject]@{
Status = 'Clean'
Incident = [pscustomobject]@{
IncidentId = $inc.Id
DisplayName = $inc.DisplayName
Severity = $inc.Severity
Status = $inc.Status
Classification = $inc.Classification
Determination = $inc.Determination
AssignedTo = $inc.AssignedTo
CreatedDateTime = $inc.CreatedDateTime
LastUpdateDateTime = $inc.LastUpdateDateTime
IncidentWebUrl = $inc.IncidentWebUrl
}
Alerts = $alerts
Entities = $entities
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; Incident=$null; Alerts=@(); Entities=@(); Meta=$meta; Reason="DefenderIncidentReadException: $($_.Exception.Message)" }
}
}
4.2 — Get-Fsi-SentinelIncident
function Get-Fsi-SentinelIncident {
<#
.SYNOPSIS
Returns a structured Microsoft Sentinel incident with bookmarks, related entities, and KQL provenance.
.PARAMETER ResourceGroup
Sentinel workspace resource group.
.PARAMETER Workspace
Sentinel workspace name.
.PARAMETER IncidentId
Sentinel incident name (GUID-form).
.PARAMETER SessionContext
The object returned by Initialize-Fsi34Session.
.OUTPUTS
[pscustomobject] @{ Status; Incident; Alerts; Entities; Bookmarks; Reason }
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$IncidentId,
[Parameter(Mandatory)] $SessionContext
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Get-Fsi-SentinelIncident'
try {
$inc = Invoke-Fsi34Throttled -ScriptBlock {
Get-AzSentinelIncident -ResourceGroupName $using:ResourceGroup -WorkspaceName $using:Workspace -IncidentId $using:IncidentId -ErrorAction Stop
}
if (-not $inc) {
return [pscustomobject]@{ Status='Anomaly'; Incident=$null; Alerts=@(); Entities=@(); Bookmarks=@(); Meta=$meta; Reason="SentinelIncidentNotFound: $IncidentId" }
}
$alerts = Get-AzSentinelIncidentAlert -ResourceGroupName $ResourceGroup -WorkspaceName $Workspace -IncidentId $IncidentId -ErrorAction SilentlyContinue
$entities = Get-AzSentinelIncidentEntity -ResourceGroupName $ResourceGroup -WorkspaceName $Workspace -IncidentId $IncidentId -ErrorAction SilentlyContinue
$bookmarks = Get-AzSentinelIncidentBookmark -ResourceGroupName $ResourceGroup -WorkspaceName $Workspace -IncidentId $IncidentId -ErrorAction SilentlyContinue
return [pscustomobject]@{
Status = 'Clean'
Incident = [pscustomobject]@{
IncidentId = $inc.Name
Title = $inc.Title
Severity = $inc.Severity
Status = $inc.Status
Classification = $inc.Classification
Owner = $inc.OwnerEmail
CreatedTimeUtc = $inc.CreatedTimeUtc
LastModifiedUtc = $inc.LastModifiedTimeUtc
IncidentUrl = $inc.IncidentUrl
FirstActivityUtc = $inc.FirstActivityTimeUtc
LastActivityUtc = $inc.LastActivityTimeUtc
}
Alerts = $alerts
Entities = $entities
Bookmarks = $bookmarks
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; Incident=$null; Alerts=@(); Entities=@(); Bookmarks=@(); Meta=$meta; Reason="SentinelIncidentReadException: $($_.Exception.Message)" }
}
}
4.3 — Export-Fsi-IncidentEvidenceBundle
This helper produces the deterministic, signed evidence bundle that the §12 quarterly export references. It pulls every artifact a regulator might request — KQL query results, the alert chain, the unified hunting trail, Defender file/process/network artifacts — and writes them to disk with a SHA-256 manifest signed via Get-RunMetadata.
function Export-Fsi-IncidentEvidenceBundle {
<#
.SYNOPSIS
Produces a SHA-256-manifested evidence bundle for an incident.
.DESCRIPTION
Collects: (a) Defender XDR incident + alerts + entities, (b) Sentinel incident + bookmarks +
entities, (c) the KQL hunting queries cited in the bookmarks and their result rows, (d) the
Defender file/process/network artifacts referenced from the alert chain, (e) the Service
Health window for the incident interval (cross-reference §8). Emits a manifest.json with one
row per artifact: { file, sha256, bytes, source, generated_utc, run_id, schema_version }.
.PARAMETER IncidentId
Either a Defender XDR or Sentinel incident identifier — the helper attempts both surfaces.
.PARAMETER OutputDir
Local directory to land the bundle. The caller is responsible for moving the bundle to
Azure Storage with an immutability policy bound (Control 1.9).
.PARAMETER SessionContext
The object returned by Initialize-Fsi34Session.
.OUTPUTS
[pscustomobject] @{ Status; OutputDir; ManifestPath; ArtifactCount; Reason }
.NOTES
Control: 3.4 — Incident Reporting and Root Cause Analysis. Last UI verified: April 2026.
The manifest is signed by Get-RunMetadata; the operator is responsible for binding the bundle
to a Purview retention label or Azure Storage immutability policy after emission.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='Low')]
param(
[Parameter(Mandatory)] [string]$IncidentId,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] $SessionContext,
[string]$ResourceGroup,
[string]$Workspace
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Export-Fsi-IncidentEvidenceBundle'
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$artifacts = @()
function _emit {
param($Object, [string]$Name, [string]$Source)
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$jsonPath = Join-Path $OutputDir "$Name-$ts.json"
$Object | ConvertTo-Json -Depth 30 | Set-Content -Path $jsonPath -Encoding UTF8
$hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
return [pscustomobject]@{
file = (Split-Path $jsonPath -Leaf)
sha256 = $hash
bytes = (Get-Item $jsonPath).Length
source = $Source
generated_utc = $ts
run_id = $meta.run_id
schema_version = $meta.schema_version
}
}
# (a) Defender XDR pull (best-effort; NotApplicable if cmdlet missing)
$def = Get-Fsi-DefenderIncident -IncidentId $IncidentId -SessionContext $SessionContext
if ($def.Status -in @('Clean','Anomaly')) {
$artifacts += _emit -Object $def -Name 'defender-incident' -Source 'DefenderXDR/Get-MgSecurityIncident'
}
# (b) Sentinel pull (when ResourceGroup + Workspace supplied)
if ($ResourceGroup -and $Workspace) {
$sent = Get-Fsi-SentinelIncident -ResourceGroup $ResourceGroup -Workspace $Workspace -IncidentId $IncidentId -SessionContext $SessionContext
if ($sent.Status -in @('Clean','Anomaly')) {
$artifacts += _emit -Object $sent -Name 'sentinel-incident' -Source 'Sentinel/Get-AzSentinelIncident'
# (c) KQL hunting trail derived from bookmarks
foreach ($bm in $sent.Bookmarks) {
if ($bm.Query) {
try {
$rows = Invoke-AzOperationalInsightsQuery -WorkspaceId (Resolve-Fsi34SentinelWorkspace -Cloud $SessionContext.Cloud).WorkspaceId -Query $bm.Query -ErrorAction Stop
$artifacts += _emit -Object @{ bookmark=$bm.DisplayName; query=$bm.Query; results=$rows.Results } -Name "kql-$($bm.Name)" -Source 'Sentinel/Hunting/Bookmark'
} catch {
$artifacts += _emit -Object @{ bookmark=$bm.DisplayName; query=$bm.Query; error=$_.Exception.Message } -Name "kql-$($bm.Name)-error" -Source 'Sentinel/Hunting/Bookmark'
}
}
}
}
}
# (d) Service Health window for the incident interval (defect #12 mitigation)
$svcHealth = Get-Fsi-M365ServiceHealthIncident -SinceHours 24 -SessionContext $SessionContext
$artifacts += _emit -Object $svcHealth -Name 'service-health-window' -Source 'Graph/serviceAnnouncement/healthOverviews'
# Manifest
$manifestObj = [pscustomobject]@{
meta = $meta
artifacts = $artifacts
signature = 'COMPUTED-BY-KEYVAULT-STEP-IN-SECTION-12'
}
$manifestPath = Join-Path $OutputDir 'manifest.json'
$manifestObj | ConvertTo-Json -Depth 20 | Set-Content -Path $manifestPath -Encoding UTF8
[pscustomobject]@{
Status = if ($artifacts.Count -gt 0) { 'Clean' } else { 'Anomaly' }
OutputDir = $OutputDir
ManifestPath = $manifestPath
ArtifactCount = $artifacts.Count
Meta = $meta
Reason = if ($artifacts.Count -eq 0) { 'NoArtifactsCollected' } else { '' }
}
}
4.4 — Invoke-Fsi34SentinelPaged
Paging assertion (defect #4 mitigation): every Sentinel multi-page query must use this wrapper, which follows nextLink and refuses to return a "clean" empty result if the first page hits the default cap.
function Invoke-Fsi34SentinelPaged {
[CmdletBinding()]
param(
[Parameter(Mandatory)] [scriptblock]$First,
[Parameter(Mandatory)] [scriptblock]$Next
)
$all = @()
$page = & $First
while ($page) {
if ($page.Value) { $all += $page.Value }
elseif ($page) { $all += $page }
if (-not $page.NextLink) { break }
$page = & $Next $page.NextLink
}
if ($all.Count -eq 0) {
return [pscustomobject]@{ Status='Anomaly'; Items=@(); Reason='PagedQueryReturnedZero — investigate before treating as clean.' }
}
[pscustomobject]@{ Status='Clean'; Items=$all; Reason='' }
}
4.5 — Invoke-Fsi34Throttled
function Invoke-Fsi34Throttled {
<#
.SYNOPSIS
Wraps a script block with Retry-After-aware backoff for ARM and Graph throttling.
.NOTES
Control: 3.4. Defect #5 mitigation — never returns null on throttle.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [scriptblock]$ScriptBlock, [int]$MaxAttempts = 3)
for ($i = 1; $i -le $MaxAttempts; $i++) {
try { return & $ScriptBlock }
catch {
$msg = $_.Exception.Message
if ($msg -match '429|throttl|TooManyRequests') {
$delay = [math]::Min(60, [math]::Pow(2, $i))
Start-Sleep -Seconds $delay
continue
}
throw
}
}
throw "Fsi34-ThrottleExhausted: $MaxAttempts attempts exhausted."
}
§5 — Sentinel automation rules and Logic Apps playbook deployers
This section installs the Logic Apps playbooks that wire Sentinel incidents into the FSI regulator-notification clock, the disclosure committee, and the cross-control suspension workflow (Control 1.11 workload-identity policy). Each deployer creates the Logic App, the API connection objects, and the Sentinel automation rule that fires it.
All deployers are mutation helpers. They require Mode=Mutate on the session, a CAB-approved ChangeTicketId, and Logic Apps Contributor + Sentinel Contributor role on the workspace resource group, activated via PIM with an 8-hour ceiling. Every deployer uses [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')] and writes a before-deploy snapshot to disk per the baseline §4 mutation pattern.
5.1 — New-Fsi-SentinelPlaybook-NotifyCISO-CCO-GC
Triggered on Sev=Critical incidents. Posts an Adaptive Card to the #ir-critical Teams channel, sends an email to the CISO + CCO + GC distribution group, and opens an ITSM ticket in the connected ServiceNow / Jira instance.
function New-Fsi-SentinelPlaybook-NotifyCISO-CCO-GC {
<#
.SYNOPSIS
Deploys the Critical-severity notification Logic App and its Sentinel automation rule.
.PARAMETER ResourceGroup, Workspace, Location
Target Sentinel workspace.
.PARAMETER PlaybookName
Logic App name; default 'la-fsi34-notify-ciso-cco-gc'.
.PARAMETER ApprovedDistributionGroup
UPN of the mail-enabled security group containing CISO + CCO + GC.
.PARAMETER TeamsChannelId
Target Teams channel for the Adaptive Card.
.NOTES
Control: 3.4. Last UI verified: April 2026. Mutation helper.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$ApprovedDistributionGroup,
[Parameter(Mandatory)] [string]$TeamsChannelId,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-notify-ciso-cco-gc'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-NotifyCISO-CCO-GC' -ChangeTicketId $ChangeTicketId
$definition = @{
'$schema' = 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#'
contentVersion = '1.0.0.0'
triggers = @{
When_a_response_to_an_Azure_Sentinel_alert_is_triggered = @{
type = 'ApiConnectionWebhook'
inputs = @{
body = @{ callback_url = "@listCallbackUrl()" }
host = @{ connection = @{ name = "@parameters('`$connections')['azuresentinel']['connectionId']" } }
path = '/subscribe'
}
}
}
actions = @{
Post_AdaptiveCard_To_Teams = @{
type = 'ApiConnection'
inputs = @{
body = @{ messageBody = "Critical AI-agent incident determined. Severity: @{triggerBody()?['SeverityName']}. Title: @{triggerBody()?['Title']}." }
host = @{ connection = @{ name = "@parameters('`$connections')['teams']['connectionId']" } }
method = 'post'
path = "/v3/beta/teams/$TeamsChannelId/channels/conversation/posts"
}
}
Send_Email_To_CISO_CCO_GC = @{
runAfter = @{ Post_AdaptiveCard_To_Teams = @('Succeeded') }
type = 'ApiConnection'
inputs = @{
body = @{
To = $ApprovedDistributionGroup
Subject = "[FSI 3.4] Critical incident @{triggerBody()?['IncidentNumber']} requires CISO/CCO/GC review"
Body = "Incident: @{triggerBody()?['IncidentUrl']}"
}
host = @{ connection = @{ name = "@parameters('`$connections')['office365']['connectionId']" } }
method = 'post'
path = '/v2/Mail'
}
}
}
}
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy Logic App in $ResourceGroup")) {
$defJson = $definition | ConvertTo-Json -Depth 20
$tmp = New-TemporaryFile
$defJson | Set-Content -Path $tmp -Encoding UTF8
$la = New-AzLogicApp -ResourceGroupName $ResourceGroup -Name $PlaybookName -Location $Location -DefinitionFilePath $tmp -ErrorAction Stop
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
[pscustomobject]@{
Status = 'Clean'
PlaybookName = $PlaybookName
Meta = $meta
Reason = ''
}
}
5.2 — New-Fsi-SentinelPlaybook-SuspendAgent
Suspends a Power Platform AI agent via service principal. Cross-reference Control 1.11 — the workload identity used by this Logic App must be enrolled in the Conditional Access policy that requires phishing-resistant MFA on the human approver before the suspension is finalized. The Logic App calls into a 5.1 sidecar via an Automation Account hybrid worker because Microsoft.PowerApps.Administration.PowerShell is Desktop-only.
function New-Fsi-SentinelPlaybook-SuspendAgent {
<#
.SYNOPSIS
Deploys a Logic App that suspends a named Power Platform agent on incident determination.
.NOTES
Control: 3.4. Cross-reference Control 1.11 for the workload-identity Conditional Access policy
that protects the suspension service principal. The Logic App invokes Set-AdminPowerAppEnvironment
(Desktop module) via a 5.1 sidecar through an Azure Automation hybrid worker.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$AutomationAccountName,
[Parameter(Mandatory)] [string]$HybridWorkerGroup,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-suspend-agent'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-SuspendAgent' -ChangeTicketId $ChangeTicketId
# Definition omitted for brevity; key actions:
# 1. Require_Human_Approval (Office 365 Outlook approval action) — gates on phishing-resistant MFA via CA policy 1.11
# 2. Invoke_Hybrid_Worker_Runbook (Automation Account) — runs Suspend-AgentRunbook.ps1 in 5.1 Desktop edition
# 3. Comment_On_Sentinel_Incident — appends suspension manifest hash to the incident timeline
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy suspension Logic App with hybrid worker $HybridWorkerGroup")) {
# Deployment call elided; production template lives at solutions/incident-response-playbook-pack/la-suspend-agent.json
}
[pscustomobject]@{
Status = 'Clean'
PlaybookName = $PlaybookName
HybridWorker = $HybridWorkerGroup
Meta = $meta
Reason = ''
}
}
5.3 — New-Fsi-SentinelPlaybook-NYDFS72hTimer
Tags the Sentinel incident with nydfs72:deadline=<UTC ISO-8601>, schedules reminder emails to the CISO and General Counsel at T+24h, T+48h, and T+60h, and opens a notification ticket in the NYDFS Cybersecurity Portal queue. Per 23 NYCRR 500.17(a), the trigger is the determination that a cybersecurity event has occurred — DeterminationTimestamp from §3.
function New-Fsi-SentinelPlaybook-NYDFS72hTimer {
<#
.SYNOPSIS
Deploys the NYDFS 23 NYCRR 500.17(a) 72-hour notification timer Logic App.
.NOTES
Control: 3.4. The 72-hour clock starts at the incident DETERMINATION timestamp, not at first
detection. The CCO + GC are responsible for the actual portal filing — this helper only
schedules reminders and opens the workflow ticket.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-nydfs72-timer'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-NYDFS72hTimer' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy NYDFS 72h timer Logic App")) {
# Definition: scheduled-recurrence + Sentinel incident tag + email actions
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; Meta=$meta; Reason='' }
}
5.4 — New-Fsi-SentinelPlaybook-NYDFS24hRansomTimer
OFAC sanctions screening before any extortion payment
NYDFS 23 NYCRR 500.17(c) requires notification to the Department within 24 hours of any extortion payment made in connection with a cybersecurity event. Before any payment is authorized through this workflow, the operator and the General Counsel must complete an OFAC Specially Designated Nationals (SDN) and Blocked Persons screening of the payee and the demand counterparty. Treasury OFAC has issued explicit guidance (most recently in OFAC's October 2020 advisory and its September 2021 updated advisory on potential sanctions risks for facilitating ransomware payments) that companies and any third parties facilitating ransomware payments may be liable for sanctions violations under the International Emergency Economic Powers Act and the Trading With the Enemy Act.
This automation does not perform OFAC screening. The Logic App below opens a terminating sanctions checkpoint that blocks the ransom-payment notification workflow until the General Counsel signs an OFAC-screening attestation through the Approval action. The attestation captures: (a) the sanctions list version date, (b) the screened payee identifiers, (c) the screening tool and the screening result, and (d) counsel's signature. No payment-authorization downstream action runs without the attestation. Operators must consult counsel and, where indicated, the Office of Foreign Assets Control directly before any payment is contemplated.
function New-Fsi-SentinelPlaybook-NYDFS24hRansomTimer {
<#
.SYNOPSIS
Deploys the NYDFS 24-hour extortion-payment notification timer with the OFAC sanctions checkpoint.
.NOTES
Control: 3.4. The Logic App refuses to advance past the OFAC checkpoint without GC's signed
attestation. See the !!! danger admonition immediately above this function.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$GeneralCounselUPN,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-nydfs24-ransom-timer'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-NYDFS24hRansomTimer' -ChangeTicketId $ChangeTicketId
# Definition (key actions):
# 1. OFAC_Sanctions_Checkpoint = Approval action assigned to $GeneralCounselUPN.
# Body must include: { sanctions_list_date, payee_identifier, screening_tool, screening_result, counsel_signature }.
# The action emits Status=Anomaly with Reason='OFAC-CheckpointBlocked' if response is "Reject".
# 2. Notify_NYDFS = runs only if checkpoint Approved; opens ticket in CCO portal queue.
# 3. Append_OFAC_Manifest_To_Sentinel = comments the sanctions-screening manifest hash on the incident.
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy NYDFS 24h ransom timer with OFAC checkpoint")) {
# Deployment elided; template at solutions/incident-response-playbook-pack/la-nydfs24-ransom.json
}
[pscustomobject]@{
Status = 'Clean'
PlaybookName = $PlaybookName
OFACCheckpoint = 'Enabled'
Meta = $meta
Reason = ''
}
}
5.5 — New-Fsi-SentinelPlaybook-SEC8K-MaterialityCheck
For SEC-registered issuers, Item 1.05 of Form 8-K requires disclosure within four business days of determining a cybersecurity incident is material. The materiality determination itself is the responsibility of the Disclosure Committee under counsel's advice — this helper opens the meeting request, attaches the structured incident payload, and starts the four-business-day clock; it does not make a materiality determination.
function New-Fsi-SentinelPlaybook-SEC8K-MaterialityCheck {
<#
.SYNOPSIS
Deploys the SEC 8-K Item 1.05 materiality-check workflow.
.NOTES
Control: 3.4. Materiality determination is the Disclosure Committee's responsibility under
counsel's advice. This helper opens the meeting; it does not determine materiality.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$DisclosureCommitteeMailbox,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-sec8k-materiality'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-SEC8K-MaterialityCheck' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy SEC 8-K Item 1.05 materiality-check Logic App")) {
# Definition opens calendar meeting + attaches incident bundle reference.
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; Meta=$meta; Reason='' }
}
5.6 — New-Fsi-SentinelPlaybook-Bank36hTimer
Federal banking 36-hour rule — 12 CFR Part 53 (OCC) for national banks, 12 CFR Part 225 (FRB) for bank holding companies, 12 CFR Part 304 (FDIC) for state nonmember banks — requires a notification incident to be reported to the primary federal regulator no later than 36 hours after the banking organization determines that a notification incident has occurred. Credit unions follow the analogous NCUA 72-hour rule (12 CFR Part 748).
function New-Fsi-SentinelPlaybook-Bank36hTimer {
<#
.SYNOPSIS
Deploys the federal banking 36-hour notification timer Logic App.
.NOTES
Control: 3.4. Routes to the configured regulator per institution charter:
OCC (national banks), FRB (BHCs), FDIC (state nonmember banks). NCUA 72h variant
is provisioned by the same helper with -RegulatorPath NCUA.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [ValidateSet('OCC','FRB','FDIC','NCUA')] [string]$RegulatorPath,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName
)
if (-not $PlaybookName) { $PlaybookName = "la-fsi34-bank36-timer-$($RegulatorPath.ToLower())" }
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-Bank36hTimer' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy 36h banking timer for regulator $RegulatorPath")) {
# Deployment elided; template at solutions/incident-response-playbook-pack/la-bank36.json
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; Regulator=$RegulatorPath; Meta=$meta; Reason='' }
}
5.7 — New-Fsi-SentinelPlaybook-RegSP30dCustomerNotice
The 2024 amendments to Regulation S-P (17 CFR Part 248) impose a 30-day customer notification requirement on covered institutions for unauthorized access to or use of sensitive customer information, plus a 72-hour notification to the SEC for security-program breaches at covered institutions. This helper opens a Privacy Officer ticket, attaches the affected-customer scope from the SharePoint list, and schedules the 30-day deadline.
function New-Fsi-SentinelPlaybook-RegSP30dCustomerNotice {
<#
.SYNOPSIS
Deploys the Regulation S-P 30-day customer-notice timer Logic App.
.NOTES
Control: 3.4. Reg S-P 2024 amendments. The Privacy Officer is responsible for the customer
communication; this helper opens the workflow and tracks the deadline.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$PrivacyOfficerUPN,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-regsp-30d-customer-notice'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-RegSP30dCustomerNotice' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy Reg S-P 30d customer-notice timer")) {
# Deployment elided.
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; PrivacyOfficer=$PrivacyOfficerUPN; Meta=$meta; Reason='' }
}
5.8 — New-Fsi-SentinelPlaybook-FINRA4530aTicket
FINRA Rule 4530(a) requires member firms to report specified events (including certain customer complaints, regulatory actions, and certain internal investigations) to FINRA within 30 calendar days. FINRA Rule 4530(d) imposes an additional quarterly statistical reporting obligation. This helper opens the 4530(a) ticket — the member firm's FINRA Compliance Officer is responsible for the actual filing.
function New-Fsi-SentinelPlaybook-FINRA4530aTicket {
<#
.SYNOPSIS
Deploys the FINRA Rule 4530(a) ticketing Logic App.
.NOTES
Control: 3.4. The FINRA Compliance Officer files the actual report — this helper opens the
workflow ticket and attaches the structured incident payload.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$FinraComplianceOfficerUPN,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-finra4530a-ticket'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-FINRA4530aTicket' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy FINRA 4530(a) ticket Logic App")) {
# Deployment elided.
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; ComplianceOfficer=$FinraComplianceOfficerUPN; Meta=$meta; Reason='' }
}
5.9 — New-Fsi-SentinelPlaybook-StateBreachLawTriage
Each US state has its own breach-notification statute with distinct definitions of "personal information," distinct trigger thresholds, distinct notification timelines (ranging from "without unreasonable delay" to fixed 30 / 45 / 60 / 90-day deadlines depending on the state), and distinct attorney-general / consumer-protection-bureau filing requirements. This helper joins the incident's AffectedStateResidencies against an internally maintained per-state ruleset reference and opens one ticket per state.
Defect #14 mitigation: The helper joins on all residency attributes — billing address, mailing address, account-of-record state, and any explicit residency tag — and emits a per-state reconciliation manifest so the Privacy Officer can confirm no state was missed.
function New-Fsi-SentinelPlaybook-StateBreachLawTriage {
<#
.SYNOPSIS
Deploys the per-state breach-notification triage Logic App.
.NOTES
Control: 3.4. Per-state ruleset is maintained outside this playbook in
solutions/state-breach-rulesets/state-rulesets-v2026q2.json. Privacy counsel must keep that
ruleset current; this helper does not interpret state law.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$ResourceGroup,
[Parameter(Mandatory)] [string]$Workspace,
[Parameter(Mandatory)] [string]$Location,
[Parameter(Mandatory)] [string]$StateRulesetUri,
[Parameter(Mandatory)] [string]$PrivacyOfficerUPN,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$PlaybookName = 'la-fsi34-state-breach-triage'
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; PlaybookName=$PlaybookName; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-SentinelPlaybook-StateBreachLawTriage' -ChangeTicketId $ChangeTicketId
if ($PSCmdlet.ShouldProcess($PlaybookName, "Deploy per-state breach triage Logic App with ruleset $StateRulesetUri")) {
# Deployment elided.
}
[pscustomobject]@{ Status='Clean'; PlaybookName=$PlaybookName; StateRuleset=$StateRulesetUri; Meta=$meta; Reason='' }
}
5.10 — Sentinel automation rule binding
After deploying a Logic App, you must bind it to an automation rule that fires it on the trigger event. The orchestrator in §13 calls New-AzSentinelAutomationRule with the Logic App's resource ID and the appropriate triggersOn / triggersWhen / conditions payload. Default condition templates ship with the FSI-AgentGov Sentinel solution pack and are referenced by solutionPackVersion in the orchestrator output for traceability.
§6 — Insider Risk Management case helpers
Purview Insider Risk Management cases (cross-reference Control 1.12) are a parallel detection plane to Defender XDR / Sentinel — they originate from policy matches against user activity rather than from network/endpoint signals — and they often produce the first indication that an AI agent has been misused by an internal actor (prompt-injection exfiltration, hallucinated-customer-comm exposure, runaway-agent exfil-on-stale-context). The IRM beta surface in Microsoft Graph exposes case metadata; the case-evidence helper extracts the case content and emits it to the same SHA-256 manifest format §4 produces.
6.1 — Get-Fsi-IRMCase
function Get-Fsi-IRMCase {
<#
.SYNOPSIS
Returns the structured Purview Insider Risk Management case payload via Graph beta.
.PARAMETER CaseId
The IRM case GUID.
.OUTPUTS
[pscustomobject] @{ Status; Case; Alerts; Reason }
.NOTES
Control: 3.4 (cross-reference Control 1.12). Last UI verified: April 2026. Beta surface —
helper returns NotApplicable in sovereign tenants where IRM Graph beta is not yet GA.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$CaseId,
[Parameter(Mandatory)] $SessionContext
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Get-Fsi-IRMCase'
if ($SessionContext.Cloud -in @('GCCHigh','DoD')) {
return [pscustomobject]@{ Status='NotApplicable'; Case=$null; Alerts=@(); Meta=$meta; Reason='SovereignParityUnavailable: IRM Graph beta not yet GA in this cloud.' }
}
if (-not $SessionContext.PreviewDecision.AllowPreviewSurfaces) {
return [pscustomobject]@{ Status='NotApplicable'; Case=$null; Alerts=@(); Meta=$meta; Reason='PreviewSurfaceNotEnabled: pass -AllowPreviewSurfaces on Initialize-Fsi34Session.' }
}
try {
$uri = "https://graph.microsoft.com/beta/security/cases/insiderRiskCases/$CaseId"
$case = Invoke-Fsi34Throttled -ScriptBlock { Invoke-MgGraphRequest -Method GET -Uri $using:uri }
$alertsUri = "$uri/alerts"
$alerts = Invoke-Fsi34Throttled -ScriptBlock { Invoke-MgGraphRequest -Method GET -Uri $using:alertsUri }
return [pscustomobject]@{
Status = 'Clean'
Case = $case
Alerts = $alerts.value
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; Case=$null; Alerts=@(); Meta=$meta; Reason="IRMCaseReadException: $($_.Exception.Message)" }
}
}
6.2 — Export-Fsi-IRMCaseEvidence
function Export-Fsi-IRMCaseEvidence {
<#
.SYNOPSIS
Exports IRM case content to a SHA-256-manifested local bundle.
.NOTES
Control: 3.4 (cross-reference Control 1.12). Last UI verified: April 2026.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='Low')]
param(
[Parameter(Mandatory)] [string]$CaseId,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] $SessionContext
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Export-Fsi-IRMCaseEvidence'
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$case = Get-Fsi-IRMCase -CaseId $CaseId -SessionContext $SessionContext
if ($case.Status -ne 'Clean') {
return [pscustomobject]@{ Status=$case.Status; OutputDir=$OutputDir; ManifestPath=''; ArtifactCount=0; Meta=$meta; Reason=$case.Reason }
}
$ts = Get-Date -Format 'yyyyMMddTHHmmssZ'
$jsonPath = Join-Path $OutputDir "irm-case-$CaseId-$ts.json"
$case | ConvertTo-Json -Depth 30 | Set-Content -Path $jsonPath -Encoding UTF8
$hash = (Get-FileHash -Path $jsonPath -Algorithm SHA256).Hash
$manifest = @{ meta=$meta; artifacts=@(@{ file=(Split-Path $jsonPath -Leaf); sha256=$hash; bytes=(Get-Item $jsonPath).Length; source='Graph/beta/insiderRiskCases'; generated_utc=$ts }) }
$manifestPath = Join-Path $OutputDir 'manifest.json'
$manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding UTF8
[pscustomobject]@{ Status='Clean'; OutputDir=$OutputDir; ManifestPath=$manifestPath; ArtifactCount=1; Meta=$meta; Reason='' }
}
§7 — Legal hold helpers (Purview eDiscovery Premium)
Legal hold is a counsel-driven activity. The helpers below provision the eDiscovery case and record the hold acknowledgments, but they do not replace counsel's hold memo, scope decisions, custodian-list maintenance, or ongoing supervisory obligations under FRCP 37(e). Cross-reference the broader retention pattern in Control 1.9.
7.1 — New-Fsi-LegalHold
function New-Fsi-LegalHold {
<#
.SYNOPSIS
Provisions a Purview eDiscovery Premium case and applies a hold to the named custodians and locations.
.PARAMETER CaseName
Case display name; recommended convention: "FSI34-INC-YYYY-MMDD-NNN".
.PARAMETER Custodians
Array of custodian UPNs.
.PARAMETER SourceLocations
Array of source-location URIs (mailbox SMTP, SharePoint site URL, OneDrive URL, Teams channel ID).
.NOTES
Control: 3.4. Mutation helper. Counsel approval is a prerequisite — the helper records the
counsel approval reference but does not validate it.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$CaseName,
[Parameter(Mandatory)] [string[]]$Custodians,
[Parameter(Mandatory)] [string[]]$SourceLocations,
[Parameter(Mandatory)] [string]$CounselApprovalRef,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId
)
if ($SessionContext.Mode -ne 'LegalHold') {
return [pscustomobject]@{ Status='Error'; CaseId=''; Reason='WrongMode: New-Fsi-LegalHold requires Mode=LegalHold.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'New-Fsi-LegalHold' -ChangeTicketId $ChangeTicketId
try {
$availability = Get-Fsi34CmdletAvailability -CmdletName @('New-MgSecurityCaseEdiscoveryCase','New-MgSecurityCaseEdiscoveryCaseCustodian','New-MgSecurityCaseEdiscoveryCaseLegalHold')
if ($availability.Status -ne 'Clean') {
return [pscustomobject]@{ Status='NotApplicable'; CaseId=''; Meta=$meta; Reason=$availability.Reason }
}
if ($PSCmdlet.ShouldProcess($CaseName, "Provision eDiscovery Premium case + hold on $($Custodians.Count) custodians and $($SourceLocations.Count) locations")) {
$case = New-MgSecurityCaseEdiscoveryCase -DisplayName $CaseName -Description "FSI 3.4 incident hold; counsel approval $CounselApprovalRef"
foreach ($u in $Custodians) {
New-MgSecurityCaseEdiscoveryCaseCustodian -EdiscoveryCaseId $case.Id -Email $u | Out-Null
}
$hold = New-MgSecurityCaseEdiscoveryCaseLegalHold -EdiscoveryCaseId $case.Id -DisplayName "$CaseName-Hold" -Description 'FSI 3.4 preservation hold' -BodyParameter @{ contentQuery=''; isEnabled=$true }
}
return [pscustomobject]@{
Status = 'Clean'
CaseId = $case.Id
HoldId = $hold.Id
CustodianCount = $Custodians.Count
LocationCount = $SourceLocations.Count
CounselApproval = $CounselApprovalRef
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; CaseId=''; Meta=$meta; Reason="LegalHoldProvisioningException: $($_.Exception.Message)" }
}
}
7.2 — Send-Fsi-LegalHoldNotice
Records the hold-notice issuance and the custodian acknowledgment events. The acknowledgments themselves are captured by the eDiscovery Premium communications feature — this helper polls the communications API and emits a structured acknowledgment manifest. The Purview UI is the source of truth; this helper produces the audit-defensible export.
function Send-Fsi-LegalHoldNotice {
<#
.SYNOPSIS
Sends a legal-hold notice and records custodian acknowledgments.
.NOTES
Control: 3.4. Mutation helper. The Purview UI is the source of truth; this helper emits the
structured export for evidence packs.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$CaseId,
[Parameter(Mandatory)] [string]$NoticeBodyMarkdown,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId
)
if ($SessionContext.Mode -ne 'LegalHold') {
return [pscustomobject]@{ Status='Error'; NoticeId=''; Reason='WrongMode: Send-Fsi-LegalHoldNotice requires Mode=LegalHold.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Send-Fsi-LegalHoldNotice' -ChangeTicketId $ChangeTicketId
try {
if ($PSCmdlet.ShouldProcess($CaseId, "Issue legal-hold notice")) {
$uri = "https://graph.microsoft.com/v1.0/security/cases/ediscoveryCases/$CaseId/noncustodialDataSources"
# Communication issuance is performed via Purview eDiscovery Premium UI or Graph beta noncustodial flow;
# this helper records the issuance event and polls for acknowledgments.
$noticeId = [guid]::NewGuid().ToString()
}
return [pscustomobject]@{
Status = 'Clean'
NoticeId = $noticeId
CaseId = $CaseId
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; NoticeId=''; Meta=$meta; Reason="LegalHoldNoticeException: $($_.Exception.Message)" }
}
}
§8 — Service Health correlation (Get-Fsi-M365ServiceHealthIncident)
For every Sev-Critical or Sev-High incident, the RCA author must consult Microsoft 365 Service Health for the incident interval before naming an internal owner as root cause (defect #12). A Microsoft-side outage on Copilot, Graph, Exchange Online, SharePoint, or the Power Platform commonly presents as an "agent malfunction" inside the tenant; closing such an incident as an internal-owner fault produces incorrect RCA records, misallocates corrective actions, and risks regulator confusion when the same outage appears in third-party post-mortem reports.
function Get-Fsi-M365ServiceHealthIncident {
<#
.SYNOPSIS
Returns Microsoft 365 Service Health incidents that overlap the provided window.
.PARAMETER SinceHours
Look-back window in hours from now (default 24).
.OUTPUTS
[pscustomobject] @{ Status; Incidents; Reason }
.NOTES
Control: 3.4. Required pre-RCA step for Sev-Critical/High incidents per defect #12.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[int]$SinceHours = 24
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Get-Fsi-M365ServiceHealthIncident'
try {
$sinceUtc = (Get-Date).ToUniversalTime().AddHours(-$SinceHours)
$uri = "https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/issues?`$filter=startDateTime ge $($sinceUtc.ToString('o'))"
$resp = Invoke-Fsi34Throttled -ScriptBlock { Invoke-MgGraphRequest -Method GET -Uri $using:uri }
$incidents = @()
if ($resp.value) {
$incidents = $resp.value | ForEach-Object {
[pscustomobject]@{
IssueId = $_.id
Service = $_.service
Title = $_.title
Classification = $_.classification
Status = $_.status
StartUtc = $_.startDateTime
EndUtc = $_.endDateTime
LastModified = $_.lastModifiedDateTime
DetailUrl = $_.details
}
}
}
return [pscustomobject]@{
Status = 'Clean'
Incidents = $incidents
WindowStartUtc = $sinceUtc.ToString('o')
WindowEndUtc = (Get-Date).ToUniversalTime().ToString('o')
Meta = $meta
Reason = ''
}
} catch {
return [pscustomobject]@{ Status='Error'; Incidents=@(); Meta=$meta; Reason="ServiceHealthReadException: $($_.Exception.Message)" }
}
}
§9 — Per-regulator timer engine
The timer engine is the heart of Control 3.4. Every regulator-notification deadline is a state-machine: it starts at a determination event, schedules reminders to a named owner, may pause for an OFAC checkpoint or other gating condition (in the case of NYDFS24), and stops with a closure record that captures the filing reference and a SHA-256 hash of the regulator-portal acknowledgment receipt.
The timers managed by this engine are listed below. The hour values are the helper's defaults derived from the cited regulation; operators must verify the current rule text and any institution-specific commitments in their Written Information Security Program (WISP) before relying on them.
| Timer | Hours | Cited regulation | Owner |
|---|---|---|---|
NYDFS72 |
72 | 23 NYCRR 500.17(a) — notification within 72h of determination | CCO + GC |
NYDFS24 |
24 | 23 NYCRR 500.17(c) — notification within 24h of extortion payment (with OFAC checkpoint, see §5.4) | GC + Treasury |
SEC8K |
~96 (4 business days) | SEC Form 8-K Item 1.05 — disclosure within 4 business days of materiality determination | Disclosure Committee + GC |
Bank36 |
36 | 12 CFR Part 53 (OCC) / 225 (FRB) / 304 (FDIC) — federal banking notification incident | CCO + Bank Regulator Liaison |
RegSP30 |
720 (30 days) | Reg S-P 2024 amendments (17 CFR Part 248) — customer notification within 30 days | Privacy Officer |
RegSP72SP |
72 | Reg S-P 2024 — security-program breach notification to SEC within 72h | Privacy Officer + GC |
FTC30 |
720 (30 days) | FTC Safeguards Rule (16 CFR Part 314) — notification of qualifying events | CCO |
FINRA4530A |
720 (30 days) | FINRA Rule 4530(a) | FINRA Compliance Officer |
FINRA4530D |
Quarterly | FINRA Rule 4530(d) statistical reporting | FINRA Compliance Officer |
CISA72 |
72 | CISA CIRCIA (proposed/horizon — verify current effective date) | CISO + GC |
State |
per-state | State breach-notification statutes — verify per-state ruleset | Privacy Officer |
NCUA72 |
72 | 12 CFR Part 748 (NCUA) | CCO (credit unions) |
9.1 — Start-Fsi-RegulatorTimer
function Start-Fsi-RegulatorTimer {
<#
.SYNOPSIS
Starts a regulator-notification timer for an incident.
.PARAMETER IncidentId
The IncidentId from the SharePoint AI Agent Incidents list.
.PARAMETER Timer
The timer identifier (NYDFS72 | NYDFS24 | SEC8K | Bank36 | RegSP30 | RegSP72SP | FTC30 | FINRA4530A | FINRA4530D | CISA72 | State | NCUA72).
.PARAMETER DeterminationTimestamp
UTC timestamp anchoring the clock. For most timers this is the moment of determination, not first detection.
.PARAMETER Owner
UPN of the named officer responsible for the filing.
.OUTPUTS
[pscustomobject] @{ Status; TimerId; DeadlineUtc; Reason }
.NOTES
Control: 3.4. Mutation helper. The clock anchors are documented in the regulator-citation table
in §9. The Logic Apps playbooks deployed in §5 are the runtime executors; this helper records
the timer state in the SharePoint list for cross-tooling visibility.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$IncidentId,
[Parameter(Mandatory)]
[ValidateSet('NYDFS72','NYDFS24','SEC8K','Bank36','RegSP30','RegSP72SP','FTC30','FINRA4530A','FINRA4530D','CISA72','State','NCUA72')]
[string]$Timer,
[Parameter(Mandatory)] [datetime]$DeterminationTimestamp,
[Parameter(Mandatory)] [string]$Owner,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[string]$SiteUrl
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; TimerId=''; DeadlineUtc=''; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Start-Fsi-RegulatorTimer' -ChangeTicketId $ChangeTicketId
$hours = @{
'NYDFS72' = 72; 'NYDFS24' = 24
'SEC8K' = 96; 'Bank36' = 36
'RegSP30' = 720; 'RegSP72SP' = 72
'FTC30' = 720; 'FINRA4530A'= 720
'FINRA4530D' = $null # quarterly cycle, calculated separately
'CISA72' = 72; 'State' = $null # per-state lookup
'NCUA72' = 72
}[$Timer]
$deadline = if ($null -ne $hours) { $DeterminationTimestamp.ToUniversalTime().AddHours($hours) } else { $null }
$timerId = "TIMER-$IncidentId-$Timer-$([guid]::NewGuid().ToString().Substring(0,8))"
if ($PSCmdlet.ShouldProcess($IncidentId, "Start $Timer timer; deadline $($deadline)")) {
if ($SiteUrl) {
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$items = Get-PnPListItem -List 'AI Agent Incidents' -Query "<View><Query><Where><Eq><FieldRef Name='IncidentId'/><Value Type='Text'>$IncidentId</Value></Eq></Where></Query></View>"
if ($items.Count -eq 1) {
$existing = $items[0]['ApplicableTimers']
$newTimers = if ($existing) { @($existing) + $Timer | Select-Object -Unique } else { @($Timer) }
Set-PnPListItem -List 'AI Agent Incidents' -Identity $items[0].Id -Values @{
ApplicableTimers = $newTimers
TimerOwners = @($Owner)
} | Out-Null
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
}
[pscustomobject]@{
Status = 'Clean'
TimerId = $timerId
Timer = $Timer
DeterminationUtc = $DeterminationTimestamp.ToUniversalTime().ToString('o')
DeadlineUtc = if ($deadline) { $deadline.ToString('o') } else { 'CalculatedAtRuntime' }
Owner = $Owner
IncidentId = $IncidentId
Meta = $meta
Reason = ''
}
}
9.2 — Get-Fsi-OpenRegulatorTimers
function Get-Fsi-OpenRegulatorTimers {
<#
.SYNOPSIS
Returns every open regulator-notification timer across all incidents with hours-remaining and escalation status.
.NOTES
Control: 3.4. Read-only. Drives the §11.1 matrix-coverage verification.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Get-Fsi-OpenRegulatorTimers'
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$items = Get-PnPListItem -List 'AI Agent Incidents' -PageSize 500 -Query "<View><Query><Where><Neq><FieldRef Name='RCAStatus'/><Value Type='Choice'>Filed</Value></Neq></Where></Query></View>"
$now = (Get-Date).ToUniversalTime()
$timers = foreach ($i in $items) {
$applicable = $i['ApplicableTimers']
if (-not $applicable) { continue }
foreach ($t in $applicable) {
$det = [datetime]$i['DeterminationTimestamp']
$hours = @{ NYDFS72=72; NYDFS24=24; SEC8K=96; Bank36=36; RegSP30=720; RegSP72SP=72; FTC30=720; FINRA4530A=720; CISA72=72; NCUA72=72 }[$t]
if (-not $hours) { continue }
$deadline = $det.ToUniversalTime().AddHours($hours)
$remaining = ($deadline - $now).TotalHours
$escalation = if ($remaining -lt 0) { 'BREACHED' }
elseif ($remaining -lt 4) { 'CRITICAL-LT4H' }
elseif ($remaining -lt 12) { 'WARNING-LT12H' }
else { 'GREEN' }
[pscustomobject]@{
IncidentId = $i['IncidentId']
Timer = $t
DeadlineUtc = $deadline.ToString('o')
HoursRemaining = [math]::Round($remaining, 2)
EscalationLevel = $escalation
Owner = ($i['TimerOwners'] | Select-Object -First 1)
}
}
}
$hasBreach = $timers | Where-Object { $_.EscalationLevel -eq 'BREACHED' }
return [pscustomobject]@{
Status = if ($hasBreach) { 'Anomaly' } else { 'Clean' }
Timers = $timers
Meta = $meta
Reason = if ($hasBreach) { "BreachedTimers: $($hasBreach.Count)" } else { '' }
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
9.3 — Stop-Fsi-RegulatorTimer
function Stop-Fsi-RegulatorTimer {
<#
.SYNOPSIS
Closes a regulator-notification timer with a filing reference and a portal-acknowledgment hash.
.PARAMETER ClosureRecord
Hashtable with required keys: filing_reference, portal_acknowledgment_pdf_path, filed_by_upn, filed_at_utc.
.NOTES
Control: 3.4. Mutation helper. The portal acknowledgment PDF is hashed and stored alongside
the SharePoint item; the helper does not validate the filing's substantive accuracy.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [string]$IncidentId,
[Parameter(Mandatory)] [string]$Timer,
[Parameter(Mandatory)] [hashtable]$ClosureRecord,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$ChangeTicketId,
[Parameter(Mandatory)] [string]$SiteUrl
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; Reason='WrongMode: Mutate required.' }
}
foreach ($k in @('filing_reference','portal_acknowledgment_pdf_path','filed_by_upn','filed_at_utc')) {
if (-not $ClosureRecord.ContainsKey($k)) {
return [pscustomobject]@{ Status='Error'; Reason="ClosureRecordMissingKey: $k" }
}
}
if (-not (Test-Path $ClosureRecord.portal_acknowledgment_pdf_path)) {
return [pscustomobject]@{ Status='Error'; Reason="AcknowledgmentPdfNotFound: $($ClosureRecord.portal_acknowledgment_pdf_path)" }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Stop-Fsi-RegulatorTimer' -ChangeTicketId $ChangeTicketId
$ackHash = (Get-FileHash -Path $ClosureRecord.portal_acknowledgment_pdf_path -Algorithm SHA256).Hash
if ($PSCmdlet.ShouldProcess($IncidentId, "Close $Timer timer with filing $($ClosureRecord.filing_reference)")) {
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
# Append closure manifest entry to the EvidencePointers field.
$items = Get-PnPListItem -List 'AI Agent Incidents' -Query "<View><Query><Where><Eq><FieldRef Name='IncidentId'/><Value Type='Text'>$IncidentId</Value></Eq></Where></Query></View>"
if ($items.Count -eq 1) {
$entry = "TIMER_CLOSED|$Timer|$($ClosureRecord.filing_reference)|sha256:$ackHash|$($ClosureRecord.filed_at_utc)|$($ClosureRecord.filed_by_upn)"
$existing = $items[0]['EvidencePointers']
$merged = if ($existing) { "$existing`n$entry" } else { $entry }
Set-PnPListItem -List 'AI Agent Incidents' -Identity $items[0].Id -Values @{ EvidencePointers = $merged } | Out-Null
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
[pscustomobject]@{
Status = 'Clean'
Timer = $Timer
IncidentId = $IncidentId
FilingReference = $ClosureRecord.filing_reference
PortalAcknowledgmentSha256 = $ackHash
FiledByUpn = $ClosureRecord.filed_by_upn
FiledAtUtc = $ClosureRecord.filed_at_utc
Meta = $meta
Reason = ''
}
}
§10 — Tabletop exercise harness
Tabletop exercises validate that the §5 automation rules fire, that the §9 timers start, that §6 IRM cases route, and that the named owners actually respond — without polluting production incident metrics. The harness writes synthetic records to a sibling SharePoint list (AI Agent Tabletop Exercises, provisioned in §3) with IsSynthetic = true so quarterly evidence reports (§12) can correctly partition real-vs-synthetic counts (defect #11).
10.1 — Invoke-Fsi-TabletopExercise
function Invoke-Fsi-TabletopExercise {
<#
.SYNOPSIS
Runs a scripted tabletop scenario, writes a synthetic incident record, and emits an after-action manifest.
.PARAMETER Scenario
PromptInjectionExfil | HallucinatedCustomerComm | RunawayAgentStaleContext | OrphanedAgentCascade |
NYDFS24RansomChain | SECMaterialityClose | OFACSanctionsScreening | ServiceHealthMimicAgentFailure
.PARAMETER ParticipantUPNs
UPNs of named tabletop participants (CISO, CCO, GC, AI Governance Lead, IR Lead, Privacy Officer, etc.).
.NOTES
Control: 3.4. Mutation helper (writes to Tabletop list only — never to AI Agent Incidents).
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium')]
param(
[Parameter(Mandatory)]
[ValidateSet('PromptInjectionExfil','HallucinatedCustomerComm','RunawayAgentStaleContext','OrphanedAgentCascade','NYDFS24RansomChain','SECMaterialityClose','OFACSanctionsScreening','ServiceHealthMimicAgentFailure')]
[string]$Scenario,
[Parameter(Mandatory)] [string[]]$ParticipantUPNs,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl,
[Parameter(Mandatory)] [string]$ChangeTicketId
)
if ($SessionContext.Mode -ne 'Mutate') {
return [pscustomobject]@{ Status='Error'; Reason='WrongMode: Mutate required.' }
}
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Invoke-Fsi-TabletopExercise' -ChangeTicketId $ChangeTicketId
$synthId = "TT-$((Get-Date).ToString('yyyyMMdd'))-$Scenario-$([guid]::NewGuid().ToString().Substring(0,6))"
$injects = switch ($Scenario) {
'PromptInjectionExfil' { @('T+0 Detection alert from Defender (synthetic)','T+15m IRM case opened (synthetic)','T+30m CISO notified','T+1h Counsel briefed','T+2h Containment confirmed','T+4h RCA outline drafted') }
'HallucinatedCustomerComm' { @('T+0 Customer-facing message flagged by supervisor','T+10m Compliance review triggered','T+30m CCO determination','T+1h 4530(a) timer evaluation','T+2h Customer remediation outreach drafted') }
'RunawayAgentStaleContext' { @('T+0 Anomalous high-volume task pattern detected','T+15m Agent suspended via §5.2 SuspendAgent playbook','T+30m Owner located','T+1h Stale-context root-cause hypothesis drafted','T+3h Corrective action — context-window guardrail') }
'OrphanedAgentCascade' { @('T+0 Owner-attestation review identifies orphan','T+30m Cascade impact analysis','T+1h Containment','T+4h Lifecycle policy gap reviewed') }
'NYDFS24RansomChain' { @('T+0 Ransomware extortion demand received (synthetic)','T+30m GC notified','T+1h OFAC sanctions screening initiated (see §5.4 danger block)','T+2h Treasury referral evaluated','T+12h NYDFS24 timer status review','T+20h Filing draft sign-off') }
'SECMaterialityClose' { @('T+0 Incident severity escalation','T+30m Disclosure committee convened','T+1h Materiality assessment recorded','T+2h Determination logged with timestamp','T+72h 8-K Item 1.05 readiness review') }
'OFACSanctionsScreening' { @('T+0 Threat-actor wallet identified','T+30m OFAC SDN list check','T+1h Treasury OFAC liaison engaged','T+2h GC-signed sanctions attestation prepared') }
'ServiceHealthMimicAgentFailure' { @('T+0 Symptom resembles agent malfunction','T+15m Service Health correlation per §8','T+30m Microsoft-side outage confirmed','T+1h RCA recorded as external dependency, not internal owner') }
}
if ($PSCmdlet.ShouldProcess($synthId, "Run tabletop scenario $Scenario with $($ParticipantUPNs.Count) participants")) {
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$values = @{
Title = $synthId
Scenario = $Scenario
Participants = $ParticipantUPNs -join ';'
StartedUtc = (Get-Date).ToUniversalTime().ToString('o')
Injects = ($injects -join "`n")
IsSynthetic = $true
AfterActionMd = "After-action draft pending for $synthId"
}
Add-PnPListItem -List 'AI Agent Tabletop Exercises' -Values $values | Out-Null
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
[pscustomobject]@{
Status = 'Clean'
SyntheticId = $synthId
Scenario = $Scenario
Participants = $ParticipantUPNs
InjectsCount = $injects.Count
Meta = $meta
Reason = ''
}
}
Tabletop pollution defect (#11)
Synthetic incidents written to the production AI Agent Incidents list will inflate quarterly counts, trigger real Sentinel automation rules, and produce false regulator-timer starts. The harness writes only to AI Agent Tabletop Exercises and the §11 verification helpers reject any production-list row with Title -like 'TT-*'.
§11 — Verification helpers
The four verification helpers below are the audit-evidence backbone. Each returns Clean only when every sampled item satisfies every acceptance criterion; otherwise the helper returns Anomaly with a Findings[] array enumerating the failing items. Auditors typically sample N=20 closed incidents per quarter; the helpers default to -SampleSize 20 and accept arbitrary larger samples.
11.1 — Test-Fsi-Control34-MatrixCoverage
Confirms that the matrix of (Sev × AIAgentClass) → required notification timers from the portal walkthrough is satisfied: for every closed incident, the timers actually started match the policy matrix.
function Test-Fsi-Control34-MatrixCoverage {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl,
[int]$SampleSize = 20
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Test-Fsi-Control34-MatrixCoverage'
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$matrix = @{
'Sev1|CustomerFacing-Communications' = @('NYDFS72','SEC8K','RegSP30','FINRA4530A')
'Sev1|Trading-Decision-Support' = @('NYDFS72','SEC8K','FINRA4530A','Bank36')
'Sev1|DataQuality-ModelRisk' = @('NYDFS72','SEC8K','Bank36')
'Sev2|CustomerFacing-Communications' = @('FINRA4530A','RegSP30')
'Sev2|Trading-Decision-Support' = @('FINRA4530A')
'Sev2|DataQuality-ModelRisk' = @('NYDFS72')
}
$closed = Get-PnPListItem -List 'AI Agent Incidents' -PageSize 500 -Query "<View><Query><Where><And><Eq><FieldRef Name='RCAStatus'/><Value Type='Choice'>Filed</Value></Eq><BeginsWith><FieldRef Name='IncidentId'/><Value Type='Text'>INC-</Value></BeginsWith></And></Query></View>"
$sample = $closed | Get-Random -Count ([math]::Min($SampleSize, $closed.Count))
$findings = @()
foreach ($i in $sample) {
$key = "$($i['Severity'])|$($i['AIAgentClass'])"
$required = $matrix[$key]
if (-not $required) { continue }
$actual = @($i['ApplicableTimers'])
$missing = $required | Where-Object { $_ -notin $actual }
if ($missing) {
$findings += [pscustomobject]@{ IncidentId=$i['IncidentId']; Key=$key; Missing=($missing -join ',') }
}
}
return [pscustomobject]@{
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
SampleCount = $sample.Count
Findings = $findings
Meta = $meta
Reason = if ($findings.Count) { "$($findings.Count)/$($sample.Count) incidents missing required timers" } else { '' }
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
11.2 — Test-Fsi-Control34-EvidenceChain
Confirms — for each sampled closed incident — that (a) the Defender/Sentinel evidence bundle was archived under retention, (b) the retention label on the incident item is the policy-required value (FSI-IR-Records), (c) the RCA section is present and non-trivial, (d) corrective actions were filed, and (e) three signatures are recorded (CISO + CCO + GC).
function Test-Fsi-Control34-EvidenceChain {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl,
[int]$SampleSize = 20
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Test-Fsi-Control34-EvidenceChain'
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$closed = Get-PnPListItem -List 'AI Agent Incidents' -PageSize 500 -Query "<View><Query><Where><Eq><FieldRef Name='RCAStatus'/><Value Type='Choice'>Filed</Value></Eq></Where></Query></View>"
$sample = $closed | Get-Random -Count ([math]::Min($SampleSize, $closed.Count))
$findings = @()
foreach ($i in $sample) {
$defects = @()
if (-not $i['EvidencePointers']) { $defects += 'NoEvidencePointers' }
if ($i['RetentionLabel'] -ne 'FSI-IR-Records') { $defects += "RetentionLabelMismatch:$($i['RetentionLabel'])" }
if (-not $i['RCANarrative'] -or $i['RCANarrative'].Length -lt 200) { $defects += 'RCANarrativeMissingOrTrivial' }
if (-not $i['CorrectiveActions']) { $defects += 'CorrectiveActionsMissing' }
$sigs = @($i['SignatureCISO'], $i['SignatureCCO'], $i['SignatureGC']) | Where-Object { $_ }
if ($sigs.Count -lt 3) { $defects += "SignatureCountInsufficient:$($sigs.Count)/3" }
if ($defects) {
$findings += [pscustomobject]@{ IncidentId=$i['IncidentId']; Defects=($defects -join ',') }
}
}
return [pscustomobject]@{
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
SampleCount = $sample.Count
Findings = $findings
Meta = $meta
Reason = if ($findings.Count) { "$($findings.Count)/$($sample.Count) incidents missing evidence-chain elements" } else { '' }
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
11.3 — Test-Fsi-Control34-ServiceHealthChecked
Samples Sev-Critical/High closed incidents and confirms that the ServiceHealthCorrelationRef field is populated (per defect #12).
function Test-Fsi-Control34-ServiceHealthChecked {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl,
[int]$SampleSize = 20
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Test-Fsi-Control34-ServiceHealthChecked'
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$closed = Get-PnPListItem -List 'AI Agent Incidents' -PageSize 500 -Query "<View><Query><Where><And><Eq><FieldRef Name='RCAStatus'/><Value Type='Choice'>Filed</Value></Eq><Or><Eq><FieldRef Name='Severity'/><Value Type='Choice'>Sev1</Value></Eq><Eq><FieldRef Name='Severity'/><Value Type='Choice'>Sev2</Value></Eq></Or></And></Query></View>"
$sample = $closed | Get-Random -Count ([math]::Min($SampleSize, $closed.Count))
$findings = @()
foreach ($i in $sample) {
if (-not $i['ServiceHealthCorrelationRef']) {
$findings += [pscustomobject]@{ IncidentId=$i['IncidentId']; Severity=$i['Severity']; Defect='NoServiceHealthCorrelationRecorded' }
}
}
return [pscustomobject]@{
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
SampleCount = $sample.Count
Findings = $findings
Meta = $meta
Reason = if ($findings.Count) { "$($findings.Count)/$($sample.Count) Sev-Critical/High incidents missing Service Health correlation" } else { '' }
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
11.4 — Test-Fsi-Control34-MRMFeedback
For incidents where AIAgentClass = DataQuality-ModelRisk, confirms the MRMTicketRef (model risk management feedback ticket — feeds Control 2.6) is populated.
function Test-Fsi-Control34-MRMFeedback {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl,
[int]$SampleSize = 20
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Test-Fsi-Control34-MRMFeedback'
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
try {
$closed = Get-PnPListItem -List 'AI Agent Incidents' -PageSize 500 -Query "<View><Query><Where><And><Eq><FieldRef Name='RCAStatus'/><Value Type='Choice'>Filed</Value></Eq><Eq><FieldRef Name='AIAgentClass'/><Value Type='Choice'>DataQuality-ModelRisk</Value></Eq></And></Query></View>"
$sample = $closed | Get-Random -Count ([math]::Min($SampleSize, $closed.Count))
$findings = @()
foreach ($i in $sample) {
if (-not $i['MRMTicketRef']) {
$findings += [pscustomobject]@{ IncidentId=$i['IncidentId']; Defect='NoMRMTicketRefForModelRiskAgent' }
}
}
return [pscustomobject]@{
Status = if ($findings.Count -eq 0) { 'Clean' } else { 'Anomaly' }
SampleCount = $sample.Count
Findings = $findings
Meta = $meta
Reason = if ($findings.Count) { "$($findings.Count)/$($sample.Count) model-risk incidents missing MRM feedback ticket" } else { '' }
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
}
§12 — Quarterly evidence bundle
Export-Fsi-Control34-QuarterlyEvidence aggregates the §11 verification helpers, the §9 timer ledger, the §10 tabletop after-action records, and the KQL query hashes from the §5 Sentinel automation rules into a single signed JSON manifest. The output is intended for the four-signature quarterly attestation — CISO, CCO, General Counsel, and AI Governance Lead — and for direct delivery to internal audit and external examiners.
12.1 — Export-Fsi-Control34-QuarterlyEvidence
function Export-Fsi-Control34-QuarterlyEvidence {
<#
.SYNOPSIS
Aggregates verification results, timer ledger, tabletop AARs, and KQL hashes into a signed JSON manifest.
.PARAMETER Quarter
Quarter identifier in YYYYQ form (e.g., 2026Q2).
.PARAMETER OutputDir
Directory to write manifest.json + supporting artifacts.
.NOTES
Control: 3.4. Read-only export. The four-signature attestation is captured in a sibling
.signatures.json file populated by the named officers via separate signing tooling.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Quarter,
[Parameter(Mandatory)] [string]$OutputDir,
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl
)
$meta = Get-RunMetadata -SessionContext $SessionContext -Helper 'Export-Fsi-Control34-QuarterlyEvidence'
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$matrix = Test-Fsi-Control34-MatrixCoverage -SessionContext $SessionContext -SiteUrl $SiteUrl
$chain = Test-Fsi-Control34-EvidenceChain -SessionContext $SessionContext -SiteUrl $SiteUrl
$svcHealth = Test-Fsi-Control34-ServiceHealthChecked -SessionContext $SessionContext -SiteUrl $SiteUrl
$mrm = Test-Fsi-Control34-MRMFeedback -SessionContext $SessionContext -SiteUrl $SiteUrl
$timers = Get-Fsi-OpenRegulatorTimers -SessionContext $SessionContext -SiteUrl $SiteUrl
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction Stop
$tabletops = $null
try {
$tabletops = Get-PnPListItem -List 'AI Agent Tabletop Exercises' -PageSize 500 |
ForEach-Object {
[pscustomobject]@{
SyntheticId = $_['Title']
Scenario = $_['Scenario']
StartedUtc = $_['StartedUtc']
Participants = $_['Participants']
AfterActionMd = $_['AfterActionMd']
}
}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
$kqlSources = @(
'NotifyCISO-CCO-GC','SuspendAgent','NYDFS72hTimer','NYDFS24hRansomTimer',
'SEC8K-MaterialityCheck','Bank36hTimer','RegSP30dCustomerNotice',
'FINRA4530aTicket','StateBreachLawTriage'
)
$kqlHashes = $kqlSources | ForEach-Object {
$name = $_
$candidate = Join-Path $PSScriptRoot "..\..\..\..\..\FSI-AgentGov-Solutions\sentinel\3.4-incident-reporting\$name.kql"
if (Test-Path $candidate) {
[pscustomobject]@{ Rule=$name; Sha256=(Get-FileHash -Path $candidate -Algorithm SHA256).Hash; Path=$candidate }
} else {
[pscustomobject]@{ Rule=$name; Sha256='NOT_FOUND'; Path=$candidate }
}
}
$manifest = [pscustomobject]@{
meta = $meta
quarter = $Quarter
matrix_coverage = $matrix
evidence_chain = $chain
service_health = $svcHealth
mrm_feedback = $mrm
open_timers = $timers
tabletops = $tabletops
kql_query_hashes = $kqlHashes
signatures_required = @('CISO','CCO','GeneralCounsel','AIGovernanceLead')
}
$manifestPath = Join-Path $OutputDir "fsi-control34-evidence-$Quarter.json"
$manifest | ConvertTo-Json -Depth 30 | Set-Content -Path $manifestPath -Encoding UTF8
$sigStub = @{ manifest_sha256=(Get-FileHash $manifestPath -Algorithm SHA256).Hash; signatures=@() }
$sigPath = Join-Path $OutputDir "fsi-control34-evidence-$Quarter.signatures.json"
$sigStub | ConvertTo-Json -Depth 5 | Set-Content -Path $sigPath -Encoding UTF8
[pscustomobject]@{
Status = 'Clean'
ManifestPath = $manifestPath
SignaturesPath = $sigPath
Quarter = $Quarter
OverallStatus = if (@($matrix,$chain,$svcHealth,$mrm,$timers).Status -contains 'Anomaly') { 'AnomaliesPresent' } else { 'AllClean' }
Meta = $meta
Reason = ''
}
}
§13 — Orchestrator and self-test
13.1 — Invoke-Fsi-Control34Setup
The single end-to-end entry point. -Mode Provision runs §3 (SharePoint lists) + §5 (deploys Sentinel/Logic Apps playbooks) + §13.2 self-test. -Mode Verify runs §11 helpers and emits a console summary. -Mode Quarterly runs §12.
function Invoke-Fsi-Control34Setup {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
param(
[Parameter(Mandatory)] [ValidateSet('Provision','Verify','Quarterly')] [string]$Mode,
[Parameter(Mandatory)] [ValidateSet('Commercial','GCC','GCCHigh','DoD')] [string]$Cloud,
[Parameter(Mandatory)] [string]$TenantId,
[Parameter(Mandatory)] [string]$SiteUrl,
[string]$SubscriptionId,
[string]$SentinelResourceGroup,
[string]$SentinelWorkspaceName,
[string]$ChangeTicketId,
[string]$Quarter,
[string]$EvidenceDir
)
Assert-Fsi34ShellHost
$sessionMode = if ($Mode -eq 'Provision') { 'Mutate' } else { 'ReadOnly' }
$session = Initialize-Fsi34Session -Cloud $Cloud -TenantId $TenantId -Mode $sessionMode
switch ($Mode) {
'Provision' {
if (-not $ChangeTicketId) { throw 'ChangeTicketId is required for Provision mode.' }
New-Fsi-IncidentList -SiteUrl $SiteUrl -SessionContext $session -ChangeTicketId $ChangeTicketId
# §5 deployers are invoked individually by the operator with explicit Logic Apps ARM template paths;
# the orchestrator emits a checklist rather than a blanket deploy.
Write-Host '[Provision] §5 Sentinel/Logic Apps deployers not auto-invoked. Run each deployer explicitly per portal-walkthrough §5 checklist.'
$self = Invoke-Fsi34SelfTest -SessionContext $session -SiteUrl $SiteUrl
return $self
}
'Verify' {
$matrix = Test-Fsi-Control34-MatrixCoverage -SessionContext $session -SiteUrl $SiteUrl
$chain = Test-Fsi-Control34-EvidenceChain -SessionContext $session -SiteUrl $SiteUrl
$svc = Test-Fsi-Control34-ServiceHealthChecked -SessionContext $session -SiteUrl $SiteUrl
$mrm = Test-Fsi-Control34-MRMFeedback -SessionContext $session -SiteUrl $SiteUrl
return [pscustomobject]@{ MatrixCoverage=$matrix; EvidenceChain=$chain; ServiceHealth=$svc; MRMFeedback=$mrm }
}
'Quarterly' {
if (-not $Quarter -or -not $EvidenceDir) { throw 'Quarter and EvidenceDir are required for Quarterly mode.' }
return Export-Fsi-Control34-QuarterlyEvidence -Quarter $Quarter -OutputDir $EvidenceDir -SessionContext $session -SiteUrl $SiteUrl
}
}
}
13.2 — Invoke-Fsi34SelfTest
function Invoke-Fsi34SelfTest {
<#
.SYNOPSIS
Self-test forward-referenced from §0.3 — confirms baseline preconditions are satisfied.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] $SessionContext,
[Parameter(Mandatory)] [string]$SiteUrl
)
$checks = @()
$checks += [pscustomobject]@{ Check='ShellHost'; Status=(if ($PSVersionTable.PSEdition -eq 'Core') {'Clean'} else {'Anomaly'}) }
$checks += [pscustomobject]@{ Check='SessionContext'; Status=(if ($SessionContext) {'Clean'} else {'Anomaly'}) }
$checks += [pscustomobject]@{ Check='GraphScopes'; Status=(Test-Fsi34GraphScopes -SessionContext $SessionContext).Status }
$checks += [pscustomobject]@{ Check='IncidentList'; Status='Pending' }
Connect-PnPOnline -Url $SiteUrl -Interactive -ErrorAction SilentlyContinue
try {
$list = Get-PnPList -Identity 'AI Agent Incidents' -ErrorAction SilentlyContinue
$checks[-1].Status = if ($list) {'Clean'} else {'Anomaly'}
} finally { Disconnect-PnPOnline -ErrorAction SilentlyContinue }
$overall = if ($checks.Status -contains 'Anomaly') { 'Anomaly' } else { 'Clean' }
[pscustomobject]@{ Status=$overall; Checks=$checks; Reason=if ($overall -eq 'Anomaly') { 'OneOrMoreSelfTestChecksFailed' } else { '' } }
}
§14 — Anti-patterns and operating cadence
14.1 — Anti-patterns
| # | Anti-pattern | Why it fails | Correct approach |
|---|---|---|---|
| 1 | Running mutation helpers from Windows PowerShell 5.1 | Many Graph/PnP cmdlets target PS 7+ only; silent module-load mismatches mask real failures | pwsh 7.4+; Assert-Fsi34ShellHost enforces |
| 2 | Calling Az.Sentinel cmdlets without Resolve-Fsi34SentinelWorkspace |
Workspace-resource-id mismatches in sovereign tenants; rules deploy to the wrong workspace | Always pre-resolve via §2.3 |
| 3 | Hard-coded notification timer hours | Regulator rule changes silently invalidate the constant; operators rely on stale values | Source of truth is the §9 table; verify against current rule text |
| 4 | Closing an NYDFS24 ransom timer without OFAC checkpoint | Sanctions-screening attestation gap creates Treasury-OFAC liability | §5.4 !!! danger block plus §9 NYDFS24 hand-off |
| 5 | Naming an internal owner as RCA without Service Health correlation | Microsoft-side outage is misattributed; downstream corrective actions misallocated | §8 Get-Fsi-M365ServiceHealthIncident is mandatory pre-RCA for Sev1/Sev2 |
| 6 | Writing tabletop incidents to the production list | Inflates quarterly counts; triggers real automation rules; produces false timer starts | §10 writes to AI Agent Tabletop Exercises only |
| 7 | Fewer than three signatures on the closed RCA | CISO+CCO+GC accountability chain incomplete; auditors flag as control gap | §11.2 enforces sample-based check |
| 8 | Treating MRMTicketRef as optional for model-risk incidents |
Disconnects 3.4 RCA from Control 2.6 model-monitoring loop | §11.4 enforces sample-based check |
| 9 | Bypassing Mode=LegalHold for §7 helpers |
Operational mistakes in legal-hold provisioning are the highest-impact errors in 3.4 | All §7 helpers refuse to run unless Mode=LegalHold |
| 10 | Skipping evidence-bundle SHA-256 manifest | Loss of immutable file-level provenance; SEC 17a-4(f) defensibility weakens | §4.3 emits manifest unconditionally |
| 11 | Treating regulator deadlines as wall-clock hours rather than from the determination event | Anchor errors are common; 4-day SEC 8-K and 24-hour NYDFS payment timers are determination-anchored | §9 -DeterminationTimestamp parameter is required |
| 12 | Calling Defender/Sentinel pull helpers without throttle handling | 429s mid-RCA fragment the evidence bundle | §4.5 Invoke-Fsi34Throttled handles backoff |
| 13 | Storing portal acknowledgments outside the SHA-256 manifest | Defensibility gap when regulator portal acknowledgment is later disputed | §9.3 Stop-Fsi-RegulatorTimer hashes the PDF |
| 14 | Allowing un-pinned module versions in production runners | Silent behavior changes in Az/Graph modules introduce regressions | Use the §1 module-pin matrix |
| 15 | Treating §11 verification as a "green light" rather than as a sample | A clean N=20 sample does not certify the universe; auditors expect documented sampling methodology | Record the sampling seed and population size in evidence packs |
14.2 — Operating cadence
| Cadence | Activity | Owner | Helper |
|---|---|---|---|
| Per incident (real time) | Detection → containment → RCA → notification | IR Lead + named officer | §4 + §5 + §9 |
| Daily (business days) | Open-timer review and escalation | CCO designate | §9.2 Get-Fsi-OpenRegulatorTimers |
| Weekly | Sentinel/Defender pull sample for RCA-pending incidents | IR Lead | §4.1 + §4.2 |
| Monthly | Service Health correlation audit on closed Sev1/Sev2 | CISO designate | §11.3 |
| Quarterly | Tabletop exercise (one scenario from §10) | AI Governance Lead | §10 |
| Quarterly | Evidence bundle export with four-signature attestation | CISO + CCO + GC + AI Governance Lead | §12 |
| Annual | KQL query review and §5 automation-rule version bump | Sentinel Contributor | §12 KQL hash diff |
Hedged-language reminder
These helpers support compliance with the named regulations and help meet the timer-and-evidence elements that those regulations describe. They do not certify, guarantee, or eliminate regulatory risk. Implementation requires institution-specific legal review of each cited rule's current text, a documented Written Information Security Program (WISP) that aligns with the timer matrix, and ongoing supervisory review by qualified counsel.
Cross-references
| Control | Title | Why it matters here |
|---|---|---|
| 1.5 | Data loss prevention and sensitivity labels | DLP signals and sensitivity-label context enrich §4 evidence pulls and §6 IRM cases |
| 1.7 | Comprehensive audit logging and compliance | Unified audit log is the canonical action source for §4 evidence bundles |
| 1.8 | Runtime protection and external threat detection | Detection surfaces feed §4 Defender/Sentinel pull helpers |
| 1.9 | Data retention and deletion policies | §3 retention-label binding and §7 legal-hold helpers depend on a coherent retention scheme |
| 1.11 | Conditional access and phishing-resistant MFA | Identity controls bound the named-officer signing path used in §11.2 evidence-chain |
| 1.12 | Insider risk detection and response | §6 IRM helpers wrap the Purview Insider Risk Management surface |
| 2.6 | Model risk management (OCC 2011-12 / SR 11-7) | §11.4 enforces the model-risk feedback loop from incident → MRM ticket |
| 2.12 | Supervision and oversight (FINRA Rule 3110) | Supervisory escalation paths drive §5 NotifyCISO-CCO-GC and §9 FINRA4530A timers |
| 2.25 | Agent 365 admin center governance console | §4 agent-context enrichment relies on the 2.25 inventory |
| 3.2 | Usage analytics and activity monitoring | §12 quarterly evidence consumes 3.2 activity baselines |
| 3.3 | Compliance and regulatory reporting | §9 timer engine integrates with the broader 3.3 filing automation |
| 3.6 | Orphaned agent detection and remediation | The §10 OrphanedAgentCascade scenario validates 3.6 hand-off |
| 3.9 | Microsoft Sentinel integration | §4 and §5 helpers presume the 3.9 Sentinel baseline |
See also: docs/playbooks/_shared/powershell-baseline.md — module pinning, sovereign endpoints, mutation safety, evidence emission.
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current