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))

準備

  1. Azure CLI がインストールされていること
  2. 適切な権限で 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リポジトリ
https://github.com/yutaka-art/Export-Azure-SpCredentialExpiry

GitHubで編集を提案

Discussion