probelane
Free tool · Microsoft 365 · read-only

Find unused Microsoft 365 licences and risky app access in one read-only scan

A single PowerShell script for Microsoft 365 admins and MSPs. It reads your tenant through Microsoft Graph, writes the results to a folder on your own machine, and changes nothing.

Download the script (.ps1)View the codeRequest the full audit report

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

  1. Install the Microsoft Graph PowerShell SDK once: Install-Module Microsoft.Graph -Scope CurrentUser
  2. Download Probelane-M365-SelfScan.ps1 (or copy the code below) and read it first. It is plain text.
  3. Run ./Probelane-M365-SelfScan.ps1 and 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.
  4. First run creates prices.csv with starter list prices for three SKUs. Put in what you pay per seat, then run again (for rands: -Currency ZAR).
  5. Open summary.html in the output folder. Use -HideNames to 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

Download .ps1

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.

Are you an MSP or IT provider managing tenants for clients? *

We use this only to reply to your request. No mailing list, no sharing. Do not paste scan output or user data into this form.