Claude Media
BitLocker回復キーが未保管のWindows端末をGraph APIとClaude Codeで洗い出す

BitLocker回復キーが未保管のWindows端末をGraph APIとClaude Codeで洗い出す

Microsoft Graphの端末一覧と回復キー一覧を、キー値を読まずにdeviceIdだけで突き合わせ、Entraにキーが預けられていないWindows端末の候補をClaude Codeで出す手順です。

BitLockerの回復キーがEntra IDに預けられていない端末は、ロックされたときにヘルプデスクが助けられません。その候補を洗い出すには、端末の一覧と回復キーの一覧をMicrosoft Graphで取り、deviceIdで突き合わせます。この記事では、その突き合わせをClaude Codeに任せつつ、回復キーの値そのものはClaudeに一切渡さない構成を組みます。

渡すのはIDと端末IDだけです。キー値は取得もしません。それでも「どの端末のキーが無いか」は十分に分かります。

全体像: 2つの一覧を突き合わせるだけ

使うAPIは2本です。

手順

照合の流れ

  1. 1

    端末一覧を取る

    GET /devices でWindows端末だけを取り、deviceId と表示名を残します。

  2. 2

    回復キー一覧を取る

    GET /informationProtection/bitlocker/recoveryKeys で、キーのID・deviceId・volumeType だけを取ります。

  3. 3

    deviceIdで突き合わせる

    キーが1本も無い端末と、OSボリュームのキーが無い端末を書き出します。

  4. 4

    Claude Codeに結果だけ読ませる

    出力ファイルには端末名とIDしか入りません。Claudeはそれを整形し、確認の優先順位を付けます。

Claude Codeに任せる範囲は、スクリプトを書くことと、出力を読んで報告を整えることです。トークンを使ってAPIを叩く実行は、人間が自分の端末で行います。

回復キー一覧APIが返すもの・返さないもの

回復キーのリソース(bitlockerRecoveryKey)には、次のプロパティがあります。

プロパティ内容
id内容キーの一意なID
deviceId内容キーの預託元の端末ID。$filter で eq が使える
createdDateTime内容キーがEntra IDに最初にバックアップされた日時
volumeType内容1 がOSボリューム、2 が固定データボリューム、3 が取り外し可能ボリューム、4 が unknownFutureValue
key内容回復キーそのもの。取得には $select が必要

一覧の取得(List)は key プロパティを返しません。値を読むには別に、キーを1本指定する取得(Get)で $select=key を付ける必要があります。つまり、一覧を取るだけなら、キー値が手元に来ることはありません。

この性質が、今回の設計の土台です。スクリプトに $select=key を書かない限り、Claudeはもちろん、ログにもファイルにもキー値は残りません。

一覧のAPIには、扱いの癖が3つあります。

  • $filter で絞れるのは deviceId で、$top は使えません
  • 続きがあると @odata.nextLink が返るので、辿って全件を取ります
  • リクエストには User-Agent ヘッダーが必須です

$top が使えないので、件数を絞ってから試すことはできません。最初の1回から全件を取る前提で書きます。

必要な権限とロール

アプリケーション権限でも委任権限でも、最小権限として挙げられているのは BitlockerKey.ReadBasic.All です。上位の権限は BitlockerKey.Read.All です。端末側の GET /devices には Device.Read.All が必要です。

委任権限で呼ぶ場合、呼び出すユーザーは対象端末の登録済み所有者か、次のいずれかのロールを持つ必要があります。

  • Cloud device administrator
  • Helpdesk administrator
  • Intune service administrator
  • Security administrator
  • Security reader
  • Global reader

棚卸しだけが目的なら、キー値を読む必要はありません。ReadBasic を最小権限として選び、Read.All は付与しない運用が素直です。

ただし、ReadBasic のトークンで $select=key が通るかどうかは、Getのページの権限表からは読み取れません。表には最小権限として同じ ReadBasic が載っているだけです。権限を絞ったから安全、と決めつけず、スクリプトにキー取得の呼び出しを入れないことを第一の防御にしてください。

照合スクリプトをClaude Codeに書かせる

Claude Codeには、仕様を渡して書かせます。依頼文は、次のように「やらないこと」を先に書くのがコツです。

Microsoft Graph v1.0 で Windows 端末と BitLocker 回復キーの一覧を取り、
deviceId で突き合わせる Python スクリプト audit.py を書いて。
- トークンは環境変数 GRAPH_TOKEN から読む(ファイルに書かない)
- 回復キー側は $select を使わず、id / deviceId / volumeType / createdDateTime
  だけを保持する。key というフィールドは捨てる
- 一覧は @odata.nextLink を辿って全件取る。$top は使わない
- User-Agent ヘッダーを付ける
- 出力は out/missing.json のみ。端末名・deviceId・不足の種類だけ

例えば次のような形のスクリプトになります(公式のAPI仕様に沿った一例です)。

import json, os, urllib.request
from urllib.parse import quote
 
BASE = "https://graph.microsoft.com/v1.0"
HEAD = {
    "Authorization": "Bearer " + os.environ["GRAPH_TOKEN"],
    "User-Agent": "bitlocker-audit/0.1",
}
 
def pages(path):
    url = BASE + path
    while url:
        req = urllib.request.Request(url, headers=HEAD)
        with urllib.request.urlopen(req) as res:
            body = json.load(res)
        yield from body["value"]
        url = body.get("@odata.nextLink")
 
flt = quote("operatingSystem eq 'Windows'")
sel = "id,deviceId,displayName,trustType,isManaged"
devices = list(pages(f"/devices?$filter={flt}&$select={sel}"))
 
keys = {}
for k in pages("/informationProtection/bitlocker/recoveryKeys"):
    keys.setdefault(k["deviceId"], set()).add(str(k["volumeType"]))

続きは突き合わせです。

missing = []
for d in devices:
    vols = keys.get(d["deviceId"], set())
    if not vols:
        kind = "no-key"
    elif "1" not in vols:
        kind = "no-os-volume-key"
    else:
        continue
    missing.append({
        "displayName": d["displayName"],
        "deviceId": d["deviceId"],
        "trustType": d.get("trustType"),
        "isManaged": d.get("isManaged"),
        "kind": kind,
    })
 
os.makedirs("out", exist_ok=True)
with open("out/missing.json", "w") as f:
    json.dump(missing, f, ensure_ascii=False, indent=2)

実行は自分のターミナルで行います。トークンはその場の環境変数に入れ、Claudeのセッションには流しません。

export GRAPH_TOKEN="<トークン>"
python3 audit.py
python3 -c "import json; print(len(json.load(open('out/missing.json'))))"

端末の $select に並べた項目は、端末リソースのプロパティ名です。trustType は Workplace(個人所有端末の持ち込み)、AzureAd(クラウド専用の参加)、ServerAd(オンプレミスのドメイン参加をEntra IDに参加させたもの)の値を取ります。後で優先順位を付けるときの材料になります。

deviceIdで突き合わせるときの注意

端末リソースには id と deviceId の2つがあります。deviceId は端末登録サービスが登録時に付ける識別子で、端末オブジェクトを参照する代替キーとして使えます。回復キー側の deviceId も同じ名前ですが、2つが同じ値を指すとは、リソースの説明に書かれていません。

だから、全件を信用する前に、既知の端末で1回確かめてください。

curl -s -H "Authorization: Bearer $GRAPH_TOKEN" \
  -H "User-Agent: bitlocker-audit/0.1" \
  "https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?\$filter=deviceId%20eq%20'<端末のdeviceId>'"

キーが既にあると分かっている端末で1件以上返れば、突き合わせの軸は合っています。0件なら、軸が違うか、その端末のキーが預けられていません。後者を疑う前に、端末の deviceId を取り違えていないか見直します。

また、一覧API側のフィルターの説明は「キーが最も最近バックアップされた端末のdeviceId」、リソース側は「キーの預託元の端末」と書かれています。どちらの定義で突き合わされるかで、再登録した端末の扱いが変わる余地があります。件数が合わない端末は、個別に $filter=deviceId eq で引き直してください。

出力をClaudeに読ませ、確認の順番を付ける

out/missing.json には、端末名・deviceId・不足の種類・trustType・isManagedしか入っていません。ここからはClaude Codeに読ませてよい範囲です。

out/missing.json を読んで、次の順に整理して。
1. kind が no-key の端末を trustType 別に件数で
2. isManaged が false の端末を別枠で
3. 調査の優先順位のたたき台(理由つき)

ここでの「優先順位」は、Claudeの提案にすぎません。たとえば次のような観点が候補に挙がります。

  • no-key で isManaged が true: 管理下にあるのに預託されていないので、ポリシーの適用漏れを疑う
  • no-key で isManaged が false: 管理外の端末なので、そもそも預託の仕組みの対象か
  • no-os-volume-key: データボリュームのキーだけがある。OSドライブが暗号化されていない可能性も含めて見る

どれも「疑う」止まりです。Entraにキーが無い端末が暗号化されていないとは限りませんし、暗号化されていてもバックアップが走っていない端末もあります。この一覧は結論ではなく、確認すべき端末の絞り込みです。

Claude Codeの権限を絞って事故を防ぐ

キー値を取らない設計でも、別の経路で値が漏れる余地は潰しておきます。作業ディレクトリの .claude/settings.json に、トークンを置きがちなファイルを読めなくする Read の拒否ルールを入れます。

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./secrets/**)"
    ]
  }
}

Claude Codeの権限は、拒否、確認、許可の順に評価され、最初に当たったルールで結果が決まります。拒否ルールに当たる呼び出しは、より狭い許可ルールがあっても通りません。Read の拒否ルールは、Claudeの組み込みのファイル系ツールに加え、Bashで認識される cat や head などにも効きます。

一方で、次の限界も押さえておく必要があります。grep -r のように対象ファイルを名指ししないコマンドや、PythonやNodeのスクリプトが自分でファイルを開く場合には、Read の拒否は効きません。OSレベルで確実に止めたいなら、サンドボックスを使います。

そのためこの記事の構成では、そもそもキー値をどこにも書かないことを主軸にしています。拒否ルールは、その外側の保険です。

見つけた端末をどう扱うか

洗い出した端末への対応は、Microsoftのヘルプデスク向け手順が前提になります。回復キーのEntra IDへのバックアップは、自動で行われない場合があり、端末側にバックアップを有効にするポリシー設定が必要です。

回復キーのローテーションについては、自動ローテーションを構成していれば、Entra参加またはハイブリッド参加の端末が新しい回復パスワードを作り、Entra IDに保存するとされています。管理者は、IntuneやConfiguration Managerから手動でローテーションを起動することもできます。

回復キーはロック解除と管理操作ができる機微な情報です。セルフサービスでの回復を許すのは信頼できる環境に限るか、ヘルプデスク経由にするよう、公式の手順でも勧められています。この棚卸しで集めたのはキーの有無だけです。それでも、結果の共有範囲は、通常の端末台帳と同じ扱いにするのが無難です。

なお、Intuneで管理しているiPhoneにClaudeを配る手順はClaude for Intuneの設定手順に、Entra側のログを調べる流れはEntra IDのサインインログをKQLで調べるにあります。端末の棚卸しとサインインの点検は、同じテナントの別の面です。

よくあるつまずき

つまずきは、ほぼ権限と取得範囲に集約されます。

切り分け

結果が空・少ないときの原因

  • 403が返る

    BitlockerKey.ReadBasic.All と Device.Read.All のどちらかが同意されていない可能性があります。委任権限なら、ユーザーが上記のロールを持つかも見ます。

  • 件数が少ない

    @odata.nextLink を辿れていないと、1ページ目しか取れません。$top は使えないため、ページ送りで全件を取る必要があります。

  • 全端末が未保管になる

    突き合わせの軸が id になっていないか確認します。deviceId 同士で比べます。

まとめ

回復キーの未保管端末は、端末一覧と回復キー一覧を deviceId で突き合わせれば絞れます。Claude Codeに任せるのはスクリプト作成と結果の整理で、キーの値は一覧APIが返さず、スクリプトも取りに行きません。出てきた一覧は「Entraにキーが無い端末の候補」です。暗号化の有無は別の情報源で確かめて、初めて対応が決まります。

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