public/Get-MsecSharePointSite.ps1

function Get-MsecSharePointSite {
    <#
    .SYNOPSIS
        Every SharePoint site in the tenant, classified - the inventory to run a site access
        review against.

    .DESCRIPTION
        Enumerates sites through Microsoft Graph rather than the SharePoint tenant-admin API,
        and that choice is about privilege rather than preference.

        Get-PnPTenantSite talks to the tenant-admin endpoint, which accepts nothing less than
        Sites.FullControl.All - full read, WRITE and DELETE over every site in the tenant. For a
        list of site names, in a module that only reads, that is a bad trade. Graph answers the
        same question with Sites.Read.All.

        MOST OF WHAT GRAPH CALLS A SITE IS NOT A SITE YOU WANT. On a real tenant /sites?search=*
        returned 432 results of which 286 were app containers - the backing storage for Loop
        workspaces, Designer files and similar, one per artefact. Running a site access review
        across those is noise and hundreds of wasted calls. They are classified and excluded by
        default rather than filtered out silently, so the count you see is the count you meant.

        SUBWEBS COME BACK TOO, not only site collections. /sites?search=* indexes them, so
        '.../sites/Finance/Archive' appears as its own row. Worth knowing because the obvious
        alternative - walking Get-PnPSubWeb per site - CANNOT work app-only: enumerating
        Web.Webs needs the Browse Directories right, which the Read level that Sites.Read.All
        maps to does not include, and every call returns a bare E_ACCESSDENIED.

        The corollary is that a subweb is classified by its URL like anything else, so it
        reports SiteType 'SiteCollection'. The type describes the shape of the URL, not the
        object's place in the hierarchy.

        SiteType is one of:
          SiteCollection a real site - /sites/ or /teams/. What a review is about.
          AppContainer /contentstorage/ - Loop, Designer and other app-created storage.
          Personal someone's OneDrive. Technically a site, never part of a site review,
                          and there is one per person in the tenant.
          Root the tenant root site.

    .PARAMETER IncludeAppContainer
        Include Loop and other app-created storage containers.

    .PARAMETER IncludePersonal
        Include OneDrive personal sites. Note these do not appear in the search Graph uses here
        anyway on most tenants; the switch exists so the exclusion is explicit rather than
        accidental.

    .PARAMETER All
        Include everything, classified but unfiltered.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid>
        Get-MsecSharePointSite

    .EXAMPLE
        # The access review: every real site, and who owns it.
        Get-MsecSharePointSite | ForEach-Object {
            Get-MsecSharePointSiteUser -Url $_.WebUrl
        }

    .EXAMPLE
        # What the tenant actually holds, before deciding what to review.
        Get-MsecSharePointSite -All | Group-Object SiteType | Sort-Object Count -Descending

    .OUTPUTS
        PSCustomObject per site, PSTypeName 'MsecSharePointSite'.

    .NOTES
        Needs Connect-Msec and Sites.Read.All on MICROSOFT GRAPH - which is a different
        permission from the identically-named one on the SharePoint service principal. Both are
        granted by New-MsecApp -Workload SharePoint, and they do different jobs: this one
        enumerates sites, the SharePoint one lets PnP read what is inside them.

        No PnP session is needed here. Get-MsecSharePointSiteUser is what needs that, and it
        connects itself.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [switch] $IncludeAppContainer,
        [switch] $IncludePersonal,
        [switch] $All
    )

    Assert-MsecSession

    $sites = @()
    try {
        # search=* is the documented way to enumerate every site an app can see. The plain
        # /sites collection returns a different and less complete set.
        $sites = @(Invoke-MsecGraphRequest -All -Path '/v1.0/sites?search=*')
    }
    catch {
        $detail = $_.Exception.Message
        if ($detail -match '403|Forbidden') {
            throw "Forbidden enumerating sites. The msec app needs 'Sites.Read.All' on MICROSOFT GRAPH - note this is NOT the same permission as Sites.Read.All on the SharePoint service principal, and having one does not grant the other. Run New-MsecApp -Workload SharePoint. Original error: $detail"
        }
        throw
    }

    if (-not $sites.Count) {
        Write-Warning 'No sites returned. Check that Sites.Read.All is granted on Microsoft Graph and consented.'
        return
    }

    foreach ($site in $sites) {
        $webUrl = [string] $site.webUrl

        $siteType =
            if ($webUrl -match '(?i)-my\.sharepoint\.[a-z.]+/personal/') { 'Personal' }
            elseif ($webUrl -match '(?i)/contentstorage/')               { 'AppContainer' }
            elseif ($webUrl -match '(?i)/(sites|teams)/')                { 'SiteCollection' }
            elseif ($site.root)                                          { 'Root' }
            else                                                         { 'Other' }

        if (-not $All) {
            if ($siteType -eq 'AppContainer' -and -not $IncludeAppContainer) { continue }
            if ($siteType -eq 'Personal' -and -not $IncludePersonal) { continue }
        }

        [PSCustomObject]@{
            PSTypeName       = 'MsecSharePointSite'
            DisplayName      = $site.displayName
            SiteType         = $siteType
            WebUrl           = $webUrl
            Name             = $site.name
            Description      = $site.description
            CreatedDateTime  = $site.createdDateTime
            LastModifiedDateTime = $site.lastModifiedDateTime
            Hostname         = $site.siteCollection.hostname
            Id               = $site.id
        }
    }
}