Public/Get-OversharedContent.ps1

function Get-OversharedContent {
    <#
    .SYNOPSIS
        Finds content shared through Anonymous ("Anyone") and Organization-wide
        sharing links - the links that let Copilot surface files to people who were
        never explicitly given access.

    .DESCRIPTION
        Reads each site's hidden "SharingLinks.*" groups rather than walking every
        file, so it scales. Each sharing link SharePoint creates leaves one such
        group; the group title encodes the link kind (Anonymous*, Organization*,
        Flexible/Specific), and its membership is who the link was shared with.

        Anonymous links = highest risk (no sign-in). Organization links = anyone in
        the tenant. Specific/people links are normal and excluded unless
        -IncludeSpecific. Read-only.

        Complements SharingLinkAudit (which revokes/filters links); here the output
        feeds Invoke-CopilotReadinessAssessment.

    .PARAMETER Site
        A single site collection URL to scan.

    .PARAMETER AllSites
        Scan the whole tenant (admin connection + -ClientId). Skips the tenant root
        and system sites by default.

    .PARAMETER LinkType
        Which oversharing links to report: Anonymous, Organization, or All (both, default).

    .PARAMETER IncludeSpecific
        Also include people-specific ("Flexible") links (normally not oversharing).

    .PARAMETER IncludeRootSite
        Include the tenant root site during -AllSites (large; off by default).

    .PARAMETER ClientId
        Your PnP Entra app client id (needed to reconnect per-site during -AllSites).

    .EXAMPLE
        Get-OversharedContent -Site "https://contoso.sharepoint.com/sites/hr"

    .EXAMPLE
        Get-OversharedContent -AllSites -ClientId $cid -LinkType Anonymous |
            Export-Csv anon-links.csv -NoTypeInformation
    #>

    [CmdletBinding(DefaultParameterSetName = 'Site')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Site', Position = 0)]
        [string]$Site,

        [Parameter(Mandatory, ParameterSetName = 'AllSites')]
        [switch]$AllSites,

        [ValidateSet('Anonymous', 'Organization', 'All')]
        [string]$LinkType = 'All',

        [switch]$IncludeSpecific,

        [switch]$IncludeRootSite,

        [string]$ClientId
    )

    $results = [System.Collections.Generic.List[object]]::new()
    $skippedSites = [System.Collections.Generic.List[string]]::new()  # coverage: sites we couldn't read

    # Which classes are we keeping?
    $keep = switch ($LinkType) {
        'Anonymous'    { @('Anonymous') }
        'Organization' { @('Organization') }
        default        { @('Anonymous', 'Organization') }
    }
    if ($IncludeSpecific) { $keep += 'Specific' }

    # Resolve sites to scan
    if ($AllSites) {
        if (-not $ClientId) { throw "-AllSites requires -ClientId to reconnect to each site." }
        Write-Verbose "Enumerating tenant sites..."
        $siteUrls = Get-PnPTenantSite -ErrorAction Stop -Verbose:$false |
            Where-Object {
                $_.Template -notmatch 'Redirect' -and
                $_.Template -notmatch 'SPSMSITEHOST' -and
                ($IncludeRootSite -or ([System.Uri]$_.Url).AbsolutePath -ne '/')
            } | Select-Object -ExpandProperty Url
    }
    else {
        $siteUrls = @($Site)
    }

    $total = @($siteUrls).Count
    $i = 0
    foreach ($siteUrl in $siteUrls) {
        $i++
        if ($AllSites) {
            Write-Progress -Activity "Scanning for oversharing links" -Status ("{0}/{1} {2}" -f $i, $total, $siteUrl) -PercentComplete (($i / [Math]::Max($total, 1)) * 100)
            Write-Verbose ("[{0}/{1}] {2}" -f $i, $total, $siteUrl)
        }
        # Connect to the target site if a ClientId is given (so -Site "just works"
        # without a prior Connect); otherwise use the current ambient connection.
        if ($AllSites -or $ClientId) {
            try { Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $ClientId -ErrorAction Stop -Verbose:$false }
            catch { Write-Warning "Could not connect to $siteUrl : $($_.Exception.Message)"; $skippedSites.Add($siteUrl); continue }
        }

        # Scan with bounded retry on transient throttling; accumulate per-site and
        # only merge on success so a retry never double-counts.
        $attempt = 0
        while ($true) {
            $attempt++
            $siteFindings = [System.Collections.Generic.List[object]]::new()
            try {
                $groups = Get-PnPGroup -Verbose:$false | Where-Object { $_.Title -like 'SharingLinks.*' }
                foreach ($g in $groups) {
                    $info = Get-CGLinkClass -Title $g.Title
                    if ($keep -notcontains $info.Class) { continue }

                    # Anonymous/Organization links are broad by nature (no explicit member
                    # list), so only pay for a membership call on people-specific links.
                    $sharedWith = 0
                    if ($info.Class -eq 'Specific') {
                        try { $sharedWith = @(Get-PnPGroupMember -Group $g -Verbose:$false | Where-Object { $_.LoginName }).Count }
                        catch { Write-Verbose "Could not count members for $($g.Title): $($_.Exception.Message)" }
                    }

                    $siteFindings.Add([pscustomobject]@{
                        Site            = $siteUrl
                        LinkClass       = $info.Class
                        LinkKind        = $info.Kind
                        SharedWithCount = $sharedWith
                        GroupTitle      = $g.Title
                    })
                }

                foreach ($f in $siteFindings) { $results.Add($f) }
                break
            }
            catch {
                $msg = $_.Exception.Message
                if (($msg -match '429|throttl|Too Many Requests|503|Service Unavailable') -and $attempt -lt 4) {
                    $delay = [int](3 * [Math]::Pow(2, $attempt - 1))
                    Write-Warning ("Throttled on {0} (attempt {1}/3); retrying in {2}s..." -f $siteUrl, $attempt, $delay)
                    Start-Sleep -Seconds $delay
                    continue
                }
                Write-Warning "Skipped $siteUrl : $msg"
                $skippedSites.Add($siteUrl)
                break
            }
        }
    }

    if ($AllSites) { Write-Progress -Activity "Scanning for oversharing links" -Completed }

    # Expose scan coverage so the orchestrator (and curious callers) can report blind spots.
    $script:CGCoverage = [pscustomobject]@{ Total = $total; Skipped = @($skippedSites) }

    $anon = @($results | Where-Object { $_.LinkClass -eq 'Anonymous' }).Count
    $org  = @($results | Where-Object { $_.LinkClass -eq 'Organization' }).Count
    Write-Host ""
    Write-Host (" {0} oversharing link(s): {1} anonymous, {2} organization-wide." -f $results.Count, $anon, $org) -ForegroundColor Yellow
    if ($skippedSites.Count -gt 0) {
        Write-Host (" Coverage: {0} of {1} site(s) could not be read (see warnings) - results are partial." -f $skippedSites.Count, $total) -ForegroundColor DarkYellow
    }
    Write-Host " Useful? Star: https://github.com/gvijaikumar9/CopilotGovernance | Feedback: Send-CopilotGovernanceFeedback" -ForegroundColor DarkCyan

    return $results
}