Checks/SharePoint/ARG.SPO.009.ps1
|
Register-ArgusCheck @{ Id = 'ARG.SPO.009' Title = 'Inactive SharePoint sites (more than {InactiveDays} days)' Area = 'SharePoint' Category = 'Lifecycle' Severity = 'Low' Description = 'SharePoint sites whose `Last Activity Date` in the site usage report (`getSharePointSiteUsageDetail`, period D180) is older than **{InactiveDays}** days or empty (no activity in the report period). Deleted sites and system sites (`ExcludedTemplates`: search center, compliance policy center, tenant fundamental site, app catalog) are ignored; OneDrive accounts are not part of this report. When the report leaves `Site URL` empty (a known issue of the usage reports), the URL is read from the `Site Id`: first from the PnP tenant site list when SharePoint is connected (it includes private group and channel sites), then with `GET /sites/<id>`, which in delegated mode only reads the sites the account can access; the owner falls back to `Owner Display Name` and is "Not reported" when the report has none. With concealed report names, or when the report returns no rows, the result is Investigate.' Rationale = 'Inactive sites hold content and permissions that nobody curates, use storage and appear in search. Reviewing them regularly keeps the information architecture clean.' Remediation = @{ Steps = "1. Contact the site owners and confirm whether each site is still needed.`n2. Archive (Microsoft 365 Archive) or set to read-only the sites whose content must be kept, delete the others after your retention period.`n3. Consider a site lifecycle policy for inactive sites (SharePoint Advanced Management)." PortalUrl = 'https://admin.microsoft.com/sharepoint?page=siteManagement&modern=true' } References = @( @{ Title = 'Microsoft 365 reports in the admin center - SharePoint site usage'; Url = 'https://learn.microsoft.com/en-us/microsoft-365/admin/activity-reports/sharepoint-site-usage-ww' } @{ Title = 'Manage site lifecycle policies'; Url = 'https://learn.microsoft.com/en-us/sharepoint/site-lifecycle-management' } ) Requires = @{ Services = @('Graph') GraphScopes = @('Reports.Read.All', 'Sites.Read.All') DataSets = @('SharePointSiteUsage', 'ReportSettings') OptionalDataSets = @('SharePointSites') } Parameters = @{ InactiveDays = 180 ExcludedTemplates = @('Basic Search Center', 'Enterprise Search Center', 'Compliance Policy Center', 'SharePoint Online Tenant Fundamental Site', 'App Catalog Site', 'Tenant Admin Site') } Tags = @('SharePoint', 'Lifecycle', 'Reports') Test = { param($Context) $rows = @($Context.Data['SharePointSiteUsage'] | Where-Object { $_ -and (Get-ArgusProperty $_ 'Is Deleted' '') -ne 'True' }) $issue = Get-ArgusReportIssue -ReportSettings $Context.Data['ReportSettings'] -Row $rows -ObjectName 'inactive sites' if ($issue) { return New-ArgusResult -Status Investigate -Summary $issue } # System sites (search center, compliance policy center, tenant fundamental site...) are not governed content. $excluded = @($Context.Parameters.ExcludedTemplates | ForEach-Object { [string]$_ }) $rows = @($rows | Where-Object { [string](Get-ArgusProperty $_ 'Root Web Template' '') -notin $excluded }) $threshold = $Context.Now.AddDays(-[int]$Context.Parameters.InactiveDays) $inactive = @($rows | Where-Object { $last = ConvertTo-ArgusUtcDate (Get-ArgusProperty $_ 'Last Activity Date' '') -not $last -or $last -lt $threshold }) # The report can return an empty Site URL (advisory SP676147): resolve it from the Site Id. $missing = @($inactive | Where-Object { -not (Get-ArgusProperty $_ 'Site URL' '') } | ForEach-Object { [string](Get-ArgusProperty $_ 'Site Id' '') }) $urls = if ($missing.Count -gt 0) { Resolve-ArgusSiteUrl -SiteId $missing -TenantSite $Context.Data['SharePointSites'] } else { @{} } $findings = @($inactive | ForEach-Object { $last = ConvertTo-ArgusUtcDate (Get-ArgusProperty $_ 'Last Activity Date' '') $url = [string](Get-ArgusProperty $_ 'Site URL' '') $siteId = ([string](Get-ArgusProperty $_ 'Site Id' '')).ToLowerInvariant() if (-not $url -and $urls.ContainsKey($siteId)) { $url = $urls[$siteId] } $owner = [string](Get-ArgusProperty $_ 'Owner Principal Name' '') if (-not $owner) { $owner = [string](Get-ArgusProperty $_ 'Owner Display Name' '') } [pscustomobject]@{ SiteUrl = $url Template = Get-ArgusProperty $_ 'Root Web Template' '' LastActivity = Format-ArgusDate $last 'None in the last 180 days' DaysInactive = if ($last) { [int]($Context.Now - $last).TotalDays } else { $null } StorageUsedGB = [math]::Round([double](Get-ArgusProperty $_ 'Storage Used (Byte)' 0) / 1GB, 2) Files = Get-ArgusProperty $_ 'File Count' '' Owner = if ($owner) { $owner } else { 'Not reported' } SiteId = $siteId } } | Sort-Object @{ Expression = { if ($null -eq $_.DaysInactive) { [int]::MaxValue } else { $_.DaysInactive } }; Descending = $true }, SiteUrl) $unresolved = @($findings | Where-Object { -not $_.SiteUrl }).Count $note = if ($unresolved -gt 0 -and -not $Context.Data['SharePointSites']) { " The URL of $unresolved site(s) could not be read: private group and channel sites need the SharePoint (PnP) connection." } else { '' } $status = if ($findings.Count -gt 0) { 'Failed' } else { 'Passed' } New-ArgusResult -Status $status -Findings $findings -EvaluatedCount $rows.Count ` -Summary ('{AffectedCount} of {EvaluatedCount} SharePoint sites have had no activity for {InactiveDays}+ days.' + $note) } } |