Checks/SharePoint/ARG.SPO.008.ps1
|
Register-ArgusCheck @{ Id = 'ARG.SPO.008' Title = 'Orphaned SharePoint sites' Area = 'SharePoint' Category = 'Ownership' Severity = 'High' Description = 'Group-connected sites whose Microsoft 365 group has no owner (site URL read with JSON batching, `GET /groups/<id>/sites/root`). When SharePoint is connected through PnP.PowerShell, sites without a Microsoft 365 group (classic team sites, communication sites) are also evaluated: a site is orphaned when its primary site collection administrator (`Owner` in `Get-PnPTenantSite`) is empty, disabled or no longer exists. Without PnP those sites are not evaluated and the summary says so.' Rationale = 'Nobody manages permissions, sharing or content of a site without a responsible owner, and access requests go unanswered. Orphaned sites are also the hardest to clean up later.' Remediation = @{ Steps = "1. For group-connected sites, add owners to the Microsoft 365 group (see ARG.GRP.004).`n2. For other sites, set a new primary site collection administrator in the SharePoint admin center (Active sites > Permissions).`n3. Delete or archive sites that are no longer needed." PowerShell = "# Sample only - review before running. Sets the primary administrator of a site without a group.`nConnect-PnPOnline -Url 'https://<tenant>-admin.sharepoint.com' -Interactive -ClientId '<ClientId>'`nSet-PnPTenantSite -Identity '<SiteUrl>' -Owners @('<UserPrincipalName>')" PortalUrl = 'https://admin.microsoft.com/sharepoint?page=siteManagement&modern=true' } References = @( @{ Title = 'Manage site admins in the SharePoint admin center'; Url = 'https://learn.microsoft.com/en-us/sharepoint/manage-site-collection-administrators' } @{ Title = 'Manage ownerless Microsoft 365 groups and teams'; Url = 'https://learn.microsoft.com/en-us/microsoft-365/admin/create-groups/ownerless-groups-teams' } ) Requires = @{ Services = @('Graph') GraphScopes = @('Group.Read.All', 'Sites.Read.All') DataSets = @('Groups') OptionalDataSets = @('SharePointSites', 'Users') } Tags = @('SharePoint', 'Ownership') Test = { param($Context) $unified = @($Context.Data['Groups'] | Where-Object { $_.IsUnified }) $ownerless = @($unified | Where-Object { $_.OwnerCount -eq 0 }) $findings = [System.Collections.Generic.List[object]]::new() if ($ownerless.Count -gt 0) { $requests = @($ownerless | ForEach-Object { $id = [string](Get-ArgusProperty $_ 'id' ''); @{ Id = $id; Url = "/groups/$id/sites/root?`$select=webUrl,displayName" } }) $sites = @{} foreach ($response in @(Invoke-ArgusGraphBatch -Request $requests)) { if ($response.Status -eq 200) { $sites[$response.Id] = [string](Get-ArgusProperty $response.Body 'webUrl' '') } } foreach ($group in $ownerless) { $id = [string](Get-ArgusProperty $group 'id' '') if (-not $sites[$id]) { continue } # group without a provisioned site $findings.Add([pscustomobject]@{ SiteUrl = $sites[$id] Title = Get-ArgusProperty $group 'displayName' '' Type = if ($group.IsTeam) { 'Team site (Teams)' } else { 'Group-connected team site' } Reason = 'Microsoft 365 group has no owner' Owner = '' GroupId = $id }) } } $evaluated = $unified.Count $note = ' Sites without a Microsoft 365 group were not evaluated (connect SharePoint with PnP.PowerShell to include them).' if ($Context.Data.ContainsKey('SharePointSites')) { $note = '' $users = @{} foreach ($user in @($Context.Data['Users'])) { $upn = [string](Get-ArgusProperty $user 'userPrincipalName' '') if ($upn) { $users[$upn.ToLowerInvariant()] = $user } } $haveUsers = $Context.Data.ContainsKey('Users') $skipTemplates = 'SPSPERS*', 'SPSMSITEHOST*', 'RedirectSite*', 'APPCATALOG*', 'SRCHCEN*', 'POINTPUBLISHINGHUB*', 'POINTPUBLISHINGTOPIC*', 'TEAMCHANNEL*', 'GROUP*' foreach ($site in @($Context.Data['SharePointSites'])) { $template = [string](Get-ArgusProperty $site 'Template' '') $groupId = [string](Get-ArgusProperty $site 'GroupId' '') if (($groupId -and $groupId -ne [guid]::Empty.ToString()) -or @($skipTemplates | Where-Object { $template -like $_ }).Count) { continue } $evaluated++ $owner = [string](Get-ArgusProperty $site 'Owner' '') $reason = if (-not $owner) { 'No primary site collection administrator' } elseif ($owner -notlike '*@*' -or -not $haveUsers) { $null } elseif (-not $users.ContainsKey($owner.ToLowerInvariant())) { 'Primary administrator no longer exists' } elseif ((Get-ArgusProperty $users[$owner.ToLowerInvariant()] 'accountEnabled') -ne $true) { 'Primary administrator is disabled' } else { $null } if (-not $reason) { continue } $findings.Add([pscustomobject]@{ SiteUrl = Get-ArgusProperty $site 'Url' '' Title = Get-ArgusProperty $site 'Title' '' Type = "Site without group ($template)" Reason = $reason Owner = $owner GroupId = '' }) } } $sorted = @($findings | Sort-Object Type, SiteUrl) if ($sorted.Count -eq 0) { return New-ArgusResult -Status Passed -EvaluatedCount $evaluated -Summary ('No orphaned site found ({EvaluatedCount} sites and groups evaluated).' + $note) } New-ArgusResult -Status Failed -Findings $sorted -EvaluatedCount $evaluated ` -Summary ('{AffectedCount} of {EvaluatedCount} sites have no responsible owner.' + $note) } } |