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
}