Public/SharePoint/Get-SPOSharingLinkReport.ps1

function Get-SPOSharingLinkReport {
    <#
    .SYNOPSIS
    Lists every sharing link of one or more SharePoint Online sites, one row per link, using PnP.PowerShell app-only.
 
    .DESCRIPTION
    Get-SPOSharingLinkReport is the file-level companion of Get-SPOSiteReport -IncludeSharingLinks: where the
    latter answers "which sites have an exposure worth looking at" with a per-site count, this function
    answers "which file is shared, with whom, until when, and through which URL" for the sites you point it
    at. It is deliberately not tenant-wide by default: the detail costs Microsoft Graph calls per shared
    item, so the intended workflow is to triage with Get-SPOSiteReport, then drill into the sites it flags.
 
    HOW THE SHARED ITEMS ARE FOUND
    A naive implementation walks every document library, loads HasUniqueRoleAssignments on every single item,
    and asks Graph for the links of the ones that have unique permissions. That is one round trip per file,
    which does not survive a real tenant. This function inverts the problem: SharePoint already materialises
    every sharing link as a hidden site group named 'SharingLinks.<itemUniqueId>.<linkType>.<linkGuid>', so a
    single Get-PnPGroup call per site yields the exact list of items that carry a link. Only those items are
    then resolved and queried. The cost is proportional to the number of shared items, not to the number of
    files in the site, which is usually several orders of magnitude smaller.
 
    Each candidate item is resolved from its unique ID through the REST endpoints /_api/web/GetFileById and,
    if that returns nothing, /_api/web/GetFolderById - an item is one or the other, and the group name does
    not say which. The links themselves are then read with Get-PnPFileSharingLink / Get-PnPFolderSharingLink,
    which is what provides the authoritative link type (the Graph 'scope' property), the expiration date, the
    password protection, the download block and the identities the link was shared with. The group name is
    used only as an index of what to look at, never as the source of truth for the link itself.
 
    TWO MODES, AND WHY THE DEFAULT AVOIDS GRAPH
    By default the function never calls Microsoft Graph. Everything it reports comes from the SharingLinks
    groups themselves: the shared item (resolved through SharePoint REST), the link type read from the group
    name, and - the part that matters most for an access audit - the people the link was shared with, who
    are literally the members of that group. This runs with the SharePoint Sites.FullControl.All permission
    the rest of the module already needs, and nothing more.
 
    IncludeGraphDetails adds what only Graph knows: the link URL, the expiration date, the password
    protection, the download block, the role granted, and the authoritative scope of a Flexible link. That
    costs one Graph call per shared item AND the Microsoft Graph APPLICATION permissions Files.Read.All and
    Sites.Read.All - which mean "read every file of the tenant, with no user behind the call". On a
    production tenant that is a decision worth making deliberately, not a checkbox: consider Sites.Selected
    instead, which scopes the app to the sites you explicitly grant it.
 
    WHAT THE DEFAULT MODE CANNOT TELL YOU
    A link type of 'Flexible' is reported as Flexible, never as 'SpecificPeople'. Flexible is the modern
    SharePoint link model where the scope is set per link, so the group name genuinely does not say whether
    such a link is restricted to named people or open to anyone holding it. Guessing here would be actively
    harmful: reporting a Flexible link as 'SpecificPeople' makes SharingLinksAnyoneCount read as zero on a
    site that may well have an anonymous link. When that distinction matters, IncludeGraphDetails is the
    only way to resolve it - the shared-with list of the default mode is often enough to conclude on its own.
 
    PERMISSIONS
    - Default mode: SharePoint (Office 365 SharePoint Online) Sites.FullControl.All application permission,
      the same one Get-SPOSiteReport uses. No Graph permission at all.
    - IncludeGraphDetails: the above, plus the Microsoft Graph APPLICATION permissions Files.Read.All and
      Sites.Read.All. These are a different Entra resource - consenting to the SharePoint one does not cover
      Graph. Without them every item fails at the last step with 'accessDenied 403' (Graph token present but
      insufficient) or 'Either scp or roles claim need to be present in the token' (no Graph permission on
      the certificate at all). After granting consent, allow a few minutes for propagation and reconnect:
      PnP caches the acquired token.
 
    Client secrets are not supported by SharePoint for app-only: a certificate is mandatory. Provide it
    through CertificateThumbprint (Windows certificate store), CertificatePath (.pfx file) or
    CertificateBase64Encoded (base64 string, handy for Azure Automation or a pipeline variable).
 
    Sites are processed in parallel (ForEach-Object -Parallel) with one PnP connection per site. Tune the
    concurrency with ThrottleLimit. PowerShell 7 is required.
 
    .PARAMETER ClientId
    (Mandatory) Application (client) ID of the Entra app registration used for the app-only connection.
 
    .PARAMETER Tenant
    (Mandatory) Tenant domain name for the PnP connection, for example contoso.onmicrosoft.com.
    Used by Connect-PnPOnline (it expects the domain, not the tenant GUID).
 
    .PARAMETER CertificateThumbprint
    Thumbprint of the certificate located in the current user's Windows certificate store.
    Provide exactly one of CertificateThumbprint, CertificatePath or CertificateBase64Encoded.
 
    .PARAMETER CertificatePath
    Path to a local .pfx certificate file. Use CertificatePassword when the file is protected.
    Provide exactly one of CertificateThumbprint, CertificatePath or CertificateBase64Encoded.
 
    .PARAMETER CertificatePassword
    (Optional) SecureString password protecting the .pfx file passed to CertificatePath.
 
    .PARAMETER CertificateBase64Encoded
    Base64-encoded certificate (with its private key) passed directly to Connect-PnPOnline.
    Provide exactly one of CertificateThumbprint, CertificatePath or CertificateBase64Encoded.
 
    .PARAMETER SiteUrl
    (Mandatory) One or more site collection URLs to scan. Accepts pipeline input, by value or by the Url
    property, so the output of Get-SPOSiteReport can be piped straight in (its Url column binds to this
    parameter through its alias).
 
    .PARAMETER IncludeGraphDetails
    (Optional) Re-reads every link through Microsoft Graph to add the link URL, the expiration date, the
    password protection, the download block, the granted role, and the real scope of a Flexible link.
    Requires the Microsoft Graph Files.Read.All and Sites.Read.All application permissions - see the
    PERMISSIONS section above before turning this on against a production tenant.
 
    Without it, the report carries LinkType/LinkTypeRaw, SharedWith, the item, and the SharingLinks group it
    came from. With it, those Graph columns are added and the type is resolved. Columns that were not
    collected are absent rather than empty, so an empty cell always means the data really is empty.
 
    .PARAMETER LinkType
    (Optional) Restricts the output to one or more link types: Anyone (anonymous 'Anyone with the link'),
    Company (people in the organization), Flexible (modern link whose scope is only resolvable through
    Graph), SpecificPeople (requires IncludeGraphDetails), or Other for a type this function does not know
    about yet. Filtering happens after collection, so it does not make the run faster - it only makes the
    report shorter.
 
    .PARAMETER ExpiredOnly
    (Optional) Keeps only the links whose expiration date is in the past. Requires IncludeGraphDetails:
    expiration dates only exist in the Graph payload. Mutually exclusive with NeverExpiringOnly and
    ExpiringInDays.
 
    .PARAMETER NeverExpiringOnly
    (Optional) Keeps only the links that carry no expiration date at all. Requires IncludeGraphDetails.
    Mutually exclusive with ExpiredOnly and ExpiringInDays.
 
    .PARAMETER ExpiringInDays
    (Optional) Keeps only the links expiring within that many days from now, excluding those already
    expired. Requires IncludeGraphDetails. Mutually exclusive with ExpiredOnly and NeverExpiringOnly.
 
    .PARAMETER ThrottleLimit
    (Optional) Number of sites processed concurrently. Defaults to 8. Lower it if SharePoint starts
    answering 429: past a certain rate, more concurrency means more throttling and a longer total run.
 
    .PARAMETER ExportToExcel
    (Optional) Exports the result to an .xlsx file instead of returning the objects to the pipeline.
 
    .PARAMETER ExportPath
    (Optional) Output directory for the Excel export. Defaults to the user profile directory.
 
    .EXAMPLE
    Get-SPOSharingLinkReport -ClientId $clientId -Tenant 'contoso.onmicrosoft.com' -CertificateThumbprint $thumb -SiteUrl 'https://contoso.sharepoint.com/sites/marketing'
 
    Lists every sharing link of a single site.
 
    .EXAMPLE
    Get-SPOSiteReport -ClientId $clientId -Tenant 'contoso.onmicrosoft.com' -CertificateThumbprint $thumb -IncludeSharingLinks |
        Where-Object { $_.SharingLinksAnyoneCount -gt 0 } |
        Get-SPOSharingLinkReport -ClientId $clientId -Tenant 'contoso.onmicrosoft.com' -CertificateThumbprint $thumb -LinkType Anyone
 
    The intended workflow: triage the whole tenant with the cheap per-site counts, then get the file-level
    detail of the anonymous links only on the sites that actually have one.
 
    .EXAMPLE
    Get-SPOSharingLinkReport -ClientId $clientId -Tenant 'contoso.onmicrosoft.com' -CertificateThumbprint $thumb -SiteUrl $urls -NeverExpiringOnly -ExportToExcel
 
    Exports every link that will never expire on the given sites to an Excel file in the user profile directory.
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-SPOSharingLinkReport
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Tenant,

        [Parameter(Mandatory = $false)]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $false)]
        [string]$CertificatePath,

        [Parameter(Mandatory = $false)]
        [securestring]$CertificatePassword,

        [Parameter(Mandatory = $false)]
        [string]$CertificateBase64Encoded,

        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Url')]
        [string[]]$SiteUrl,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeGraphDetails,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Anyone', 'Company', 'SpecificPeople', 'Flexible', 'Other')]
        [string[]]$LinkType,

        [Parameter(Mandatory = $false)]
        [switch]$ExpiredOnly,

        [Parameter(Mandatory = $false)]
        [switch]$NeverExpiringOnly,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 3650)]
        [int]$ExpiringInDays,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 32)]
        [int]$ThrottleLimit = 8,

        [Parameter(Mandatory = $false)]
        [switch]$ExportToExcel,

        [Parameter(Mandatory = $false, HelpMessage = 'Optional output directory for the Excel export (defaults to the user profile).')]
        [string]$ExportPath
    )

    begin {
        [System.Collections.Generic.List[string]]$siteUrlsList = @()

        # Exactly one certificate method is required for the app-only connection
        $certMethods = @($CertificateThumbprint, $CertificatePath, $CertificateBase64Encoded | Where-Object { $_ })
        if ($certMethods.Count -ne 1) {
            Write-Warning 'Provide exactly one certificate method: CertificateThumbprint, CertificatePath or CertificateBase64Encoded.'
            $inputIsValid = $false
        }
        else {
            $inputIsValid = $true
        }

        # The three expiration filters answer mutually exclusive questions: combining them can only ever
        # return an empty report, so refuse instead of silently producing nothing.
        $expirationFilters = @($ExpiredOnly.IsPresent, $NeverExpiringOnly.IsPresent, ($ExpiringInDays -gt 0)) | Where-Object { $_ }
        if ($expirationFilters.Count -gt 1) {
            Write-Warning 'ExpiredOnly, NeverExpiringOnly and ExpiringInDays are mutually exclusive. Choose one of them.'
            $inputIsValid = $false
        }

        # Expiration dates only exist in the Graph payload. Filtering on them without IncludeGraphDetails
        # would silently return an empty report (nothing has an expiration date to compare), so it is
        # refused rather than answered with a misleading result.
        if ($expirationFilters.Count -gt 0 -and -not $IncludeGraphDetails.IsPresent) {
            Write-Warning 'ExpiredOnly, NeverExpiringOnly and ExpiringInDays require IncludeGraphDetails: expiration dates are only exposed by Microsoft Graph, not by the SharePoint sharing link groups.'
            $inputIsValid = $false
        }

        # Same reason: without Graph the link type comes from the group name, which reports Flexible links
        # as such because their real scope is per-link. Asking for SpecificPeople in that mode would return
        # nothing while Flexible links possibly are exactly that.
        if ($LinkType -contains 'SpecificPeople' -and -not $IncludeGraphDetails.IsPresent) {
            Write-Warning "LinkType 'SpecificPeople' requires IncludeGraphDetails. Without it, use 'Flexible': SharePoint only exposes the link type through the group name, which does not resolve the scope of a Flexible link."
            $inputIsValid = $false
        }

        if (-not (Get-Module PnP.PowerShell -ListAvailable)) {
            Write-Warning 'Please install the PnP.PowerShell module: Install-Module PnP.PowerShell'
            $inputIsValid = $false
        }

        $pnpAuthParams = @{
            ClientId = $ClientId
            Tenant   = $Tenant
        }
        if ($CertificateThumbprint) {
            $pnpAuthParams.Add('Thumbprint', $CertificateThumbprint)
        }
        elseif ($CertificatePath) {
            $pnpAuthParams.Add('CertificatePath', $CertificatePath)
            if ($CertificatePassword) {
                $pnpAuthParams.Add('CertificatePassword', $CertificatePassword)
            }
        }
        elseif ($CertificateBase64Encoded) {
            $pnpAuthParams.Add('CertificateBase64Encoded', $CertificateBase64Encoded)
        }
    }

    process {
        if (-not $inputIsValid) {
            return
        }

        foreach ($url in $SiteUrl) {
            if ($url -and -not $siteUrlsList.Contains($url)) {
                $siteUrlsList.Add($url)
            }
        }
    }

    end {
        if (-not $inputIsValid) {
            return
        }

        if ($siteUrlsList.Count -eq 0) {
            Write-Warning 'No site URL to process.'
            return
        }

        Write-Host -ForegroundColor Cyan "Scanning $($siteUrlsList.Count) site(s) for sharing links with $ThrottleLimit concurrent connection(s)..."

        $rawResults = $siteUrlsList | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
            $auth = $using:pnpAuthParams
            $useGraph = $using:IncludeGraphDetails
            $currentSiteUrl = $_

            $siteConnection = $null
            try {
                $siteConnection = Connect-PnPOnline -Url $currentSiteUrl @auth -ReturnConnection -ErrorAction Stop
            }
            catch {
                [PSCustomObject]@{
                    SiteUrl = $currentSiteUrl
                    Links   = @()
                    Status  = "ERROR: $($_.Exception.Message)"
                }
                return
            }

            # One call per site: the hidden 'SharingLinks.<itemUniqueId>.<linkType>.<linkGuid>' groups are the
            # index of every item carrying a link. Users are expanded in that same call because the members of
            # such a group ARE the people the link was shared with - that is the whole 'shared with' answer,
            # obtained without touching Microsoft Graph.
            try {
                $siteGroups = Get-PnPGroup -Includes Users -Connection $siteConnection -ErrorAction Stop
            }
            catch {
                [PSCustomObject]@{
                    SiteUrl = $currentSiteUrl
                    Links   = @()
                    Status  = "ERROR: unable to list the site groups - $($_.Exception.Message)"
                }
                return
            }

            # One entry per sharing link group, kept whole: the item ID says what to resolve, the link type
            # and the members are already the answer in the default (Graph-free) mode.
            [System.Collections.Generic.List[PSCustomObject]]$linkGroups = @()
            $itemUniqueIds = [System.Collections.Generic.HashSet[string]]::new()

            foreach ($siteGroup in $siteGroups) {
                if ($siteGroup.Title -notlike 'SharingLinks.*') {
                    continue
                }

                # 'SharingLinks' . <itemUniqueId> . <linkType> . <linkGuid>
                $titleParts = $siteGroup.Title.Split('.')
                if ($titleParts.Count -lt 2 -or -not $titleParts[1]) {
                    continue
                }

                $null = $itemUniqueIds.Add($titleParts[1])

                # The type token is read as-is, never normalised into something it may not be: Flexible in
                # particular carries its real scope per link, which only Graph can tell.
                $linkGroups.Add([PSCustomObject]@{
                        ItemUniqueId = $titleParts[1]
                        TypeToken    = if ($titleParts.Count -ge 3) { $titleParts[2] } else { $null }
                        LinkGuid     = if ($titleParts.Count -ge 4) { $titleParts[3] } else { $null }
                        GroupTitle   = $siteGroup.Title
                        GroupId      = $siteGroup.Id
                        SharedWith   = (@($siteGroup.Users | ForEach-Object {
                                    if ($_.Email) { $_.Email } else { $_.LoginName }
                                }) -join '|')
                    })
            }

            [System.Collections.Generic.List[PSCustomObject]]$siteLinks = @()
            [System.Collections.Generic.List[string]]$itemErrors = @()
            $resolvedItems = @{}

            foreach ($itemUniqueId in $itemUniqueIds) {
                # The group name does not say whether the item is a file or a folder, and the two have
                # distinct REST endpoints and distinct PnP cmdlets. Try the file first (by far the common
                # case), fall back to the folder.
                $itemUrl = $null
                $itemName = $null
                $itemType = $null

                try {
                    $fileResponse = Invoke-PnPSPRestMethod -Url "/_api/web/GetFileById(guid'$itemUniqueId')" -Connection $siteConnection -ErrorAction Stop
                    if ($fileResponse -and $fileResponse.ServerRelativeUrl) {
                        $itemUrl = $fileResponse.ServerRelativeUrl
                        $itemName = $fileResponse.Name
                        $itemType = 'File'
                    }
                }
                catch {
                    # Not a file (or no longer exists): the folder endpoint below decides
                    Write-Verbose "GetFileById failed for $itemUniqueId on $currentSiteUrl - $($_.Exception.Message)"
                }

                if (-not $itemUrl) {
                    try {
                        $folderResponse = Invoke-PnPSPRestMethod -Url "/_api/web/GetFolderById(guid'$itemUniqueId')" -Connection $siteConnection -ErrorAction Stop
                        if ($folderResponse -and $folderResponse.ServerRelativeUrl) {
                            $itemUrl = $folderResponse.ServerRelativeUrl
                            $itemName = $folderResponse.Name
                            $itemType = 'Folder'
                        }
                    }
                    catch {
                        Write-Verbose "GetFolderById failed for $itemUniqueId on $currentSiteUrl - $($_.Exception.Message)"
                    }
                }

                if (-not $itemUrl) {
                    # A SharingLinks group whose item resolves to neither a file nor a folder is an orphan
                    # left behind by a deleted item. Reported rather than dropped: those groups keep granting
                    # nothing, but their presence skews the per-site counts of Get-SPOSiteReport.
                    $itemErrors.Add("Item $itemUniqueId could not be resolved (deleted item, or orphaned SharingLinks group)")
                    continue
                }

                $resolvedItems[$itemUniqueId] = [PSCustomObject]@{
                    ItemType = $itemType
                    ItemName = $itemName
                    ItemUrl  = $itemUrl
                }
            }

            if (-not $useGraph) {
                # Default mode: everything comes from the site groups already read above. One row per group,
                # which is one row per link, and no Graph call anywhere in the path.
                foreach ($linkGroup in $linkGroups) {
                    $resolvedItem = $resolvedItems[$linkGroup.ItemUniqueId]
                    if ($null -eq $resolvedItem) {
                        continue
                    }

                    $siteLinks.Add([PSCustomObject]@{
                            ItemType   = $resolvedItem.ItemType
                            ItemName   = $resolvedItem.ItemName
                            ItemUrl    = $resolvedItem.ItemUrl
                            TypeToken  = $linkGroup.TypeToken
                            SharedWith = $linkGroup.SharedWith
                            GroupTitle = $linkGroup.GroupTitle
                            LinkGuid   = $linkGroup.LinkGuid
                        })
                }
            }
            else {
                # Graph mode: the links are re-read from Graph, which is the only source for the expiration
                # date, the password protection, the download block and the authoritative scope of a
                # Flexible link. One call per shared item, and the Graph application permissions that go
                # with it - see the PERMISSIONS section of the help.
                foreach ($itemUniqueId in $resolvedItems.Keys) {
                    $resolvedItem = $resolvedItems[$itemUniqueId]

                    try {
                        if ($resolvedItem.ItemType -eq 'File') {
                            $sharingLinks = Get-PnPFileSharingLink -Identity $resolvedItem.ItemUrl -Connection $siteConnection -ErrorAction Stop
                        }
                        else {
                            $sharingLinks = Get-PnPFolderSharingLink -Folder $resolvedItem.ItemUrl -Connection $siteConnection -ErrorAction Stop
                        }
                    }
                    catch {
                        $itemErrors.Add("Links of $($resolvedItem.ItemUrl) - $($_.Exception.Message)")
                        continue
                    }

                    foreach ($sharingLink in $sharingLinks) {
                        $link = $sharingLink.Link
                        if ($null -eq $link) {
                            continue
                        }

                        $siteLinks.Add([PSCustomObject]@{
                                ItemType           = $resolvedItem.ItemType
                                ItemName           = $resolvedItem.ItemName
                                ItemUrl            = $resolvedItem.ItemUrl
                                Scope              = $link.Scope
                                Permission         = $link.Type
                                Roles              = ($sharingLink.Roles -join '|')
                                SharedWith         = ($sharingLink.GrantedToIdentitiesV2.User.Email -join '|')
                                HasPassword        = $sharingLink.HasPassword
                                PreventsDownload   = $link.PreventsDownload
                                ExpirationDateTime = $sharingLink.ExpirationDateTime
                                LinkUrl            = $link.WebUrl
                                LinkId             = $sharingLink.Id
                            })
                    }
                }
            }

            $status = if ($itemErrors.Count -gt 0) { "PARTIAL: $($itemErrors -join ' | ')" } else { 'OK' }

            # Releasing the variable disposes the connection: the parallel runspace pool is reused across
            # sites, so live connection objects would otherwise accumulate inside each runspace.
            $siteConnection = $null

            [PSCustomObject]@{
                SiteUrl = $currentSiteUrl
                Links   = $siteLinks
                Status  = $status
            }
        }

        [System.Collections.Generic.List[PSCustomObject]]$sharingLinksArray = @()
        $siteErrorCount = 0
        $sitePartialErrorCount = 0
        $currentDate = (Get-Date).Date

        foreach ($rawResult in $rawResults) {
            if ($rawResult.Status -like 'ERROR:*') {
                $siteErrorCount++
                Write-Verbose "Sharing link collection failed for $($rawResult.SiteUrl): $($rawResult.Status)"
                continue
            }

            if ($rawResult.Status -like 'PARTIAL:*') {
                $sitePartialErrorCount++
                Write-Verbose "Sharing link collection partially failed for $($rawResult.SiteUrl): $($rawResult.Status)"
            }

            foreach ($link in $rawResult.Links) {
                if ($IncludeGraphDetails.IsPresent) {
                    # Graph mode: the scope is authoritative. An unknown value is surfaced as 'Other' with
                    # its raw form kept in LinkScope, so a new SharePoint link type shows up in the report
                    # instead of being silently misfiled.
                    $friendlyLinkType = switch ($link.Scope) {
                        'anonymous' { 'Anyone' }
                        'organization' { 'Company' }
                        'users' { 'SpecificPeople' }
                        default { 'Other' }
                    }
                    $rawLinkType = $link.Scope
                }
                else {
                    # Default mode: the type comes from the group name, which is all SharePoint exposes
                    # without Graph. Flexible is reported as such and NOT mapped to 'SpecificPeople': that
                    # link type carries its scope per link, so calling it anything else would be a guess -
                    # and a guess that reads as 'no anonymous link here' is exactly the wrong way to be
                    # wrong in an audit. Use IncludeGraphDetails to resolve those.
                    $friendlyLinkType = switch -Wildcard ($link.TypeToken) {
                        'Anonymous*' { 'Anyone' }
                        'Organization*' { 'Company' }
                        'Flexible' { 'Flexible' }
                        default { 'Other' }
                    }
                    $rawLinkType = $link.TypeToken
                }

                if ($LinkType -and $friendlyLinkType -notin $LinkType) {
                    continue
                }

                $expirationDate = $null
                $daysToExpiry = $null
                $linkStatus = 'Active'

                if ($link.ExpirationDateTime) {
                    $expirationDate = ([datetime]$link.ExpirationDateTime).ToLocalTime()
                    $daysToExpiry = (New-TimeSpan -Start $currentDate -End $expirationDate).Days
                    if ($expirationDate -lt $currentDate) {
                        $linkStatus = 'Expired'
                    }
                }

                if ($ExpiredOnly.IsPresent -and $linkStatus -ne 'Expired') {
                    continue
                }

                if ($NeverExpiringOnly.IsPresent -and $null -ne $expirationDate) {
                    continue
                }

                if ($ExpiringInDays -gt 0) {
                    if (($null -eq $expirationDate) -or ($linkStatus -eq 'Expired') -or ($daysToExpiry -gt $ExpiringInDays)) {
                        continue
                    }
                }

                # Same rule as Get-SPOSiteReport: a column that was not collected is absent, never present
                # and empty. An empty ExpirationDateTime would read as "this link never expires", which is
                # the opposite of "nobody asked Graph".
                $linkProperties = [ordered]@{
                    SiteUrl      = $rawResult.SiteUrl
                    ItemType     = $link.ItemType
                    ItemName     = $link.ItemName
                    ItemUrl      = $link.ItemUrl
                    LinkType     = $friendlyLinkType
                    LinkTypeRaw  = $rawLinkType
                    SharedWith   = $link.SharedWith
                }

                if ($IncludeGraphDetails.IsPresent) {
                    $linkProperties['Permission'] = $link.Permission
                    $linkProperties['Roles'] = $link.Roles
                    $linkProperties['LinkStatus'] = $linkStatus
                    $linkProperties['ExpirationDateTime'] = $expirationDate
                    $linkProperties['DaysToExpiry'] = $daysToExpiry
                    $linkProperties['HasPassword'] = $link.HasPassword
                    $linkProperties['PreventsDownload'] = $link.PreventsDownload
                    $linkProperties['LinkUrl'] = $link.LinkUrl
                    $linkProperties['LinkId'] = $link.LinkId
                }
                else {
                    $linkProperties['SharePointGroup'] = $link.GroupTitle
                    $linkProperties['LinkGuid'] = $link.LinkGuid
                }

                $sharingLinksArray.Add([PSCustomObject]$linkProperties)
            }
        }

        if ($siteErrorCount -gt 0) {
            Write-Host -ForegroundColor Yellow "$siteErrorCount site(s) could not be scanned at all. Run with -Verbose for the reason of each one."
            Write-Host -ForegroundColor Yellow 'A recurring error usually means the app registration lacks the SharePoint Sites.FullControl.All application permission.'
        }

        if ($sitePartialErrorCount -gt 0) {
            Write-Host -ForegroundColor Yellow "$sitePartialErrorCount site(s) returned partial results (unresolvable items, or links that could not be read). Run with -Verbose for details."

            if ($IncludeGraphDetails.IsPresent) {
                Write-Host -ForegroundColor Yellow 'A Graph error on every item points at the app registration: IncludeGraphDetails reads the links through Microsoft Graph, a DIFFERENT Entra resource from the SharePoint Sites.FullControl.All permission used everywhere else.'
                Write-Host -ForegroundColor Yellow " 'accessDenied 403' : the Graph token is valid but lacks the permission - add the Microsoft Graph APPLICATION permissions Files.Read.All and Sites.Read.All, then grant admin consent."
                Write-Host -ForegroundColor Yellow " 'Either scp or roles claim need to be present in the token' : the certificate carries no Graph permission at all - same fix."
                Write-Host -ForegroundColor Yellow ' Allow a few minutes for the consent to propagate and reconnect: PnP caches the acquired token. Dropping IncludeGraphDetails also works, at the cost of the expiration/URL/password columns.'
            }
            else {
                Write-Host -ForegroundColor Yellow ' Unresolvable items are usually orphaned SharingLinks groups left behind by deleted files - they grant nothing, but they do inflate the per-site counts.'
            }
        }

        Write-Host -ForegroundColor Green "Found $($sharingLinksArray.Count) sharing link(s) across $($siteUrlsList.Count) site(s)."

        if ($ExportToExcel.IsPresent) {
            $now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
            $exportDirectory = if ($ExportPath) { $ExportPath } else { $env:userprofile }
            $excelFilePath = Join-Path -Path $exportDirectory -ChildPath "$now-SPOSharingLinkReport.xlsx"

            Write-Host -ForegroundColor Cyan "Exporting to Excel file: $excelFilePath"
            $sharingLinksArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -WorksheetName 'SPO-SharingLinks' -TableStyle Light9
            Write-Host -ForegroundColor Green 'Export completed successfully!'
        }
        else {
            return $sharingLinksArray
        }
    }
}