Public/Export-M365AppSecretReport.ps1
|
function Export-M365AppSecretReport { <# .SYNOPSIS Finds app registrations with expiring/expired secrets and certificates. .DESCRIPTION Reads all app registrations via Graph and lists every credential (secret or certificate) with expiry date and remaining lifetime. Renders an interactive HTML report (search, filter by expiry status, sorting). Read-only. .PARAMETER Path Target path of the HTML file. Default: .\AppSecret-Report.html .PARAMETER WarnDays Threshold in days for the "expiring soon" warning. Default: 30. .PARAMETER BrandName Branding. For CloudNest365: -BrandName 'CloudNest365' .PARAMETER PassThru Also emit the objects on the pipeline. .PARAMETER NoOpen Do not open the report automatically. .EXAMPLE Export-M365AppSecretReport -Path .\secrets.html -BrandName 'CloudNest365' #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string]$Path = (Join-Path (Get-Location) 'AppSecret-Report.html'), [int]$WarnDays = 30, [string]$BrandName = 'TenantToolbox', [string]$BrandTagline = 'M365 Tenant Administration', [switch]$Csv, [switch]$Excel, [string]$DataPath, [switch]$NoHtml, [switch]$PassThru, [switch]$NoOpen ) Assert-TTGraph Write-TTLog -Level INFO -Message "Reading app registrations and their credentials ..." $apps = Get-MgApplication -All -Property 'id,appId,displayName,passwordCredentials,keyCredentials' -ErrorAction Stop $now = Get-Date $records = foreach ($a in $apps) { foreach ($cred in @($a.PasswordCredentials)) { if (-not $cred.EndDateTime) { continue } [pscustomobject]@{ App = $a.DisplayName; AppId = $a.AppId; Kind = 'Secret'; Name = $cred.DisplayName; End = $cred.EndDateTime; Days = [math]::Floor(([datetime]$cred.EndDateTime - $now).TotalDays) } } foreach ($cred in @($a.KeyCredentials)) { if (-not $cred.EndDateTime) { continue } [pscustomobject]@{ App = $a.DisplayName; AppId = $a.AppId; Kind = 'Certificate'; Name = $cred.DisplayName; End = $cred.EndDateTime; Days = [math]::Floor(([datetime]$cred.EndDateTime - $now).TotalDays) } } } $records = @($records | Sort-Object Days) # Buckets function Get-Bucket { param($d) if ($d -lt 0) { 'expired' } elseif ($d -le $WarnDays) { 'soon' } elseif ($d -le 90) { 'warn' } else { 'ok' } } $total = $records.Count $expired = @($records | Where-Object { $_.Days -lt 0 }).Count $soon = @($records | Where-Object { $_.Days -ge 0 -and $_.Days -le $WarnDays }).Count $warn = @($records | Where-Object { $_.Days -gt $WarnDays -and $_.Days -le 90 }).Count $ok = @($records | Where-Object { $_.Days -gt 90 }).Count $genAt = Get-Date -Format 'yyyy-MM-dd HH:mm' $tenant = (Get-MgContext).TenantId $badgeMap = @{ expired = @{c = 'b-crit'; t = 'expired' }; soon = @{c = 'b-bad'; t = "$WarnDays days" }; warn = @{c = 'b-warn'; t = '90 days' }; ok = @{c = 'b-ok'; t = 'ok' } } $rows = foreach ($r in $records) { $bucket = Get-Bucket $r.Days $daysTxt = if ($r.Days -lt 0) { "$([math]::Abs($r.Days)) days ago" } else { "in $($r.Days) days" } $badge = $badgeMap[$bucket] $sortDays = ($r.Days + 100000) $endTxt = ([datetime]$r.End).ToString('yyyy-MM-dd') $searchAttr = TTEnc ("$($r.App) $($r.Name) $($r.Kind) $($r.AppId)".ToLower()) $nameAttr = TTEnc ([string]$r.App).ToLower() @" <tr class="item" data-f-$bucket="1" data-name="$nameAttr" data-s-days="$sortDays" data-search="$searchAttr"> <td><div class="u"><b>$(TTEnc $r.App)</b><span class="upn">$(TTEnc $r.AppId)</span></div></td> <td>$(TTEnc $r.Kind)</td> <td>$(if ($r.Name) { TTEnc $r.Name } else { '<span class="muted">–</span>' })</td> <td>$endTxt</td> <td><span class="b $($badge.c)">$daysTxt</span></td> </tr> "@ } $kpiHtml = New-TTKpis @( @{ n = $total; l = 'Credentials'; filter = 'all' } @{ n = $expired; l = 'Expired'; kind = 'bad'; filter = 'expired' } @{ n = $soon; l = "< $WarnDays days"; kind = 'bad'; filter = 'soon' } @{ n = $warn; l = '< 90 days'; kind = 'warn'; filter = 'warn' } @{ n = $ok; l = 'OK'; kind = 'ok'; filter = 'ok' } ) $toolbar = New-TTToolbar -SearchPlaceholder 'Search app, secret or AppId ...' -Filters @( @{ label = 'All'; key = 'all' }, @{ label = 'Expired'; key = 'expired' }, @{ label = "< $WarnDays days"; key = 'soon' }, @{ label = '< 90 days'; key = 'warn' }, @{ label = 'OK'; key = 'ok' } ) $body = @" <div class="panel"> <table class="tbl"> <thead><tr><th data-sort="name">App</th><th>Type</th><th>Name</th><th>Expiry</th><th data-sort="days">Remaining</th></tr></thead> <tbody> $($rows -join "`n") </tbody> </table> <div class="empty" id="empty">No credential matches the filters.</div> </div> "@ $sub = "Tenant $tenant · generated $genAt · $total credentials" $html = New-TTHtmlPage -Title 'App Secret & Certificate Report' -Heading 'App Secret & Certificate Report' -Sub $sub ` -BrandName $BrandName -BrandTagline $BrandTagline -KpiHtml $kpiHtml -ToolbarHtml $toolbar -BodyHtml $body Write-TTLog -Level INFO -Message "App secret report created: $Path ($expired expired, $soon expiring soon)." if ($expired -gt 0) { Write-Host " Warning: $expired expired credential(s)!" -ForegroundColor Red } if ($soon -gt 0) { Write-Host " Note: $soon expire within the next $WarnDays days." -ForegroundColor Yellow } $flat = $records | Select-Object App, AppId, Kind, Name, @{N = 'End'; E = { if ($_.End) { ([datetime]$_.End).ToString('yyyy-MM-dd') } } }, @{N = 'DaysRemaining'; E = { $_.Days } }, @{N = 'Status'; E = { Get-Bucket $_.Days } } Complete-TTReport -Html $html -Path $Path -Data $flat -Csv:$Csv -Excel:$Excel -DataPath $DataPath -NoHtml:$NoHtml -NoOpen:$NoOpen -Kind 'App-Secret-Report' if ($PassThru) { $records } } |