checks/identity/expiring-app-credentials.ps1

# Secrets and certificates expiring within the next 30 days, plus any that have ALREADY
# lapsed, across BOTH app registrations (applications) and enterprise applications
# (servicePrincipals) — the latter is where the SAML token-signing certificate lives.
# Assumes an open Graph session (run-checks.ps1 connects once). Emits the contract.
$WindowDays = 30
$now   = Get-Date
$limit = $now.AddDays($WindowDays)
$items = @()
$checked = 0

# ── collect (owner-kind, object) pairs: app registrations + tenant-owned SPs ──
$objects = @()
$apps = Get-AppsCached
foreach ($app in $apps) { $objects += [pscustomobject]@{ Kind = 'App registration'; O = $app } }

$sel = 'id,appId,displayName,servicePrincipalType,appOwnerOrganizationId,passwordCredentials,keyCredentials,preferredTokenSigningKeyThumbprint'
$sps = Get-GraphAll "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=$sel&`$top=999"
foreach ($sp in $sps) {
    if ($sp.servicePrincipalType -eq 'ManagedIdentity') { continue }   # platform-rotated
    if (Test-MicrosoftFirstParty $sp) { continue }                     # Microsoft-owned
    $objects += [pscustomobject]@{ Kind = 'Enterprise application'; O = $sp }
}

$nExpired  = 0
$nExpiring = 0
foreach ($entry in $objects) {
    $obj = $entry.O
    $creds = @()

    foreach ($s in @($obj.passwordCredentials)) {
        if ($s) { $creds += [pscustomobject]@{ Type = 'Client secret'; C = $s } }
    }

    # A SAML signing cert is returned TWICE (usage 'Sign' and 'Verify') sharing one
    # customKeyIdentifier — keep the 'Sign' copy so it isn't reported as two credentials.
    $keys = @($obj.keyCredentials) | Where-Object { $_ }
    $signIds = @($keys | Where-Object { $_.usage -eq 'Sign' } | ForEach-Object { "$($_.customKeyIdentifier)" })
    foreach ($k in $keys) {
        if ($k.usage -eq 'Verify' -and $signIds -contains "$($k.customKeyIdentifier)") { continue }
        $type = if ($k.usage -eq 'Sign' -and $k.type -eq 'AsymmetricX509Cert') {
            'SAML signing certificate'
        } else { 'Certificate' }
        $creds += [pscustomobject]@{ Type = $type; C = $k }
    }

    $expired = @()
    foreach ($cred in $creds) {
        $end = To-Date $cred.C.endDateTime
        if (-not $end) { continue }
        $checked++
        if ($end -gt $limit) { continue }                     # beyond the window
        $desc = if ($cred.C.displayName) { " '$($cred.C.displayName)'" } else { '' }
        if ($end -le $now) {
            $expired += [pscustomobject]@{ Type = $cred.Type; End = $end }
            $nExpired++
            continue
        }
        $days = [math]::Floor(($end - $now).TotalDays)
        $sev  = if ($days -le 7) { 'high' } else { 'medium' }
        $nExpiring++
        $items += [pscustomobject]@{
            Title    = "$($obj.displayName) — $($cred.Type)"
            Detail   = "Expires $($end.ToString('yyyy-MM-dd')) (in $days day(s)); $($entry.Kind)$desc; appId $($obj.appId)"
            Severity = $sev
        }
    }

    # ── already lapsed ────────────────────────────────────────────────────────────
    # ONE item per app, not per credential: the Title is the resource identity, so four dead
    # secrets on one app must not emit four rows sharing a Title (they would collide in
    # finding_item_state). The count goes in the Detail.
    # A lapsed SAML signing certificate is NOT hygiene — that application's sign-in is already
    # broken — so it is its own item at its own severity.
    foreach ($grp in @($expired | Group-Object { if ($_.Type -eq 'SAML signing certificate') { 'saml' } else { 'other' } })) {
        $cs  = @($grp.Group | Sort-Object End)
        $ago = [math]::Floor(($now - $cs[0].End).TotalDays)
        if ($grp.Name -eq 'saml') {
            $items += [pscustomobject]@{
                Title    = "$($obj.displayName) — expired SAML signing certificate"
                Detail   = "Expired $($cs[0].End.ToString('yyyy-MM-dd')) ($ago day(s) ago); sign-in to this application is already broken; $($entry.Kind); appId $($obj.appId)"
                Severity = 'critical'
            }
        } else {
            $what = (@($cs | ForEach-Object { $_.Type }) | Sort-Object -Unique) -join ' + '
            $items += [pscustomobject]@{
                Title    = "$($obj.displayName) — expired credentials"
                Detail   = "$($cs.Count) lapsed $what; oldest $($cs[0].End.ToString('yyyy-MM-dd')), newest $($cs[-1].End.ToString('yyyy-MM-dd')); authenticates nothing, safe to delete; $($entry.Kind); appId $($obj.appId)"
                Severity = 'low'
            }
        }
    }
}

$items = @($items | Sort-Object { $_.Detail })
[pscustomobject]@{
    Name     = "App credentials expired or expiring within $WindowDays days"
    Severity = 'high'
    Evidence = "$nExpiring expiring within $WindowDays days, $nExpired already lapsed, of $checked credential(s) checked"
    Fix      = 'Roll the affected secrets/certificates before they lapse and update every consumer. For SAML signing certificates, upload the new certificate, notify the relying party, then activate it during a change window. Delete credentials that have already lapsed - they authenticate nothing, so removing them is safe and needs no change window. Prefer federated (workload identity) credentials over secrets where supported.'
    Items    = $items
}