Skip to content

Control 4.5: SharePoint Security and Compliance Monitoring - PowerShell Setup

Read the FSI PowerShell baseline first

Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show the patterns required for FSI tenants; the baseline is authoritative if anything appears to conflict.

This playbook provides PowerShell automation guidance for Control 4.5. All commands are read-only (evidence collection); they do not modify tenant state.


Module Prerequisites (Pinned)

Replace each <version> with the version approved by your Change Advisory Board (CAB). Floating versions break SOX 404 evidence reproducibility.

$ErrorActionPreference = 'Stop'

# Pin to CAB-approved versions before each change window.
Install-Module -Name Microsoft.Online.SharePoint.PowerShell -RequiredVersion '<version>' `
    -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name ExchangeOnlineManagement -RequiredVersion '<version>' `
    -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense
Install-Module -Name Microsoft.Graph -RequiredVersion '<version>' `
    -Repository PSGallery -Scope CurrentUser -AllowClobber -AcceptLicense

Edition guard

Microsoft.Online.SharePoint.PowerShell is supported on both Windows PowerShell 5.1 and PowerShell 7+, but a small number of cmdlets behave differently between editions. Standardise on one edition for evidence collection — typically PowerShell 7+ — and document the choice in your run book.


Step 1: Sovereign-Cloud-Aware Connection

Wrong endpoints produce false-clean evidence. Always pass the explicit endpoint for your cloud.

param(
    [Parameter(Mandatory)] [string] $TenantAdminUrl,           # e.g., https://contoso-admin.sharepoint.com (or .sharepoint.us)
    [Parameter(Mandatory)] [string] $AdminUpn,                  # admin user principal name
    [ValidateSet('Commercial','GCC','GCCHigh','DoD','China')]
    [string] $Cloud = 'Commercial'
)

# Map cloud to endpoints
$graphEnv = switch ($Cloud) {
    'Commercial' { 'Global' }
    'GCC'        { 'USGov' }
    'GCCHigh'    { 'USGovDoD' }   # verify per Microsoft Learn for your tenant
    'DoD'        { 'USGovDoD' }
    'China'      { 'China' }
}

$exchangeEnv = switch ($Cloud) {
    'Commercial' { 'O365Default' }
    'GCC'        { 'O365USGovGCCHigh' }   # verify; GCC commercial uses O365Default in some configurations
    'GCCHigh'    { 'O365USGovGCCHigh' }
    'DoD'        { 'O365USGovDoD' }
    'China'      { 'O365China' }
}

Connect-SPOService -Url $TenantAdminUrl
Connect-IPPSSession -UserPrincipalName $AdminUpn -ConnectionUri "https://ps.compliance.protection.outlook.com/powershell-liveid/" -ErrorAction Stop
Connect-MgGraph -Scopes 'AuditLog.Read.All','Directory.Read.All' -Environment $graphEnv -NoWelcome

# Sanity check — non-zero result confirms connection
if (-not (Get-SPOTenant)) { throw 'SPO connection failed' }

Step 2: Verify Foundational Audit Logging

$auditConfig = Get-AdminAuditLogConfig
if (-not $auditConfig.UnifiedAuditLogIngestionEnabled) {
    throw 'Unified audit logging is NOT enabled. Halt evidence collection — downstream results would be incomplete.'
}
Write-Host '[PASS] Unified audit logging is enabled' -ForegroundColor Green

Step 3: Search SharePoint Audit Logs (Paginated)

Search-UnifiedAuditLog returns at most 5,000 records per call. In FSI tenants this silently truncates without pagination. Always use session-based paging for evidence collection.

function Get-AllUnifiedAuditEvents {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [datetime] $StartDate,
        [Parameter(Mandatory)] [datetime] $EndDate,
        [string] $RecordType = 'SharePoint',
        [string[]] $Operations
    )

    $sessionId = [guid]::NewGuid().ToString()
    $all = New-Object System.Collections.Generic.List[object]
    do {
        $params = @{
            StartDate      = $StartDate
            EndDate        = $EndDate
            RecordType     = $RecordType
            SessionId      = $sessionId
            SessionCommand = 'ReturnLargeSet'
            ResultSize     = 5000
        }
        if ($Operations) { $params['Operations'] = $Operations }
        $batch = Search-UnifiedAuditLog @params
        if ($batch) { $all.AddRange($batch) }
    } while ($batch -and $batch.Count -eq 5000)

    return ,$all
}

$startDate = (Get-Date).AddDays(-30)
$endDate   = Get-Date

$sharepointEvents = Get-AllUnifiedAuditEvents -StartDate $startDate -EndDate $endDate `
    -RecordType SharePoint -Operations FileAccessed,FileDownloaded,FileModified

Write-Host "Retrieved $($sharepointEvents.Count) SharePoint events"

Step 4: Identify Agent / Copilot Access Patterns

$agentEvents = $sharepointEvents | ForEach-Object {
    $audit = $_.AuditData | ConvertFrom-Json
    if ($audit.UserAgent -match 'Copilot|Agent' -or $audit.ApplicationDisplayName -match 'Copilot|Agent') {
        [pscustomobject]@{
            Timestamp        = $_.CreationDate
            User             = $_.UserIds
            Operation        = $_.Operations
            FileName         = $audit.ObjectId
            SiteUrl          = $audit.SiteUrl
            UserAgent        = $audit.UserAgent
            AppDisplayName   = $audit.ApplicationDisplayName
            SensitivityLabel = $audit.SensitivityLabelId
        }
    }
}

Write-Host "Agent / Copilot related access events: $($agentEvents.Count)"

Detection caveat

Agent and Copilot identification from audit data is heuristic. The set of identifying fields (UserAgent, ApplicationDisplayName, ApplicationId) is evolving; review Microsoft Learn quarterly and adjust the filter. Do not treat the result as a definitive count of agent reads — treat it as a signal for further investigation.


Step 5: Inventory High-Risk Sites (Single Pass)

Get-SPOSite -Limit All is throttled in large tenants. Enumerate once, then derive metrics in memory.

$allSites = Get-SPOSite -Limit All -IncludePersonalSite:$false |
    Select-Object Url, Owner, SensitivityLabel, SharingCapability, ConditionalAccessPolicy,
                  LockState, RestrictedAccessControl, RestrictedToGeo, IsHubSite, Template

$siteMetrics = [pscustomobject]@{
    TotalSites              = $allSites.Count
    LabeledSites            = ($allSites | Where-Object SensitivityLabel).Count
    ExternalSharingEnabled  = ($allSites | Where-Object { $_.SharingCapability -ne 'Disabled' }).Count
    ConfidentialSites       = ($allSites | Where-Object { $_.SensitivityLabel -match 'Confidential' }).Count
    LockedSites             = ($allSites | Where-Object { $_.LockState -ne 'Unlock' }).Count
    RACEnabled              = ($allSites | Where-Object RestrictedAccessControl).Count
    GeoRestricted           = ($allSites | Where-Object RestrictedToGeo).Count
}

$highRiskSites = $allSites | Where-Object {
    $_.SensitivityLabel -match 'Confidential|Restricted' -or $_.LockState -ne 'Unlock'
}

Step 6: Verify Privileged Role Assignment

Avoid fragile displayName -like '*SharePoint*' matches. Resolve role IDs explicitly.

function Test-DirectoryRoleAssignment {
    param(
        [Parameter(Mandatory)] [string] $UserUpn,
        [Parameter(Mandatory)] [string] $RoleDisplayName    # e.g., 'SharePoint Administrator'
    )
    $role = Get-MgDirectoryRole -Filter "displayName eq '$RoleDisplayName'" -ErrorAction SilentlyContinue
    if (-not $role) {
        # Activate the role from its template if it has not yet been activated in the tenant
        $template = Get-MgDirectoryRoleTemplate -Filter "displayName eq '$RoleDisplayName'"
        if ($template) { $role = New-MgDirectoryRole -RoleTemplateId $template.Id }
    }
    if (-not $role) { return $false }

    $user = Get-MgUser -Filter "userPrincipalName eq '$UserUpn'"
    if (-not $user) { return $false }

    $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All
    return [bool]($members | Where-Object { $_.Id -eq $user.Id })
}

Test-DirectoryRoleAssignment -UserUpn $AdminUpn -RoleDisplayName 'SharePoint Administrator'

Step 7: Emit Evidence with SHA-256 Hashes

Every export should be hashed at capture time and recorded in an evidence manifest. Hashes prove the file you produce in audit is the file you captured.

$evidenceRoot = Join-Path -Path '.\evidence\4.5' -ChildPath (Get-Date -Format 'yyyy-MM-dd')
New-Item -Path $evidenceRoot -ItemType Directory -Force | Out-Null

function Export-EvidenceArtifact {
    param(
        [Parameter(Mandatory)] $InputObject,
        [Parameter(Mandatory)] [string] $RelativePath,   # e.g., 'sharepoint-audit-30d.csv'
        [ValidateSet('Csv','Json')] [string] $Format = 'Csv'
    )
    $full = Join-Path $evidenceRoot $RelativePath
    if ($Format -eq 'Csv') {
        $InputObject | Export-Csv -Path $full -NoTypeInformation -Encoding UTF8
    } else {
        $InputObject | ConvertTo-Json -Depth 10 | Out-File $full -Encoding UTF8
    }
    $hash = (Get-FileHash -Path $full -Algorithm SHA256).Hash
    [pscustomobject]@{
        Artifact   = $RelativePath
        SizeBytes  = (Get-Item $full).Length
        SHA256     = $hash
        CapturedAt = (Get-Date).ToString('o')
        CapturedBy = $AdminUpn
        Tenant     = $TenantAdminUrl
        Cloud      = $Cloud
    }
}

$manifest = New-Object System.Collections.Generic.List[object]
$manifest.Add( (Export-EvidenceArtifact -InputObject $sharepointEvents -RelativePath 'sharepoint-audit-30d.csv') )
$manifest.Add( (Export-EvidenceArtifact -InputObject $agentEvents      -RelativePath 'agent-access-30d.csv') )
$manifest.Add( (Export-EvidenceArtifact -InputObject $allSites         -RelativePath 'all-sites-inventory.csv') )
$manifest.Add( (Export-EvidenceArtifact -InputObject $highRiskSites    -RelativePath 'high-risk-sites.csv') )
$manifest.Add( (Export-EvidenceArtifact -InputObject $siteMetrics      -RelativePath 'site-metrics.json' -Format Json) )

$manifest | Export-Csv -Path (Join-Path $evidenceRoot 'EVIDENCE-MANIFEST.csv') -NoTypeInformation -Encoding UTF8

Audit log retention

A 30- or 90-day audit window is insufficient for SEC 17a-4 / FINRA 4511 (typically 6 years). The exports above are point-in-time snapshots. Configure a Microsoft Purview audit retention policy that matches your regulatory window — Standard audit retains 180 days and Premium audit retains 1 year by default, neither of which satisfies broker-dealer record-keeping obligations on its own. See Manage audit log retention policies.


Step 8: Cleanup

Disconnect-SPOService -ErrorAction SilentlyContinue
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
Disconnect-MgGraph -ErrorAction SilentlyContinue

Complete Orchestration Script (Reference)

Save as Invoke-Control-4.5-EvidenceCollection.ps1 and execute under change control. The script is read-only and safe to run on production tenants outside change windows, but the evidence it produces is part of your regulatory record — treat the output directory accordingly.

<#
.SYNOPSIS
    Read-only evidence collection for FSI Control 4.5 — SharePoint Security and Compliance Monitoring.

.DESCRIPTION
    Connects to SharePoint Online, Purview (IPPS), and Microsoft Graph using sovereign-cloud-aware
    endpoints; enumerates SharePoint audit events with paginated Search-UnifiedAuditLog calls;
    inventories sites in a single pass; emits CSV/JSON evidence with SHA-256 hashes recorded in a
    manifest file.

.PARAMETER TenantAdminUrl
    SharePoint Admin Center URL (e.g., https://contoso-admin.sharepoint.com or .sharepoint.us).

.PARAMETER AdminUpn
    User principal name of the executing administrator.

.PARAMETER Cloud
    One of: Commercial, GCC, GCCHigh, DoD, China.

.PARAMETER DaysBack
    Audit window in days. Default 30. Note: this is a snapshot; full retention is governed by a
    separate Purview audit retention policy.

.NOTES
    Read the FSI PowerShell baseline:
    docs/playbooks/_shared/powershell-baseline.md
#>
param(
    [Parameter(Mandatory)] [string] $TenantAdminUrl,
    [Parameter(Mandatory)] [string] $AdminUpn,
    [ValidateSet('Commercial','GCC','GCCHigh','DoD','China')]
    [string] $Cloud = 'Commercial',
    [int] $DaysBack = 30
)

# (Body assembles Steps 1–8 above into a single transcript-logged run.
# Wrap the body in Start-Transcript / Stop-Transcript and a try/catch/finally
# to ensure evidence integrity even on partial failure.)

Back to Control 4.5 | Portal Walkthrough | Verification Testing | Troubleshooting


Updated: April 2026 | Version: v1.4.0