Public/Scan/Get-SPCGuestAccess.ps1
|
<#
.SYNOPSIS Scans, detects, and classifies the Risk Level of Guest accounts in SharePoint Online. .DESCRIPTION Gets Guest accounts from SharePoint Online and evaluates their risk level based on permission levels and inactivity. Risk Levels: - HIGH: Guest has Full Control / Owner OR inactive > 180 days. - MEDIUM: Guest has Edit / Write OR inactive > 90 days. - LOW: Guest has Read AND active within 90 days. #> function Get-SPCGuestAccess { [CmdletBinding(SupportsShouldProcess=$false)] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$SiteUrl, [Parameter(Mandatory=$false)] [ValidateRange(0, 3650)] [int]$DaysInactive = 90 ) begin { if (-not (Get-Command -Name 'Test-SPCConnection' -ErrorAction SilentlyContinue)) { throw "ERR-GUA-002: Test-SPCConnection cmdlet not found. Resource: Environment. Time: $(Get-Date -Format 'o')" } Test-SPCConnection $connectToSite = { param([string] $SiteUrl, [PSCustomObject] $Ctx) $tenantId = if ($Ctx.TenantName -match '\.') { $Ctx.TenantName } else { "$($Ctx.TenantName).onmicrosoft.com" } switch ($Ctx.AuthMethod) { 'Interactive' { $token = Get-PnPAccessToken -ResourceTypeName SharePoint -Connection $Ctx.PnPContext Connect-PnPOnline -Url $SiteUrl -AccessToken $token -ReturnConnection } 'AppOnly' { if ($Ctx._CertificatePath) { Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -Tenant $tenantId ` -CertificatePath $Ctx._CertificatePath ` -CertificatePassword $Ctx._CertificatePassword -ReturnConnection } elseif ($Ctx._CertificateThumbprint) { Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -Tenant $tenantId ` -Thumbprint $Ctx._CertificateThumbprint -ReturnConnection } else { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Ctx._ClientSecret) try { $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -ClientSecret $plain -ReturnConnection } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) $plain = $null } } } } } $highCount = 0 $mediumCount = 0 $lowCount = 0 } process { try { $sites = @() if ([string]::IsNullOrEmpty($SiteUrl)) { Write-Verbose "No SiteUrl provided. Fetching all sites in the tenant..." $sites = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext | Select-Object -ExpandProperty Url } else { $sites = @($SiteUrl) } $totalSites = $sites.Count $siteIndex = 0 $guestDict = @{} foreach ($site in $sites) { $siteIndex++ Write-Progress -Activity "Scanning Sites" -Status "Processing site $siteIndex of $totalSites" -PercentComplete (($siteIndex / $totalSites) * 100) try { Write-Verbose "Connecting to $site" $siteConnection = & $connectToSite -SiteUrl $site -Ctx $script:SPCContext Write-Verbose "Retrieving users for $site" $users = Get-PnPUser -Connection $siteConnection -ErrorAction Stop | Where-Object { $_.PrincipalType -eq 'Guest' -or $_.LoginName -match '#EXT#' } if ($users.Count -gt 0) { $roleAssignments = (Invoke-PnPSPRestMethod -Connection $siteConnection -Method Get -Url "/_api/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -ErrorAction SilentlyContinue).value $allGroups = Get-PnPGroup -Connection $siteConnection -Includes Users -ErrorAction SilentlyContinue } } catch { $errCode = "ERR-GUA-003" $timestamp = (Get-Date -Format 'o') Write-Error "[$errCode] ${timestamp}: Failed to process site collection '$site'. Resource: $site. Details: $($_.Exception.Message)" -ErrorAction Continue continue } foreach ($user in $users) { $upn = $user.LoginName if ($user.LoginName -match '\|([^\|]+#ext#[^\|]+)' -or $user.LoginName -match '\|([^\|]+#EXT#[^\|]+)') { $upn = $matches[1] } if (-not $guestDict.ContainsKey($upn)) { $guestDict[$upn] = @{ DisplayName = if (-not [string]::IsNullOrEmpty($user.Title)) { $user.Title } else { "Guest User" } Email = if ([string]::IsNullOrEmpty($user.Email)) { $upn } else { $user.Email } UPN = $upn InvitedBy = "Unknown" SiteCount = 0 MaxPermission = 0 PermissionLevel = "Read" LastAccess = "N/A" } } $guestDict[$upn].SiteCount++ $permLevel = 1 if ($user.IsSiteAdmin) { $permLevel = 3 } else { $roles = "" $userGroups = @() if ($null -ne $allGroups) { $userGroups = $allGroups | Where-Object { $_.Users.LoginName -contains $user.LoginName } } $userGroupLogins = @($userGroups.LoginName) $userRoles = $roleAssignments | Where-Object { $_.Member.LoginName -eq $user.LoginName -or $userGroupLogins -contains $_.Member.LoginName } if ($userRoles) { $roles = ($userRoles.RoleDefinitionBindings.Name | Select-Object -Unique) -join ',' } if ($roles -match "Full Control|Owner") { $permLevel = 3 } elseif ($roles -match "Edit|Write|Contribute|Design") { $permLevel = 2 } else { $permLevel = 1 } } if ($permLevel -gt $guestDict[$upn].MaxPermission) { $guestDict[$upn].MaxPermission = $permLevel if ($permLevel -eq 3) { $guestDict[$upn].PermissionLevel = "Full Control" } elseif ($permLevel -eq 2) { $guestDict[$upn].PermissionLevel = "Edit" } else { $guestDict[$upn].PermissionLevel = "Read" } } } } $guestUPNs = @($guestDict.Keys) $totalGuests = $guestUPNs.Count if ($totalGuests -gt 0) { Write-Verbose "Found $totalGuests unique guests. Querying Microsoft Graph for sign-in activity..." $graphToken = if ($script:SPCContext) { $script:SPCContext.GraphAccessToken } else { Get-PnPAccessToken -ResourceTypeName "Graph" -ErrorAction SilentlyContinue } $batchSize = 20 $abortGraphQueries = $false for ($i = 0; $i -lt $totalGuests; $i += $batchSize) { if ($abortGraphQueries) { break } $batchUPNs = $guestUPNs[$i..([math]::Min($i + $batchSize - 1, $totalGuests - 1))] Write-Progress -Activity "Querying Graph API" -Status "Processing batch $([math]::Floor($i/$batchSize) + 1)" -PercentComplete (($i / $totalGuests) * 100) $retryCount = 0 $maxRetries = 5 $success = $false while (-not $success -and $retryCount -lt $maxRetries) { try { $batchRequests = [System.Collections.Generic.List[hashtable]]::new() foreach ($upn in $batchUPNs) { # Escape single quotes in UPN if any, though rare $safeUpn = $upn -replace "'", "''" # We have to URL encode the # character for the query string $encodedUpn = $safeUpn.Replace('#', '%23') $batchRequests.Add(@{ id = $upn method = "GET" url = "/users?`$filter=userPrincipalName eq '$encodedUpn'&`$select=displayName,givenName,surname,userPrincipalName,signInActivity,createdDateTime" }) } $batchResult = Invoke-SPCGraphBatch -Requests $batchRequests -AccessToken $graphToken -ErrorAction Stop $success = $true if ($batchResult) { foreach ($res in $batchResult) { $upn = $res.id if ($res.status -eq 200 -and $null -ne $res.body -and $null -ne $res.body.value -and $res.body.value.Count -gt 0) { $userObj = $res.body.value[0] if ($guestDict.ContainsKey($upn)) { if (-not [string]::IsNullOrWhiteSpace($userObj.displayName)) { $guestDict[$upn].DisplayName = $userObj.displayName } elseif (-not [string]::IsNullOrWhiteSpace($userObj.givenName) -or -not [string]::IsNullOrWhiteSpace($userObj.surname)) { $guestDict[$upn].DisplayName = ("$($userObj.givenName) $($userObj.surname)").Trim() } if ($null -ne $userObj.signInActivity -and -not [string]::IsNullOrEmpty($userObj.signInActivity.lastSignInDateTime)) { $guestDict[$upn].LastAccess = $userObj.signInActivity.lastSignInDateTime } if ($null -ne $userObj.invitedBy -and $null -ne $userObj.invitedBy.user -and -not [string]::IsNullOrEmpty($userObj.invitedBy.user.email)) { $guestDict[$upn].InvitedBy = $userObj.invitedBy.user.email } } } } } } catch { if ($_.Exception.Message -match "429") { $retryCount++ $waitTime = [math]::Pow(2, $retryCount) Write-Verbose "Throttled (429). Retrying in $waitTime seconds (Attempt $retryCount of $maxRetries)..." Start-Sleep -Seconds $waitTime if ($retryCount -eq $maxRetries) { Write-Error "[ERR-GUA-004] $(Get-Date -Format 'o'): Failed to query Graph API for batch after $maxRetries retries. Resource: Tenant. Details: $($_.Exception.Message)" -ErrorAction Continue } } else { Write-Error "[ERR-GUA-005] $(Get-Date -Format 'o'): Failed to query Graph API for batch. Resource: Tenant. Details: $($_.Exception.Message)" -ErrorAction Continue $abortGraphQueries = $true break } } } } } foreach ($upn in $guestDict.Keys) { $guestInfo = $guestDict[$upn] $inactiveDays = 0 $isInactiveOver180Days = $false $isInactiveOverThreshold = $false if ($guestInfo.LastAccess -ne "N/A") { try { $lastAccessDate = ([datetime]$guestInfo.LastAccess).ToUniversalTime() $inactiveDays = ([DateTime]::UtcNow - $lastAccessDate).Days if ($inactiveDays -gt 180) { $isInactiveOver180Days = $true $isInactiveOverThreshold = $true } elseif ($inactiveDays -gt $DaysInactive) { $isInactiveOverThreshold = $true } } catch { Write-Verbose "Could not parse LastAccess date: $($guestInfo.LastAccess)" } } else { $isInactiveOver180Days = $true # Treat missing as highly risky/inactive } $riskLevel = "LOW" if ($guestInfo.PermissionLevel -match "Full Control|Owner" -or $isInactiveOver180Days) { $riskLevel = "HIGH" $highCount++ } elseif ($guestInfo.PermissionLevel -match "Edit|Write|Contribute|Design" -or $isInactiveOverThreshold) { $riskLevel = "MEDIUM" $mediumCount++ } else { $riskLevel = "LOW" $lowCount++ } $outputObj = [PSCustomObject]@{ DisplayName = if (-not [string]::IsNullOrWhiteSpace($guestInfo.DisplayName)) { $guestInfo.DisplayName } else { "Guest User" } UPN = if (-not [string]::IsNullOrWhiteSpace($guestInfo.UPN)) { $guestInfo.UPN } else { $upn } GuestEmail = $guestInfo.Email InvitedBy = $guestInfo.InvitedBy LastAccess = $guestInfo.LastAccess SiteCount = $guestInfo.SiteCount PermissionLevel = $guestInfo.PermissionLevel RiskLevel = $riskLevel } $outputObj.PSObject.TypeNames.Insert(0, 'SPC.GuestUser') Write-Output $outputObj } } catch { $errCode = "ERR-GUA-500" throw "${errCode}: An unexpected error occurred while processing guests. Resource: Tenant. Time: $(Get-Date -Format 'o'). Details: $($_.Exception.Message)" } } end { $total = $highCount + $mediumCount + $lowCount Write-Information "Total: $total Guests - HIGH: $highCount, MEDIUM: $mediumCount, LOW: $lowCount" } } |