Skip to content

SharePointAudit

vaarg edited this page Jun 11, 2026 · 5 revisions

Resume-SharePointAudit.ps1

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.

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, 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.

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)
    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.


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..."

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.


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 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.

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 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 contentSources property only applies to external Microsoft Search Connectors — it cannot be used to scope native SharePoint/OneDrive searches. Scoping is done via path: 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.

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'

Fallback Pre-step — Get-SharePointDrives

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.

Parameters

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

Output columns

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

Example

Get-SharePointDrives -Tokens $tokens `
    -InputCsv '.\AccessibleSharePointSites.csv'

Fallback Search — Invoke-DriveSearchSharePointByList

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: and filename: 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.

Parameters

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

Example

# 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
}

Full Workflow Example

Standard path

# 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'

Fallback path (Graph Search API blocked — HTTP 403 received in Phase 3)

# 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'

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.

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
}

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..."

To download all hits from a CSV produced by Invoke-SearchSharePointByList or Invoke-DriveSearchSharePointByList:

# Download every file recorded in hits.csv
Import-Csv '.\hits.csv' | ForEach-Object {
    Invoke-DriveFileDownload -Tokens $tokens `
        -FileName     $_.'File Name' `
        -DriveItemIDs $_.'DriveItemID'
}

To selectively download based on a filter — for example, only .xlsx files larger than 100 KB:

Import-Csv '.\hits.csv' |
    Where-Object {
        $_.'File Name' -like '*.xlsx' -and
        $_.'Size' -notlike '*Bytes'
    } |
    ForEach-Object {
        Invoke-DriveFileDownload -Tokens $tokens `
            -FileName     $_.'File Name' `
            -DriveItemIDs $_.'DriveItemID'
    }

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 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.

Clone this wiki locally