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
        THE SITE INVENTORY, AS THE APP. What this does NOT carry is the pair of settings that
        actually decide whether an external share succeeds - the site's own sharing level and
        its own domain allow-list. Those are tenant-admin properties, unreadable at any
        permission this module is willing to hold, and they live in
        Get-MsecSharePointSiteSharing, which signs in as a human instead.

        SO A PERMISSIVE READING HERE SETTLES NOTHING ABOUT SHARING. Site settings combine with
        the tenant's as an INTERSECTION - a site can be stricter but never broader - and two
        lists that disagree leave a site unable to share with anybody. Measured on one
        investigation: the tenant allowed one domain, the site allowed three different ones,
        the intersection was empty, and every external share failed with a domain error while
        every value available here read as permissive.

    .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

    .PARAMETER CreatedSince
        Only sites created on or after this date.

        THE POINT IS A WORKLIST, NOT AN ANSWER. Self-service site creation means new sites
        arrive with the tenant's default sharing level and no review; this narrows 149 sites to
        the few worth checking - measured, 10 in 180 days. The sharing level itself still has
        to be read in the SharePoint admin centre, for the reason in the description.

        FILTERS ON CREATION, NOT LAST MODIFIED. "What appeared recently" and "what changed
        recently" are different questions and this answers the first. A site with no
        createdDateTime is included and warned about rather than dropped, because absent is not
        the same as old.

    .PARAMETER IncludeSettings
        Fetch each site's own properties - sharing-link policy, sensitivity label, restricted
        access control, lock state. ONE EXTRA REQUEST PER SITE, which is why it is opt-in;
        measured on one tenant, 149 sites.

        IT DOES NOT INCLUDE THE SITE'S SHARING LEVEL OR ITS DOMAIN LIST - those are in
        Get-MsecSharePointSiteSharing, and reading these as though they answered the same
        question is the mistake this paragraph exists to prevent. ShareByEmailEnabled in
        particular is NOT the sharing mode: measured, it was True on a site where external
        sharing was completely blocked. Settings is $null when a site could not be read, never
        a blank object.

    .OUTPUTS
        PSCustomObject per site, PSTypeName 'MsecSharePointSite'.

    .NOTES
        Runs as the msec app throughout - both the site list and -IncludeSettings. 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 do different jobs: the Graph one enumerates sites,
        the SharePoint one reads what is inside them.

        Get-MsecSharePointSiteSharing is the one that needs a human.
    #>

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

        # ONE EXTRA REQUEST PER SITE, which is why it is opt-in - measured, 149 sites.
        [switch] $IncludeSettings,

        # FILTERS ON CREATION, NOT ON LAST CHANGE - see the help.
        [datetime] $CreatedSince
    )

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

        # A site with no createdDateTime is NOT silently dropped by a date filter - absent is
        # not "old", and a site nobody can date is exactly the one worth looking at.
        if ($PSBoundParameters.ContainsKey('CreatedSince')) {
            if (-not $site.createdDateTime) {
                Write-Warning "$webUrl has no createdDateTime; included rather than filtered out."
            }
            elseif ([datetime] $site.createdDateTime -lt $CreatedSince) { continue }
        }

        # PER-SITE PROPERTIES COST A CALL EACH, and the token audience is the site's own host.
        # WHAT THIS CANNOT SHOW is the sharing level and the domain list - tenant-admin
        # properties, in Get-MsecSharePointSiteSharing.
        $settings = $null
        if ($IncludeSettings) {
            try {
                $token = Get-MsecAccessToken -Resource "https://$($site.siteCollection.hostname)"
                $r = Invoke-MsecRetryableRequest -What 'SharePoint' -RequestArgs @{
                    Uri     = "$webUrl/_api/site"
                    Headers = @{ Authorization = "Bearer $token"; Accept = 'application/json;odata=nometadata' }
                    ErrorAction = 'Stop' }
                $settings = [PSCustomObject]@{
                    ShareByEmailEnabled            = [bool] $r.ShareByEmailEnabled
                    ShareByLinkEnabled             = [bool] $r.ShareByLinkEnabled
                    DisableCompanyWideSharingLinks = [bool] $r.DisableCompanyWideSharingLinks
                    ExternalSharingTipsEnabled     = [bool] $r.ExternalSharingTipsEnabled
                    RestrictedAccessControl        = [bool] $r.IsRestrictedAccessControlPolicyEnforcedOnSite
                    SensitivityLabelId             = $(if ($r.SensitivityLabelId -and "$($r.SensitivityLabelId)" -ne '00000000-0000-0000-0000-000000000000') { [string] $r.SensitivityLabelId } else { $null })
                    Classification                 = $(if ($r.Classification) { [string] $r.Classification } else { $null })
                    LockIssue                      = $(if ($r.LockIssue) { [string] $r.LockIssue } else { $null })
                    ReadOnly                       = [bool] $r.ReadOnly
                    GroupId                        = $(if ("$($r.GroupId)" -ne '00000000-0000-0000-0000-000000000000') { [string] $r.GroupId } else { $null })
                }
            }
            catch {
                # $null, never a blank object - "could not read" must not look like "all off".
                Write-Warning "Could not read settings for $webUrl : $(($_.Exception.Message -split [char]10)[0])"
            }
        }

        [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
            Settings         = $settings
        }
    }
}