public/Get-MsecEntraAppCredential.ps1
|
function Get-MsecEntraAppCredential { <# .SYNOPSIS Every client secret and certificate on the tenant's app registrations, with how long each has left - the credential expiry inventory. .DESCRIPTION Calls Microsoft Graph /v1.0/applications and projects ONE ROW PER CREDENTIAL. An app registration with two secrets and a certificate is three rows, because the credential is the thing that expires and the thing you have to rotate - rolling them up per app would hide the one that lapses on Friday behind the two that are good for a year. THIS IS BOTH A SECURITY AND AN AVAILABILITY QUESTION. An expired credential is an outage: the app stops authenticating, usually at 3am, usually on the integration nobody remembers owning. A long-lived one is the security half - a secret minted with a two-year lifetime is two years of standing access if it ever leaks, so LifetimeDays is carried alongside the expiry rather than only the end date. APP REGISTRATIONS ARE INVENTORIED COMPLETELY, INCLUDING THE ONES WITH NO CREDENTIALS. Those get a single row with CredentialType 'None' and null dates. That is deliberate: an app with no secret at all is usually the GOOD state - it has moved to federated credentials (workload identity) or is a pure public client - and a report that silently omitted it could not tell you that, nor tell "no credentials" apart from "app not returned". SERVICE PRINCIPALS ARE NOT INVENTORIED THAT WAY, and are opt-in via -IncludeServicePrincipal. A tenant carries hundreds of Microsoft-owned service principals holding no credentials whatever, so listing them all would bury the rows that matter. Only service principals that actually HOLD a credential are returned. They are worth asking for: a SAML app's token-signing certificate lives here rather than on the app registration, and its expiry is a sign-in outage for every user of that app. Requires the 'Application.Read.All' application permission. A clearer error is raised on the typical 403. Secret VALUES are never returned by Graph - only the metadata. Nothing here can leak a usable credential. .PARAMETER ExpiringWithinDays Keep only credentials that expire within this many days. ALREADY-EXPIRED credentials are always included, whatever the number: expired is strictly worse than expiring, and a window that hid them would be answering the wrong question. Apps with no credentials drop out under this filter - they have no expiry to fall inside a window. Omit the parameter for the full inventory. .PARAMETER IncludeServicePrincipal Also return credentials held on service principals - SAML token-signing certificates in particular. Only service principals holding at least one credential are returned; see .DESCRIPTION. .EXAMPLE # The rotation list: what is already dead, worst first. Get-MsecEntraAppCredential | Where-Object IsExpired | Sort-Object DaysUntilExpiry .EXAMPLE # The next 60 days, expired ones included. Get-MsecEntraAppCredential -ExpiringWithinDays 60 | Sort-Object DaysUntilExpiry | Format-Table DisplayName, CredentialType, CredentialName, EndDateTime, DaysUntilExpiry .EXAMPLE # The security half rather than the outage half: secrets minted to last for years. Get-MsecEntraAppCredential | Where-Object { $_.CredentialType -eq 'Secret' -and $_.LifetimeDays -gt 365 } | Sort-Object LifetimeDays -Descending .EXAMPLE # SAML sign-in outages waiting to happen. Get-MsecEntraAppCredential -IncludeServicePrincipal | Where-Object { $_.ObjectType -eq 'ServicePrincipal' -and $_.CredentialUsage -eq 'Sign' } | Sort-Object DaysUntilExpiry .EXAMPLE # Apps that need no rotating at all, because they hold no secret. Get-MsecEntraAppCredential | Where-Object CredentialType -eq 'None' .OUTPUTS PSCustomObject per credential, PSTypeName 'MsecEntraAppCredential'. See .NOTES for the projection. .NOTES Needs Connect-Msec and the 'Application.Read.All' application permission, which New-MsecApp already grants. Projection (Graph field path -> output property): displayName -> DisplayName appId -> AppId id -> ObjectId <which collection it came from> -> ObjectType ('Application' / 'ServicePrincipal') <which array it came from> -> CredentialType ('Secret' / 'Certificate' / 'None') *Credentials[].displayName -> CredentialName *Credentials[].keyId -> KeyId keyCredentials[].usage -> CredentialUsage ('Verify' / 'Sign'; null for secrets) *Credentials[].startDateTime -> StartDateTime *Credentials[].endDateTime -> EndDateTime <derived> -> DaysUntilExpiry, IsExpired, LifetimeDays signInAudience -> SignInAudience (null on service principals) servicePrincipalType / tags -> PrincipalType ('Application' / 'ManagedIdentity' / ...) createdDateTime -> CreatedDateTime <entire app / SP object verbatim> -> Raw DaysUntilExpiry FLOORS, so a credential that lapsed two hours ago reads -1 rather than 0. IsExpired is the exact test and is computed from the timestamps, not from the rounded number - so DaysUntilExpiry 0 means "expires later today", not "expired". Raw is the whole application or servicePrincipal object, not the credential - the credential is a member of its passwordCredentials / keyCredentials array, findable by KeyId. Keeping the app means the row can still reach requiredResourceAccess (what the app is allowed to do), which is what decides whether a leaked secret would matter. OWNERS ARE NOT RESOLVED. "Who do I chase about this" is the field you most want, and Graph only answers it per app - one extra call each, so a tenant with several hundred registrations would pay several hundred round trips for it. Look the handful you actually care about up by ObjectId instead. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateRange(0, 3650)] [int] $ExpiringWithinDays, [switch] $IncludeServicePrincipal ) Assert-MsecSession # One clock for the whole run. Reading UtcNow per credential would let a long # enumeration produce rows measured against slightly different "now"s, which is # exactly the kind of drift that makes two runs disagree for no reason. $now = [DateTime]::UtcNow $hasWindow = $PSBoundParameters.ContainsKey('ExpiringWithinDays') # EVERY TIMESTAMP IS FORCED TO UTC BEFORE IT IS COMPARED. A plain [datetime] cast of # Graph's '2026-09-01T08:00:00Z' returns Kind=Local - the right instant, expressed in # local wall-clock - and comparing that against [DateTime]::UtcNow, which is a naive # UTC number, silently shifts every result by the local offset. In Stockholm that is # one or two hours: enough to report a credential that lapsed at 23:30 as still valid, # and enough to move DaysUntilExpiry by a whole day either side of midnight. # # AssumeUniversal covers the case where Graph omits the trailing Z, which would # otherwise be read as local and shifted the other way. $toUtc = { param($value) if (-not $value) { return $null } if ($value -is [datetime]) { return $value.ToUniversalTime() } [datetime]::Parse([string] $value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal) } $get = { param($Path, $Endpoint, $Permission) try { @(Invoke-MsecGraphRequest -Path $Path -All) } catch { $detail = $_.Exception.Message if ($detail -match '403|Forbidden|Authorization_RequestDenied') { throw "Forbidden when calling $Endpoint. The msec app needs the '$Permission' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $detail" } throw } } $select = 'id,appId,displayName,createdDateTime,signInAudience,passwordCredentials,keyCredentials' $applications = & $get "/v1.0/applications?`$select=$select" '/applications' 'Application.Read.All' $servicePrincipals = @() if ($IncludeServicePrincipal) { # No signInAudience on a service principal - it is a property of the registration, # not of the tenant-local object - so it is left out of the $select rather than # asked for and silently returned null. $spSelect = 'id,appId,displayName,createdDateTime,servicePrincipalType,tags,passwordCredentials,keyCredentials' $servicePrincipals = & $get "/v1.0/servicePrincipals?`$select=$spSelect" '/servicePrincipals' 'Application.Read.All' } # MANAGED IDENTITIES ARE NOT APP REGISTRATIONS, and conflating them makes an expiry report # read wrongly in both directions. A managed identity's credential is rotated by Azure and # is not yours to renew - listing it beside secrets that need a human is noise. An app # registration's certificate IS yours, and missing it is an outage. # # servicePrincipalType is the authoritative answer where Graph gives one # ('ManagedIdentity', 'Application', 'Legacy', 'SocialIdp'). The tag is the fallback: an # App Service with a system-assigned identity carries 'AppServiceIntegratedApp', which is # how the Az-based inventories told them apart before that property existed. $principalType = { param($Object, $ObjectType) if ($ObjectType -ne 'ServicePrincipal') { return 'Application' } $type = [string] $Object.servicePrincipalType if ($type) { return $type } if (@($Object.tags) -contains 'AppServiceIntegratedApp') { return 'ManagedIdentity' } return 'Unknown' } $emit = { param($Object, $ObjectType) $secrets = @($Object.passwordCredentials | Where-Object { $_ }) $certificates = @($Object.keyCredentials | Where-Object { $_ }) $credentials = @( foreach ($c in $secrets) { [pscustomobject]@{ Type = 'Secret'; Cred = $c } } foreach ($c in $certificates) { [pscustomobject]@{ Type = 'Certificate'; Cred = $c } } ) # An app registration with nothing on it is still a fact worth reporting - usually # a good one. A service principal with nothing on it is one of the hundreds # Microsoft owns, and reporting those would bury everything else. if (-not $credentials.Count) { if ($ObjectType -ne 'Application') { return } [PSCustomObject]@{ PSTypeName = 'MsecEntraAppCredential' DisplayName = $Object.displayName AppId = $Object.appId ObjectId = $Object.id ObjectType = $ObjectType CredentialType = 'None' CredentialName = $null KeyId = $null CredentialUsage = $null StartDateTime = $null EndDateTime = $null DaysUntilExpiry = $null IsExpired = $null LifetimeDays = $null SignInAudience = $Object.signInAudience PrincipalType = & $principalType $Object $ObjectType CreatedDateTime = & $toUtc $Object.createdDateTime Raw = $Object } return } foreach ($entry in $credentials) { $c = $entry.Cred $start = & $toUtc $c.startDateTime $end = & $toUtc $c.endDateTime # IsExpired from the timestamps, not from the floored day count - otherwise a # credential with nine hours left (DaysUntilExpiry 0) would read as expired. $days = if ($end) { [int][Math]::Floor(($end - $now).TotalDays) } else { $null } $isExpired = if ($end) { $end -lt $now } else { $null } $lifetime = if ($start -and $end) { [int][Math]::Round(($end - $start).TotalDays) } else { $null } [PSCustomObject]@{ PSTypeName = 'MsecEntraAppCredential' DisplayName = $Object.displayName AppId = $Object.appId ObjectId = $Object.id ObjectType = $ObjectType CredentialType = $entry.Type CredentialName = $c.displayName KeyId = $c.keyId # Only certificates carry a usage - 'Sign' is a SAML token-signing cert, # 'Verify' is one used to authenticate as the app. CredentialUsage = if ($entry.Type -eq 'Certificate') { $c.usage } else { $null } StartDateTime = $start EndDateTime = $end DaysUntilExpiry = $days IsExpired = $isExpired LifetimeDays = $lifetime SignInAudience = $Object.signInAudience PrincipalType = & $principalType $Object $ObjectType CreatedDateTime = & $toUtc $Object.createdDateTime Raw = $Object } } } $rows = @( foreach ($a in $applications) { & $emit $a 'Application' } foreach ($s in $servicePrincipals) { & $emit $s 'ServicePrincipal' } ) if ($hasWindow) { # Expired ones survive any window. A credential that lapsed last month is not less # urgent than one lapsing next week, so a filter that dropped it would be reporting # the opposite of the truth. $rows = @($rows | Where-Object { $null -ne $_.DaysUntilExpiry -and ($_.IsExpired -or $_.DaysUntilExpiry -le $ExpiringWithinDays) }) } $rows } |