public/Get-MsecSharePointSiteSharing.ps1

function Get-MsecSharePointSiteSharing {
    <#
    .SYNOPSIS
        Each site's external sharing level and its own domain allow-list - the two settings
        that decide whether an external share succeeds.

    .DESCRIPTION
        SEPARATE FROM Get-MsecSharePointSite BECAUSE IT RUNS AS A DIFFERENT IDENTITY. That one
        reads the inventory as the msec app; this one cannot. Tenant administration needs
        Sites.FullControl.All - write access to every site - which this module deliberately
        does not hold. Verified rather than assumed: the app certificate authenticates against
        the -admin host and then fails every admin call with "Attempted to perform an
        unauthorized operation".

        SO IT SIGNS IN AS YOU, through Connect-MsecSharePointOnline -Interactive. The
        capability comes from your own SharePoint Administrator role; the app gains nothing.
        The same bargain Connect-MsecAdmin makes, and unrelated to the Az context - an
        Az-derived token is refused by SharePoint whatever the account, because its audience is
        normalised to the service principal's GUID.

        THE TWO VALUES COST VERY DIFFERENT AMOUNTS, which is why the domain list is opt-in. The
        sharing level comes from ONE call for the whole tenant - measured, 144 sites in about a
        second. The domain list cannot: see below.

        THE BULK CALL LIES ABOUT THE DOMAIN RESTRICTION. Get-PnPTenantSite with no -Identity
        returns SharingDomainRestrictionMode as "None" and an empty domain list for a site that
        genuinely has one. Measured on the same site: bulk said None, -Identity said AllowList
        with four domains. A report built on the bulk value would state confidently that no
        site in the tenant restricts by domain, and that is exactly the kind of wrong answer
        nobody re-checks. -IncludeDomainRestriction therefore queries each site individually,
        about 0.4s each, and leaves DomainRestriction $null rather than "None" where a site
        could not be read.

        THE TWO LISTS INTERSECT, THEY DO NOT ADD. A site's domain list is combined with the
        tenant's from Get-MsecSharePointTenantSetting: a site can be stricter but never
        broader. Measured on one investigation, the tenant allowed one domain and the site
        allowed three different ones, so the effective set was EMPTY and every external share
        from that site failed - while each list read as perfectly reasonable on its own.

    .PARAMETER Url
        A single site collection. Without it, every site the admin API lists is returned.

    .PARAMETER IncludeDomainRestriction
        Also fetch each site's domain allow/block list. ONE CALL PER SITE - see the
        description. About 0.4s each; roughly a minute for 150 sites.

    .PARAMETER RestrictedOnly
        Only sites that are not wide open: anything whose sharing level is not
        ExternalUserAndGuestSharing, or that carries a domain restriction. Applied after the
        reads, so it narrows the output rather than the work.

    .EXAMPLE
        Connect-MsecSharePointOnline -Url https://contoso-admin.sharepoint.com -Interactive -ClientId <app>
        Get-MsecSharePointSiteSharing | Group-Object SharingLevel

        The estate at a glance. On one tenant: 95 ExternalUserSharingOnly, 27
        ExistingExternalUserSharingOnly, 21 Disabled, and a single
        ExternalUserAndGuestSharing - which is the most permissive site in the tenant and worth
        knowing by name.

    .EXAMPLE
        Get-MsecSharePointSiteSharing -IncludeDomainRestriction |
            Where-Object { $_.DomainRestriction -eq 'AllowList' }

        The sites carrying their own domain list. Each one overrides the tenant's by
        intersection, so a stale list here is invisible until a share fails.

    .OUTPUTS
        One PSCustomObject per site, PSTypeName 'MsecSharePointSiteSharing'.

    .NOTES
        Needs Connect-MsecSharePointOnline -Interactive against the tenant's -admin host, and
        SharePoint Administrator on YOUR account. Runs as you, not as the msec app, and the
        app is deliberately not able to do this.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $Url,
        [switch] $IncludeDomainRestriction,
        [switch] $RestrictedOnly
    )

    if (-not (Get-Command Get-PnPTenantSite -ErrorAction SilentlyContinue)) {
        throw 'PnP.PowerShell is required for Get-MsecSharePointSiteSharing. Install with: Install-Module PnP.PowerShell -Scope CurrentUser'
    }

    try {
        $tenantSites = if ($Url) { @(Get-PnPTenantSite -Identity $Url -ErrorAction Stop) }
                       else      { @(Get-PnPTenantSite -ErrorAction Stop) }
    }
    catch {
        $detail = ($_.Exception.Message -split [char]10)[0]
        throw ("Could not read tenant site settings: $detail " +
               'This needs an INTERACTIVE connection to the admin host - the app-only certificate ' +
               'is refused there, because tenant administration requires Sites.FullControl.All, a ' +
               'write permission msec does not hold. Run: Connect-MsecSharePointOnline -Url ' +
               'https://<tenant>-admin.sharepoint.com -Interactive -ClientId <app>')
    }

    # A single-site query already carries the domain fields, so it needs no second call.
    $haveDomains = [bool] $Url

    foreach ($t in $tenantSites) {
        $siteUrl         = "$($t.Url)".TrimEnd('/')
        $restrictionMode = $null
        $allowedDomains  = $null
        $blockedDomains  = $null

        if ($haveDomains -or $IncludeDomainRestriction) {
            $detail = $t
            if (-not $haveDomains) {
                # ONE CALL PER SITE, because the bulk result is WRONG here - see the help.
                try { $detail = Get-PnPTenantSite -Identity $siteUrl -ErrorAction Stop }
                catch {
                    # $null, never 'None' - that is precisely the lie the cheap path tells.
                    Write-Warning "Could not read the domain restriction for $siteUrl : $(($_.Exception.Message -split [char]10)[0])"
                    $detail = $null
                }
            }
            if ($detail) {
                $restrictionMode = [string] $detail.SharingDomainRestrictionMode
                $allowedDomains  = @("$($detail.SharingAllowedDomainList)" -split '[,\s]+' | Where-Object { $_ })
                $blockedDomains  = @("$($detail.SharingBlockedDomainList)" -split '[,\s]+' | Where-Object { $_ })
            }
        }

        $row = [PSCustomObject]@{
            PSTypeName        = 'MsecSharePointSiteSharing'
            Title             = [string] $t.Title
            Url               = $siteUrl
            SharingLevel      = [string] $t.SharingCapability
            DomainRestriction = $restrictionMode
            AllowedDomains    = $allowedDomains
            BlockedDomains    = $blockedDomains
            Template          = [string] $t.Template
            Raw               = $t
        }

        if ($RestrictedOnly) {
            $wideOpen = $row.SharingLevel -eq 'ExternalUserAndGuestSharing' -and
                        (-not $row.DomainRestriction -or $row.DomainRestriction -eq 'None')
            if ($wideOpen) { continue }
        }
        $row
    }
}