Public/Scan/Get-SPCPreservationHoldWaste.ps1

function Get-SPCPreservationHoldWaste {
    <#
    .SYNOPSIS
        Audits Preservation Hold Library (PHL) storage consumption and compliance hold locks.
 
    .DESCRIPTION
        Get-SPCPreservationHoldWaste discovers and measures the storage consumed by Preservation Hold Libraries (PHL)
        across SharePoint Online sites. These libraries are automatically provisioned and managed by Microsoft Purview
        Retention Policies, Retention Labels, or eDiscovery / Litigation Holds.
 
        Items inside a Preservation Hold Library are legally locked and cannot be directly deleted or altered by
        administrators. This cmdlet provides visibility into PHL storage growth, flags compliance hold statuses,
        and assigns risk alert levels (Normal, Warning, Critical) based on user-defined capacity thresholds.
 
    .PARAMETER SiteUrl
        One or more SharePoint Online site collection URLs to audit. When omitted, all sites in the tenant
        are evaluated using Microsoft Graph usage reports.
        Supports pipeline input by value and property name.
 
    .PARAMETER WarningThresholdMB
        The storage threshold in megabytes for triggering a 'Warning' alert level. Sites with PHL storage
        exceeding 2x this threshold are categorized as 'Critical'. Default is 5120 MB (5 GB).
        Range: 1 to 1,048,576 MB.
 
    .INPUTS
        System.String[]
            Accepts site collection URLs from the pipeline.
 
    .OUTPUTS
        SPC.PreservationHoldWaste
            Returns custom objects containing site URL, title, PHL presence, file count, size in MB,
            percentage of total site storage consumed by PHL, compliance hold state, alert level, and compliance notes.
 
    .EXAMPLE
        Get-SPCPreservationHoldWaste -SiteUrl 'https://contoso.sharepoint.com/sites/LegalArchive'
 
        Audits the Preservation Hold Library on the 'LegalArchive' site, measuring file count and size.
 
    .EXAMPLE
        Get-SPCPreservationHoldWaste -WarningThresholdMB 10240 | Where-Object AlertLevel -in @('Warning', 'Critical')
 
        Scans the entire tenant for Preservation Hold Libraries exceeding 10 GB (Warning) or 20 GB (Critical).
 
    .EXAMPLE
        Get-SPCPreservationHoldWaste -SiteUrl (Get-Content C:\sites.txt) | Export-Csv -Path 'C:\PHL_Audit.csv' -NoTypeInformation
 
        Audits a batch of site collections from a text file and exports the compliance hold metrics to CSV.
 
    .NOTES
        Requires an active SPClean connection initialized via Connect-SPCTenant.
        Preservation Hold Libraries are immutable by design and cannot be purged directly without modifying
        retention policies in Microsoft Purview Compliance Center.
 
    .LINK
        Get-SPCStorageWaste
        Get-SPCVersionWaste
        Export-SPCStorageReport
    #>

    [CmdletBinding(DefaultParameterSetName = 'AllSites')]
    [OutputType('SPC.PreservationHoldWaste')]
    param(
        [Parameter(ParameterSetName = 'SpecificSites', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]$SiteUrl,

        [Parameter()]
        [ValidateRange(1, 1048576)]
        [double]$WarningThresholdMB = 5120
    )

    begin {
        Test-SPCConnection
        $pipelineUrls = [System.Collections.Generic.List[string]]::new()
    }

    process {
        if ($SiteUrl) {
            foreach ($url in $SiteUrl) {
                if (-not [string]::IsNullOrWhiteSpace($url)) {
                    $pipelineUrls.Add($url)
                }
            }
        }
    }

    end {
        $targetUrls = if ($pipelineUrls.Count -gt 0) {
            $pipelineUrls
        } else {
            $usageReport = Get-SPCGraphSiteUsageInternal -Period 'D7'
            $urls = [System.Collections.Generic.List[string]]::new()
            if ($null -ne $usageReport) {
                foreach ($row in $usageReport) {
                    if (-not [string]::IsNullOrWhiteSpace($row.SiteUrl)) {
                        $urls.Add($row.SiteUrl)
                    }
                }
            }
            $urls
        }

        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
        foreach ($url in $targetUrls) {
            try {
                $siteConn = Connect-SPCSiteInternal -SiteUrl $url -Context $script:SPCContext
                $web = Get-PnPWeb -Connection $siteConn
                $siteTitle = if ($null -ne $web -and $null -ne $web.Title) { $web.Title } else { 'Site' }

                $phlList = Get-PnPList -Identity "PreservationHoldLibrary" -Connection $siteConn -ErrorAction SilentlyContinue
                if ($null -eq $phlList) {
                    $phlList = Get-PnPList -Identity "Preservation Hold Library" -Connection $siteConn -ErrorAction SilentlyContinue
                }

                $site = Get-PnPSite -Connection $siteConn
                if ($null -ne $site -and ($null -eq $site.Usage -or $null -eq $site.Usage.Storage)) {
                    Get-PnPProperty -ClientObject $site -Property Usage -Connection $siteConn -ErrorAction SilentlyContinue | Out-Null
                }
                $storageBytes = if ($null -ne $site -and $null -ne $site.Usage -and $null -ne $site.Usage.Storage) { [int64]$site.Usage.Storage } else { [int64]0 }
                $totalSiteMB = [Math]::Round(($storageBytes / 1MB), 2)

                if ($null -ne $phlList) {
                    $items = Get-PnPListItem -List $phlList -Connection $siteConn -PageSize 1000
                    $phlBytes = [int64]0
                    if ($null -ne $items) {
                        foreach ($it in $items) {
                            $fSize = 0
                            if ($it -is [System.Collections.IDictionary] -or $null -ne $it['File_x0020_Size']) {
                                $fSize = $it['File_x0020_Size']
                            } elseif ($it.File_x0020_Size) {
                                $fSize = $it.File_x0020_Size
                            }
                            $phlBytes += [int64]$fSize
                        }
                    }
                    $phlSizeMB = [Math]::Round(($phlBytes / 1MB), 2)
                    $pctSite = if ($totalSiteMB -gt 0) { [Math]::Round(($phlSizeMB / $totalSiteMB * 100), 2) } else { 0.0 }
                    
                    $alertLevel = if ($phlSizeMB -ge ($WarningThresholdMB * 2)) { 'Critical' } elseif ($phlSizeMB -ge $WarningThresholdMB) { 'Warning' } else { 'Normal' }

                    $results.Add([PSCustomObject][ordered]@{
                        PSTypeName           = 'SPC.PreservationHoldWaste'
                        SiteUrl              = $url
                        SiteTitle            = $siteTitle
                        HasPreservationHold  = $true
                        PHLFileCount         = $phlList.ItemCount
                        PHLSizeMB            = $phlSizeMB
                        PercentOfSiteStorage = $pctSite
                        ComplianceHoldActive = $true
                        AlertLevel           = $alertLevel
                        ComplianceNote       = 'Under Microsoft Purview Retention Policy - Immutable'
                    })
                } else {
                    $results.Add([PSCustomObject][ordered]@{
                        PSTypeName           = 'SPC.PreservationHoldWaste'
                        SiteUrl              = $url
                        SiteTitle            = $siteTitle
                        HasPreservationHold  = $false
                        PHLFileCount         = 0
                        PHLSizeMB            = 0.0
                        PercentOfSiteStorage = 0.0
                        ComplianceHoldActive = $false
                        AlertLevel           = 'Normal'
                        ComplianceNote       = 'No active PHL detected'
                    })
                }
            }
            catch {
                Write-Warning "Get-SPCPreservationHoldWaste: Error querying site '$url': $($_.Exception.Message)"
            }
        }
        $results | ForEach-Object { $_ }
    }
}