Claude Media
Claude CodeでEntraのシークレット期限切れを一覧にする手順

Claude CodeでEntraのシークレット期限切れを一覧にする手順

Microsoft Graphのapplicationsからクライアントシークレットの期限を引き、30日以内に切れる順の一覧と担当者への連絡文までをClaude Codeで作る手順です。権限は読み取りに限ります。

Entraのアプリ登録に作ったクライアントシークレットは、期限が来ると黙って認証に失敗し始めます。切れる前に気づく一番手軽な方法は、Microsoft Graphで全アプリの期限を引いて、近い順に並べることです。この記事では、その一覧を作るスクリプトをClaude Codeに書かせ、30日以内に切れるものの一覧と担当者への連絡文まで作る流れを説明します。

Graphへの権限は読み取りだけに絞ります。シークレットの更新や削除は、人が管理センターで行う前提です。

期限はGraphのどこに入っているか

クライアントシークレットは、applicationオブジェクトのpasswordCredentialsに入っています。applicationリソースの説明では「アプリケーションに関連付けられたパスワード資格情報のコレクション」で、空にならない項目です。

1件ごとの項目(passwordCredentialリソース)のうち、棚卸しに使うのは次の4つです。

項目内容
endDateTime内容期限。ISO 8601形式で、常にUTC
startDateTime内容有効になる日時
displayName内容シークレットにつけた名前(任意)
keyId内容シークレットごとの識別子

secretTextは、addPasswordで作った直後の応答でしか返りません。後から取り出す方法はないと、リソースの説明に書かれています。つまり、この一覧を作る過程でシークレットの値が漏れる経路はありません。hintには先頭3文字が入りますが、今回は使いません。

証明書の期限はpasswordCredentialsではなくkeyCredentialsにあります。このスクリプトの対象は、あくまでパスワード型のシークレットです。証明書認証に切り替えれば期限の管理方法が変わるため、その場合はClaude apps gatewayのprivate_key_jwt設定の考え方が参考になります。

読み取り専用の権限を用意する

applicationsの一覧を取るAPIは、GET /applications です。必要な権限のうち最小のものは、委任(職場または学校アカウント)でもアプリケーションでもApplication.Read.Allです。ReadWrite系やDirectory.Read.Allは上位の権限として並んでいますが、一覧には要りません。

委任で使うときは、サインインしたユーザーが対象の操作に必要なEntraロールを持っている必要があります。Microsoftのドキュメントには、既定のユーザー権限を持つ一般ユーザーのほか、Global ReaderやApplication Administratorなどが対応ロールとして挙がっています。

オーナー(担当者)の取得は別のAPIで、GET /applications/{id}/owners です。こちらも最小権限はApplication.Read.Allです。ただし、読む権限のない種類のオブジェクトは、種類とidだけが返り、ほかの項目がnullになります。ユーザーのメールアドレスまで欲しい場合は、一覧の段階で空欄が出ることを前提に設計します。

読み取り専用のトークンを取る

スクリプトが読む GRAPH_TOKEN は、Claude Codeの外で人が取ります。取り方は、人のアカウントで取る方法と、アプリの権限で取る方法の2通りです。

人のアカウントで一度だけ実行するなら、Azure CLIが手軽です。Azure CLIのドキュメントには、Microsoft Graph用のトークンを取る例として次のコマンドが載っています。

az login
export GRAPH_TOKEN="$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv)"

この場合、権限はサインインした人のものです。前節のとおり、一覧を読めるEntraロールを持つアカウントで実行します。

週次などで繰り返すなら、アプリケーション権限を使うクライアント資格情報フローがあります。Application.Read.Allをアプリケーション権限として追加し、管理者の同意を済ませたアプリ登録を1つ用意します。Microsoft Graphのアプリケーション権限は、常に管理者の同意が要ります。トークンは、tenant、client_id、client_secretを渡して取ります。scopeは https://graph.microsoft.com/.default です。

curl -s -X POST \
  -d "client_id=$CLIENT_ID" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token"

応答の access_token を GRAPH_TOKEN に入れます。応答には有効秒数を示す expires_in があり、ドキュメントの例では3599秒、つまり約1時間です。長く使い回せるトークンではないので、スクリプトを走らせる直前に取り直します。

この方法でも、CLIENT_SECRET はClaudeに見せません。トークンを取るコマンドは自分のシェルで実行し、Claudeにはトークンだけが渡る形にします。そのシークレットも期限を持つため、棚卸しの対象に含めます。

Claude Codeに書かせる前の約束事

スクリプトを書かせる前に、プロジェクトのCLAUDE.mdに境界を書いておきます。例えば次のような内容です。

## Entraのシークレット棚卸し
- Graphは GET のみ。POST / PATCH / DELETE を含むスクリプトは書かない
- トークンは環境変数 GRAPH_TOKEN から読む。値を出力・ログ・ファイルに書かない
- 出力にはシークレットの値(secretText)・hintを含めない
- 結果は reports/ 以下にTSVで保存する

次に、権限設定です。公式ドキュメントでは、規則はdeny、ask、allowの順に評価され、最初に一致したものが結果を決めます。denyに書いたものは、allowで例外にできません。.envを読ませたくないなら、Read(./.env) のdenyが使えます。

{
  "permissions": {
    "allow": ["Bash(python3 secret_expiry.py *)"],
    "deny": ["Read(./.env)"]
  }
}

Bashのdenyは、コマンドの書かれ方が変わると効かない場合があります。例えば Bash(curl *) をdenyにしても、/usr/bin/curl や sh -c で包んだ形は一致しません。通信の制限を守らせたい場面では、ドキュメントが案内するサンドボックスのネットワーク許可リストと組み合わせます。

トークンは環境変数で渡すため、Claudeが実行するコマンドからも読める状態になります。期限の短い、読み取りだけのトークンを使う理由はここにあります。

30日以内に切れる順の一覧を出すスクリプト

「Graphからapplicationsを取って、passwordCredentialsの期限が指定日数以内のものを近い順に出して」と頼みます。形としては次のようなスクリプトになります。これは公式のAPI仕様に沿って書いた例です。

#!/usr/bin/env python3
"""Entraのクライアントシークレット期限を一覧にする(GETのみ)"""
import json, os, sys, urllib.request
from datetime import datetime, timedelta, timezone
 
BASE = "https://graph.microsoft.com/v1.0"
TOKEN = os.environ["GRAPH_TOKEN"]
DAYS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
 
 
def get(url):
    req = urllib.request.Request(
        url, headers={"Authorization": "Bearer " + TOKEN})
    with urllib.request.urlopen(req) as res:
        return json.load(res)
 
 
def pages(url):
    while url:
        body = get(url)
        yield from body["value"]
        url = body.get("@odata.nextLink")
 
 
def parse(ts):
    # 小数秒が付いても読めるよう、秒までで切ってUTCとして扱う
    t = datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S")
    return t.replace(tzinfo=timezone.utc)
 
 
def owners(app_id):
    return [o.get("userPrincipalName") or o.get("displayName")
            or o["id"]
            for o in pages(f"{BASE}/applications/{app_id}/owners")]
 
 
now = datetime.now(timezone.utc)
limit = now + timedelta(days=DAYS)
url = (BASE + "/applications"
       "?$select=id,appId,displayName,passwordCredentials&$top=999")
rows = []
for app in pages(url):
    creds = [c for c in app.get("passwordCredentials", [])
             if c.get("endDateTime")]
    for c in creds:
        end = parse(c["endDateTime"])
        if end > limit:
            continue
        later = any(parse(o["endDateTime"]) > limit for o in creds)
        rows.append((end, app, c, later))
 
rows.sort(key=lambda r: r[0])
print("残日数\t期限(UTC)\tアプリ名\tappId\tシークレット名\t後継\t所有者")
for end, app, c, later in rows:
    days = (end - now).days
    who = ", ".join(owners(app["id"])) or "所有者なし"
    print("\t".join([
        str(days), end.strftime("%Y-%m-%d"), app["displayName"],
        app["appId"], c.get("displayName") or "(名前なし)",
        "あり" if later else "なし", who]))

仕様に合わせた点は4つあります。

  • 1ページの上限は既定100件、最大999件なので、$top=999 を付け、応答の @odata.nextLink が無くなるまでたどります
  • ownersの一覧も @odata.nextLink をたどります。ownersが100件を超えるアプリでも打ち切られません
  • 取得する項目は $select で絞り、使わないプロパティを持ち帰りません
  • 期限は常にUTCなので、現在時刻もUTCで比べます
export GRAPH_TOKEN="<読み取り専用のトークン>"
mkdir -p reports
python3 secret_expiry.py 30 > reports/secret-expiry.tsv

出力は次のような形になります(アプリ名やidはダミーです)。

残日数  期限(UTC)   アプリ名       appId      シークレット名  後継  所有者
-3      2026-10-17  sales-portal   1111-...   prod-2025       なし  所有者なし
6       2026-10-26  batch-runner   2222-...   cron-key        なし  a.tanaka
19      2026-11-08  hr-sync        3333-...   v1              あり  b.suzuki

Graphの応答に期限切れの項目が含まれていれば、残日数が負の行として出ます。表示順は、近いものが上に来ます。

一覧の読み方 — 後継の有無と所有者なしを見る

件数が多いときは、優先順位をつけないと連絡が回りません。上のスクリプトは、その材料を2列で出しています。

後継の列は、同じアプリの別のシークレットが指定の期間より後まで有効かを示します。「あり」なら、すでに新しいシークレットが作られている可能性があります。ただし、アプリが実際にどちらを使っているかは、この一覧からは分かりません。古いシークレットを使い続けているケースを防ぐには、持ち主への確認が要ります。

所有者の列は、連絡先が分かるかを示します。「所有者なし」の行は、誰に連絡するか決める作業が先に発生します。Microsoftのドキュメントには、アプリには少なくとも2人のオーナーを置くのがよいとあります。所有者が0人や1人のアプリは、owners/$count eq 0 or owners/$count eq 1 のフィルターで探せます。このフィルターは、ConsistencyLevel: eventual ヘッダーと $count=true が必要です。

優先順位は、次の順で見るのが分かりやすくなります。

  1. 残日数が負のもの。すでに認証が失敗している可能性があります
  2. 後継が「なし」で残日数が少ないもの
  3. 後継が「あり」のもの。古いシークレットの削除を依頼する段階です

連絡文を作る

TSVができたら、Claude Codeに連絡文を作らせます。ここで送信まで任せないのがポイントです。

reports/secret-expiry.tsvを所有者ごとにまとめて、連絡文を作って。アプリ名・期限・後継の有無を表にして、更新方法は、新しいシークレットを作って利用側の設定を差し替える手順だけを書く。所有者なしの行は別にまとめる

出てきた文面は、宛先と内容を人が確認してから送ります。新しいシークレットの値は、作った直後にしか表示されません。そのため、連絡文には「作成直後に値を控える」旨と、控えた値を保管場所へ入れる手順を添えます。

所有者なしの行は、連絡文ではなく依頼文にします。誰を所有者にするか、あるいはアプリを使っていなければ削除するかを、アプリ登録の管理者に確認する内容です。

運用に乗せるときの注意

この手順は、実行した時点の一覧を得るためのものです。毎週回したいなら、cronやGitHub Actionsに載せる前に、トークンの持たせ方を決めます。読み取り専用のアプリ登録を1つ作り、Application.Read.Allのアプリケーション権限を管理者が許可する形が基本です。その場合、アプリ側のシークレットにも期限があるため、この棚卸しの対象に自分自身も入れておくと管理漏れが減ります。

サインイン側の調査と組み合わせるなら、期限切れの直後に認証エラーが急増していないかをEntra IDのサインインログをKQLで調べる手順で確かめられます。MCPサーバーの認証で見かけるエラーは、Claude Code MCPのAADSTS9010010の記事で原因を切り分けています。

期限を突合して一覧にする考え方は、他の台帳にも使えます。サポート終了日を扱う例は、endoflife.date APIで資産台帳を突合するで説明しています。

よくある質問

証明書の期限も同じスクリプトで見られますか

passwordCredentialsはシークレット専用で、証明書はkeyCredentialsにあります。keyCredentialsのkeyは、既定では一覧に含まれず、$select で明示したときだけ返ります。また、$select でkeyCredentialsを取る場合は、テナントごとに1分あたり150リクエストの制限があります。

この記事を共有:XはてブLinkedIn