⏰
Azure のサービスプリンシパルの有効期限を一括チェックする PowerShell スクリプトについて
はじめに
こんにちは。今日はプラットフォームエンジニアリングの観点で投稿したいと思います。
Azure を使って開発をしていると、いつの間にかサービスプリンシパルがたくさん作られていて、「あれ、このシークレット、いつ切れるんだっけ?」となることはありませんか?
Azure Portal で一つずつ確認するのも良いのですが、テナント内に大量のサービスプリンシパルがある場合、手動でのチェックは現実的ではありません。そこで、Microsoft Graph API を使って一括で情報を取得し、CSV形式で出力できるスクリプトを作成しました。同じような悩みを抱えている方のお役に立てればと思い、今回はその作り方と使い方をご紹介します。
スクリプトの特徴
- Service Principal 側の資格情報(Managed Identity 等で生成されるもの)
- 対応する Application 側の資格情報(通常のクライアントシークレット/証明書)
両方の情報を収集できます。 - 有効期限までの残り日数を自動計算し、期限切れ状態も判定してくれます。
- CSV形式で出力されるため、Excel や Power BI などで簡単に分析・可視化できます。
- UTF-8エンコーディングで出力されるため、日本語の表示名なども正しく表示されます。
使い方
実際のスクリプト
Export-SpCredentialExpiry.ps1
Export-SpCredentialExpiry.ps1
# ------------- 共通: Graph 呼び出し準備 -------------
Write-Host "Getting Microsoft Graph access token via Azure CLI..." -ForegroundColor Cyan
$azError = $null
try {
$accessToken = az account get-access-token --resource-type ms-graph --query accessToken -o tsv 2>&1
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) {
throw
}
} catch {
$azError = $_
throw "Failed to acquire Graph access token. Please run: az login --tenant <your-tenant-id>`nError details: $azError"
}
$Headers = @{ Authorization = "Bearer $accessToken" }
$GraphBase = "https://graph.microsoft.com/v1.0"
function Invoke-GraphGetAll {
param(
[Parameter(Mandatory=$true)][string]$Url
)
$all = @()
$next = $Url
while ($next) {
try {
$resp = Invoke-RestMethod -Method GET -Uri $next -Headers $Headers -ErrorAction Stop
if ($resp.value) { $all += $resp.value }
$next = $resp.'@odata.nextLink'
} catch {
Write-Host "Error during HTTP request" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
break
}
}
return $all
}
# ------------- 取得: Service Principals -------------
Write-Host "Fetching service principals..." -ForegroundColor Cyan
# SP自身に資格情報が付くケースもあるため passwordCredentials/keyCredentials も取得
$spUrl = "$GraphBase/servicePrincipals?`$select=id,appId,displayName,createdDateTime,passwordCredentials,keyCredentials&`$top=999"
$servicePrincipals = Invoke-GraphGetAll -Url $spUrl
# ------------- 取得: Applications -------------
Write-Host "Fetching applications..." -ForegroundColor Cyan
# Application 側の資格情報も取得
$appUrl = "$GraphBase/applications?`$select=id,appId,displayName,passwordCredentials,keyCredentials&`$top=999"
$applications = Invoke-GraphGetAll -Url $appUrl
# ------------- インデックス化(appId -> application)-------------
$appByAppId = @{}
foreach ($app in $applications) {
if ($app.appId -and -not $appByAppId.ContainsKey($app.appId)) {
$appByAppId[$app.appId] = $app
}
}
# ------------- 整形: 出力行の作成 -------------
$now = (Get-Date).ToUniversalTime()
$rows = New-Object System.Collections.Generic.List[object]
foreach ($sp in $servicePrincipals) {
$spCreated = $null
if ($sp.createdDateTime) {
$spCreated = [datetime]::Parse($sp.createdDateTime).ToUniversalTime()
}
# 1) SPエンティティ側の資格情報(Managed Identity 等)
$spPwCreds = @($sp.passwordCredentials) | Where-Object { $_ -ne $null }
$spKeyCreds = @($sp.keyCredentials) | Where-Object { $_ -ne $null }
foreach ($c in $spPwCreds) {
$start = if ($c.startDateTime) { [datetime]::Parse($c.startDateTime).ToUniversalTime() } else { $null }
$end = if ($c.endDateTime) { [datetime]::Parse($c.endDateTime).ToUniversalTime() } else { $null }
$days = if ($end) { [math]::Floor(($end - $now).TotalDays) } else { $null }
$rows.Add([pscustomobject]@{
Source = "ServicePrincipal"
ServicePrincipalDisplayName = $sp.displayName
ServicePrincipalObjectId = $sp.id
AppId = $sp.appId
SPCreatedDateTimeUtc = $spCreated
CredentialType = "Password"
CredentialDisplayName = $c.displayName
KeyId = $c.keyId
StartDateTimeUtc = $start
EndDateTimeUtc = $end
DaysToExpire = $days
IsExpired = if ($end) { $end -lt $now } else { $null }
})
}
foreach ($c in $spKeyCreds) {
$start = if ($c.startDateTime) { [datetime]::Parse($c.startDateTime).ToUniversalTime() } else { $null }
$end = if ($c.endDateTime) { [datetime]::Parse($c.endDateTime).ToUniversalTime() } else { $null }
$days = if ($end) { [math]::Floor(($end - $now).TotalDays) } else { $null }
$rows.Add([pscustomobject]@{
Source = "ServicePrincipal"
ServicePrincipalDisplayName = $sp.displayName
ServicePrincipalObjectId = $sp.id
AppId = $sp.appId
SPCreatedDateTimeUtc = $spCreated
CredentialType = "Certificate"
CredentialDisplayName = $c.displayName
KeyId = $c.keyId
StartDateTimeUtc = $start
EndDateTimeUtc = $end
DaysToExpire = $days
IsExpired = if ($end) { $end -lt $now } else { $null }
})
}
# 2) Application エンティティ側の資格情報(一般的なクライアントシークレット/証明書)
if ($sp.appId -and $appByAppId.ContainsKey($sp.appId)) {
$app = $appByAppId[$sp.appId]
$appPwCreds = @($app.passwordCredentials) | Where-Object { $_ -ne $null }
$appKeyCreds = @($app.keyCredentials) | Where-Object { $_ -ne $null }
foreach ($c in $appPwCreds) {
$start = if ($c.startDateTime) { [datetime]::Parse($c.startDateTime).ToUniversalTime() } else { $null }
$end = if ($c.endDateTime) { [datetime]::Parse($c.endDateTime).ToUniversalTime() } else { $null }
$days = if ($end) { [math]::Floor(($end - $now).TotalDays) } else { $null }
$rows.Add([pscustomobject]@{
Source = "Application"
ServicePrincipalDisplayName = $sp.displayName
ServicePrincipalObjectId = $sp.id
AppId = $sp.appId
ApplicationObjectId = $app.id
SPCreatedDateTimeUtc = $spCreated
CredentialType = "Password"
CredentialDisplayName = $c.displayName
KeyId = $c.keyId
StartDateTimeUtc = $start
EndDateTimeUtc = $end
DaysToExpire = $days
IsExpired = if ($end) { $end -lt $now } else { $null }
})
}
foreach ($c in $appKeyCreds) {
$start = if ($c.startDateTime) { [datetime]::Parse($c.startDateTime).ToUniversalTime() } else { $null }
$end = if ($c.endDateTime) { [datetime]::Parse($c.endDateTime).ToUniversalTime() } else { $null }
$days = if ($end) { [math]::Floor(($end - $now).TotalDays) } else { $null }
$rows.Add([pscustomobject]@{
Source = "Application"
ServicePrincipalDisplayName = $sp.displayName
ServicePrincipalObjectId = $sp.id
AppId = $sp.appId
ApplicationObjectId = $app.id
SPCreatedDateTimeUtc = $spCreated
CredentialType = "Certificate"
CredentialDisplayName = $c.displayName
KeyId = $c.keyId
StartDateTimeUtc = $start
EndDateTimeUtc = $end
DaysToExpire = $days
IsExpired = if ($end) { $end -lt $now } else { $null }
})
}
}
}
if ($rows.Count -eq 0) {
Write-Warning "No credentials found on service principals or applications."
return
}
Write-Host "Found $($rows.Count) credential entries" -ForegroundColor Yellow
# ------------- CSV 出力 -------------
$sortedRows = $rows | Sort-Object -Property EndDateTimeUtc, ServicePrincipalDisplayName
# UTF-8(BOMなし)でCSVファイルを作成
$csvContent = $sortedRows | ConvertTo-Csv -NoTypeInformation
$csvString = $csvContent -join "`r`n"
[System.IO.File]::WriteAllText($OutputPath, $csvString, [System.Text.UTF8Encoding]::new($false))
準備
- Azure CLI がインストールされていること
- 適切な権限で Azure Tenant にログインしていること
az login --tenant <your-tenant-id>
基本的な実行方法
# デフォルトのファイル名(sp-credentials.csv)で出力
.\Export-SpCredentialExpiry.ps1
# カスタムパスで出力
.\Export-SpCredentialExpiry.ps1 -OutputPath "C:\Reports\credential-report.csv"
実行例
PS C:\> .\Export-SpCredentialExpiry.ps1 -OutputPath ".\reports\sp-audit-2025-09-17.csv"
Getting Microsoft Graph access token via Azure CLI...
Fetching service principals...
Fetching applications...
Found 45 credential entries
Done. Exported 45 credential entries to .\reports\sp-audit-2025-09-17.csv (UTF-8 encoding)
出力される情報
CSV ファイルには以下のような情報が含まれます。
項目 | 説明 |
---|---|
Source | 資格情報のソース(ServicePrincipal/Application) |
ServicePrincipalDisplayName | Service Principalの表示名 |
AppId | アプリケーションID |
CredentialType | 資格情報の種類(Password/Certificate) |
EndDateTimeUtc | 有効期限日時(UTC) |
DaysToExpire | 有効期限までの日数 |
IsExpired | 期限切れかどうか |
これらの情報をもとに、「30 日以内に期限切れになる資格情報」をフィルタリングしたり、期限切れ順にソートして優先度を決めたりできます。
注意点
- このスクリプトは資格情報の値そのものは取得しません(メタデータのみ)
- 出力 CSV ファイルには機密情報が含まれる可能性があるため、適切に管理してください
- Microsoft Graph API の
Directory.Read.All
権限が必要です
まとめ
Azureのサービスプリンシパルの管理は、運用において重要な課題の一つだと思っています。シークレットの更新を意識しなくちゃいけないのがつらいですよね。このツールが同じような悩みを抱えている方の助けになれば嬉しいです。
ソースコードは以下の GitHub リポジトリで公開していますので、ぜひ活用してみてください。
改善点やバグ報告なども大歓迎です。
GitHubリポジトリ
Discussion