What it checks
Licences: assigned vs unused
Purchased, assigned and unassigned seats per SKU, so you can see seats you pay for that nobody holds.
Inactive and disabled users
Licensed users with no sign-in for 30, 60 and 90+ days, users who never signed in, and disabled accounts still holding licences.
Estimated monthly waste
A conservative monthly figure from a price table you edit (prices.csv), so the estimate matches what you actually pay.
App secrets and certificates
App registrations and service principals with expired, soon-to-expire (30 days) or long-lived (over 1 year) secrets and certificates.
High-privilege app access
Apps holding powerful application permissions on Microsoft Graph, Exchange Online and SharePoint, plus tenant-wide admin consents, with third-party apps marked.
Guest users
Guest accounts, pending invitations, guests who never signed in and guests inactive for 90+ days.
How to run it
- Install the Microsoft Graph PowerShell SDK once:
Install-Module Microsoft.Graph -Scope CurrentUser - Download Probelane-M365-SelfScan.ps1 (or copy the code below) and read it first. It is plain text.
- Run
./Probelane-M365-SelfScan.ps1and sign in as an admin. It asks only for these delegated read scopes:User.Read.All, Directory.Read.All, Application.Read.All, AuditLog.Read.All, Organization.Read.All. - First run creates
prices.csvwith starter list prices for three SKUs. Put in what you pay per seat, then run again (for rands:-Currency ZAR). - Open
summary.htmlin the output folder. Use-HideNamesto replace names with one-way hashes in every file.
Inactivity checks need sign-in activity, which needs Microsoft Entra ID P1 or P2. Without it the scan still runs the licence, app and guest checks and tells you what it skipped. Starter prices are Microsoft list prices after the 1 July 2026 update (Business Basic USD 7, Business Standard USD 14, Microsoft 365 E3 USD 39 per user per month; source: microsoft.com licensing news, 2026 M365 packaging and pricing updates). Your own invoice is the better source.
Privacy
No data leaves your tenant to us. The script talks only to Microsoft Graph with read-only scopes, using your own sign-in. It has no telemetry, uploads nothing and makes no changes. All results are written as CSV and HTML files in a local folder that you control.
The output contains names and email addresses of your users (unless you use -HideNames). Store it like any HR record and delete it when you are done. Probelane never sees it unless you choose to send it.
This page counts anonymous page views and download clicks with hits.sh (no cookies, no IP tracking, per hits.sh). The request form below is processed by FormSubmit and delivered to the Probelane inbox.
The script
Version 1.0 (2026-10-08) · SHA-256 dd4c782d4a60fb495917ffea3f1b40716afef84a0bf26aa7ca8e1af4660b53b4
<#
.SYNOPSIS
Probelane M365 Self-Scan: READ-ONLY licence waste and identity hygiene scan for one Microsoft 365 tenant.
.DESCRIPTION
Runs on the admin's own machine. Makes NO changes to the tenant and sends NO data anywhere except
the Microsoft Graph read calls needed to build the report. All output is written to a local folder.
Checks:
1. Licences: purchased vs assigned vs unassigned seats per SKU.
2. Licensed users with no sign-in for 30, 60 and 90+ days, never signed in, and disabled users who still hold licences.
3. Estimated monthly waste, using a price table YOU edit (prices.csv is created on first run).
4. App registrations and service principals with expired, expiring (default 30 days) or long-lived (default over 365 days) secrets and certificates.
5. Apps holding high-privilege permissions: application permissions (app roles) on Microsoft Graph, Exchange Online and SharePoint,
and tenant-wide (admin) delegated consents with high-privilege scopes.
6. Guest users: count, state, last sign-in, never signed in.
Delegated, read-only Graph scopes requested (nothing else):
User.Read.All, Directory.Read.All, Application.Read.All, AuditLog.Read.All, Organization.Read.All
Requirements:
PowerShell 7+ (or Windows PowerShell 5.1) and the Microsoft Graph PowerShell SDK:
Install-Module Microsoft.Graph -Scope CurrentUser
An admin who can consent to the scopes above (Global Reader can read; first-time consent may need a Global Administrator).
Sign-in activity (checks 2 and 6) needs Microsoft Entra ID P1 or P2 in the tenant. Without it the scan still runs and says so.
.PARAMETER OutDir
Local output folder. Default: .\probelane-m365-scan-<yyyyMMdd-HHmm>
.PARAMETER PriceFile
CSV with columns SkuPartNumber,MonthlyPricePerSeat. Created with starter values on first run if missing. Edit it to match YOUR invoice.
.PARAMETER Currency
Label for prices in the report, for example ZAR or USD. Default: USD (the starter table uses USD list prices).
.PARAMETER SecretWarnDays
Flag secrets and certificates that expire within this many days. Default 30.
.PARAMETER LongLivedDays
Flag secrets and certificates whose total lifetime is longer than this many days. Default 365.
.PARAMETER HideNames
Replace user principal names and display names with a short one-way hash in ALL output files.
.EXAMPLE
./Probelane-M365-SelfScan.ps1
.EXAMPLE
./Probelane-M365-SelfScan.ps1 -Currency ZAR -PriceFile .\my-prices.csv -HideNames
.NOTES
Probelane, 2026. Free to use inside your own organisation or for your own managed tenants. Provided as is, without warranty.
Version 1.0 (2026-10-08).
#>
[CmdletBinding()]
param(
[string]$OutDir = (Join-Path -Path (Get-Location) -ChildPath ("probelane-m365-scan-" + (Get-Date -Format "yyyyMMdd-HHmm"))),
[string]$PriceFile = (Join-Path -Path (Get-Location) -ChildPath "prices.csv"),
[string]$Currency = "USD",
[ValidateRange(1, 3650)][int]$SecretWarnDays = 30,
[ValidateRange(1, 36500)][int]$LongLivedDays = 365,
[switch]$HideNames
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = "Stop"
$Now = [DateTime]::UtcNow
$Scopes = @("User.Read.All", "Directory.Read.All", "Application.Read.All", "AuditLog.Read.All", "Organization.Read.All")
# ------------------------------------------------------------------ helpers
function Get-ShortHash([string]$Text) {
if ([string]::IsNullOrEmpty($Text)) { return "" }
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Text.ToLowerInvariant()))
return "id-" + (($bytes[0..5] | ForEach-Object { $_.ToString("x2") }) -join "")
} finally { $sha.Dispose() }
}
function Show-Name([string]$Text) { if ($HideNames) { return (Get-ShortHash $Text) } else { return $Text } }
function Get-DaysSince($When) {
if ($null -eq $When) { return $null }
return [int][Math]::Floor(($Now - ([DateTime]$When).ToUniversalTime()).TotalDays)
}
function Get-LatestDate([object[]]$Dates) {
$valid = @($Dates | Where-Object { $null -ne $_ } | ForEach-Object { ([DateTime]$_).ToUniversalTime() })
if ($valid.Count -eq 0) { return $null }
return ($valid | Sort-Object -Descending | Select-Object -First 1)
}
function ConvertTo-HtmlSafe([string]$Text) { return [System.Net.WebUtility]::HtmlEncode([string]$Text) }
function Write-Step([string]$Text) { Write-Host ("[probelane] " + $Text) }
# ------------------------------------------------------------------ modules
$needed = @("Microsoft.Graph.Authentication", "Microsoft.Graph.Users", "Microsoft.Graph.Identity.DirectoryManagement",
"Microsoft.Graph.Applications", "Microsoft.Graph.Identity.SignIns")
foreach ($m in $needed) {
if (-not (Get-Module -ListAvailable -Name $m)) {
throw "Module $m is missing. Install the Microsoft Graph SDK first: Install-Module Microsoft.Graph -Scope CurrentUser"
}
Import-Module $m -ErrorAction Stop
}
# ------------------------------------------------------------------ price table (local, editable)
if (-not (Test-Path -LiteralPath $PriceFile)) {
# Starter values: USD list prices per user per month after the 1 July 2026 Microsoft price update for the three SKUs below
# (source: https://www.microsoft.com/en-us/licensing/news/2026-M365-Packaging-Pricing-Updates). Every other SKU starts at 0:
# put in what YOU pay (from your invoice or CSP portal) and run the scan again.
@(
[pscustomobject]@{ SkuPartNumber = "O365_BUSINESS_ESSENTIALS"; MonthlyPricePerSeat = 7 }
[pscustomobject]@{ SkuPartNumber = "O365_BUSINESS_PREMIUM"; MonthlyPricePerSeat = 14 }
[pscustomobject]@{ SkuPartNumber = "SPE_E3"; MonthlyPricePerSeat = 39 }
[pscustomobject]@{ SkuPartNumber = "SPB"; MonthlyPricePerSeat = 0 }
[pscustomobject]@{ SkuPartNumber = "ENTERPRISEPACK"; MonthlyPricePerSeat = 0 }
[pscustomobject]@{ SkuPartNumber = "SPE_E5"; MonthlyPricePerSeat = 0 }
[pscustomobject]@{ SkuPartNumber = "EXCHANGESTANDARD"; MonthlyPricePerSeat = 0 }
) | Export-Csv -LiteralPath $PriceFile -NoTypeInformation -Encoding UTF8
Write-Step "Created starter price table $PriceFile. Edit it to match your invoice for an accurate waste estimate."
}
$Prices = @{}
foreach ($row in (Import-Csv -LiteralPath $PriceFile)) {
$p = 0.0
if ([double]::TryParse(([string]$row.MonthlyPricePerSeat).Replace(",", "."), [System.Globalization.NumberStyles]::Float,
[System.Globalization.CultureInfo]::InvariantCulture, [ref]$p)) { $Prices[[string]$row.SkuPartNumber] = $p }
}
# ------------------------------------------------------------------ connect (read-only scopes)
Write-Step "Connecting to Microsoft Graph with read-only scopes: $($Scopes -join ', ')"
Connect-MgGraph -Scopes $Scopes -NoWelcome | Out-Null
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$Notes = New-Object System.Collections.Generic.List[string]
$org = Get-MgOrganization | Select-Object -First 1
$tenantName = if ($org) { $org.DisplayName } else { "(unknown)" }
# ------------------------------------------------------------------ 1. licences
Write-Step "Reading licences"
$skus = @(Get-MgSubscribedSku -All)
$skuById = @{}
$skuRows = foreach ($s in $skus) {
$skuById[[string]$s.SkuId] = $s.SkuPartNumber
$enabled = [int]$s.PrepaidUnits.Enabled
$consumed = [int]$s.ConsumedUnits
$price = if ($Prices.ContainsKey($s.SkuPartNumber)) { $Prices[$s.SkuPartNumber] } else { 0 }
[pscustomobject]@{
SkuPartNumber = $s.SkuPartNumber; CapabilityStatus = $s.CapabilityStatus
Purchased = $enabled; Assigned = $consumed; Unassigned = [Math]::Max(0, $enabled - $consumed)
MonthlyPricePerSeat = $price; PriceSet = ($price -gt 0)
}
}
$skuRows | Export-Csv -LiteralPath (Join-Path $OutDir "licences-by-sku.csv") -NoTypeInformation -Encoding UTF8
# ------------------------------------------------------------------ 2. users and sign-in activity
Write-Step "Reading users (this can take a few minutes in large tenants)"
$baseProps = @("id", "displayName", "userPrincipalName", "accountEnabled", "userType", "createdDateTime", "assignedLicenses", "externalUserState")
$haveSignIn = $true
try {
$users = @(Get-MgUser -All -Property ($baseProps + "signInActivity") -PageSize 500)
} catch {
$haveSignIn = $false
$Notes.Add("Sign-in activity could not be read (usually: no Microsoft Entra ID P1/P2, or AuditLog.Read.All not consented). Inactivity checks are skipped; disabled-user and unassigned-seat checks still ran.")
$users = @(Get-MgUser -All -Property $baseProps -PageSize 500)
}
$userRows = foreach ($u in $users) {
$last = $null
if ($haveSignIn -and $u.SignInActivity) {
$sia = $u.SignInActivity
$cands = @($sia.LastSignInDateTime, $sia.LastNonInteractiveSignInDateTime)
if ($sia.PSObject.Properties.Name -contains "LastSuccessfulSignInDateTime") { $cands += $sia.LastSuccessfulSignInDateTime }
$last = Get-LatestDate $cands
}
$days = Get-DaysSince $last
$skuNames = @($u.AssignedLicenses | ForEach-Object { if ($skuById.ContainsKey([string]$_.SkuId)) { $skuById[[string]$_.SkuId] } else { [string]$_.SkuId } })
$bucket = if (-not $haveSignIn) { "unknown" }
elseif ($null -eq $last) { "never" }
elseif ($days -ge 90) { "90+" }
elseif ($days -ge 60) { "60-89" }
elseif ($days -ge 30) { "30-59" }
else { "active" }
[pscustomobject]@{
User = (Show-Name $u.UserPrincipalName); DisplayName = (Show-Name $u.DisplayName)
UserType = $u.UserType; AccountEnabled = $u.AccountEnabled; Created = $u.CreatedDateTime
Licences = ($skuNames -join ";"); LicenceCount = $skuNames.Count
LastSignInUtc = $last; DaysSinceSignIn = $days; InactivityBucket = $bucket
ExternalUserState = $u.ExternalUserState
}
}
$licensed = @($userRows | Where-Object { $_.LicenceCount -gt 0 })
$disabledLicensed = @($licensed | Where-Object { $_.AccountEnabled -eq $false })
$inactive30 = @($licensed | Where-Object { $_.AccountEnabled -ne $false -and $_.InactivityBucket -in @("30-59", "60-89", "90+", "never") })
$inactive60 = @($licensed | Where-Object { $_.AccountEnabled -ne $false -and $_.InactivityBucket -in @("60-89", "90+", "never") })
$inactive90 = @($licensed | Where-Object { $_.AccountEnabled -ne $false -and $_.InactivityBucket -in @("90+", "never") })
$licensed | Export-Csv -LiteralPath (Join-Path $OutDir "licensed-users.csv") -NoTypeInformation -Encoding UTF8
$disabledLicensed | Export-Csv -LiteralPath (Join-Path $OutDir "disabled-users-with-licences.csv") -NoTypeInformation -Encoding UTF8
$inactive30 | Export-Csv -LiteralPath (Join-Path $OutDir "licensed-users-inactive-30plus.csv") -NoTypeInformation -Encoding UTF8
# ------------------------------------------------------------------ 3. waste estimate
function Get-SeatCost([object[]]$Rows) {
$total = 0.0
foreach ($r in $Rows) {
foreach ($sku in ([string]$r.Licences).Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) {
if ($Prices.ContainsKey($sku)) { $total += $Prices[$sku] }
}
}
return [Math]::Round($total, 2)
}
$unassignedCost = [Math]::Round((($skuRows | ForEach-Object { $_.Unassigned * $_.MonthlyPricePerSeat }) | Measure-Object -Sum).Sum + 0, 2)
$disabledCost = Get-SeatCost $disabledLicensed
$inactive90Cost = Get-SeatCost $inactive90
$inactive60Cost = Get-SeatCost $inactive60
$inactive30Cost = Get-SeatCost $inactive30
$wasteConservative = [Math]::Round($unassignedCost + $disabledCost + $inactive90Cost, 2)
$unpriced = @($skuRows | Where-Object { -not $_.PriceSet -and ($_.Unassigned -gt 0 -or $_.Assigned -gt 0) } | ForEach-Object { $_.SkuPartNumber })
if ($unpriced.Count -gt 0) {
$Notes.Add("No price set for: $($unpriced -join ', '). Their seats count as 0 in the waste estimate. Add prices to $PriceFile and run again.")
}
$waste = [pscustomobject]@{
Currency = $Currency
UnassignedSeatsMonthly = $unassignedCost; DisabledUsersMonthly = $disabledCost
Inactive30PlusMonthly = $inactive30Cost; Inactive60PlusMonthly = $inactive60Cost; Inactive90PlusMonthly = $inactive90Cost
EstimatedMonthlyWaste_Conservative = $wasteConservative
Method = "Conservative = unassigned seats + licences on disabled users + licences on enabled users with no sign-in for 90+ days (or never). Prices from the local price table."
}
$waste | Export-Csv -LiteralPath (Join-Path $OutDir "waste-estimate.csv") -NoTypeInformation -Encoding UTF8
# ------------------------------------------------------------------ 4. secrets and certificates
Write-Step "Reading app registrations and service principals"
function Get-CredentialRows($Objects, [string]$Kind) {
foreach ($o in $Objects) {
$creds = @()
foreach ($c in @($o.PasswordCredentials)) { if ($c) { $creds += [pscustomobject]@{ Type = "Secret"; C = $c } } }
foreach ($c in @($o.KeyCredentials)) { if ($c) { $creds += [pscustomobject]@{ Type = "Certificate"; C = $c } } }
foreach ($x in $creds) {
$start = $x.C.StartDateTime; $end = $x.C.EndDateTime
$lifetime = if ($start -and $end) { [int](([DateTime]$end) - ([DateTime]$start)).TotalDays } else { $null }
$daysLeft = if ($end) { [int][Math]::Floor((([DateTime]$end).ToUniversalTime() - $Now).TotalDays) } else { $null }
$flags = @()
if ($null -ne $daysLeft -and $daysLeft -lt 0) { $flags += "expired" }
elseif ($null -ne $daysLeft -and $daysLeft -le $SecretWarnDays) { $flags += "expiring" }
if ($null -ne $lifetime -and $lifetime -gt $LongLivedDays) { $flags += "long-lived" }
[pscustomobject]@{
ObjectKind = $Kind; DisplayName = $o.DisplayName; AppId = $o.AppId; CredentialType = $x.Type
CredentialName = $x.C.DisplayName; StartUtc = $start; EndUtc = $end; LifetimeDays = $lifetime; DaysLeft = $daysLeft
Flags = ($flags -join ";")
}
}
}
}
$apps = @(Get-MgApplication -All -Property "id,appId,displayName,createdDateTime,passwordCredentials,keyCredentials" -PageSize 500)
$sps = @(Get-MgServicePrincipal -All -Property "id,appId,displayName,servicePrincipalType,appOwnerOrganizationId,accountEnabled,passwordCredentials,keyCredentials" -PageSize 500)
$credRows = @(Get-CredentialRows $apps "AppRegistration") + @(Get-CredentialRows ($sps | Where-Object { $_.ServicePrincipalType -ne "ManagedIdentity" }) "ServicePrincipal")
$flaggedCreds = @($credRows | Where-Object { $_.Flags })
$credRows | Export-Csv -LiteralPath (Join-Path $OutDir "app-credentials.csv") -NoTypeInformation -Encoding UTF8
# ------------------------------------------------------------------ 5. high-privilege permissions
Write-Step "Reading application permissions and admin consents"
$HighPriv = @(
"Directory.ReadWrite.All", "RoleManagement.ReadWrite.Directory", "AppRoleAssignment.ReadWrite.All", "Application.ReadWrite.All",
"Application.ReadWrite.OwnedBy", "User.ReadWrite.All", "Group.ReadWrite.All", "GroupMember.ReadWrite.All", "Policy.ReadWrite.ConditionalAccess",
"Mail.ReadWrite", "Mail.Send", "Mail.Read", "MailboxSettings.ReadWrite", "Files.ReadWrite.All", "Files.Read.All", "Sites.FullControl.All",
"Sites.ReadWrite.All", "Sites.Manage.All", "Domain.ReadWrite.All", "UserAuthenticationMethod.ReadWrite.All", "DelegatedPermissionGrant.ReadWrite.All",
"Directory.AccessAsUser.All", "full_access_as_app", "Exchange.ManageAsApp", "EWS.AccessAsUser.All", "Calendars.ReadWrite", "Contacts.ReadWrite",
"Chat.ReadWrite.All", "ChannelMessage.Read.All", "Team.ReadBasic.All", "TeamSettings.ReadWrite.All", "IdentityRiskyUser.ReadWrite.All"
)
$spById = @{}; foreach ($s in $sps) { $spById[[string]$s.Id] = $s }
$resourceAppIds = @{ "00000003-0000-0000-c000-000000000000" = "Microsoft Graph"; "00000002-0000-0ff1-ce00-000000000000" = "Exchange Online"; "00000003-0000-0ff1-ce00-000000000000" = "SharePoint Online" }
$permRows = New-Object System.Collections.Generic.List[object]
foreach ($rid in $resourceAppIds.Keys) {
$res = $sps | Where-Object { $_.AppId -eq $rid } | Select-Object -First 1
if (-not $res) { continue }
$resFull = Get-MgServicePrincipal -ServicePrincipalId $res.Id -Property "id,appRoles"
$roleNames = @{}; foreach ($r in @($resFull.AppRoles)) { $roleNames[[string]$r.Id] = $r.Value }
foreach ($a in @(Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $res.Id -All)) {
if ($a.PrincipalType -ne "ServicePrincipal") { continue }
$perm = $roleNames[[string]$a.AppRoleId]
$client = $spById[[string]$a.PrincipalId]
$permRows.Add([pscustomobject]@{
Client = $a.PrincipalDisplayName; ClientAppId = $(if ($client) { $client.AppId } else { "" })
ClientIsThirdParty = $(if ($client -and $org) { [string]$client.AppOwnerOrganizationId -ne [string]$org.Id } else { $null })
Resource = $resourceAppIds[$rid]; PermissionType = "Application"; Permission = $perm; ConsentScope = "Tenant (app-only)"
GrantedUtc = $a.CreatedDateTime; HighPrivilege = ($HighPriv -contains $perm)
})
}
}
foreach ($g in @(Get-MgOauth2PermissionGrant -All)) {
$client = $spById[[string]$g.ClientId]; $res = $spById[[string]$g.ResourceId]
foreach ($sc in ([string]$g.Scope).Split(" ", [System.StringSplitOptions]::RemoveEmptyEntries)) {
$permRows.Add([pscustomobject]@{
Client = $(if ($client) { $client.DisplayName } else { [string]$g.ClientId }); ClientAppId = $(if ($client) { $client.AppId } else { "" })
ClientIsThirdParty = $(if ($client -and $org) { [string]$client.AppOwnerOrganizationId -ne [string]$org.Id } else { $null })
Resource = $(if ($res) { $res.DisplayName } else { [string]$g.ResourceId }); PermissionType = "Delegated"; Permission = $sc
ConsentScope = $(if ($g.ConsentType -eq "AllPrincipals") { "Admin consent (all users)" } else { "Single user" })
GrantedUtc = $null; HighPrivilege = ($HighPriv -contains $sc)
})
}
}
$permRows | Export-Csv -LiteralPath (Join-Path $OutDir "app-permissions.csv") -NoTypeInformation -Encoding UTF8
$highPerm = @($permRows | Where-Object { $_.HighPrivilege -and ($_.PermissionType -eq "Application" -or $_.ConsentScope -like "Admin consent*") })
$highPermApps = @($highPerm | Select-Object -ExpandProperty Client -Unique)
# ------------------------------------------------------------------ 6. guests
$guests = @($userRows | Where-Object { $_.UserType -eq "Guest" })
$guestsNever = @($guests | Where-Object { $_.InactivityBucket -eq "never" })
$guests90 = @($guests | Where-Object { $_.InactivityBucket -eq "90+" })
$guestsPending = @($guests | Where-Object { $_.ExternalUserState -eq "PendingAcceptance" })
$guests | Export-Csv -LiteralPath (Join-Path $OutDir "guest-users.csv") -NoTypeInformation -Encoding UTF8
Disconnect-MgGraph | Out-Null
# ------------------------------------------------------------------ summary (local text + HTML)
$summary = [ordered]@{
"Tenant" = $tenantName
"Scanned (UTC)" = $Now.ToString("yyyy-MM-dd HH:mm")
"Licences purchased / assigned / unassigned" = "{0} / {1} / {2}" -f (($skuRows | Measure-Object Purchased -Sum).Sum + 0), (($skuRows | Measure-Object Assigned -Sum).Sum + 0), (($skuRows | Measure-Object Unassigned -Sum).Sum + 0)
"Disabled users still holding licences" = $disabledLicensed.Count
"Licensed users with no sign-in 30+ / 60+ / 90+ days (incl. never)" = $(if ($haveSignIn) { "{0} / {1} / {2}" -f $inactive30.Count, $inactive60.Count, $inactive90.Count } else { "not available (see notes)" })
"Estimated monthly waste, conservative ($Currency)" = $wasteConservative
"App secrets/certificates expired / expiring in $SecretWarnDays days / long-lived (>$LongLivedDays days)" = "{0} / {1} / {2}" -f @($credRows | Where-Object { $_.Flags -like "*expired*" }).Count, @($credRows | Where-Object { $_.Flags -like "*expiring*" }).Count, @($credRows | Where-Object { $_.Flags -like "*long-lived*" }).Count
"Apps with high-privilege application permissions or admin consents" = $highPermApps.Count
"Guest users (never signed in / no sign-in 90+ days / invite pending)" = "{0} ({1} / {2} / {3})" -f $guests.Count, $guestsNever.Count, $guests90.Count, $guestsPending.Count
}
$txt = @("Probelane M365 Self-Scan (read-only)", ("=" * 40))
foreach ($k in $summary.Keys) { $txt += ("{0}: {1}" -f $k, $summary[$k]) }
$txt += ""; $txt += "Notes:"; if ($Notes.Count -eq 0) { $txt += "- none" } else { foreach ($n in $Notes) { $txt += "- $n" } }
$txt += ""; $txt += "Waste method: $($waste.Method)"
$txt += "Files: licences-by-sku.csv, licensed-users.csv, disabled-users-with-licences.csv, licensed-users-inactive-30plus.csv, waste-estimate.csv, app-credentials.csv, app-permissions.csv, guest-users.csv"
$txt += "Nothing was changed in your tenant and nothing was sent to Probelane. These files contain names and emails: store them like any HR record."
$txt | Set-Content -LiteralPath (Join-Path $OutDir "summary.txt") -Encoding UTF8
$rowsHtml = ($summary.Keys | ForEach-Object { "<tr><th>" + (ConvertTo-HtmlSafe $_) + "</th><td>" + (ConvertTo-HtmlSafe ([string]$summary[$_])) + "</td></tr>" }) -join "`n"
$notesHtml = if ($Notes.Count -eq 0) { "<li>none</li>" } else { ($Notes | ForEach-Object { "<li>" + (ConvertTo-HtmlSafe $_) + "</li>" }) -join "`n" }
$credHtml = ($flaggedCreds | Sort-Object DaysLeft | Select-Object -First 50 | ForEach-Object { "<tr><td>" + (ConvertTo-HtmlSafe $_.DisplayName) + "</td><td>" + $_.ObjectKind + "</td><td>" + $_.CredentialType + "</td><td>" + $_.DaysLeft + "</td><td>" + $_.LifetimeDays + "</td><td>" + $_.Flags + "</td></tr>" }) -join "`n"
$permHtml = ($highPerm | Sort-Object Client | Select-Object -First 100 | ForEach-Object { "<tr><td>" + (ConvertTo-HtmlSafe $_.Client) + "</td><td>" + (ConvertTo-HtmlSafe $_.Resource) + "</td><td>" + $_.PermissionType + "</td><td>" + (ConvertTo-HtmlSafe $_.Permission) + "</td><td>" + $_.ConsentScope + "</td><td>" + $_.ClientIsThirdParty + "</td></tr>" }) -join "`n"
$html = @"
<!doctype html><html><head><meta charset="utf-8"><title>Probelane M365 Self-Scan: $(ConvertTo-HtmlSafe $tenantName)</title>
<style>body{font:15px/1.5 -apple-system,Segoe UI,Arial,sans-serif;color:#0B1220;max-width:1000px;margin:30px auto;padding:0 18px}h1{font-size:24px}h2{font-size:18px;margin-top:28px;border-bottom:2px solid #0D9488}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:6px;border-bottom:1px solid #ddd;vertical-align:top}th{width:45%}.n{color:#475569;font-size:13px}</style></head><body>
<h1>Probelane M365 Self-Scan (read-only)</h1><p class="n">Generated locally. Nothing was changed in the tenant and nothing was sent to Probelane.</p>
<h2>Summary</h2><table>$rowsHtml</table>
<h2>Notes</h2><ul>$notesHtml</ul><p class="n">$(ConvertTo-HtmlSafe $waste.Method)</p>
<h2>Flagged secrets and certificates (first 50)</h2><table><tr><th>App</th><th>Object</th><th>Type</th><th>Days left</th><th>Lifetime (days)</th><th>Flags</th></tr>$credHtml</table>
<h2>High-privilege permissions (first 100)</h2><table><tr><th>Client app</th><th>Resource</th><th>Type</th><th>Permission</th><th>Consent</th><th>Third party</th></tr>$permHtml</table>
<p class="n">Full detail is in the CSV files next to this report. Probelane M365 Self-Scan v1.0.</p></body></html>
"@
$html | Set-Content -LiteralPath (Join-Path $OutDir "summary.html") -Encoding UTF8
Write-Host ""
$txt | Select-Object -First 12 | ForEach-Object { Write-Host $_ }
Write-Host ""
Write-Step "Done. Report folder: $OutDir (open summary.html)"
Request the full audit report
Want a written report with a clean-up plan, licence-by-licence savings and an app-risk review, or a white-label version for your clients? Tell us about the tenant. No tenant data is needed for this request.