-
Notifications
You must be signed in to change notification settings - Fork 0
SharePointAudit
A PowerShell supplement to GraphRunner that provides a multi-phase workflow for enumerating SharePoint site collections, testing per-site Graph API access, and searching accessible sites for sensitive files. A drive-level fallback search is included for tenants where the Graph Search API is restricted.
GraphRunner's built-in SharePoint functions hit two problems in restricted environments:
-
Get-SharePointSiteURLsfails 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-SearchSharePointAndOneDrivedepends 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, scoping each request to known site URLs via the KQL path: operator rather than relying on automatic discovery. If the Graph Search API is restricted in the tenant (HTTP 403), a fallback path enumerates document library drives once and searches each drive individually via a separate endpoint.
- 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-GraphTokensorInvoke-RefreshGraphTokens -
Phase 1 only: SharePoint administrator access and the
Microsoft.Online.SharePoint.PowerShellmodule (installed automatically if absent)
# Dot-source GraphRunner first, then this file
. .\GraphRunner\GraphRunner.ps1
. .\Resume-SharePointAudit.ps1Encoding 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.
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)
KQL path:-scoped Graph Search API -> hits appended to OutFile CSV
[if HTTP 403 received, prints fallback instructions]
|
| (if Search API is blocked)
v
Fallback pre-step - Get-SharePointDrives
Enumerate drives per accessible site -> AccessibleSharePointDrives.csv
|
v
Fallback search - Invoke-DriveSearchSharePointByList
Per-drive search endpoint, client-side file filtering
-> hits appended to OutFile CSV (same format)
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.
The fallback path is only needed when Invoke-SearchSharePointByList returns HTTP 403. Run Get-SharePointDrives once to cache the drives list; Invoke-DriveSearchSharePointByList then reuses it across all searches without re-enumerating.
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..."Test-SharePointSiteAccess, Invoke-SearchSharePointByList, Get-SharePointDrives, and Invoke-DriveSearchSharePointByList all 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.
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.
| 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 |
| 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
|
Get-SPOSiteInventory -AdminUrl 'https://contoso-admin.sharepoint.com'
# Custom output path
Get-SPOSiteInventory -AdminUrl 'https://contoso-admin.sharepoint.com' `
-OutputFile '.\audit\sites.csv'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 WebUrl and SiteId written to AccessibleSharePointSites.csv are used by Phase 3 and the fallback path respectively.
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.
| 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 |
| 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 |
# 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 600Searches accessible SharePoint and OneDrive sites for files matching a KQL query. Rather than discovering sites at search time, it reads site WebUrl values from AccessibleSharePointSites.csv, batches them into groups of 15, and scopes each Graph Search API request via the KQL path: operator. This avoids the automatic site-discovery step that fails in restricted environments.
Note: The Graph Search API's
contentSourcesproperty only applies to external Microsoft Search Connectors — it cannot be used to scope native SharePoint/OneDrive searches. Scoping is done viapath:in the KQL query string instead.
If any batch returns HTTP 403 (Search API blocked), the function prints instructions for the drive-level fallback at the end of execution.
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.
| 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 |
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 |
# 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'Enumerates the document library drives within each accessible site and writes them to AccessibleSharePointDrives.csv. Run this once before using Invoke-DriveSearchSharePointByList; the drives CSV is reused across all fallback searches without re-enumerating.
Token handling is identical to Test-SharePointSiteAccess.
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-InputCsv |
No | AccessibleSharePointSites.csv |
Accessible sites CSV from Phase 2 |
-OutputFile |
No | AccessibleSharePointDrives.csv |
Path to write the drives CSV |
-RefreshInterval |
No | 300 |
Seconds between proactive token refreshes |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
| Column | Description |
|---|---|
SiteId |
Graph compound site ID |
SiteWebUrl |
Canonical site URL |
DriveId |
Graph drive ID |
DriveName |
Document library name (e.g. Documents, Site Assets) |
DriveType |
documentLibrary, business, or personal
|
DriveWebUrl |
URL of the document library |
Get-SharePointDrives -Tokens $tokens `
-InputCsv '.\AccessibleSharePointSites.csv'Searches each drive from AccessibleSharePointDrives.csv using GET /drives/{id}/root/search(q='...') instead of the Graph Search API. Use this when Invoke-SearchSharePointByList returns HTTP 403.
Because the drive search endpoint does not support KQL managed properties, the SearchTerm is parsed before use:
-
filetype:andfilename:tokens are stripped from the query and re-applied as a client-side post-filter on returned item names. -
NEAR()operators are removed; their surrounding keywords are kept. - The cleaned remainder is passed as
q=. If nothing survives stripping, the function derives a keyword from the extracted extensions or filenames. - Boolean operators (
AND,OR) and quoted phrases are passed through as-is and handled by the drive search engine.
The Preview column in the output CSV is always blank — the drive search endpoint does not return content snippets.
Output format, parameters, and interactive download behavior are otherwise identical to Invoke-SearchSharePointByList.
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-SearchTerm |
Yes | KQL-style query (same format as Invoke-SearchSharePointByList) |
|
-InputCsv |
No | AccessibleSharePointDrives.csv |
Drives CSV from Get-SharePointDrives
|
-ResultCount |
No | 25 |
Results per page per drive |
-DetectorName |
No | Custom |
Label written to the Detector Name column |
-OutFile |
No | CSV file to append results to | |
-ReportOnly |
No | switch | Suppress the interactive download prompt |
-PageResults |
No | switch | Page through all results via @odata.nextLink
|
-GraphRun |
No | switch | Suppress status output unless hits are found |
-RefreshInterval |
No | 300 |
Seconds between proactive token refreshes |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
# Single search
Invoke-DriveSearchSharePointByList -Tokens $tokens `
-InputCsv '.\AccessibleSharePointDrives.csv' `
-SearchTerm 'password filetype:xlsx' `
-ReportOnly -OutFile '.\fallback_hits.csv'
# Detector loop (fallback variant)
$folderName = "SharePointFallback-" + (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-DriveSearchSharePointByList -Tokens $tokens `
-InputCsv '.\AccessibleSharePointDrives.csv' `
-SearchTerm $detect.SearchQuery `
-DetectorName $detect.DetectorName `
-PageResults -ResultCount 500 `
-ReportOnly -OutFile $spout -GraphRun
}# 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'# Enumerate drives once from the same accessible sites CSV
Get-SharePointDrives -Tokens $tokens `
-InputCsv '.\AccessibleSharePointSites.csv'
# Search each drive directly
Invoke-DriveSearchSharePointByList -Tokens $tokens `
-InputCsv '.\AccessibleSharePointDrives.csv' `
-SearchTerm 'password filetype:xlsx' `
-ReportOnly -OutFile '.\fallback_hits.csv'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.
If the Graph Search API is blocked, swap in the fallback variant — the output CSV format is identical, so results can be written to the same file:
# Run Get-SharePointDrives once before the loop
Get-SharePointDrives -Tokens $tokens `
-InputCsv '.\AccessibleSharePointSites.csv'
$folderName = "SharePointFallback-" + (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-DriveSearchSharePointByList -Tokens $tokens `
-InputCsv '.\AccessibleSharePointDrives.csv' `
-SearchTerm $detect.SearchQuery `
-DetectorName $detect.DetectorName `
-PageResults -ResultCount 500 `
-ReportOnly -OutFile $spout -GraphRun
}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..."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 uses the same Graph Search API and produces the same output columns as Invoke-SearchSharePointAndOneDrive — the difference is that automatic site discovery is replaced by explicit per-site path: scoping in the KQL query, derived from the accessible-sites CSV produced in Phase 2.
GraphRunner is authored by Beau Bullock (@dafthack) and licensed under MIT.