EntraCredentialExpiry.psm1
|
<# EntraCredentialExpiry Finds Entra (Azure AD) app registration and enterprise application credentials (client secrets and certificates) that are expiring, so you get a reminder BEFORE they lapse - Entra does not email you about client secret expiry. Read-only. Needs an existing Microsoft Graph connection with Application.Read.All (app-only via managed identity or certificate is recommended - never a client secret, since that is the very thing that expires on you). Author: Vijay Kumar G (fivenumber.com) #> # -- internal: ensure a Graph connection exists ------------------------------- function Assert-GraphConnection { $ctx = $null try { $ctx = Get-MgContext } catch { $ctx = $null } if (-not $ctx) { throw "No Microsoft Graph connection. Connect first with app-only auth, for example:`n" + " Connect-MgGraph -Identity # managed identity`n" + " Connect-MgGraph -TenantId <tenant> -ClientId <appId> -CertificateThumbprint <thumb>`n" + "The app needs the Application.Read.All application permission." } } # -- internal: run a Graph/HTTP call with retry on throttling + transient 5xx - function Invoke-WithGraphRetry { param( [Parameter(Mandatory)][scriptblock] $Action, [int] $MaxAttempts = 5 ) $attempt = 0 while ($true) { $attempt++ try { return & $Action } catch { # Work out the HTTP status, if there is one. $status = $null $resp = $_.Exception.Response if ($resp) { try { $status = [int]$resp.StatusCode } catch { $status = $null } } if (-not $status -and $_.ErrorDetails.Message -match 'TooManyRequests|"code"\s*:\s*"TooManyRequests"') { $status = 429 } $retryable = $status -and ($status -eq 429 -or $status -ge 500) if (-not $retryable -or $attempt -ge $MaxAttempts) { throw } # Honour Retry-After when the service sends one; otherwise exponential backoff. $delay = [math]::Min(60, [math]::Pow(2, $attempt)) try { $ra = $resp.Headers.RetryAfter if ($ra -and $ra.Delta) { $delay = [double]$ra.Delta.TotalSeconds } elseif ($ra -and $ra.Date) { $delay = [math]::Max(1, ($ra.Date - [DateTimeOffset]::UtcNow).TotalSeconds) } } catch { Write-Verbose "No usable Retry-After header; using backoff delay." } Write-Verbose "Graph returned $status on attempt $attempt/$MaxAttempts; retrying in $([int]$delay)s..." Start-Sleep -Seconds ([int][math]::Ceiling($delay)) } } } # -- internal: GET a Graph collection, following @odata.nextLink --------------- function Get-GraphCollection { param([Parameter(Mandatory)][string]$Uri) $items = New-Object System.Collections.Generic.List[object] $next = $Uri while ($next) { $resp = Invoke-WithGraphRetry { Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject } if ($resp.value) { foreach ($v in $resp.value) { $items.Add($v) } } $next = $resp.'@odata.nextLink' } return $items } # -- internal: normalise a Graph timestamp (string or DateTime) to real UTC --- function ConvertTo-Utc { param($Value) if ($Value -is [datetime]) { $dt = [datetime]$Value if ($dt.Kind -eq [System.DateTimeKind]::Unspecified) { # Graph timestamps are UTC; an unspecified Kind means "already UTC, just untagged". return [datetime]::SpecifyKind($dt, [System.DateTimeKind]::Utc) } return $dt.ToUniversalTime() } return [datetime]::Parse( [string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal) } # -- internal: severity label from days-left + the configured windows --------- function Get-Severity { param([int]$DaysLeft, [int[]]$Windows) if ($DaysLeft -lt 0) { return 'Expired' } foreach ($w in ($Windows | Sort-Object)) { if ($DaysLeft -le $w) { return "Within $w days" } } return $null # outside all windows } function Get-EntraCredentialExpiry { <# .SYNOPSIS Lists Entra app registration and enterprise app credentials (secrets + certificates) with their expiry dates, days remaining, and a severity bucket. .DESCRIPTION Read-only. Reads passwordCredentials (client secrets) and keyCredentials (certificates) directly from /applications and, optionally, /servicePrincipals, so you choose your own expiry windows instead of being limited to the Entra recommendation's fixed 30-day view (which also needs a Workload ID licence). Requires an existing Connect-MgGraph session with Application.Read.All. .PARAMETER DaysWindow One or more day thresholds used to bucket results (default 90, 60, 30, 7). The largest value is also the look-ahead horizon used by -ExpiringOnly. .PARAMETER IncludeServicePrincipals Also scan enterprise applications (service principals), not just app registrations. .PARAMETER ExpiringOnly Return only credentials that are expired or within the largest DaysWindow. .PARAMETER CsvPath Optional. Writes the rows to this CSV. .PARAMETER HtmlPath Optional. Writes a styled HTML report to this path. .EXAMPLE Connect-MgGraph -Identity Get-EntraCredentialExpiry -ExpiringOnly | Format-Table .EXAMPLE Get-EntraCredentialExpiry -IncludeServicePrincipals -HtmlPath .\cred-expiry.html #> [CmdletBinding()] [OutputType([pscustomobject])] param( [ValidateNotNullOrEmpty()] [ValidateRange(1, 3650)] [int[]] $DaysWindow = @(90,60,30,7), [switch] $IncludeServicePrincipals, [switch] $ExpiringOnly, [string] $CsvPath, [string] $HtmlPath ) Assert-GraphConnection $now = [datetime]::UtcNow $max = ($DaysWindow | Measure-Object -Maximum).Maximum $rows = New-Object System.Collections.Generic.List[object] $sources = @( @{ Type='Application'; Uri="v1.0/applications?`$select=id,appId,displayName,passwordCredentials,keyCredentials&`$top=999" } ) if ($IncludeServicePrincipals) { $sources += @{ Type='ServicePrincipal'; Uri="v1.0/servicePrincipals?`$select=id,appId,displayName,passwordCredentials,keyCredentials&`$top=999" } } foreach ($src in $sources) { Write-Verbose "Reading $($src.Type) objects..." $objs = Get-GraphCollection -Uri $src.Uri foreach ($o in $objs) { $creds = @() if ($o.passwordCredentials) { $creds += ,@($o.passwordCredentials, 'Secret') } if ($o.keyCredentials) { $creds += ,@($o.keyCredentials, 'Certificate') } foreach ($pair in $creds) { foreach ($c in $pair[0]) { if (-not $c.endDateTime) { continue } $end = ConvertTo-Utc $c.endDateTime $days = [int][math]::Floor(($end - $now).TotalDays) $sev = Get-Severity -DaysLeft $days -Windows $DaysWindow if ($ExpiringOnly -and ($days -gt $max)) { continue } $portal = if ($src.Type -eq 'Application') { "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Credentials/appId/$($o.appId)" } else { "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Credentials/objectId/$($o.id)/appId/$($o.appId)" } $rows.Add([pscustomobject]@{ ObjectType = $src.Type DisplayName = [string]$o.displayName AppId = [string]$o.appId CredentialType = $pair[1] CredentialName = [string]($c.displayName) EndDateUtc = $end.ToString('yyyy-MM-dd') DaysUntilExpiry = $days Severity = $sev ObjectId = [string]$o.id PortalUrl = $portal }) } } } } $out = @($rows | Sort-Object DaysUntilExpiry) if ($CsvPath) { $out | Select-Object ObjectType,DisplayName,AppId,CredentialType,CredentialName,EndDateUtc,DaysUntilExpiry,Severity,PortalUrl | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8 Write-Verbose "CSV written: $CsvPath" } if ($HtmlPath) { Write-CredentialExpiryHtml -Rows $out -Path $HtmlPath -MaxWindow $max } return $out } # -- internal: styled HTML report --------------------------------------------- function Write-CredentialExpiryHtml { param([object[]]$Rows, [string]$Path, [int]$MaxWindow) $enc = { param($t) [System.Net.WebUtility]::HtmlEncode([string]$t) } $body = ($Rows | ForEach-Object { $cls = if ($_.DaysUntilExpiry -lt 0) { 'exp' } elseif ($_.DaysUntilExpiry -le 7) { 'crit' } elseif ($_.DaysUntilExpiry -le 30) { 'warn' } else { 'ok' } "<tr class='$cls'><td>$(& $enc $_.ObjectType)</td><td>$(& $enc $_.DisplayName)</td><td>$(& $enc $_.CredentialType)</td><td>$(& $enc $_.EndDateUtc)</td><td>$($_.DaysUntilExpiry)</td><td>$(& $enc $_.Severity)</td><td class='id'>$(& $enc $_.AppId)</td></tr>" }) -join "`n" $expired = @($Rows | Where-Object { $_.DaysUntilExpiry -lt 0 }).Count $html = @" <!doctype html><html><head><meta charset="utf-8"><title>Entra credential expiry</title> <style>body{font-family:Segoe UI,Arial,sans-serif;margin:2rem;color:#201f1e} h1{font-size:1.3rem}.sub{color:#605e5c} table{border-collapse:collapse;margin-top:1rem;width:100%} th,td{border:1px solid #edebe9;padding:.4rem .6rem;text-align:left;font-size:.92rem} th{background:#f3f2f1}.id{font-family:Consolas,monospace;color:#605e5c} tr.exp td{background:#fde7e9}tr.crit td{background:#fdf3f2}tr.warn td{background:#fff8f0}tr.ok td{background:#f3faf3}</style></head><body> <h1>Entra credential expiry</h1> <div class="sub">Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm') · look-ahead $MaxWindow days · $($Rows.Count) credential(s), $expired expired</div> <table><tr><th>Object</th><th>Name</th><th>Type</th><th>Expires (UTC)</th><th>Days</th><th>Severity</th><th>App ID</th></tr> $body </table></body></html> "@ Set-Content -Path $Path -Value $html -Encoding UTF8 Write-Verbose "HTML written: $Path" } function Send-EntraCredentialExpiryAlert { <# .SYNOPSIS Runs Get-EntraCredentialExpiry -ExpiringOnly and sends a Teams card and/or email. .DESCRIPTION The notification step for scheduled, unattended runs (Azure Automation, an Azure Function timer, a GitHub Action, or Task Scheduler). Read-only reads; it only sends an alert. Requires an existing Connect-MgGraph session with Application.Read.All (plus Mail.Send if you use -MailTo). Each delivery channel is isolated: if one fails the other is still attempted, and the cmdlet throws at the end if any channel failed, so a scheduled host records the run as failed. .PARAMETER TeamsWebhook A Teams Incoming Webhook / Workflows ("post to a channel when a webhook request is received") URL. Posts an Adaptive Card summary. .PARAMETER MailTo One or more recipient addresses. Sends an HTML email via Graph sendMail. .PARAMETER MailFrom The sender mailbox (user or shared mailbox the app can send as). Required with -MailTo. .PARAMETER DaysWindow Expiry thresholds (default 90, 60, 30, 7). Largest value = look-ahead horizon. .PARAMETER IncludeServicePrincipals Also scan enterprise applications. .EXAMPLE Connect-MgGraph -Identity Send-EntraCredentialExpiryAlert -TeamsWebhook $env:TEAMS_WEBHOOK #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param( [string] $TeamsWebhook, [string[]] $MailTo, [string] $MailFrom, [ValidateNotNullOrEmpty()] [ValidateRange(1, 3650)] [int[]] $DaysWindow = @(90,60,30,7), [switch] $IncludeServicePrincipals ) if (-not $TeamsWebhook -and -not $MailTo) { throw "Provide -TeamsWebhook and/or -MailTo." } if ($MailTo -and -not $MailFrom) { throw "-MailFrom is required when using -MailTo." } $max = ($DaysWindow | Measure-Object -Maximum).Maximum $rows = Get-EntraCredentialExpiry -DaysWindow $DaysWindow -IncludeServicePrincipals:$IncludeServicePrincipals -ExpiringOnly if (-not $rows -or $rows.Count -eq 0) { Write-Verbose "Nothing expiring within $max days." return } $expired = @($rows | Where-Object { $_.DaysUntilExpiry -lt 0 }).Count $soon = @($rows | Where-Object { $_.DaysUntilExpiry -ge 0 -and $_.DaysUntilExpiry -le 7 }).Count $summary = "$($rows.Count) Entra credential(s) need attention: $expired expired, $soon within 7 days." $failures = New-Object System.Collections.Generic.List[string] if ($TeamsWebhook) { if ($PSCmdlet.ShouldProcess('Teams channel', 'Post credential-expiry Adaptive Card')) { try { $factRows = ($rows | Select-Object -First 15 | ForEach-Object { @{ title = "$($_.DisplayName) [$($_.CredentialType)]"; value = "$($_.EndDateUtc) ($($_.DaysUntilExpiry) days)" } }) $card = @{ type = "message" attachments = @(@{ contentType = "application/vnd.microsoft.card.adaptive" content = @{ '$schema' = "http://adaptivecards.io/schemas/adaptive-card.json" type = "AdaptiveCard"; version = "1.4" body = @( @{ type="TextBlock"; size="Large"; weight="Bolder"; text="Entra credential expiry" } @{ type="TextBlock"; wrap=$true; text=$summary } @{ type="FactSet"; facts=$factRows } ) } }) } $json = $card | ConvertTo-Json -Depth 12 Invoke-WithGraphRetry { Invoke-RestMethod -Uri $TeamsWebhook -Method Post -ContentType 'application/json' -Body $json } | Out-Null Write-Verbose "Teams card posted." } catch { Write-Warning "Teams post failed: $($_.Exception.Message)" $failures.Add("Teams: $($_.Exception.Message)") } } } if ($MailTo) { if ($PSCmdlet.ShouldProcess(($MailTo -join ', '), 'Send credential-expiry email')) { $tmp = $null try { $tmp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "cred-expiry-$([guid]::NewGuid().ToString('N')).html") Write-CredentialExpiryHtml -Rows $rows -Path $tmp -MaxWindow $max $htmlBody = Get-Content $tmp -Raw $msg = @{ message = @{ subject = "Entra credential expiry: $($rows.Count) need attention ($expired expired)" body = @{ contentType = "HTML"; content = $htmlBody } toRecipients = @($MailTo | ForEach-Object { @{ emailAddress = @{ address = $_ } } }) } saveToSentItems = $false } $mailJson = $msg | ConvertTo-Json -Depth 12 Invoke-WithGraphRetry { Invoke-MgGraphRequest -Method POST -Uri "v1.0/users/$MailFrom/sendMail" -Body $mailJson } | Out-Null Write-Verbose "Email sent to $($MailTo -join ', ')." } catch { Write-Warning "Email send failed: $($_.Exception.Message)" $failures.Add("Email: $($_.Exception.Message)") } finally { if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } } } } if ($failures.Count -gt 0) { throw "Alert delivery failed: $($failures -join ' | ')" } } Export-ModuleMember -Function Get-EntraCredentialExpiry, Send-EntraCredentialExpiryAlert |