Skip to content

SharePointAudit

vaarg edited this page Jun 11, 2026 · 5 revisions

Resume-SharePointAudit.ps1

A PowerShell supplement to GraphRunner that provides a three-phase workflow for enumerating SharePoint site collections, testing per-site Graph API access, and searching accessible sites for sensitive files.

Background

GraphRunner's built-in SharePoint functions hit two problems in restricted environments:

  • Get-SharePointSiteURLs fails silently. The function uses the Graph Search API to enumerate drives. In tenants where that endpoint is restricted to privileged accounts, it returns no results and the caller has no site list to work from.
  • Invoke-SearchSharePointAndOneDrive depends on enumeration succeeding. When site discovery fails, the search has no scope and either returns nothing or errors out.

Resume-SharePointAudit.ps1 splits the work into three independent phases so each can be run, retried, or skipped without affecting the others, and so site discovery never blocks the search step.

Phase 1 uses the SharePoint Online PowerShell SDK (admin-only) to enumerate all site collections authoritatively. Phase 2 tests, as a standard user, which of those sites the current Graph token can actually reach. Phase 3 searches only those confirmed-accessible sites, passing them directly as contentSources to the Graph Search API rather than relying on any automatic discovery.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7+
  • GraphRunner dot-sourced in the same session (provides Invoke-RefreshGraphTokens, Invoke-ForgeUserAgent, Invoke-DriveFileDownload)
  • A valid Graph access token obtained via GraphRunner's Get-GraphTokens or Invoke-RefreshGraphTokens
  • Phase 1 only: SharePoint administrator access and the Microsoft.Online.SharePoint.PowerShell module (installed automatically if absent)

Setup

# Dot-source GraphRunner first, then this file
. .\GraphRunner\GraphRunner.ps1
. .\Resume-SharePointAudit.ps1

Encoding note: The file is pure ASCII. If you edit it, save as ASCII or UTF-8 with BOM. Windows PowerShell 5.1 reads files without a BOM as Windows-1252 by default, which silently corrupts multi-byte Unicode characters (em-dashes, smart quotes, etc.) and causes parse errors.


Workflow Overview

Phase 1 - Get-SPOSiteInventory          (admin)
    Enumerate all site collections -> SharePointSiteInventory.csv
            |
            v
Phase 2 - Test-SharePointSiteAccess     (standard user)
    Test Graph API access per site -> AccessibleSharePointSites.csv
                                   -> InaccessibleSharePointSites.csv
            |
            v
Phase 3 - Invoke-SearchSharePointByList (standard user)
    Search accessible sites via contentSources
    -> hits appended to OutFile CSV

Phase 1 and Phase 2 are independent. If Phase 1 is not available (no admin access), supply SharePointSiteInventory.csv from another source — the only columns Phase 2 requires are Url, Title, Template, Owner, Status, StorageUsageCurrent, and LastContentModifiedDate.


Authentication

Authenticate using GraphRunner before running Phase 2 or Phase 3. The ClientID used matters — the refresh token is bound to the client it was issued for, and refreshing with a different ClientID returns HTTP 400.

# Example using the Azure Portal client
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
    -ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
    -RefreshToken "0.A..."

Both Test-SharePointSiteAccess and Invoke-SearchSharePointByList auto-detect the ClientID from the appid claim in the access token JWT, so you do not need to pass -ClientID explicitly unless you want to override it.

Phase 1 (Get-SPOSiteInventory) uses an interactive browser sign-in via Connect-SPOService and does not use the $tokens object.


Phase 1 — Get-SPOSiteInventory

Enumerates all SharePoint site collections using the SharePoint Online PowerShell SDK and writes them to a CSV. Requires SharePoint administrator access. Installs Microsoft.Online.SharePoint.PowerShell automatically if it is not already present.

Parameters

Parameter Required Default Description
-AdminUrl Yes SharePoint admin center URL, e.g. https://contoso-admin.sharepoint.com
-OutputFile No SharePointSiteInventory.csv Path to write the inventory CSV

Output columns

Column Description
Title Site display name
Url Full site collection URL
SiteId SPO site ID (GUID)
Template Site template code (e.g. GROUP#0, SPSPERS#10, TEAMCHANNEL#1)
Owner Primary site owner
Status Site status (Active, etc.)
StorageUsageCurrent Storage used in MB
LastContentModifiedDate Last content change timestamp
SiteCategory Derived from Template: Teams channel site, Microsoft 365 group / Teams parent site, OneDrive, or Other SharePoint site

Example

Get-SPOSiteInventory -AdminUrl 'https://contoso-admin.sharepoint.com'

# Custom output path
Get-SPOSiteInventory -AdminUrl 'https://contoso-admin.sharepoint.com' `
    -OutputFile '.\audit\sites.csv'

Phase 2 — Test-SharePointSiteAccess

Tests Graph API access for each site in SharePointSiteInventory.csv by issuing a GET /sites/{id} request per URL. Produces separate CSVs for accessible and inaccessible sites.

Unlike running the search blind, this phase tells you exactly which sites your token can reach before you spend time searching them. The SiteId written to AccessibleSharePointSites.csv is the Graph compound ID (hostname,guid,guid) that Phase 3 uses directly as a contentSources value.

Token handling mirrors Resume-GroupAudit.ps1: the ClientID is auto-detected from the JWT appid claim, proactive refreshes run on a configurable interval, 401 responses trigger an immediate refresh-and-retry, and 429 responses respect the Retry-After header without consuming the retry budget.

Parameters

Parameter Required Default Description
-Tokens Yes Token object from GraphRunner
-InputCsv No SharePointSiteInventory.csv Inventory CSV from Phase 1
-AccessibleOutputCsv No AccessibleSharePointSites.csv Output path for accessible sites
-InaccessibleOutputCsv No InaccessibleSharePointSites.csv Output path for inaccessible sites
-RefreshInterval No 300 Seconds between proactive token refreshes
-ClientID No Auto-detected Override the client ID used for token refresh

Output files

File Description
AccessibleSharePointSites.csv Sites the current token can reach. Contains Graph-format SiteId used by Phase 3.
InaccessibleSharePointSites.csv Sites that returned a non-200 response. Result column records the reason: Access denied, Not found or inaccessible, Authentication failed, etc.

Both files share the same columns:

Column Description
Accessible True or False
Result Accessible, Access denied, Not found or inaccessible, etc.
InputTitle Title from the inventory CSV
InputUrl URL from the inventory CSV
SiteId Graph compound site ID (hostname,guid,guid). Empty for inaccessible sites.
DisplayName Graph display name. Empty for inaccessible sites.
WebUrl Canonical URL from Graph. Empty for inaccessible sites.
CreatedDateTime Site creation timestamp from Graph. Empty for inaccessible sites.
Template Site template code (from inventory)
SiteCategory Derived from Template
Owner From inventory
Status From inventory
StorageUsageCurrent From inventory
LastContentModifiedDate From inventory
HttpStatus HTTP response code (200 for accessible, 401/403/404/etc. for inaccessible)
Error Error message for inaccessible sites

Example

# Test all sites from the Phase 1 inventory
Test-SharePointSiteAccess -Tokens $tokens `
    -InputCsv '.\SharePointSiteInventory.csv'

# Custom output paths, longer refresh interval for large tenants
Test-SharePointSiteAccess -Tokens $tokens `
    -InputCsv              '.\SharePointSiteInventory.csv' `
    -AccessibleOutputCsv   '.\AccessibleSharePointSites.csv' `
    -InaccessibleOutputCsv '.\InaccessibleSharePointSites.csv' `
    -RefreshInterval       600

Phase 3 — Invoke-SearchSharePointByList

Searches accessible SharePoint and OneDrive sites for files matching a KQL query. Rather than discovering sites at search time, it reads their Graph site IDs from AccessibleSharePointSites.csv and passes them as contentSources to the Graph Search API in batches of 15, which is below the API's 20-source limit.

Results are aggregated and deduplicated across all batches. Output format — CSV columns and interactive download behavior — is identical to GraphRunner's Invoke-SearchSharePointAndOneDrive, making the function a drop-in replacement in the detector-loop pattern from the GraphRunner wiki.

Parameters

Parameter Required Default Description
-Tokens Yes Token object from GraphRunner
-SearchTerm Yes KQL query string. Supports filetype:, content:, site operators, and boolean logic.
-InputCsv No AccessibleSharePointSites.csv Accessible sites CSV from Phase 2
-ResultCount No 25 Results per page per batch
-DetectorName No Custom Label written to the Detector Name column in the output CSV
-OutFile No CSV file to append results to
-ReportOnly No switch Suppress the interactive download prompt
-PageResults No switch Page through all available results rather than stopping after the first page
-GraphRun No switch Suppress status output unless hits are found; use in detector loops
-RefreshInterval No 300 Seconds between proactive token refreshes
-ClientID No Auto-detected Override the client ID used for token refresh

Output CSV columns

Identical to GraphRunner's Invoke-SearchSharePointAndOneDrive:

Column Description
Detector Name Value of -DetectorName
File Name Filename
Size Human-readable file size
Location Web URL of the file
DriveItemID driveId:itemId string for use with Invoke-DriveFileDownload
Preview Search result snippet
Last Modified Date Last modification timestamp

Examples

# Interactive search — displays results, prompts to download
Invoke-SearchSharePointByList -Tokens $tokens `
    -InputCsv   '.\AccessibleSharePointSites.csv' `
    -SearchTerm 'password filetype:xlsx'

# Report-only, write results to a file
Invoke-SearchSharePointByList -Tokens $tokens `
    -InputCsv    '.\AccessibleSharePointSites.csv' `
    -SearchTerm  'password AND filetype:xlsx' `
    -PageResults -ResultCount 500 `
    -ReportOnly  -OutFile '.\hits.csv'

# Scope to a subset — search OneDrive only (filter the accessible CSV first)
Import-Csv '.\AccessibleSharePointSites.csv' |
    Where-Object { $_.SiteCategory -eq 'OneDrive' } |
    Export-Csv '.\onedrive_sites.csv' -NoTypeInformation

Invoke-SearchSharePointByList -Tokens $tokens `
    -InputCsv   '.\onedrive_sites.csv' `
    -SearchTerm 'confidential' `
    -ReportOnly -OutFile '.\onedrive_hits.csv'

Full Workflow Example

# Load dependencies
. .\GraphRunner\GraphRunner.ps1
. .\Resume-SharePointAudit.ps1

# Authenticate
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
    -ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
    -RefreshToken "0.A..."

# Phase 1: Enumerate all site collections (admin)
Get-SPOSiteInventory -AdminUrl 'https://contoso-admin.sharepoint.com'

# Phase 2: Test which sites are accessible with the current user token
Test-SharePointSiteAccess -Tokens $tokens `
    -InputCsv '.\SharePointSiteInventory.csv'

# Phase 3: Search the sites confirmed accessible in Phase 2
Invoke-SearchSharePointByList -Tokens $tokens `
    -InputCsv   '.\AccessibleSharePointSites.csv' `
    -SearchTerm 'password filetype:xlsx'

Detector Loop

The detector loop pattern from the GraphRunner wiki works unchanged with Invoke-SearchSharePointByList substituted in:

$folderName = "SharePointSearch-" + (Get-Date -Format 'yyyyMMddHHmmss')
New-Item -Path $folderName -ItemType Directory | Out-Null
$spout     = "$folderName\interesting-files.csv"
$detectors = (Get-Content '.\default_detectors.json' | ConvertFrom-Json).Detectors

foreach ($detect in $detectors) {
    Invoke-SearchSharePointByList -Tokens $tokens `
        -InputCsv     '.\AccessibleSharePointSites.csv' `
        -SearchTerm   $detect.SearchQuery `
        -DetectorName $detect.DetectorName `
        -PageResults -ResultCount 500 `
        -ReportOnly -OutFile $spout -GraphRun
}

Results from all detectors are appended to a single CSV. The -GraphRun switch suppresses per-detector output unless hits are found, keeping the terminal clean during long runs.


Downloading files

Use GraphRunner's Invoke-DriveFileDownload with the DriveItemID from the search output or Phase 3's interactive download prompt:

Invoke-DriveFileDownload -Tokens $tokens `
    -FileName     "Passwords.docx" `
    -DriveItemIDs "b!wDDN4...:01AVEVEP..."

Relationship to GraphRunner

This file is a supplement, not a replacement. It depends on GraphRunner being loaded and uses Invoke-RefreshGraphTokens, Invoke-ForgeUserAgent, and Invoke-DriveFileDownload from it. The search logic in Invoke-SearchSharePointByList is functionally identical to Invoke-SearchSharePointAndOneDrive — same Graph Search API, same output columns, same KQL query support — the difference is that site discovery is replaced by an explicit contentSources list derived from Phase 2.

GraphRunner is authored by Beau Bullock (@dafthack) and licensed under MIT.

Clone this wiki locally