Private/Get-SPMSiteScan.ps1

function Add-SPMFolderNode {
    <#
    .SYNOPSIS
        Recursively adds folder nodes below a parent node up to the configured depth.
    .DESCRIPTION
        Each level is fetched fresh by URL (Get-PnPFolder -Includes Folders). Chaining
        Get-PnPProperty from a previously materialized folder can hit CSOM's
        instantiated-but-unpopulated collection state and silently return zero subfolders.
        Per folder only the cheap HasUniqueRoleAssignments flag is queried; role assignments
        are loaded only where it is true, otherwise the effective permissions of the parent
        are carried over.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$FolderUrl,

        [Parameter(Mandatory)]
        $ParentNode,

        [Parameter(Mandatory)]
        [int]$Level,

        [Parameter(Mandatory)]
        [int]$MaxLevel,

        [Parameter(Mandatory)]
        [System.Collections.Generic.List[object]]$Nodes,

        [Parameter(Mandatory)]
        [ref]$NextId,

        [Parameter(Mandatory)]
        [hashtable]$SiteContext
    )

    if ($Level -gt $MaxLevel) { return }

    $parent = Invoke-SPMWithRetry { Get-PnPFolder -Url $FolderUrl -Includes Folders }
    $subFolders = @($parent.Folders) |
        Where-Object { $_.Name -and $_.Name -ne 'Forms' -and -not $_.Name.StartsWith('_') } |
        Sort-Object Name

    foreach ($sub in $subFolders) {
        Write-Progress -Id 2 -ParentId 1 -Activity 'Scanning structure' -Status $sub.ServerRelativeUrl

        $hasUnique = $false
        $item = $null
        try {
            $item = Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $sub -Property ListItemAllFields }
            if ($item -and $item.Id) {
                $hasUnique = [bool](Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $item -Property HasUniqueRoleAssignments })
            }
        }
        catch {
            Write-Warning "Could not read the unique-permissions flag of '$($sub.ServerRelativeUrl)': $($_.Exception.Message)"
        }

        $assignments = if ($hasUnique) {
            Get-SPMRoleAssignmentSet -Securable $item -SiteContext $SiteContext
        }
        else {
            $ParentNode.assignments
        }

        $node = [pscustomobject]@{
            id                   = 'n{0}' -f $NextId.Value
            parentId             = $ParentNode.id
            type                 = 'Folder'
            title                = $sub.Name
            url                  = $sub.ServerRelativeUrl
            level                = $Level
            hasUniquePermissions = $hasUnique
            assignments          = $assignments
        }
        $Nodes.Add($node)
        $NextId.Value++

        if ($Level -lt $MaxLevel) {
            Add-SPMFolderNode -FolderUrl $sub.ServerRelativeUrl -ParentNode $node -Level ($Level + 1) -MaxLevel $MaxLevel -Nodes $Nodes -NextId $NextId -SiteContext $SiteContext
        }
    }
}

function Get-SPMSiteScan {
    <#
    .SYNOPSIS
        Scans a single site (site -> document libraries -> folders) against the current PnP connection.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SiteUrl,

        [Parameter(Mandatory)]
        [ValidateRange(0, 10)]
        [int]$Depth,

        [Parameter(Mandatory)]
        [hashtable]$GroupCache
    )

    $siteContext = @{
        Principals  = [ordered]@{}
        CustomRoles = @{}
        GroupCache  = $GroupCache
    }

    $web = Invoke-SPMWithRetry { Get-PnPWeb }
    Write-Progress -Id 2 -ParentId 1 -Activity 'Scanning structure' -Status $web.Url

    $nodes = [System.Collections.Generic.List[object]]::new()
    $siteNode = [pscustomobject]@{
        id                   = 'n0'
        parentId             = $null
        type                 = 'Site'
        title                = if ($web.Title) { $web.Title } else { $web.Url }
        url                  = $web.Url
        level                = 0
        hasUniquePermissions = $true
        assignments          = (Get-SPMRoleAssignmentSet -Securable $web -SiteContext $siteContext)
    }
    $nodes.Add($siteNode)
    $nextId = [ref]1

    if ($Depth -ge 1) {
        # System libraries are recognized by their URL leaf - titles are localized
        # (Style Library = "Formatbibliothek", SiteAssets = "Websiteobjekte", ...).
        $systemUrlLeafs = @('Style Library', 'SiteAssets', 'FormServerTemplates', 'SitePages', 'PreservationHoldLibrary', 'Teams Wiki Data', 'IWConvertedForms')
        $lists = @(Invoke-SPMWithRetry { Get-PnPList }) |
            Where-Object { [int]$_.BaseTemplate -eq 101 -and -not $_.Hidden } |
            Sort-Object Title

        foreach ($list in $lists) {
            $rootFolder = Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $list -Property RootFolder }
            $urlLeaf = ($rootFolder.ServerRelativeUrl.TrimEnd('/') -split '/')[-1]
            if ($urlLeaf -in $systemUrlLeafs) {
                Write-Verbose "System library skipped: $($list.Title) ($($rootFolder.ServerRelativeUrl))"
                continue
            }

            Write-Progress -Id 2 -ParentId 1 -Activity 'Scanning structure' -Status "Library: $($list.Title)"

            $hasUnique = [bool](Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $list -Property HasUniqueRoleAssignments })
            $assignments = if ($hasUnique) {
                Get-SPMRoleAssignmentSet -Securable $list -SiteContext $siteContext
            }
            else {
                $siteNode.assignments
            }

            $listNode = [pscustomobject]@{
                id                   = 'n{0}' -f $nextId.Value
                parentId             = 'n0'
                type                 = 'Library'
                title                = $list.Title
                url                  = $rootFolder.ServerRelativeUrl
                level                = 1
                hasUniquePermissions = $hasUnique
                assignments          = $assignments
            }
            $nodes.Add($listNode)
            $nextId.Value++

            if ($Depth -ge 2) {
                Add-SPMFolderNode -FolderUrl $rootFolder.ServerRelativeUrl -ParentNode $listNode -Level 2 -MaxLevel $Depth -Nodes $nodes -NextId $nextId -SiteContext $siteContext
            }
        }
    }

    Write-Progress -Id 2 -ParentId 1 -Activity 'Scanning structure' -Completed

    $principals = @($siteContext.Principals.Values)

    # Invert name -> key into key -> name for the report footnotes.
    $customRoles = [ordered]@{}
    foreach ($entry in ($siteContext.CustomRoles.GetEnumerator() | Sort-Object Value)) {
        $customRoles[$entry.Value] = $entry.Key
    }

    return [pscustomobject]@{
        url         = $web.Url
        title       = $siteNode.title
        stats       = [pscustomobject]@{
            nodeCount              = $nodes.Count
            uniqueNodeCount        = @($nodes | Where-Object { $_.hasUniquePermissions -and $_.level -gt 0 }).Count
            principalCount         = $principals.Count
            sharingLinkCount       = @($principals | Where-Object { $_.type -eq 'SharingLink' }).Count
            externalPrincipalCount = @($principals | Where-Object { $_.loginName -match '(?i)#ext#' -or $_.upn -match '(?i)#ext#' }).Count
        }
        customRoles = $customRoles
        principals  = $principals
        nodes       = $nodes.ToArray()
    }
}