LicenseMeterScan.psm1
|
#requires -Version 7.0 # LicenseMeter Scan # Free, read-only Microsoft 365 license-waste scanner. Runs locally in your own tenant. # https://github.com/ugurkocde/licensemeter Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ---------------------------------------------------------------------------- # Constants # ---------------------------------------------------------------------------- $script:ModuleVersion = '1.4.0' $script:GraphBase = 'https://graph.microsoft.com/beta' $script:UserSelect = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime,signInActivity' $script:UserSelectNoSignIn = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime' $script:DefaultScopes = @('User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All') $script:NeverSignedInGraceDays = 30 $script:RuleLabels = @{ DisabledLicensed = 'Disabled account, still licensed' InactiveLicensed = 'Inactive {0}+ days, still licensed' NeverSignedInLicensed = 'Never signed in, still licensed' } $script:RuleOrder = @{ DisabledLicensed = 1 InactiveLicensed = 2 NeverSignedInLicensed = 3 } # Friendly display names for common SKUs. Falls back to the raw skuPartNumber. $script:SkuFriendlyName = @{ STANDARDPACK = 'Office 365 E1' ENTERPRISEPACK = 'Office 365 E3' ENTERPRISEPREMIUM = 'Office 365 E5' SPE_E3 = 'Microsoft 365 E3' SPE_E5 = 'Microsoft 365 E5' SPE_F1 = 'Microsoft 365 F1' SPE_F3 = 'Microsoft 365 F3' DESKLESSPACK = 'Office 365 F3' DEVELOPERPACK_E5 = 'Microsoft 365 E5 (Developer)' SPB = 'Microsoft 365 Business Premium' O365_BUSINESS_PREMIUM = 'Microsoft 365 Business Standard' O365_BUSINESS_ESSENTIALS = 'Microsoft 365 Business Basic' O365_BUSINESS = 'Microsoft 365 Apps for Business' OFFICESUBSCRIPTION = 'Microsoft 365 Apps for Enterprise' EXCHANGESTANDARD = 'Exchange Online (Plan 1)' EXCHANGEENTERPRISE = 'Exchange Online (Plan 2)' EXCHANGEDESKLESS = 'Exchange Online Kiosk' SHAREPOINTSTANDARD = 'SharePoint Online (Plan 1)' SHAREPOINTENTERPRISE = 'SharePoint Online (Plan 2)' WACONEDRIVESTANDARD = 'OneDrive for Business (Plan 1)' WACONEDRIVEENTERPRISE = 'OneDrive for Business (Plan 2)' POWER_BI_PRO = 'Power BI Pro' PBI_PREMIUM_PER_USER = 'Power BI Premium Per User' PROJECT_PLAN1 = 'Project Plan 1' PROJECTPROFESSIONAL = 'Project Plan 3' PROJECTPREMIUM = 'Project Plan 5' VISIOONLINE_PLAN1 = 'Visio Plan 1' VISIOCLIENT = 'Visio Plan 2' Microsoft_365_Copilot = 'Microsoft 365 Copilot' AAD_PREMIUM = 'Microsoft Entra ID P1' AAD_PREMIUM_P2 = 'Microsoft Entra ID P2' EMS = 'Enterprise Mobility + Security E3' EMSPREMIUM = 'Enterprise Mobility + Security E5' INTUNE_A = 'Microsoft Intune Plan 1' Microsoft_Intune_Suite = 'Microsoft Intune Suite' DEFENDER_ENDPOINT_P1 = 'Defender for Endpoint P1' WIN_DEF_ATP = 'Defender for Endpoint P2' ATP_ENTERPRISE = 'Defender for Office 365 (Plan 1)' THREAT_INTELLIGENCE = 'Defender for Office 365 (Plan 2)' IDENTITY_THREAT_PROTECTION = 'Microsoft 365 E5 Security' INFORMATION_PROTECTION_COMPLIANCE = 'Microsoft 365 E5 Compliance' RIGHTSMANAGEMENT = 'Azure Information Protection P1' WIN10_VDA_E3 = 'Windows 10/11 Enterprise E3' WIN10_VDA_E5 = 'Windows 10/11 Enterprise E5' MCOEV = 'Microsoft Teams Phone Standard' MCOMEETADV = 'Microsoft 365 Audio Conferencing' MCOPSTN1 = 'Skype for Business PSTN Domestic Calling' TEAMS_PREMIUM = 'Microsoft Teams Premium' POWERAPPS_PER_USER = 'Power Apps per user plan' FLOW_PER_USER = 'Power Automate per user plan' FLOW_FREE = 'Microsoft Power Automate Free' POWER_BI_STANDARD = 'Power BI (free)' TEAMS_EXPLORATORY = 'Microsoft Teams Exploratory' TEAMS_FREE = 'Microsoft Teams (Free)' POWERAPPS_VIRAL = 'Power Apps Plan 2 Trial' } # ---------------------------------------------------------------------------- # Helpers (pure) # ---------------------------------------------------------------------------- function Get-LMProp { # Read a property from either a Hashtable or a PSObject, returning $null when absent. # Keeps the rule engine robust to PSObject (live Graph / fixtures) and Hashtable shapes. param($Object, [string]$Name) if ($null -eq $Object) { return $null } if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Name)) { return $Object[$Name] } return $null } $prop = $Object.PSObject.Properties[$Name] if ($prop) { return $prop.Value } return $null } function Format-LMMoney { param([long]$Cents, [string]$Currency = 'EUR') $value = [decimal]$Cents / 100 '{0} {1}' -f $Currency, $value.ToString('N2', [System.Globalization.CultureInfo]::InvariantCulture) } function ConvertTo-LMDateOffset { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $null } try { return [datetimeoffset]::Parse( $Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) } catch { return $null } } function Get-LMLastActivity { # Most recent of the available signInActivity timestamps, or $null when none. param($SignInActivity) if ($null -eq $SignInActivity) { return $null } $raw = @( (Get-LMProp $SignInActivity 'lastSignInDateTime') (Get-LMProp $SignInActivity 'lastNonInteractiveSignInDateTime') (Get-LMProp $SignInActivity 'lastSuccessfulSignInDateTime') ) $dates = foreach ($r in $raw) { $d = ConvertTo-LMDateOffset $r; if ($d) { $d } } if (-not $dates) { return $null } return ($dates | Sort-Object | Select-Object -Last 1) } function Get-LMSkuFriendlyName { param([string]$PartNumber) if ($PartNumber -and $script:SkuFriendlyName.ContainsKey($PartNumber)) { return $script:SkuFriendlyName[$PartNumber] } if ($PartNumber) { return $PartNumber } return '(unknown)' } function Get-LMCentTotal { # Integer accumulation in cents. Avoids Measure-Object -Sum, which returns a [double]. param($Items) $sum = [long]0 foreach ($item in @($Items)) { if ($null -ne $item) { $sum += [long]$item.MonthlyCents } } return $sum } # ---------------------------------------------------------------------------- # Pricing (pure) # ---------------------------------------------------------------------------- function Get-LMPriceBook { # Load a price-book JSON file and normalise prices to integer cents. param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "Price file not found: $Path" } $json = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json $currency = if ($json.PSObject.Properties['currency']) { [string]$json.currency } else { 'EUR' } $period = if ($json.PSObject.Properties['period']) { [string]$json.period } else { 'monthly' } $prices = @{} if ($json.PSObject.Properties['prices'] -and $json.prices) { foreach ($entry in $json.prices.PSObject.Properties) { $cents = [long][math]::Round(([decimal]$entry.Value) * 100, [System.MidpointRounding]::AwayFromZero) $prices[$entry.Name] = $cents } } [pscustomobject]@{ Currency = $currency Period = $period Prices = $prices Path = $Path } } function Resolve-LMPriceMap { # Build skuId -> { PartNumber; Cents; Priced } from subscribedSkus and a price book. param( [Parameter(Mandatory)]$SubscribedSkus, [Parameter(Mandatory)]$PriceBook ) $map = @{} foreach ($sku in @($SubscribedSkus)) { $skuId = Get-LMProp $sku 'skuId' if (-not $skuId) { continue } $part = Get-LMProp $sku 'skuPartNumber' $cents = [long]0 $known = $false if ($part -and $PriceBook.Prices.ContainsKey($part)) { $known = $true $cents = [long]$PriceBook.Prices[$part] } $map[$skuId] = [pscustomobject]@{ SkuId = $skuId PartNumber = $part Cents = $cents Known = $known Priced = ($cents -gt 0) } } return $map } function Get-LMInventory { # Per-SKU license inventory for the report: assigned (consumed) vs available (prepaid), # plus the catalog price and whether it is known. Covers every subscribed SKU. param( [Parameter(Mandatory)]$SubscribedSkus, [Parameter(Mandatory)]$PriceMap ) $list = [System.Collections.Generic.List[object]]::new() foreach ($sku in @($SubscribedSkus)) { $skuId = Get-LMProp $sku 'skuId' if (-not $skuId) { continue } $part = Get-LMProp $sku 'skuPartNumber' $prepaid = Get-LMProp $sku 'prepaidUnits' $enabled = if ($prepaid) { Get-LMProp $prepaid 'enabled' } else { $null } $entry = if ($PriceMap.ContainsKey($skuId)) { $PriceMap[$skuId] } else { $null } $list.Add([pscustomobject]@{ SkuId = $skuId Part = [string]$part Name = Get-LMSkuFriendlyName $part Assigned = [int](Get-LMProp $sku 'consumedUnits') Available = if ($null -ne $enabled) { [int]$enabled } else { 0 } Cents = if ($entry) { [long]$entry.Cents } else { [long]0 } Known = if ($entry) { [bool]$entry.Known } else { $false } }) } return $list.ToArray() } # ---------------------------------------------------------------------------- # Rule engine (pure, no I/O) # ---------------------------------------------------------------------------- function Invoke-LMRule { <# .SYNOPSIS Apply the v0.1 waste rules to already-fetched users and return findings. .DESCRIPTION Pure function. Takes plain objects plus an explicit -AsOf so results are deterministic and unit-testable. One finding per (user, paid SKU). Each user matches at most one rule (R1 disabled takes precedence). #> param( [Parameter(Mandatory)]$Users, [Parameter(Mandatory)][hashtable]$PriceMap, [Parameter(Mandatory)][datetimeoffset]$AsOf, [int]$InactiveDays = 90, [bool]$HasSignInData = $true, [int]$GraceDays = 30 ) $findings = [System.Collections.Generic.List[object]]::new() foreach ($user in @($Users)) { $licenses = @(Get-LMProp $user 'assignedLicenses') if ($licenses.Count -eq 0) { continue } $billable = [System.Collections.Generic.List[object]]::new() foreach ($lic in $licenses) { $skuId = Get-LMProp $lic 'skuId' if ($skuId -and $PriceMap.ContainsKey($skuId)) { $entry = $PriceMap[$skuId] # Flag priced AND not-yet-priced (unknown) SKUs so the report can price them; skip known-free SKUs. if (-not $entry.Known -or $entry.Cents -gt 0) { $billable.Add($entry) } } } if ($billable.Count -eq 0) { continue } $enabled = [bool](Get-LMProp $user 'accountEnabled') $rule = $null $lastActivity = $null $daysInactive = $null if (-not $enabled) { # R1: disabled account still holding a paid license. Needs no sign-in data. $rule = 'DisabledLicensed' } elseif ($HasSignInData) { $lastActivity = Get-LMLastActivity (Get-LMProp $user 'signInActivity') if ($null -eq $lastActivity) { # R3: never signed in, but only past a grace period so brand-new accounts are spared. $created = ConvertTo-LMDateOffset (Get-LMProp $user 'createdDateTime') if ($created -and (($AsOf - $created).TotalDays -gt $GraceDays)) { $rule = 'NeverSignedInLicensed' } } else { # R2: licensed but inactive for at least InactiveDays. $daysInactive = [int][math]::Floor(($AsOf - $lastActivity).TotalDays) if ($daysInactive -ge $InactiveDays) { $rule = 'InactiveLicensed' } } } # When sign-in data is unavailable and the account is enabled, R2/R3 cannot be # evaluated in v0.1; the user is skipped and the report says so. if ($null -eq $rule) { continue } foreach ($p in $billable) { $findings.Add([pscustomobject]@{ Rule = $rule UserId = Get-LMProp $user 'id' UserPrincipalName = Get-LMProp $user 'userPrincipalName' DisplayName = Get-LMProp $user 'displayName' AccountEnabled = $enabled SkuId = $p.SkuId SkuPartNumber = $p.PartNumber SkuName = Get-LMSkuFriendlyName $p.PartNumber MonthlyCents = [long]$p.Cents PriceKnown = $p.Known LastActivity = if ($lastActivity) { $lastActivity.UtcDateTime.ToString('yyyy-MM-dd') } else { $null } DaysInactive = $daysInactive }) } } return $findings.ToArray() } function Get-LMScanSummary { # Aggregate findings into the report summary. Pure. param( $Findings, [int]$UsersScanned, [string]$Currency = 'EUR', [bool]$HasSignInData = $true, [int]$InactiveDays = 90 ) $all = @($Findings | Where-Object { $null -ne $_ }) $totalCents = Get-LMCentTotal $all $byRule = foreach ($group in ($all | Group-Object Rule)) { $label = $script:RuleLabels[$group.Name] -f $InactiveDays [pscustomobject]@{ Rule = $group.Name Label = $label Users = (@($group.Group | Select-Object -ExpandProperty UserId -Unique)).Count Findings = $group.Count MonthlyCents = Get-LMCentTotal $group.Group } } $bySku = foreach ($group in ($all | Group-Object SkuPartNumber)) { [pscustomobject]@{ SkuPartNumber = $group.Name SkuName = Get-LMSkuFriendlyName $group.Name Count = $group.Count MonthlyCents = Get-LMCentTotal $group.Group } } [pscustomobject]@{ Currency = $Currency UsersScanned = $UsersScanned HasSignInData = $HasSignInData InactiveDays = $InactiveDays TotalMonthlyCents = $totalCents TotalAnnualCents = $totalCents * 12 FindingCount = $all.Count ByRule = @($byRule | Sort-Object { $script:RuleOrder[$_.Rule] }) BySku = @($bySku | Sort-Object -Property MonthlyCents -Descending) } } # ---------------------------------------------------------------------------- # Scope guard (pure) # ---------------------------------------------------------------------------- function Assert-LMReadOnlyScope { # Refuse to run with any write-capable scope. LicenseMeter Scan only ever reads. param([string[]]$Scopes) $bad = foreach ($s in @($Scopes)) { if ($s -match '(?i)(write|\.send|\.manage|manage\.|delete|owns|fullcontrol)') { $s } } if ($bad) { throw "LicenseMeter Scan is read-only and refuses write scope(s): $($bad -join ', ')" } } # ---------------------------------------------------------------------------- # Microsoft Graph I/O (via MgGraphCommunity) # ---------------------------------------------------------------------------- function Connect-LMGraph { <# .SYNOPSIS Sign in to Microsoft Graph with read-only scopes via MgGraphCommunity. .DESCRIPTION Wraps Connect-MgGraphCommunity. Interactive browser sign-in by default; -UseDeviceCode for headless sessions. No app registration is required. #> [CmdletBinding()] param( [string[]]$Scopes = $script:DefaultScopes, [string]$TenantId, [string]$ClientId, [switch]$UseDeviceCode ) if (-not (Get-Command Connect-MgGraphCommunity -ErrorAction SilentlyContinue)) { throw "MgGraphCommunity is not installed. Run: Install-Module MgGraphCommunity -Scope CurrentUser" } Assert-LMReadOnlyScope -Scopes $Scopes $params = @{ Scopes = $Scopes; NoWelcome = $true; ErrorAction = 'Stop' } if ($UseDeviceCode) { $params['UseDeviceCode'] = $true } if ($TenantId) { $params['TenantId'] = $TenantId } if ($ClientId) { $params['ClientId'] = $ClientId } Connect-MgGraphCommunity @params | Out-Null $ctx = Get-MgGraphCommunityContext $granted = Get-LMProp $ctx 'Scopes' if ($granted) { $writeish = foreach ($s in @($granted)) { if ($s -match '(?i)(write|\.send|\.manage|delete|fullcontrol)') { $s } } if ($writeish) { Write-Warning "The signed-in token carries non-read scopes ($($writeish -join ', ')). LicenseMeter Scan only reads." } } return $ctx } function Test-LMConnected { $ctx = $null try { $ctx = Get-MgGraphCommunityContext } catch { return $false } return [bool]$ctx } function Invoke-LMGraphList { # GET a Graph collection, following @odata.nextLink with host validation. param([Parameter(Mandatory)][string]$Uri) $results = [System.Collections.Generic.List[object]]::new() $next = $Uri while ($next) { # Validate every request URL (the initial one and each @odata.nextLink) is an # absolute Graph URL. Rejects off-host follow links and relative-link bypasses. if ($next -notmatch '^https://graph\.microsoft\.com/') { throw "Refusing to request a non-Graph URL: $next" } $response = Invoke-MgGraphCommunityRequest -Method GET -Uri $next -OutputType PSObject $value = Get-LMProp $response 'value' if ($value) { foreach ($item in @($value)) { $results.Add($item) } } $next = Get-LMProp $response '@odata.nextLink' } return $results.ToArray() } function Get-LMUser { # Fetch licensed-relevant user fields. Probes sign-in capability: if the tenant # lacks Entra ID P1 / AuditLog.Read.All, the signInActivity select fails and we # retry without it, reporting HasSignInData = $false. [CmdletBinding()] param() $uriFull = "$script:GraphBase/users?`$select=$script:UserSelect&`$top=999" try { $users = Invoke-LMGraphList -Uri $uriFull return [pscustomobject]@{ Users = $users; HasSignInData = $true } } catch { # Degrade gracefully only for the "no Entra P1 / no AuditLog.Read.All" case, where # selecting signInActivity is rejected. Re-throw anything else (throttling, 5xx, # network, or our own non-Graph-URL guard) so real failures are never hidden. $reason = "$($_.Exception.Message)" if ($reason -notmatch '(?i)(premium|authorization_requestdenied|forbidden|\b403\b|does not have|tenant.*licen|insufficient|aadsts)') { throw } Write-Verbose "signInActivity unavailable (permission or license): $reason. Retrying without it." $uriNoSignIn = "$script:GraphBase/users?`$select=$script:UserSelectNoSignIn&`$top=999" $users = Invoke-LMGraphList -Uri $uriNoSignIn return [pscustomobject]@{ Users = $users; HasSignInData = $false } } } function Get-LMSubscribedSku { return Invoke-LMGraphList -Uri "$script:GraphBase/subscribedSkus" } # ---------------------------------------------------------------------------- # Reporting # ---------------------------------------------------------------------------- function Format-LMConsoleReport { param($Summary, [string]$TenantLabel = 'your tenant', [string]$ReportPath) $cur = $Summary.Currency $p1 = if ($Summary.HasSignInData) { 'yes' } else { 'no' } Write-Host '' Write-Host 'LicenseMeter Scan' -ForegroundColor White -NoNewline Write-Host " v$script:ModuleVersion" -ForegroundColor DarkGray Write-Host ("Tenant: {0} Users scanned: {1} Entra P1: {2}" -f $TenantLabel, $Summary.UsersScanned, $p1) -ForegroundColor DarkGray Write-Host '' Write-Host ("Identified waste: {0} / month ({1} / year)" -f (Format-LMMoney $Summary.TotalMonthlyCents $cur), (Format-LMMoney $Summary.TotalAnnualCents $cur)) -ForegroundColor Yellow Write-Host ("Across {0} findings in {1} categories." -f $Summary.FindingCount, $Summary.ByRule.Count) Write-Host '' foreach ($r in $Summary.ByRule) { Write-Host (" {0,-42}{1,4} users {2}" -f $r.Label, $r.Users, (Format-LMMoney $r.MonthlyCents $cur)) } if ($Summary.BySku.Count -gt 0) { Write-Host '' Write-Host 'Top wasted licenses:' foreach ($s in (@($Summary.BySku) | Select-Object -First 5)) { Write-Host (" {0,-30}{1,3} {2}" -f $s.SkuName, $s.Count, (Format-LMMoney $s.MonthlyCents $cur)) } } if (-not $Summary.HasSignInData) { Write-Host '' Write-Host 'Note: inactive and never-signed-in checks were skipped. This tenant lacks Entra ID P1 or AuditLog.Read.All (sign-in activity unavailable).' -ForegroundColor DarkYellow } Write-Host '' Write-Host 'Figures use list prices. Edit prices.json for your contract.' -ForegroundColor DarkGray $tail = 'Read-only scan. Nothing left your tenant.' if ($ReportPath) { $tail += " Report: $ReportPath" } Write-Host $tail -ForegroundColor DarkGray Write-Host '' } function Protect-LMCsvCell { # Neutralise spreadsheet formula injection for tenant-controlled text fields: a leading # =, +, -, @, tab, or CR makes Excel/Sheets evaluate the cell as a formula on open. param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return $Value } if ($Value -match '^[=+\-@\t\r]') { return "'" + $Value } return $Value } function ConvertTo-LMFindingRow { # Flatten findings into CSV export rows. param($Findings, [string]$Currency = 'EUR') foreach ($f in @($Findings)) { [pscustomobject]@{ Rule = $f.Rule DisplayName = Protect-LMCsvCell $f.DisplayName UserPrincipalName = Protect-LMCsvCell $f.UserPrincipalName AccountEnabled = $f.AccountEnabled SkuPartNumber = Protect-LMCsvCell $f.SkuPartNumber SkuName = Protect-LMCsvCell $f.SkuName MonthlyCost = ([decimal]$f.MonthlyCents / 100).ToString('0.00', [System.Globalization.CultureInfo]::InvariantCulture) Currency = $Currency LastActivity = $f.LastActivity DaysInactive = $f.DaysInactive } } } function ConvertTo-LMHtmlEncoded { param([string]$Text) if ($null -eq $Text) { return '' } return [System.Net.WebUtility]::HtmlEncode($Text) } function Get-LMHtmlReport { # Interactive, self-contained HTML report: a full license inventory (assigned vs available) # plus the waste findings. Prices are editable in the browser and every total recalculates # live; the page accepts a dropped prices file and can download one back. param($Summary, $Findings, $Inventory, [string]$TenantLabel = 'your tenant') $cur = if ($Summary.Currency) { [string]$Summary.Currency } else { 'EUR' } $generated = ([datetimeoffset]::UtcNow).ToString('yyyy-MM-dd HH:mm') + ' UTC' $shortLabel = @{ DisabledLicensed = 'Disabled' InactiveLicensed = 'Inactive' NeverSignedInLicensed = 'Never signed in' } $fData = foreach ($f in @($Findings)) { [pscustomobject]@{ name = [string]$f.DisplayName upn = [string]$f.UserPrincipalName rule = [string]$f.Rule label = $(if ($shortLabel.ContainsKey([string]$f.Rule)) { $shortLabel[[string]$f.Rule] } else { [string]$f.Rule }) last = $(if ($f.LastActivity) { [string]$f.LastActivity } else { 'never' }) sku = [string]$f.SkuPartNumber skuName = [string]$f.SkuName } } $invData = foreach ($it in @($Inventory)) { if (-not $it) { continue } [pscustomobject]@{ name = [string]$it.Name part = [string]$it.Part assigned = [int]$it.Assigned available = [int]$it.Available amount = [math]::Round(([decimal]$it.Cents / 100), 2) known = [bool]$it.Known } } $data = [pscustomobject]@{ currency = $cur tenant = [string]$TenantLabel usersScanned = [int]$Summary.UsersScanned p1 = [bool]$Summary.HasSignInData inactiveDays = [int]$Summary.InactiveDays generated = $generated ruleLabels = [ordered]@{ DisabledLicensed = 'Disabled account, still licensed' InactiveLicensed = "Inactive $([int]$Summary.InactiveDays)+ days, still licensed" NeverSignedInLicensed = 'Never signed in, still licensed' } findings = @($fData) inventory = @($invData) } $dataJson = ($data | ConvertTo-Json -Depth 6 -Compress) # Neutralise <, >, & so tenant-controlled strings cannot break out of the <script> element. $dataJson = $dataJson.Replace('<', '\u003c').Replace('>', '\u003e').Replace('&', '\u0026') $template = @' <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>LicenseMeter Scan report</title> <style> :root { --paper:#faf8f3; --ink:#1c1a16; --rust:#bc3e12; --muted:#6f6a5f; --line:#e7e2d8; --card:#fffdf9; } * { box-sizing: border-box; } body { margin:0; background:var(--paper); color:var(--ink); font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; } .serif { font-family:'Iowan Old Style','Palatino Linotype',Palatino,'Hoefler Text',Georgia,ui-serif,serif; } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.78rem; color:var(--muted); } .dim { color:var(--muted); } .wrap { max-width:860px; margin:0 auto; padding:52px 44px 60px; } .topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:8px; } .brand { display:flex; align-items:center; gap:11px; } .wordmark { font-size:1.45rem; font-weight:600; letter-spacing:-0.01em; } .pill { border:1px solid var(--rust); color:var(--rust); border-radius:999px; font-size:.67rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; padding:5px 11px; white-space:nowrap; } .meta { color:var(--muted); font-size:.82rem; margin:0 0 28px; } .hero { border:1px solid var(--line); background:var(--card); border-radius:16px; padding:28px 30px; margin-bottom:13px; } .hero-label { text-transform:uppercase; letter-spacing:.09em; font-size:.71rem; font-weight:700; color:var(--muted); margin-bottom:12px; } .hero-num { font-size:3.5rem; line-height:.95; font-weight:700; color:var(--rust); font-variant-numeric:tabular-nums lining-nums; } .hero-num .per { font-size:1.25rem; color:var(--muted); font-weight:600; font-family:-apple-system,'Segoe UI',sans-serif; } .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; } .card { border:1px solid var(--line); background:var(--card); border-radius:13px; padding:17px 19px; } .card-num { font-size:1.65rem; font-weight:700; line-height:1.1; font-variant-numeric:tabular-nums lining-nums; } .card-lbl { color:var(--muted); font-size:.77rem; margin-top:3px; } h2 { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:700; margin:32px 0 9px; } .prices-hd { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:34px; } .prices-hd h2 { margin:0; } .hint { text-transform:none; letter-spacing:0; font-weight:400; color:var(--muted); font-size:.74rem; margin-left:10px; } button { font:inherit; font-size:.78rem; font-weight:600; color:var(--rust); background:var(--paper); border:1px solid var(--rust); border-radius:8px; padding:6px 13px; cursor:pointer; white-space:nowrap; } button:hover { background:var(--rust); color:var(--paper); } .controls { display:flex; align-items:center; gap:10px; } .curlbl { font-size:.76rem; color:var(--muted); display:flex; align-items:center; gap:6px; white-space:nowrap; } select { font:inherit; font-size:.8rem; color:var(--ink); background:#fff; border:1px solid var(--line); border-radius:8px; padding:5px 8px; cursor:pointer; } select:focus { outline:none; border-color:var(--rust); } table { border-collapse:collapse; width:100%; font-size:.85rem; } thead th { text-align:left; font-size:.66rem; text-transform:uppercase; letter-spacing:.05em; white-space:nowrap; color:var(--muted); font-weight:700; padding:0 10px 8px; border-bottom:2px solid var(--ink); } tbody td { padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:middle; } td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; } input[type=number] { width:7rem; text-align:right; font:inherit; font-variant-numeric:tabular-nums; border:1px solid var(--line); border-radius:6px; padding:4px 7px; background:#fff; } input[type=number]:focus { outline:none; border-color:var(--rust); } .needs { color:var(--rust); font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; margin-left:8px; } .clear { font-size:1.05rem; margin-top:24px; color:var(--ink); } .foot { margin-top:38px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:.78rem; line-height:1.5; } .foot a { color:var(--rust); text-decoration:none; } </style> </head> <body> <div class="wrap"> <div class="topbar"> <div class="brand"> <svg width="29" height="29" viewBox="0 0 32 32" aria-hidden="true"><rect x="6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="14.3" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="22.6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><line x1="4" y1="24.5" x2="28" y2="7.5" stroke="#bc3e12" stroke-width="3.2" stroke-linecap="round"/></svg> <span class="wordmark serif">LicenseMeter Scan</span> </div> <span class="pill">Read-only</span> </div> <p class="meta" id="meta"></p> <div class="hero"> <div class="hero-label">Identified Microsoft 365 license waste</div> <div class="hero-num serif" id="heroNum"></div> </div> <div class="cards"> <div class="card"><div class="card-num serif" id="cardAnnual"></div><div class="card-lbl">identified per year</div></div> <div class="card"><div class="card-num serif" id="cardAccounts"></div><div class="card-lbl">accounts flagged</div></div> <div class="card"><div class="card-num serif" id="cardUsers"></div><div class="card-lbl">users scanned</div></div> </div> <div class="prices-hd"> <h2>Licenses in your tenant <span class="hint">edit any price to update the figures</span></h2> <div class="controls"> <label class="curlbl">Currency <select id="curSel"></select></label> </div> </div> <table> <thead><tr><th>License</th><th>SKU part number</th><th class="num">Assigned</th><th class="num">Available</th><th class="num">Price / seat</th><th class="num">Monthly (assigned)</th></tr></thead> <tbody id="invRows"></tbody> </table> <div id="report"> <h2>Waste by category</h2> <table><thead><tr><th>Category</th><th class="num">Users</th><th class="num">Monthly</th></tr></thead><tbody id="catRows"></tbody></table> <h2>Waste by license</h2> <table><thead><tr><th>License</th><th class="num">Seats</th><th class="num">Monthly</th></tr></thead><tbody id="skuRows"></tbody></table> <h2>Flagged accounts</h2> <table><thead><tr><th>Name</th><th>Account</th><th>Finding</th><th>Last sign-in</th><th>License</th><th class="num">Monthly</th></tr></thead><tbody id="acctRows"></tbody></table> </div> <p class="clear" id="empty" style="display:none">No license waste found. Every paid license is assigned to an active, enabled account. Your full license inventory is shown above.</p> <p class="foot">Prices start from public list-price estimates; edit any of them above to fit your contract. Read-only scan, generated locally. Nothing left your tenant. For continuous, cross-vendor monitoring see <a href="https://www.licensemeter.com">licensemeter.com</a>.</p> </div> <script id="lm-data" type="application/json">__LM_DATA__</script> <script> (function () { var data = JSON.parse(document.getElementById('lm-data').textContent); var cur = data.currency || 'EUR'; function $(id) { return document.getElementById(id); } function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; } function fmt(cents) { return cur + ' ' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function num(n) { return Number(n || 0).toLocaleString('en-US'); } $('meta').textContent = 'Tenant: ' + data.tenant + ' \u00b7 Users scanned: ' + data.usersScanned + ' \u00b7 Entra P1: ' + (data.p1 ? 'yes' : 'no') + ' \u00b7 Generated: ' + data.generated; // License inventory (always shown) doubles as the editable price table. var inv = (data.inventory || []).slice().sort(function (a, b) { return (a.name || a.part).localeCompare(b.name || b.part); }); var invByPart = {}; inv.forEach(function (it) { invByPart[it.part] = it; }); $('invRows').innerHTML = inv.map(function (it) { var avail = it.available >= 100000 ? '<span class="dim">unlimited</span>' : num(it.available); return '<tr><td>' + esc(it.name || it.part) + '</td><td class="mono">' + esc(it.part) + '</td>' + '<td class="num">' + num(it.assigned) + '</td><td class="num">' + avail + '</td>' + '<td class="num"><input type="number" min="0" step="0.01" data-sku="' + esc(it.part) + '" value="' + (it.amount || 0) + '">' + (it.known ? '' : '<span class="needs">set price</span>') + '</td>' + '<td class="num" data-cost="' + esc(it.part) + '"></td></tr>'; }).join(''); // [code, name, region]; region drives the flag emoji (EUR gives the EU/European flag). var currencies = [ ['USD', 'US Dollar', 'US'], ['EUR', 'Euro', 'EU'], ['GBP', 'British Pound', 'GB'], ['CAD', 'Canadian Dollar', 'CA'], ['AUD', 'Australian Dollar', 'AU'], ['CHF', 'Swiss Franc', 'CH'], ['JPY', 'Japanese Yen', 'JP'], ['INR', 'Indian Rupee', 'IN'], ['BRL', 'Brazilian Real', 'BR'], ['SEK', 'Swedish Krona', 'SE'], ['NOK', 'Norwegian Krone', 'NO'], ['DKK', 'Danish Krone', 'DK'], ['SGD', 'Singapore Dollar', 'SG'], ['NZD', 'New Zealand Dollar', 'NZ'], ['ZAR', 'South African Rand', 'ZA'], ['AED', 'UAE Dirham', 'AE'] ]; // Build a flag emoji from a 2-letter region code via regional-indicator code points (keeps the source ASCII). function flagOf(cc) { if (!cc) return ''; return cc.toUpperCase().replace(/[A-Z]/g, function (ch) { return String.fromCodePoint(0x1F1E6 + ch.charCodeAt(0) - 65); }); } if (!currencies.some(function (c) { return c[0] === cur; })) currencies.unshift([cur, cur, '']); var curSel = $('curSel'); curSel.innerHTML = currencies.map(function (c) { var f = flagOf(c[2]); return '<option value="' + c[0] + '"' + (c[0] === cur ? ' selected' : '') + '>' + (f ? f + ' ' : '') + c[0] + ' (' + c[1] + ')</option>'; }).join(''); curSel.addEventListener('change', function () { cur = curSel.value; recompute(); }); function readPrices() { var p = {}, inputs = document.querySelectorAll('input[data-sku]'); for (var i = 0; i < inputs.length; i++) { var v = parseFloat(inputs[i].value); p[inputs[i].getAttribute('data-sku')] = isNaN(v) ? 0 : Math.round(v * 100); } return p; } var order = ['DisabledLicensed', 'InactiveLicensed', 'NeverSignedInLicensed']; function recompute() { var prices = readPrices(); // Inventory: monthly cost of assigned seats, live. var costCells = document.querySelectorAll('td[data-cost]'); for (var i = 0; i < costCells.length; i++) { var part = costCells[i].getAttribute('data-cost'); var it = invByPart[part]; costCells[i].textContent = fmt((prices[part] || 0) * (it ? it.assigned : 0)); } // Waste, from the findings. var total = 0, byRule = {}, ruleUsers = {}, bySku = {}, accounts = {}; data.findings.forEach(function (f) { var c = prices[f.sku] || 0; total += c; byRule[f.rule] = (byRule[f.rule] || 0) + c; (ruleUsers[f.rule] = ruleUsers[f.rule] || {})[f.upn] = 1; if (!bySku[f.sku]) bySku[f.sku] = { count: 0, cents: 0, name: f.skuName }; bySku[f.sku].count++; bySku[f.sku].cents += c; accounts[f.upn] = 1; }); $('heroNum').innerHTML = fmt(total) + ' <span class="per">/ month</span>'; $('cardAnnual').textContent = fmt(total * 12); $('cardAccounts').textContent = Object.keys(accounts).length; $('cardUsers').textContent = data.usersScanned; if (data.findings.length === 0) { $('report').style.display = 'none'; $('empty').style.display = ''; return; } $('catRows').innerHTML = order.filter(function (r) { return ruleUsers[r]; }).map(function (r) { return '<tr><td>' + esc(data.ruleLabels[r] || r) + '</td><td class="num">' + Object.keys(ruleUsers[r]).length + '</td><td class="num">' + fmt(byRule[r] || 0) + '</td></tr>'; }).join(''); $('skuRows').innerHTML = Object.keys(bySku).sort(function (a, b) { return bySku[b].cents - bySku[a].cents; }).map(function (s) { return '<tr><td>' + esc(bySku[s].name || s) + '</td><td class="num">' + bySku[s].count + '</td><td class="num">' + fmt(bySku[s].cents) + '</td></tr>'; }).join(''); var rows = data.findings.map(function (f) { return { f: f, c: prices[f.sku] || 0 }; }); rows.sort(function (a, b) { return (b.c - a.c) || a.f.name.localeCompare(b.f.name); }); $('acctRows').innerHTML = rows.map(function (x) { return '<tr><td>' + esc(x.f.name) + '</td><td>' + esc(x.f.upn) + '</td><td>' + esc(x.f.label) + '</td><td>' + esc(x.f.last) + '</td><td>' + esc(x.f.skuName) + '</td><td class="num">' + fmt(x.c) + '</td></tr>'; }).join(''); } document.addEventListener('input', function (e) { if (e.target && e.target.matches && e.target.matches('input[data-sku]')) recompute(); }); recompute(); })(); </script> </body> </html> '@ return $template.Replace('__LM_DATA__', $dataJson) } function Write-LMReport { param( $Summary, $Findings, $Inventory, [string]$TenantLabel = 'your tenant', [string]$OutputCsv, [string]$OutputJson, [string]$OutputHtml, [switch]$NoConsole ) $reportPath = if ($OutputHtml) { $OutputHtml } elseif ($OutputCsv) { $OutputCsv } elseif ($OutputJson) { $OutputJson } else { $null } if (-not $NoConsole) { Format-LMConsoleReport -Summary $Summary -TenantLabel $TenantLabel -ReportPath $reportPath } if ($OutputCsv) { ConvertTo-LMFindingRow -Findings $Findings -Currency $Summary.Currency | Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding utf8 } if ($OutputJson) { [pscustomobject]@{ generatedAtUtc = ([datetimeoffset]::UtcNow).ToString('o') tenant = $TenantLabel summary = $Summary findings = @($Findings) inventory = @($Inventory) } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputJson -Encoding utf8 } if ($OutputHtml) { Get-LMHtmlReport -Summary $Summary -Findings $Findings -Inventory $Inventory -TenantLabel $TenantLabel | Set-Content -LiteralPath $OutputHtml -Encoding utf8 } } # ---------------------------------------------------------------------------- # Price-file resolution # ---------------------------------------------------------------------------- function Get-LMPriceStorePath { # Per-user location for prices the user downloaded from a report, so they are reused on # the next scan regardless of the working directory. Cross-platform via ApplicationData. $base = [Environment]::GetFolderPath('ApplicationData') if (-not $base) { $base = Join-Path $HOME '.config' } return (Join-Path (Join-Path $base 'LicenseMeter') 'prices.json') } function Resolve-LMPriceFilePath { param([string]$PriceFile) if ($PriceFile) { if (-not (Test-Path -LiteralPath $PriceFile)) { throw "Price file not found: $PriceFile" } return (Resolve-Path -LiteralPath $PriceFile).Path } $local = Join-Path (Get-Location).Path 'prices.json' if (Test-Path -LiteralPath $local) { return (Resolve-Path -LiteralPath $local).Path } $store = Get-LMPriceStorePath if ($store -and (Test-Path -LiteralPath $store)) { return (Resolve-Path -LiteralPath $store).Path } foreach ($candidate in @( (Join-Path $PSScriptRoot '..' 'data' 'prices.sample.json'), (Join-Path $PSScriptRoot 'data' 'prices.sample.json'))) { if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path } } throw "No price file found. Provide -PriceFile or place prices.json in the working directory." } function Get-LMTenantLabel { # Prefer the tenant's default verified domain for a readable header; fall back to the # tenant id, then a generic label. Never fails the scan if /organization is unavailable. param($Context) try { $org = @(Invoke-LMGraphList -Uri "$script:GraphBase/organization?`$select=displayName,verifiedDomains") if ($org.Count -gt 0) { $domains = @(Get-LMProp $org[0] 'verifiedDomains') $pick = $domains | Where-Object { (Get-LMProp $_ 'isDefault') -eq $true } | Select-Object -First 1 if (-not $pick) { $pick = $domains | Where-Object { (Get-LMProp $_ 'isInitial') -eq $true } | Select-Object -First 1 } if (-not $pick -and $domains.Count -gt 0) { $pick = $domains[0] } $name = if ($pick) { Get-LMProp $pick 'name' } else { $null } if ($name) { return $name } } } catch { Write-Verbose "Tenant domain lookup failed: $($_.Exception.Message)" } $tid = Get-LMProp $Context 'TenantId' if ($tid) { return $tid } return 'your tenant' } # ---------------------------------------------------------------------------- # Orchestrator (public) # ---------------------------------------------------------------------------- function Invoke-LicenseMeterScan { <# .SYNOPSIS Scan a Microsoft 365 tenant for license waste and write an interactive report. .DESCRIPTION Read-only. Signs in via MgGraphCommunity if not already connected, reads users and subscribed SKUs, applies the waste rules, and writes a self-contained, interactive HTML report: prices are editable in the browser, totals recalculate live, you can drop a prices file onto the page, and download one back to reuse next scan. On completion it prints the report path. Use -PassThru to also return the summary object, -ShowConsole for a text summary in the terminal, -Quiet to print nothing. .EXAMPLE LicenseMeterScan .EXAMPLE LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html .EXAMPLE LicenseMeterScan -PriceFile ./prices.json -OutputCsv ./waste.csv #> [CmdletBinding()] param( [string]$TenantId, [string]$ClientId, [switch]$UseDeviceCode, [int]$InactiveDays = 90, [string]$PriceFile, [string]$Currency, [string]$OutputHtml, [string]$OutputCsv, [string]$OutputJson, [switch]$ShowConsole, [switch]$Quiet, [switch]$PassThru ) # Always produce an interactive HTML report; default it to the working directory so the # run ends with a clickable result the user opens to review and edit prices in. if (-not $OutputHtml) { $OutputHtml = Join-Path (Get-Location).Path 'licensemeter-scan.html' } $priceFilePath = Resolve-LMPriceFilePath -PriceFile $PriceFile $book = Get-LMPriceBook -Path $priceFilePath if ($Currency) { $book.Currency = $Currency } if (-not (Test-LMConnected)) { $connectParams = @{} if ($TenantId) { $connectParams['TenantId'] = $TenantId } if ($ClientId) { $connectParams['ClientId'] = $ClientId } if ($UseDeviceCode) { $connectParams['UseDeviceCode'] = $true } Connect-LMGraph @connectParams | Out-Null } $context = Get-MgGraphCommunityContext $tenantLabel = Get-LMTenantLabel $context $skus = Get-LMSubscribedSku $priceMap = Resolve-LMPriceMap -SubscribedSkus $skus -PriceBook $book $inventory = @(Get-LMInventory -SubscribedSkus $skus -PriceMap $priceMap) $userResult = Get-LMUser $users = $userResult.Users $hasSignIn = $userResult.HasSignInData $asOf = [datetimeoffset]::UtcNow $findings = @(Invoke-LMRule -Users $users -PriceMap $priceMap -AsOf $asOf ` -InactiveDays $InactiveDays -HasSignInData $hasSignIn -GraceDays $script:NeverSignedInGraceDays) $summary = Get-LMScanSummary -Findings $findings -UsersScanned (@($users).Count) ` -Currency $book.Currency -HasSignInData $hasSignIn -InactiveDays $InactiveDays Write-LMReport -Summary $summary -Findings $findings -Inventory $inventory -TenantLabel $tenantLabel ` -OutputCsv $OutputCsv -OutputJson $OutputJson -OutputHtml $OutputHtml -NoConsole:(-not $ShowConsole) if (-not $Quiet) { $resolved = (Resolve-Path -LiteralPath $OutputHtml).Path Write-Host '' Write-Host ' LicenseMeter Scan complete. Open your report:' -ForegroundColor Green Write-Host (" {0}" -f $resolved) -ForegroundColor Cyan Write-Host '' } if ($PassThru) { return $summary } } Set-Alias -Name LicenseMeterScan -Value Invoke-LicenseMeterScan Export-ModuleMember -Function 'Invoke-LicenseMeterScan', 'Connect-LMGraph' -Alias 'LicenseMeterScan' |