Move SharePoint permissions script to m365/sharepoint and update docs

This commit is contained in:
Ivo Oskamp 2026-02-24 16:53:25 +01:00
parent 76f808acd5
commit 5514553dd3
3 changed files with 555 additions and 2 deletions

View File

@ -11,6 +11,8 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management
- `get-mailbox-with-permissions.ps1` - `get-mailbox-with-permissions.ps1`
- `scripts/m365/identity/` - `scripts/m365/identity/`
- `m365-users-mfa.ps1` - `m365-users-mfa.ps1`
- `scripts/m365/sharepoint/`
- `get-sharepoint-permissions.ps1`
- `scripts/windows/autopilot/` - `scripts/windows/autopilot/`
- `Get-WindowsAutoPilotInfo.ps1` - `Get-WindowsAutoPilotInfo.ps1`
- `scripts/windows/endpoint/` - `scripts/windows/endpoint/`
@ -32,7 +34,7 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management
## Important Note ## Important Note
Existing script filenames are intentionally **unchanged** to preserve compatibility with existing guides and runbooks. Legacy script filenames are intentionally **unchanged** to preserve compatibility with existing guides and runbooks. New scripts may use consistent lowercase naming.
## Usage ## Usage

View File

@ -4,7 +4,7 @@ This overview helps update guides after the repository restructuring.
## Important ## Important
- Script filenames were not changed. - Legacy script filenames were not changed.
- Only folder locations were updated. - Only folder locations were updated.
## Script Locations ## Script Locations
@ -21,6 +21,8 @@ This overview helps update guides after the repository restructuring.
- New path: `scripts/m365/exchange/get-mailbox-with-permissions.ps1` - New path: `scripts/m365/exchange/get-mailbox-with-permissions.ps1`
- `m365-users-mfa.ps1` - `m365-users-mfa.ps1`
- New path: `scripts/m365/identity/m365-users-mfa.ps1` - New path: `scripts/m365/identity/m365-users-mfa.ps1`
- `Get-SharePointPermissions.ps1`
- New path: `scripts/m365/sharepoint/get-sharepoint-permissions.ps1`
- `schedule_reboot.ps1` - `schedule_reboot.ps1`
- New path: `scripts/windows/endpoint/schedule_reboot.ps1` - New path: `scripts/windows/endpoint/schedule_reboot.ps1`
- `uninstall-MS-Visual-C++-2010.ps1` - `uninstall-MS-Visual-C++-2010.ps1`

View File

@ -0,0 +1,549 @@
#Requires -Modules Microsoft.Graph.Authentication, ImportExcel
<#
.SYNOPSIS
Audits unique SharePoint permissions at library, folder, and file level across all sites.
.DESCRIPTION
Reads site URLs from a CSV export (SharePoint Admin Center) and retrieves all
permissions via the Microsoft Graph API. Results are streamed directly to CSV
after each site to minimize memory usage. An Excel report is generated at the end.
=========================================================================
REQUIRED AZURE APP REGISTRATION PERMISSIONS
=========================================================================
Create an App Registration in Azure Portal (Entra ID) with the following
APPLICATION permissions (not Delegated) and grant Admin Consent for all:
Microsoft Graph:
- Sites.Read.All : Read all SharePoint site collections and their contents
- Files.Read.All : Read all files in all site document libraries
- User.Read.All : Resolve user IDs to display names / UPNs
- Group.Read.All : Resolve group IDs to display names
How to set up:
1. Go to Azure Portal > Microsoft Entra ID > App Registrations > New Registration
2. Give it a name (e.g. "SharePoint Permissions Audit")
3. Go to API Permissions > Add a Permission > Microsoft Graph > Application Permissions
4. Add: Sites.Read.All, Files.Read.All, User.Read.All, Group.Read.All
5. Click "Grant Admin Consent for <tenant>"
6. Go to Certificates & Secrets > New Client Secret
7. Copy the SECRET VALUE (not the Secret ID) immediately after creation
8. Note down the Application (client) ID and the Directory (tenant) ID
=========================================================================
.PARAMETER TenantId
Azure AD / Entra ID Tenant ID (found on the App Registration overview page).
.PARAMETER ClientId
Application (Client) ID of the App Registration.
.PARAMETER ClientSecret
Client Secret VALUE (not the Secret ID) of the App Registration.
.PARAMETER SitesCsvPath
Path to the CSV file exported from the SharePoint Admin Center.
The CSV must contain a column named 'URL' with the site URLs.
Export: SharePoint Admin Center > Active Sites > Export to CSV
.PARAMETER OutputPath
Folder where output files (CSV, Excel, log) will be saved. Defaults to current directory.
.PARAMETER ExcludedSites
Optional array of site URLs to skip during the scan.
.PARAMETER IncludeFileLevel
Switch to enable file-level permission scanning in addition to libraries and folders.
Warning: this significantly increases scan time on large tenants.
.EXAMPLE
# Scan all sites using the exported CSV
.\Get-SharePointPermissions.ps1 `
-TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientSecret "your-secret-value-here" `
-SitesCsvPath "C:\scripts\Sites.csv" `
-OutputPath "C:\scripts\Reports"
.EXAMPLE
# Include file-level permissions and skip a specific site
.\Get-SharePointPermissions.ps1 `
-TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-ClientSecret "your-secret-value-here" `
-SitesCsvPath "C:\scripts\Sites.csv" `
-OutputPath "C:\scripts\Reports" `
-IncludeFileLevel `
-ExcludedSites @("https://contoso.sharepoint.com/sites/HR")
.NOTES
Monitor progress: Get-Content "C:\scripts\Reports\SharePoint_Permissions_*.csv" -Wait -Tail 20
Do NOT open the CSV in Excel while the script is running - this locks the file.
Use Notepad or Notepad++ to view the CSV during the scan.
#>
param(
[Parameter(Mandatory)]
[string]$TenantId,
[Parameter(Mandatory)]
[string]$ClientId,
[Parameter(Mandatory)]
[string]$ClientSecret,
[Parameter(Mandatory)]
[string]$SitesCsvPath,
[string]$OutputPath = (Get-Location).Path,
[string[]]$ExcludedSites = @(),
[switch]$IncludeFileLevel
)
$ErrorActionPreference = 'Stop'
#region ── Module check ──────────────────────────────────────────────────────
# Ensure required PowerShell modules are installed and loaded.
# Microsoft.Graph.Authentication : Connect to Graph API and make REST calls
# ImportExcel : Generate formatted Excel reports without Excel installed
foreach ($mod in @('Microsoft.Graph.Authentication', 'ImportExcel')) {
if (-not (Get-Module -ListAvailable -Name $mod)) {
Write-Host "[SETUP] Installing module '$mod'..." -ForegroundColor Yellow
Install-Module $mod -Scope CurrentUser -Force -AllowClobber
}
Import-Module $mod -ErrorAction Stop
}
#endregion
#region ── Output file paths ─────────────────────────────────────────────────
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$csvOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.csv"
$xlsxOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.xlsx"
$logOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.log"
if (-not (Test-Path $OutputPath)) {
New-Item -ItemType Directory -Path $OutputPath | Out-Null
}
#endregion
#region ── Logging ───────────────────────────────────────────────────────────
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$entry = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Add-Content -Path $logOut -Value $entry
switch ($Level) {
'ERROR' { Write-Host $entry -ForegroundColor Red }
'WARNING' { Write-Host $entry -ForegroundColor Yellow }
'SUCCESS' { Write-Host $entry -ForegroundColor Green }
default { Write-Host $entry }
}
}
#endregion
#region ── Authentication ────────────────────────────────────────────────────
# Uses the OAuth2 client credentials flow directly via Invoke-RestMethod.
# This avoids MSAL.PS compatibility issues with PowerShell strict mode.
Write-Log "Script started."
try {
$tokenBody = @{
grant_type = 'client_credentials'
client_id = $ClientId
client_secret = $ClientSecret
scope = 'https://graph.microsoft.com/.default'
}
$tokenResponse = Invoke-RestMethod `
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
-Method POST -Body $tokenBody -ContentType 'application/x-www-form-urlencoded'
Connect-MgGraph `
-AccessToken ($tokenResponse.access_token | ConvertTo-SecureString -AsPlainText -Force) `
-NoWelcome
Write-Log "Authentication successful." -Level 'SUCCESS'
} catch {
Write-Log "Authentication failed: $_" -Level 'ERROR'
exit 1
}
#endregion
#region ── Graph REST helper with automatic pagination ───────────────────────
# Graph API returns results in pages (default 200 items per page).
# This function follows @odata.nextLink until all results are retrieved.
function Invoke-GraphGet {
param([string]$Uri)
$results = [System.Collections.Generic.List[object]]::new()
$nextUri = $Uri
while ($nextUri) {
$resp = Invoke-MgGraphRequest -Uri $nextUri -Method GET -OutputType PSObject
$nextUri = $null
if ($resp.PSObject.Properties.Name -contains '@odata.nextLink') {
$nextUri = $resp.'@odata.nextLink'
}
if ($resp.PSObject.Properties.Name -contains 'value') {
foreach ($item in $resp.value) { $results.Add($item) }
} elseif ($null -ne $resp) {
$results.Add($resp)
}
}
return $results
}
#endregion
#region ── Recursive drive item retrieval ────────────────────────────────────
# Recursively fetches all folders (and optionally files) within a drive.
# Recurses into subfolders to build a complete tree.
function Get-AllDriveItems {
param([string]$DriveId, [string]$ItemId = 'root')
$all = [System.Collections.Generic.List[object]]::new()
try {
$children = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/children"
foreach ($child in $children) {
$all.Add($child)
if ($child.PSObject.Properties.Name -contains 'folder') {
$sub = Get-AllDriveItems -DriveId $DriveId -ItemId $child.id
foreach ($s in $sub) { $all.Add($s) }
}
}
} catch {
Write-Log " Error retrieving children of '$ItemId': $_" -Level 'WARNING'
}
return $all
}
#endregion
#region ── Principal name lookup with caching ────────────────────────────────
# Resolves Azure AD user/group IDs to readable display names.
# Results are cached to avoid duplicate API calls for the same principal.
$principalCache = @{}
function Get-PrincipalName {
param([string]$Id, [string]$Type)
if (-not $Id) { return 'Unknown' }
$key = "$Type|$Id"
if ($principalCache.ContainsKey($key)) { return $principalCache[$key] }
$name = "$Type`: $Id"
try {
if ($Type -eq 'user') {
$u = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/users/${Id}?`$select=displayName,userPrincipalName" -Method GET -OutputType PSObject
$name = "$($u.displayName) ($($u.userPrincipalName))"
} elseif ($Type -eq 'group') {
$g = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/${Id}?`$select=displayName" -Method GET -OutputType PSObject
$name = $g.displayName
}
} catch { }
$principalCache[$key] = $name
return $name
}
#endregion
#region ── Permission record builder ─────────────────────────────────────────
# Retrieves permissions for a single drive item and returns structured records.
# ForceInclude: always return records even if only inherited permissions exist (used for library root).
# Without ForceInclude: only returns records if explicit (non-inherited) grants are found.
function Get-ItemPermissionRecords {
param(
[string]$DriveId,
[string]$ItemId,
[string]$ItemName,
[string]$ItemPath,
[string]$ItemType, # 'Library', 'Folder', or 'File'
[string]$SiteName,
[string]$SiteUrl,
[string]$LibraryName,
[switch]$ForceInclude
)
$records = [System.Collections.Generic.List[object]]::new()
try {
$perms = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/permissions"
} catch {
Write-Log " Error retrieving permissions for '$ItemPath': $_" -Level 'WARNING'
return $records
}
if (-not $perms -or $perms.Count -eq 0) { return $records }
# Only include items with explicit direct grants (not just sharing links), unless ForceInclude
$hasExplicitGrants = $perms | Where-Object {
($_.PSObject.Properties.Name -contains 'grantedToV2' -and $_.grantedToV2) -or
($_.PSObject.Properties.Name -contains 'grantedTo' -and $_.grantedTo)
}
if (-not $ForceInclude -and -not $hasExplicitGrants) { return $records }
foreach ($perm in $perms) {
$roles = if ($perm.PSObject.Properties.Name -contains 'roles' -and $perm.roles) { $perm.roles -join ', ' } else { 'read' }
$grantedTo = 'Unknown'
$linkType = ''
# Resolve who the permission is granted to (Graph API v2 properties take priority)
if ($perm.PSObject.Properties.Name -contains 'grantedToV2' -and $perm.grantedToV2) {
$g = $perm.grantedToV2
if ($g.PSObject.Properties.Name -contains 'user' -and $g.user) { $grantedTo = Get-PrincipalName -Id $g.user.id -Type 'user' }
elseif ($g.PSObject.Properties.Name -contains 'group' -and $g.group) { $grantedTo = Get-PrincipalName -Id $g.group.id -Type 'group' }
elseif ($g.PSObject.Properties.Name -contains 'siteUser' -and $g.siteUser) { $grantedTo = $g.siteUser.displayName }
elseif ($g.PSObject.Properties.Name -contains 'siteGroup' -and $g.siteGroup) { $grantedTo = $g.siteGroup.displayName }
} elseif ($perm.PSObject.Properties.Name -contains 'grantedToIdentitiesV2' -and $perm.grantedToIdentitiesV2) {
# Multiple recipients (e.g. sharing link used by multiple people)
$names = foreach ($identity in $perm.grantedToIdentitiesV2) {
if ($identity.PSObject.Properties.Name -contains 'user' -and $identity.user) { Get-PrincipalName -Id $identity.user.id -Type 'user' }
elseif ($identity.PSObject.Properties.Name -contains 'group' -and $identity.group) { Get-PrincipalName -Id $identity.group.id -Type 'group' }
elseif ($identity.PSObject.Properties.Name -contains 'siteUser' -and $identity.siteUser) { $identity.siteUser.displayName }
else { 'Unknown' }
}
$grantedTo = ($names | Where-Object { $_ }) -join '; '
} elseif ($perm.PSObject.Properties.Name -contains 'grantedTo' -and $perm.grantedTo) {
# Fallback: older grantedTo property
$g = $perm.grantedTo
if ($g.PSObject.Properties.Name -contains 'user' -and $g.user) { $grantedTo = "$($g.user.displayName) ($($g.user.email))" }
elseif ($g.PSObject.Properties.Name -contains 'group' -and $g.group) { $grantedTo = $g.group.displayName }
}
# Detect sharing links (anonymous, organization-wide, or specific people links)
if ($perm.PSObject.Properties.Name -contains 'link' -and $perm.link) {
$linkType = "SharingLink ($($perm.link.type), $($perm.link.scope))"
if ($grantedTo -eq 'Unknown') { $grantedTo = $linkType }
}
$records.Add([PSCustomObject]@{
SiteName = $SiteName
SiteUrl = $SiteUrl
Library = $LibraryName
ItemType = $ItemType
ItemName = $ItemName
ItemPath = $ItemPath
GrantedTo = $grantedTo
Permissions = $roles
LinkType = $linkType
ScannedAt = (Get-Date -Format 'yyyy-MM-dd HH:mm')
})
}
return $records
}
#endregion
#region ── Load site list from CSV ───────────────────────────────────────────
# Reads site URLs from the SharePoint Admin Center export CSV.
# Export location: SharePoint Admin Center > Active Sites > Export
Write-Log "Loading sites from CSV: $SitesCsvPath"
if (-not (Test-Path $SitesCsvPath)) {
Write-Log "CSV file not found: $SitesCsvPath" -Level 'ERROR'
exit 1
}
$csvSites = Import-Csv -Path $SitesCsvPath
$urlColumn = $csvSites[0].PSObject.Properties.Name | Where-Object { $_ -match '^url$' } | Select-Object -First 1
if (-not $urlColumn) {
Write-Log "No 'URL' column found in CSV. Available columns: $($csvSites[0].PSObject.Properties.Name -join ', ')" -Level 'ERROR'
exit 1
}
$siteUrls = $csvSites.$urlColumn | Where-Object {
$_ -and $_ -notmatch '/personal/' -and $_ -notin $ExcludedSites
}
Write-Log "Sites loaded from CSV: $($siteUrls.Count)" -Level 'SUCCESS'
#endregion
#region ── Main scan loop ────────────────────────────────────────────────────
# Streams permission records directly to CSV after each site to keep memory usage low.
# The CSV file is written incrementally - do NOT open it in Excel during the scan.
$csvHeaders = 'SiteName;SiteUrl;Library;ItemType;ItemName;ItemPath;GrantedTo;Permissions;LinkType;ScannedAt'
Set-Content -Path $csvOut -Value $csvHeaders -Encoding UTF8
$recordCount = 0
function Write-RecordToCsv {
# Appends one or more permission records to the CSV file immediately.
param([object[]]$Records)
foreach ($rec in $Records) {
$line = '"{0}";"{1}";"{2}";"{3}";"{4}";"{5}";"{6}";"{7}";"{8}";"{9}"' -f `
$rec.SiteName, $rec.SiteUrl, $rec.Library, $rec.ItemType, `
$rec.ItemName, $rec.ItemPath, $rec.GrantedTo, $rec.Permissions, `
$rec.LinkType, $rec.ScannedAt
Add-Content -Path $csvOut -Value $line -Encoding UTF8
$script:recordCount++
}
}
$siteIndex = 0
$total = $siteUrls.Count
foreach ($siteUrl in $siteUrls) {
$siteIndex++
$pct = [math]::Round(($siteIndex / $total) * 100)
Write-Progress -Activity "SharePoint Permissions Audit" `
-Status "[$siteIndex/$total] $siteUrl | Total records: $recordCount" `
-PercentComplete $pct
# Resolve site URL to Graph site ID using the hostname:/path format
try {
$uri = [Uri]$siteUrl
$hostPart = $uri.Host
$pathPart = $uri.AbsolutePath.TrimStart('/')
if ($pathPart) {
$siteObj = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/sites/${hostPart}:/${pathPart}" -Method GET -OutputType PSObject
} else {
$siteObj = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/sites/${hostPart}" -Method GET -OutputType PSObject
}
$siteId = $siteObj.id
$siteName = if ($siteObj.PSObject.Properties.Name -contains 'displayName' -and $siteObj.displayName) { $siteObj.displayName } else { $siteObj.name }
} catch {
Write-Log "[$siteIndex/$total] Failed to resolve site ID for '$siteUrl': $_" -Level 'WARNING'
continue
}
Write-Log "[$siteIndex/$total] $siteName"
# Get all document libraries for this site (excludes system lists)
try {
$drives = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/sites/$siteId/drives"
$drives = $drives | Where-Object { $_.driveType -eq 'documentLibrary' }
} catch {
Write-Log " Error retrieving document libraries: $_" -Level 'WARNING'
continue
}
$recordsBefore = $recordCount
foreach ($drive in $drives) {
$libName = $drive.name
$driveId = $drive.id
Write-Log " Library: $libName"
# Scan library root permissions (always included)
try {
$root = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/drives/$driveId/root" -Method GET -OutputType PSObject
$records = Get-ItemPermissionRecords -DriveId $driveId -ItemId $root.id `
-ItemName $libName -ItemPath "/" -ItemType 'Library' `
-SiteName $siteName -SiteUrl $siteUrl -LibraryName $libName -ForceInclude
Write-RecordToCsv -Records $records
} catch {
Write-Log " Error scanning library root '$libName': $_" -Level 'WARNING'
}
# Scan all folders (and files if -IncludeFileLevel is set)
# Only items with unique (broken) permissions are included
try {
$items = Get-AllDriveItems -DriveId $driveId
foreach ($item in $items) {
$isFolder = $item.PSObject.Properties.Name -contains 'folder'
$isFile = $item.PSObject.Properties.Name -contains 'file'
if (-not $isFolder -and -not ($IncludeFileLevel -and $isFile)) { continue }
$itemType = if ($isFolder) { 'Folder' } else { 'File' }
$parentPath = ''
if ($item.PSObject.Properties.Name -contains 'parentReference' -and
$item.parentReference.PSObject.Properties.Name -contains 'path') {
$parentPath = $item.parentReference.path -replace '.*?/root:', ''
}
$cleanPath = "$parentPath/$($item.name)"
$records = Get-ItemPermissionRecords -DriveId $driveId -ItemId $item.id `
-ItemName $item.name -ItemPath $cleanPath -ItemType $itemType `
-SiteName $siteName -SiteUrl $siteUrl -LibraryName $libName
Write-RecordToCsv -Records $records
}
} catch {
Write-Log " Error scanning items in '$libName': $_" -Level 'WARNING'
}
}
$added = $recordCount - $recordsBefore
if ($added -gt 0) {
Write-Log " -> $added new records written (total: $recordCount)" -Level 'SUCCESS'
}
}
Write-Progress -Activity "SharePoint Permissions Audit" -Completed
Write-Log "Scan complete. $recordCount permission records found." -Level 'SUCCESS'
#endregion
#region ── Build Excel report from CSV ───────────────────────────────────────
# Reads the completed CSV and generates a multi-sheet Excel report.
# Sheet 1 - All Permissions : full dataset
# Sheet 2 - Summary by Site : record counts grouped per site
# Sheet 3 - Top 20 Risk Items: items with the most unique permission grants
# Sheet 4 - Sharing Links : all items shared via external/anonymous links
Write-Log "Building Excel report from CSV..."
if ($recordCount -gt 0) {
$allRecords = Import-Csv -Path $csvOut -Delimiter ';' -Encoding UTF8
# Sheet 1: Full permission list
$allRecords | Export-Excel -Path $xlsxOut -WorksheetName 'All Permissions' `
-TableName 'TblAllPermissions' -TableStyle Medium9 -AutoSize -FreezeTopRow -BoldTopRow
# Sheet 2: Summary grouped by site
$allRecords | Group-Object SiteName | Select-Object `
@{N='Site'; E={$_.Name}},
@{N='Total'; E={$_.Count}},
@{N='Libraries'; E={($_.Group.Library | Sort-Object -Unique).Count}},
@{N='Folders'; E={($_.Group | Where-Object ItemType -eq 'Folder').Count}},
@{N='Files'; E={($_.Group | Where-Object ItemType -eq 'File').Count}} |
Sort-Object Total -Descending |
Export-Excel -Path $xlsxOut -WorksheetName 'Summary by Site' `
-TableName 'TblSummary' -TableStyle Medium2 -AutoSize -FreezeTopRow -BoldTopRow -Append
# Sheet 3: Top 20 items with most unique grants (highest permission fragmentation risk)
$allRecords | Group-Object ItemPath | Select-Object `
@{N='Site'; E={($_.Group | Select-Object -First 1).SiteName}},
@{N='Library'; E={($_.Group | Select-Object -First 1).Library}},
@{N='Type'; E={($_.Group | Select-Object -First 1).ItemType}},
@{N='Item'; E={($_.Group | Select-Object -First 1).ItemName}},
@{N='Path'; E={($_.Group | Select-Object -First 1).ItemPath}},
@{N='Unique Grants'; E={$_.Count}} |
Sort-Object 'Unique Grants' -Descending | Select-Object -First 20 |
Export-Excel -Path $xlsxOut -WorksheetName 'Top 20 Risk Items' `
-TableName 'TblTopRisk' -TableStyle Medium6 -AutoSize -FreezeTopRow -BoldTopRow -Append
# Sheet 4: Sharing links (potential external exposure)
$links = @($allRecords | Where-Object { $_.LinkType -ne '' })
if ($links.Count -gt 0) {
$links | Export-Excel -Path $xlsxOut -WorksheetName 'Sharing Links' `
-TableName 'TblSharingLinks' -TableStyle Medium3 -AutoSize -FreezeTopRow -BoldTopRow -Append
}
Write-Log "Excel report saved: $xlsxOut" -Level 'SUCCESS'
} else {
Write-Log "No records found - Excel report not created." -Level 'WARNING'
}
#endregion
Disconnect-MgGraph | Out-Null
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " Scan complete!" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Records : $recordCount"
Write-Host " CSV : $csvOut"
Write-Host " Excel : $xlsxOut"
Write-Host " Log : $logOut"
Write-Host "========================================`n" -ForegroundColor Cyan