Private/Get-SPMSiteScan.ps1

function Add-SPMFolderNode {
    <#
    .SYNOPSIS
        Recursively adds folder nodes below a parent node up to the configured depth.
    .DESCRIPTION
        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)]
        $Folder,

        [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 }

    $subFolders = @(Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $Folder -Property Folders })
    foreach ($sub in $subFolders) {
        if ($sub.Name -eq 'Forms' -or $sub.Name.StartsWith('_')) { continue }

        Write-Progress -Id 2 -ParentId 1 -Activity 'Scanne Struktur' -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 "Berechtigungs-Flag fuer '$($sub.ServerRelativeUrl)' nicht abrufbar: $($_.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 -Folder $sub -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 'Scanne Struktur' -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) {
        # Hidden/system libraries are filtered; non-hidden system libraries via deny list.
        $systemTitles = @('Style Library', 'Formatvorlagenbibliothek', 'Form Templates', 'Formularvorlagen', 'Preservation Hold Library', 'Teams Wiki Data')
        $lists = @(Invoke-SPMWithRetry { Get-PnPList }) |
            Where-Object { [int]$_.BaseTemplate -eq 101 -and -not $_.Hidden -and $_.Title -notin $systemTitles }

        foreach ($list in $lists) {
            Write-Progress -Id 2 -ParentId 1 -Activity 'Scanne Struktur' -Status "Bibliothek: $($list.Title)"

            $hasUnique = [bool](Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $list -Property HasUniqueRoleAssignments })
            $rootFolder = Invoke-SPMWithRetry { Get-PnPProperty -ClientObject $list -Property RootFolder }

            $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 -Folder $rootFolder -ParentNode $listNode -Level 2 -MaxLevel $Depth -Nodes $nodes -NextId $nextId -SiteContext $siteContext
            }
        }
    }

    Write-Progress -Id 2 -ParentId 1 -Activity 'Scanne Struktur' -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()
    }
}