Public/Get-SiteAccess.ps1

function Get-SiteAccess {
    <#
    .SYNOPSIS
        Show WHO can reach a SharePoint site, and HOW - the site-centric mirror of
        Get-UserAccess, with the access nobody was explicitly given surfaced.

    .DESCRIPTION
        Where Get-UserAccess fixes a user and finds the objects they reach, this
        fixes a site and finds the principals that reach it: every user, SharePoint
        group, Entra group, Everyone claim and sharing link on the site, each
        classified Granted (a named user or group) or Overshared (an Everyone claim
        or a sharing link - access nobody was explicitly given, and exactly what
        Copilot will surface to whoever it reaches).

        A SharePoint group is expanded to a member count, so "who, and how many" is
        answered without one row per person.

        This is an admin tool - reading a site's permissions needs Full Control on it.
        Read-only. Pipe results to Export-Csv or work with them directly.

    .PARAMETER SiteUrl
        A single site to report on.

    .PARAMETER SiteUrls
        Several sites to report on.

    .PARAMETER OversharedOnly
        Return only the Overshared routes - the Everyone claims and sharing links.
        The old name -UnexpectedOnly still works as an alias.

    .PARAMETER ExpandMembers
        Expand every group to one row per person (the group is carried in Via),
        turning the report into the full list of people who can actually reach the
        site. Without it, each group is a single row with a member count. Needs Graph
        GroupMember.Read.All to resolve Entra groups.

    .PARAMETER Deep
        Walk below the site - subsites, then the lists and items that have their OWN
        permissions - and report who can reach each. Only objects that break
        inheritance are examined, which is exactly where oversharing (a sharing link,
        a one-off Everyone grant) lives. Add -IncludeItems to descend to files/items.

    .PARAMETER IncludeItems
        With -Deep, descend to individual items and files. The expensive level: every
        list is paged once to find which items broke inheritance.

    .PARAMETER MaxItemsPerList
        Stop after this many items in any one list (a warning names the list when it
        truncates). 0 (the default) means no cap.

    .PARAMETER ClientId
        Entra ID app registration client ID. Not needed with -UseExistingConnection.

    .EXAMPLE
        Get-SiteAccess -SiteUrl "https://contoso.sharepoint.com/sites/Sales" -ClientId $id -Interactive

    .EXAMPLE
        # Just the oversharing across a set of sites
        Get-SiteAccess -SiteUrls $sites -UseExistingConnection -OversharedOnly
    #>

    [CmdletBinding(DefaultParameterSetName = 'Site')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Site')]
        [string]   $SiteUrl,

        [Parameter(Mandatory, ParameterSetName = 'Sites')]
        [string[]] $SiteUrls,

        [Alias('UnexpectedOnly')]
        [switch]   $OversharedOnly,

        [switch]   $ExpandMembers,

        [switch]   $Deep,
        [switch]   $IncludeItems,
        [int]      $MaxItemsPerList = 0,

        [string]       $ClientId,
        [string]       $Tenant,
        [string]       $CertificatePath,
        [securestring] $CertificatePassword,
        [string]       $Thumbprint,
        [switch]       $Interactive,
        [switch]       $ManagedIdentity,
        [switch]       $UseExistingConnection
    )

    if ($IncludeItems -and -not $Deep) {
        throw "-IncludeItems only means something with -Deep. Add -Deep, or drop -IncludeItems."
    }
    if ($Deep -and $UseExistingConnection) {
        # Each subweb is connected to in turn (PnP v2 dropped -Web), which an existing
        # connection cannot do - every subsite would be silently skipped.
        throw "-Deep cannot be combined with -UseExistingConnection: subsites are connected to individually, so credentials (e.g. -ClientId -Interactive) are required."
    }

    $connectSplat = @{
        ClientId = $ClientId; Tenant = $Tenant
        CertificatePath = $CertificatePath; CertificatePassword = $CertificatePassword
        Thumbprint = $Thumbprint; Interactive = $Interactive
        ManagedIdentity = $ManagedIdentity; UseExistingConnection = $UseExistingConnection
    }

    $targets = switch ($PSCmdlet.ParameterSetName) {
        'Site'  { @($SiteUrl) }
        'Sites' { $SiteUrls }
    }

    $total = @($targets).Count
    $n = 0
    $emitted = 0
    # Sites we could not READ - tracked so an incomplete scan can be told apart from
    # a genuine result, exactly as Get-UserAccess does.
    $skipped = [System.Collections.Generic.List[string]]::new()
    # One group -> count/members cache for the whole run: a group on many sites is
    # resolved against Graph once.
    $membershipCache = @{}

    foreach ($site in $targets) {
        $n++
        Write-Progress -Activity "Checking who can reach the site(s)" -Status "$n of $total : $site" `
                       -PercentComplete (($n / [Math]::Max($total,1)) * 100)
        try {
            Connect-IfNeeded -Url $site @connectSplat
            $rows = if ($Deep) {
                Get-SiteAccessDeep -SiteUrl $site -ConnectSplat $connectSplat -IncludeItems:$IncludeItems `
                    -MaxItemsPerList $MaxItemsPerList -MembershipCache $membershipCache -ExpandMembers:$ExpandMembers
            } else {
                Get-SiteAccessForWeb -WebUrl $site -MembershipCache $membershipCache -ExpandMembers:$ExpandMembers
            }
            $rows |
                Where-Object { -not $OversharedOnly -or $_.RouteType -eq 'Overshared' } |
                ForEach-Object { $emitted++; $_ }
        }
        catch {
            $skipped.Add($site)
            Write-Warning "Skipped $site : $($_.Exception.Message)"
        }
    }

    Write-Progress -Activity "Checking who can reach the site(s)" -Completed

    # A skipped site returned nothing because it could not be READ. Surface that as a
    # non-terminating error so a caller can tell an incomplete scan from a clean one.
    if ($skipped.Count -gt 0) {
        $msg = "Scan is INCOMPLETE: $($skipped.Count) of $total site(s) could not be read and were skipped ($($skipped -join ', ')). Results cover only the sites that responded."
        $PSCmdlet.WriteError(
            [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new($msg),
                'UserAccessExplorer.IncompleteScan',
                [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
                $SiteUrl))
    }

    if ($emitted -eq 0 -and $skipped.Count -eq 0) {
        $what = if ($OversharedOnly) { 'oversharing' } else { 'access' }
        Write-Information "No $what found on the $total site(s) checked." -InformationAction Continue
    }
}