Private/Get-SiteAccessDeep.ps1

function Get-SiteAccessDeep {
    <#
        The site-centric deep engine: one site, walked all the way down -
        web -> subwebs -> lists/libraries -> items - reporting who can reach each
        object that has its OWN permissions.

        Same pruning idea as the user-centric deep engine (Get-UserAccessDeep):
        permissions inherit, so only objects with HasUniqueRoleAssignments need their
        own walk; everything else has its parent's answer. That pruned set is also
        exactly where oversharing lives - a sharing link or a one-off Everyone grant
        is what breaks inheritance in the first place.

        Unlike the user-centric engine there is NO effective-permission pass here: the
        subject is the object, so we enumerate its principals directly. That makes the
        item level cheaper - a role-assignment read per unique item, no per-item
        permission round trip.

        Assumes Connect-PnPOnline has already run for $SiteUrl. Subwebs are connected
        to in turn, because PnP v2 dropped -Web from these cmdlets.
    #>

    param(
        [Parameter(Mandatory)] [string] $SiteUrl,
        [hashtable] $ConnectSplat = @{},
        [switch]    $IncludeItems,
        [int]       $MaxItemsPerList = 0,
        [hashtable] $MembershipCache = @{},
        [switch]    $ExpandMembers
    )

    $common = @{ MembershipCache = $MembershipCache; ExpandMembers = $ExpandMembers }

    $webs = @(Invoke-WithRetry -Because 'Get-PnPSubWeb' -Action {
        Get-PnPSubWeb -Recurse -IncludeRootWeb
    })
    Write-Verbose "Webs to walk: $($webs.Count)"

    foreach ($wStub in $webs) {
        $webUrl = $wStub.Url
        try {
            if ($webUrl -ne $SiteUrl) { Connect-IfNeeded -Url $webUrl @ConnectSplat }
        } catch {
            Write-Warning "Skipped web $webUrl : $($_.Exception.Message)"
            continue
        }

        $web = Invoke-WithRetry -Because 'Get-PnPWeb' -Action { Get-PnPWeb }
        $webTitle = Get-PnPProperty -ClientObject $web -Property Title
        if ([string]::IsNullOrWhiteSpace($webTitle)) { $webTitle = $webUrl }

        $isRoot = ($webUrl -eq $SiteUrl)

        # A subweb that inherits has the same principals as its parent - already
        # reported. Walk it only when it is the root or broke inheritance.
        $webUnique = if ($isRoot) { $true } else {
            Get-PnPProperty -ClientObject $web -Property HasUniqueRoleAssignments
        }
        if ($webUnique) {
            $webKind = if ($isRoot) { 'Site' } else { 'Subsite' }
            Get-SiteAccessRowsForObject -Object $web -SiteUrl $webUrl -SiteTitle $webTitle `
                -ObjectKind $webKind -ObjectTitle $webTitle -ObjectUrl $webUrl `
                -PermUrl "$webUrl/_layouts/15/user.aspx" @common
        }

        # ---- lists ----------------------------------------------------------
        $lists = @()
        try {
            $lists = @(Invoke-WithRetry -Because 'Get-PnPList' -Action {
                Get-PnPList -Includes HasUniqueRoleAssignments, ItemCount, Hidden, BaseType
            }) | Where-Object { -not $_.Hidden }
        } catch {
            Write-Warning "Cannot read lists on $webUrl : $($_.Exception.Message)"
            continue
        }

        foreach ($list in $lists) {
            $listUrl  = "$webUrl/$($list.Title)"
            $listKind = if ("$($list.BaseType)" -eq 'DocumentLibrary') { 'Library' } else { 'List' }

            if ($list.HasUniqueRoleAssignments) {
                Get-SiteAccessRowsForObject -Object $list -SiteUrl $webUrl -SiteTitle $webTitle `
                    -ObjectKind $listKind -ObjectTitle $list.Title -ObjectUrl $listUrl `
                    -PermUrl "$webUrl/_layouts/15/user.aspx?obj=$($list.Id)%2Cdoclib&List=$($list.Id)" @common
            }

            if (-not $IncludeItems -or $list.ItemCount -eq 0) { continue }

            # ---- items: the pruning pass ------------------------------------
            $items = @()
            try {
                $items = @(Invoke-WithRetry -Because "page items of $($list.Title)" -Action {
                    Get-PnPListItem -List $list -PageSize 2000 -Includes HasUniqueRoleAssignments, Id
                })
            } catch {
                Write-Warning "Cannot page '$($list.Title)' on $webUrl : $($_.Exception.Message)"
                continue
            }

            if ($MaxItemsPerList -gt 0 -and $items.Count -gt $MaxItemsPerList) {
                Write-Warning "'$($list.Title)' has $($items.Count) items; capped at $MaxItemsPerList. Results are INCOMPLETE for this list."
                $items = $items[0..($MaxItemsPerList - 1)]
            }

            $unique = @($items | Where-Object { $_.HasUniqueRoleAssignments })
            if ($unique.Count -eq 0) { continue }
            Write-Verbose "$($list.Title): $($unique.Count) of $($items.Count) items broke inheritance"

            foreach ($it in $unique) {
                $fv       = $it.FieldValues
                $name     = if ($fv.ContainsKey('FileLeafRef')) { $fv['FileLeafRef'] } else { "Item $($it.Id)" }
                $ref      = if ($fv.ContainsKey('FileRef'))     { $fv['FileRef'] }     else { $listUrl }
                $itemKind = switch ("$($it.FileSystemObjectType)") { 'Folder' { 'Folder' } 'File' { 'File' } default { 'Item' } }

                Get-SiteAccessRowsForObject -Object $it -SiteUrl $webUrl -SiteTitle $webTitle `
                    -ObjectKind $itemKind -ObjectTitle $name -ObjectUrl $ref `
                    -PermUrl "$webUrl/_layouts/15/user.aspx?obj=$($list.Id)%2C$($it.Id)%2CLISTITEM&List=$($list.Id)" @common
            }
        }
    }
}