From 5514553dd35b0265fa6a20643f216c8af6ba99b0 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 24 Feb 2026 16:53:25 +0100 Subject: [PATCH] Move SharePoint permissions script to m365/sharepoint and update docs --- README.md | 4 +- docs/runbooks/index.md | 4 +- .../sharepoint/get-sharepoint-permissions.ps1 | 549 ++++++++++++++++++ 3 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 scripts/m365/sharepoint/get-sharepoint-permissions.ps1 diff --git a/README.md b/README.md index 1281e28..cc3df38 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management - `get-mailbox-with-permissions.ps1` - `scripts/m365/identity/` - `m365-users-mfa.ps1` +- `scripts/m365/sharepoint/` + - `get-sharepoint-permissions.ps1` - `scripts/windows/autopilot/` - `Get-WindowsAutoPilotInfo.ps1` - `scripts/windows/endpoint/` @@ -32,7 +34,7 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management ## 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 diff --git a/docs/runbooks/index.md b/docs/runbooks/index.md index 76731a8..81eec06 100644 --- a/docs/runbooks/index.md +++ b/docs/runbooks/index.md @@ -4,7 +4,7 @@ This overview helps update guides after the repository restructuring. ## Important -- Script filenames were not changed. +- Legacy script filenames were not changed. - Only folder locations were updated. ## 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` - `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` - New path: `scripts/windows/endpoint/schedule_reboot.ps1` - `uninstall-MS-Visual-C++-2010.ps1` diff --git a/scripts/m365/sharepoint/get-sharepoint-permissions.ps1 b/scripts/m365/sharepoint/get-sharepoint-permissions.ps1 new file mode 100644 index 0000000..f18a1f5 --- /dev/null +++ b/scripts/m365/sharepoint/get-sharepoint-permissions.ps1 @@ -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 " + 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