Public/Get-SPMSharingOverview.ps1
|
function Get-SPMSharingOverview { <# .SYNOPSIS Lists every sharing link and external user of a site - without walking the folder tree. .DESCRIPTION Sharing links live as site collection groups named SharingLinks.<itemId>.<kind>.<shareId>, no matter how deep the shared file sits. Enumerating those groups therefore reveals every link of a site in a single call, including the people behind each link. Each link is then enriched with its target, creator, creation date and expiration, and folders shared anonymously or organization-wide are checked for suspicious filenames. Cost is roughly two calls per site plus one per shared item - seconds instead of the minutes a full permission scan needs. Use it for daily sharing/guest monitoring and keep Invoke-SPPermissionScan for the complete permission picture. Not covered by design: broken inheritance, folder-level permissions and direct per-item grants that were made without a sharing link. .PARAMETER SiteUrl One or more site URLs to inspect. .PARAMETER AllSites Inspects all (non-system) sites of the tenant. Requires -TenantAdminUrl. .PARAMETER TenantAdminUrl URL of the SharePoint admin center, e.g. https://contoso-admin.sharepoint.com .PARAMETER IncludeGuests Also list the external users (guests) present in each site's user information list. .PARAMETER StatusCallback Script block invoked with a status string per site - for hosts that show progress. .PARAMETER ClientId Entra app registration to authenticate with. Without it an existing PnP connection is reused. .EXAMPLE Get-SPMSharingOverview -SiteUrl https://contoso.sharepoint.com/sites/hr -ClientId $appId -Interactive .EXAMPLE Get-SPMSharingOverview -AllSites -TenantAdminUrl https://contoso-admin.sharepoint.com ` -ClientId $appId -Tenant contoso.onmicrosoft.com -CertificatePath .\app.pfx -IncludeGuests | ForEach-Object { $_.links } | Where-Object risk -eq 2 | Format-Table site, target, createdBy, expiration #> [CmdletBinding(DefaultParameterSetName = 'Site')] param( [Parameter(Mandatory, ParameterSetName = 'Site', Position = 0)] [string[]]$SiteUrl, [Parameter(Mandatory, ParameterSetName = 'Tenant')] [switch]$AllSites, [Parameter(Mandatory, ParameterSetName = 'Tenant')] [string]$TenantAdminUrl, [switch]$IncludeGuests, [scriptblock]$StatusCallback, [string]$ClientId, [string]$Tenant, [string]$CertificatePath, [securestring]$CertificatePassword, [string]$Thumbprint, [switch]$Interactive ) if ($CertificatePath -and -not $Tenant) { throw 'Certificate authentication requires -Tenant (e.g. contoso.onmicrosoft.com).' } $script:SPMProgressAction = $StatusCallback $auth = @{ ClientId = $ClientId Tenant = $Tenant CertificatePath = $CertificatePath CertificatePassword = $CertificatePassword Thumbprint = $Thumbprint Interactive = $Interactive.IsPresent } $targetUrls = @() if ($PSCmdlet.ParameterSetName -eq 'Tenant') { Connect-SPMSite -Url $TenantAdminUrl -Auth $auth $excludedTemplates = '^(SRCHCEN|SPSMSITEHOST|APPCATALOG|POINTPUBLISHINGHUB|POINTPUBLISHINGTOPIC|EDISC|REDIRECTSITE|TEAMCHANNEL)' $targetUrls = @(Invoke-SPMWithRetry { Get-PnPTenantSite } | Where-Object { $_.Template -notmatch $excludedTemplates } | Select-Object -ExpandProperty Url) } else { $targetUrls = @($SiteUrl) } $linkKind = @{ Flexible = 'Specific people' OrganizationView = 'Organization (view)' OrganizationEdit = 'Organization (edit)' AnonymousView = 'Anyone with the link (view)' AnonymousEdit = 'Anyone with the link (edit)' Direct = 'Direct access' } $extMark = '#ext#|urn:spo:guest' $systemAccount = '(?i)app@sharepoint|SHAREPOINT\\system|spo-grid-all-users' $index = 0 foreach ($url in $targetUrls) { $index++ Invoke-SPMProgress -Id 1 -Activity 'SPPermissionMatrix sharing overview' ` -Status "Site $index/$($targetUrls.Count): $url" ` -PercentComplete ([int](($index - 1) / [Math]::Max($targetUrls.Count, 1) * 100)) try { Connect-SPMSite -Url $url -Auth $auth } catch { Write-Warning "Site '$url' could not be opened: $($_.Exception.Message)" continue } $title = $url try { $title = [string](Invoke-SPMWithRetry { Invoke-PnPSPRestMethod -Url "/_api/web?`$select=Title" }).Title } catch { Write-Verbose "Title of '$url' not available - falling back to the URL." } # one call returns every sharing-link group of the site collection incl. members $groups = @() try { $groups = @((Invoke-SPMWithRetry { Invoke-PnPSPRestMethod -Url "/_api/web/sitegroups?`$select=Id,Title,LoginName,Users/Title,Users/LoginName,Users/Email,Users/UserPrincipalName,Users/PrincipalType&`$expand=Users&`$top=500" }).value) } catch { Write-Warning "Sharing groups of '$url' could not be read: $($_.Exception.Message)" } $cache = @{} $links = [System.Collections.Generic.List[object]]::new() foreach ($g in $groups) { if ([string]$g.Title -notmatch '^SharingLinks\.([0-9a-fA-F-]{36})\.([A-Za-z]+)\.([0-9a-fA-F-]{36})') { continue } $itemId = $Matches[1] $kind = $Matches[2] $shareId = $Matches[3] # nometadata returns an array, verbose OData an object with .results $members = if ($null -eq $g.Users) { @() } elseif ($g.Users -is [System.Array]) { $g.Users } elseif ($g.Users.PSObject.Properties['results']) { @($g.Users.results) } else { @($g.Users) } $people = @($members | Where-Object { [int]$_.PrincipalType -eq 1 -and ([string]$_.LoginName) -notmatch $systemAccount } | ForEach-Object { [pscustomobject]@{ name = [string]$_.Title upn = [string]($_.UserPrincipalName ?? $_.Email) external = ((([string]$_.LoginName) + ' ' + ([string]$_.UserPrincipalName)) -match $extMark) } }) $info = Resolve-SPMSharingLink -ItemId $itemId -Kind $kind -ShareId $shareId -Cache $cache $risk = if ($kind -match '^Anonymous') { 2 } elseif ($kind -match '^Organization') { 1 } else { 0 } $links.Add([pscustomobject]@{ site = $title siteUrl = $url shareId = $shareId itemId = $itemId kind = $kind kindLabel = [string]($linkKind[$kind] ?? $kind) risk = $risk target = $(if ($info.target) { ($info.target -split '/')[-1] } else { $null }) targetPath = $info.target targetType = $info.targetType createdBy = $info.createdBy created = $info.created expiration = $info.expiration people = @($people) peopleCount = @($people).Count suspiciousFiles = @($info.suspiciousFiles) }) } $guests = @() if ($IncludeGuests) { try { $users = @((Invoke-SPMWithRetry { Invoke-PnPSPRestMethod -Url "/_api/web/siteusers?`$select=Title,LoginName,Email,UserPrincipalName,PrincipalType&`$top=5000" }).value) $guests = @($users | Where-Object { [int]$_.PrincipalType -eq 1 -and ((([string]$_.LoginName) + ' ' + ([string]$_.UserPrincipalName)) -match $extMark) } | ForEach-Object { [pscustomobject]@{ name = [string]$_.Title upn = [string]($_.UserPrincipalName ?? $_.Email) email = [string]$_.Email } }) } catch { Write-Warning "Site users of '$url' could not be read: $($_.Exception.Message)" } } [pscustomobject]@{ url = $url title = $title scannedUtc = (Get-Date).ToUniversalTime().ToString('o') links = @($links) guests = @($guests) stats = [pscustomobject]@{ linkCount = @($links).Count anonymousCount = @($links | Where-Object { $_.risk -eq 2 }).Count organizationCount = @($links | Where-Object { $_.risk -eq 1 }).Count specificCount = @($links | Where-Object { $_.risk -eq 0 }).Count neverExpiring = @($links | Where-Object { $_.risk -ge 1 -and -not $_.expiration }).Count suspiciousItems = @($links | Where-Object { @($_.suspiciousFiles).Count -gt 0 }).Count guestCount = @($guests).Count } } } Invoke-SPMProgress -Completed } |