Claude CodeでGitHub組織のメンバーを棚卸しする — 2FA未設定と外部コラボレーター
REST APIで取った組織メンバー・外部コラボレーター・2FA未設定者を人事名簿と突き合わせ、退職者や不要な外部権限の候補表をClaude Codeで作る手順です。
GitHub Organizationのメンバー一覧は、放っておくと現実の在籍者とずれていきます。退職者のアカウントが残り、委託が終わった外部コラボレーターにリポジトリ権限が付いたままになり、2FAを設定していない人が混ざります。この記事は、REST APIで取ったデータを人事名簿と突き合わせて、削除候補の表を作るまでをClaude Codeで進める手順です。
分担は先に決めておきます。APIでの取得と、メンバー削除などの変更操作は人間が行います。Claude Codeに渡すのは取得済みのJSONLと名簿CSVだけで、判定スクリプトと候補表の下書きを書かせます。トークンは渡しません。
棚卸しに使うエンドポイント
棚卸しで読むAPIは4つです。どれもGETで、変更は伴いません。
| 見たいもの | エンドポイント | 絞り込み |
|---|---|---|
| 組織メンバー | エンドポイントGET /orgs/{org}/members | 絞り込みfilter(2fa_disabled / 2fa_insecure / all)とrole(all / admin / member) |
| 外部コラボレーター | エンドポイントGET /orgs/{org}/outside_collaborators | 絞り込みfilter(同じ3値) |
| 承諾待ちの招待 | エンドポイントGET /orgs/{org}/invitations | 絞り込みroleとinvitation_source(all / member / scim) |
| 失敗した招待 | エンドポイントGET /orgs/{org}/failed_invitations | 絞り込みなし |
メンバー一覧のfilterは、説明文に「組織のオーナーだけが使えるオプション」と書かれています。2fa_disabledは2FA未設定の人、2fa_insecureは安全でない2FA方式の人だけを返します。つまり2FAの棚卸しは、オーナー権限のあるアカウントで認証して初めて成立します。
もう一つ、見落としやすい点があります。認証したユーザー自身が組織のメンバーなら、一覧には公開メンバーだけでなく非公開(concealed)のメンバーも含まれます。棚卸しは、組織のメンバーであるオーナーのアカウントで認証して取るのが前提になります。
ページネーションはper_pageが最大100で、既定は30です。既定のまま叩くと31人目以降が抜けます。
取得スクリプト(人間が実行)
GitHub CLIのgh apiを使うと、認証とページ送りを任せられます。--paginateと--jqを組み合わせて、1行1ユーザーのJSONLにします。
ORG=your-org
Q='per_page=100'
gh api --paginate "orgs/$ORG/members?$Q" \
--jq '.[] | {login, id, type}' > members.jsonl
gh api --paginate "orgs/$ORG/members?filter=2fa_disabled&$Q" \
--jq '.[] | {login, id, type}' > members_2fa_disabled.jsonl
gh api --paginate "orgs/$ORG/outside_collaborators?$Q" \
--jq '.[] | {login, id, type}' > outside.jsonl
gh api --paginate "orgs/$ORG/outside_collaborators?filter=2fa_disabled&$Q" \
--jq '.[] | {login, id, type}' > outside_2fa_disabled.jsonljqでlogin・id・typeだけに絞っています。応答にはアバターURLなど棚卸しに要らないフィールドが大量にあり、Claude Codeに渡すと読み込みが増えるだけです。nameとemailは応答に含まれますが、型は「文字列またはnull」です。空のことがあるため、人事名簿との突き合わせにはGitHubのログイン名を使います。
承諾待ちの招待は、次の1行で取れます。
gh api --paginate "orgs/$ORG/invitations?$Q" \
--jq '.[] | {login, email, role}' > invitations.jsonl招待の応答にはロール(direct_member・admin・billing_manager・hiring_manager)が入り、招待先がGitHubアカウントを持たない場合はloginがnullになります。承諾されないまま残った招待も一覧に出るので、送り先が今も在籍しているかを名簿で確かめます。
人事名簿とログイン名を結びつける
突き合わせの鍵は、名簿側に「GitHubのログイン名」の列を持たせることです。人事システムはGitHubのアカウント名を持っていないのが普通なので、ここだけは手作業で表を作ります。
github_login,name,status
alice-dev,Alice,active
bob,Bob,active
dave-old,Dave,terminated外部コラボレーター用には、委託先の名簿を別に用意します。社員の名簿と混ぜると、委託先が全員「名簿に無い」と出て、見るべき行が埋もれます。
github_login,contract_end
vendor-x,2026-12-31名簿とGitHubで大文字小文字の表記がずれている可能性があるので、比較の前に小文字へそろえておくと取りこぼしを防げます。
Claude Codeに判定スクリプトを書かせる
プロンプトには、入力ファイルの名前と判定ルールを具体的に書きます。
members.jsonl と members_2fa_disabled.jsonl、outside.jsonl、
outside_2fa_disabled.jsonl、hr.csv、vendors.csv が置いてある。
これらを読み、次を満たす Python スクリプトを review.py に書いて。
- type が Bot のメンバーは対象外にする
- メンバーのうち、hr.csv に無い人と status が active でない人を出す
- members_2fa_disabled.jsonl にいる人は「2FA未設定」で出す
- 外部コラボレーターのうち、vendors.csv に無い人を出す
- outside_2fa_disabled.jsonl にいる人も「2FA未設定」で出す
- 比較は小文字にそろえる
- 結果は login,kind,finding の CSV で標準出力へ
- ネットワークには接続しない作業ディレクトリに入れるのはJSONLとCSVだけにします。取得に使ったトークンは、このディレクトリにも環境変数にも渡しません。
例えば次のようなスクリプトになります。次の節に載せた偽データで動かして確かめた形です。
import csv, json, sys
def load(path):
with open(path) as f:
return [json.loads(line) for line in f if line.strip()]
members = load("members.jsonl")
no2fa_m = {u["login"].lower() for u in load("members_2fa_disabled.jsonl")}
outside = load("outside.jsonl")
no2fa_o = {u["login"].lower() for u in load("outside_2fa_disabled.jsonl")}
with open("hr.csv", newline="") as f:
hr = {r["github_login"].lower(): r for r in csv.DictReader(f)}
with open("vendors.csv", newline="") as f:
vendors = {r["github_login"].lower() for r in csv.DictReader(f)}
rows = []
for u in members:
login = u["login"].lower()
if u.get("type") == "Bot":
continue
if login not in hr:
rows.append((u["login"], "member", "HR名簿に無い"))
elif hr[login]["status"] != "active":
rows.append((u["login"], "member", "HR上は" + hr[login]["status"]))
if login in no2fa_m:
rows.append((u["login"], "member", "2FA未設定"))
for u in outside:
login = u["login"].lower()
if login in no2fa_o:
rows.append((u["login"], "outside", "2FA未設定"))
if login not in vendors:
rows.append((u["login"], "outside", "委託先名簿に無い"))
w = csv.writer(sys.stdout)
w.writerow(["login", "kind", "finding"])
w.writerows(rows)偽データで境界を確かめる
本番のデータで初めて動かす前に、偽のデータで動作を確かめます。Claude Codeに偽のJSONLとCSVを作らせ、次の境界を含めます。
- ログイン名の大文字小文字が名簿と違うメンバー(
Alice-devとalice-dev) typeがBotのメンバー- 名簿上は
terminatedで、GitHubにはまだ残っているメンバー - 2FA未設定のメンバーと、委託先名簿に無い外部コラボレーター
使ったデータは次の6ファイルです(すべて架空です)。members.jsonlは5人で、Alice-devは名簿と大文字小文字が違い、ci-botはBotです。
# members.jsonl
{"login":"Alice-dev","id":1,"type":"User"}
{"login":"bob","id":2,"type":"User"}
{"login":"carol","id":3,"type":"User"}
{"login":"dave-old","id":4,"type":"User"}
{"login":"ci-bot","id":5,"type":"Bot"}
# members_2fa_disabled.jsonl
{"login":"carol","id":3,"type":"User"}
# outside.jsonl
{"login":"vendor-x","id":6,"type":"User"}
{"login":"vendor-y","id":7,"type":"User"}
# outside_2fa_disabled.jsonl
{"login":"vendor-x","id":6,"type":"User"}hr.csvとvendors.csvは前の節に載せた内容そのままです。この組み合わせでreview.pyを実行した出力は、次のとおりです。
login,kind,finding
carol,member,HR名簿に無い
carol,member,2FA未設定
dave-old,member,HR上はterminated
vendor-x,outside,2FA未設定
vendor-y,outside,委託先名簿に無いAlice-devは大文字小文字が違っても名簿と一致して出力されず、ci-botは除外されています。委託先名簿にいるvendor-xは2FA未設定だけが出て、名簿に無いvendor-yは別の行になります。同じ人が複数の指摘に載ることもあるので、最終的な表はloginごとにまとめ直すと読みやすくなります。
2FAの必須化前後で、一覧の意味が変わる
組織で2FAを必須にしているかどうかで、2fa_disabledの一覧が示すものが変わります。
2FA必須化後、未設定者に起きること — メンバーと外部コラボレーターの違い
メンバー・請求担当者
2FAを使っていない間は組織のリソースにアクセスできません。ただしメンバーシップは残り、シート(ライセンス枠)も消費し続けます。
外部コラボレーター
2FAを使っていなければ組織から外され、リポジトリへのアクセスを失います。必須化の後に2FAを無効にした場合も、自動的に外されます。
この違いから、必須化済みの組織ではfilter=2fa_disabledの外部コラボレーター一覧に、未設定の人は原則として残りません。すでに外されているからです。逆にメンバー側は、アクセスを失ったまま席だけ消費している人が一覧に残ります。棚卸しでは「実害は出ていないが、シートを食っている人」として扱うのが適切です。
自動で外された人は、組織の監査ログで探せます。外された理由が2FA不備かどうかもイベントに出ます。また、外された外部コラボレーターは、2FAを設定してから3か月以内なら以前の権限と設定を復元できます。棚卸しで「いつの間にか名簿から消えた外部コラボレーター」を見つけたら、2FA不備による自動削除の可能性があるので、監査ログで確かめるのが近道です。
bot用やサービス用のアカウントが外部コラボレーターになっている場合も同じ扱いで、2FAを設定しないと外されます。
2FAの方式まで見る — 2fa_insecureとロール別の取得
filterには2fa_insecureもあります。2FAは設定していても、安全でない方式の人だけを返す絞り込みです。必須化の設定画面では、安全な方式にパスキー、セキュリティキー、認証アプリ、GitHub Mobileが挙げられており、SMSはその外に置かれています。SMSだけで運用している人を洗うときに使えます。取得は2fa_disabledと同じ書き方で、filterの値を替えるだけです。
オーナーの棚卸しは、role=adminで別に取ります。オーナーは人数が少なく、1人ずつ人事と確認する価値があります。メンバー全体の突き合わせと混ぜず、別の表にしておくと、削除候補の表にオーナーが紛れ込みません。
見つけたあとに何をするか
候補表を確認なしでDELETEに流すと、名簿の更新漏れで残っていた在籍者まで外れます。変更系のAPIは、影響範囲が広いものばかりです。
| やること | エンドポイント | 注意点 |
|---|---|---|
| メンバーを削除 | エンドポイントDELETE /orgs/{org}/members/{username} | 注意点全チームから外れ、組織のリポジトリへのアクセスをすべて失う。企業チーム経由の間接メンバーシップは残る |
| メンバーを外部コラボレーターに変更 | エンドポイントPUT /orgs/{org}/outside_collaborators/{username} | 注意点組織のメンバーではなくなり、現在のチームで許されるリポジトリにだけアクセスできる |
| 外部コラボレーターを削除 | エンドポイントDELETE /orgs/{org}/outside_collaborators/{username} | 注意点組織の全リポジトリから外れる。対象がメンバーだと422 |
メンバーから外部コラボレーターへの変更は、「縁は残したいが、組織の中にはいてほしくない」という中間の選択肢です。最後のオーナーに対して実行すると403になります。非同期で実行したいときはasyncをtrueにすると、成功時に202が返ります。
削除の前には、次の順で確かめる運用が安全です。
削除前の確認の順序
- 1
候補表を人事・契約窓口に渡す
名簿の更新漏れかどうかを、先に人間が確かめます。
- 2
メンバーシップの状態を個別に確認
GET /orgs/{org}/memberships/{username}で、stateがactiveかpendingか、roleがadminかどうかを見ます。 - 3
オーナーの行は別枠で扱う
role=adminで絞った一覧を別に取り、オーナーの削除は組織の運用ルールに沿って決めます。 - 4
変更を実行し、監査ログで確かめる
実行は人間が、期間を区切って少数ずつ行います。実行後は組織の監査ログで、削除したアカウントのイベントが想定した件数だけ並んでいるかを見ます。
運用の注意点
- メンバーの
nameやemailは空のことがあるため、人の特定にはログイン名と名簿を使う - ボットとサービスアカウントは
typeで見分けられるが、typeがUserのまま運用されている共有アカウントは名簿に載せる - 招待には上限があり、組織の作成から1か月以内の有料でない組織は24時間あたり50件、1か月を超えるか有料プランなら500件。棚卸し後に一括で再招待するときの上限になる
- 取得したJSONLには従業員のログイン名が並ぶ。共有ストレージに置かず、作業が終わったら消す
隣接する棚卸しとの使い分け
ID基盤側の棚卸しは別の記事にあります。Entra IDのゲストはEntraゲストユーザーの棚卸しをClaude Codeで進める、SailPointでアクセスレビューを回している組織はClaudeをSailPoint連携でアクセスレビューに載せる手順が扱っています。GitHub側のメンバー棚卸しは、IdP側の棚卸しとは別の表を見る作業です。ここで扱うのはGitHubの組織が持つメンバー・外部コラボレーター・招待の一覧です。
Claude側のメンバー管理はClaude Admin APIの使い方に手順があります。Claude Code自体をGitHub組織に導入する話はClaude CodeのGitHub Actionsを組織に導入にまとめています。
まとめ
棚卸しの要点は、取得と変更を人間に残し、判定と下書きだけをClaude Codeに任せることです。filter=2fa_disabledはオーナー限定で、外部コラボレーターは組織の名簿ではなく委託先の名簿と突き合わせます。2FA必須化済みの組織では、未設定者の一覧の意味が変わる点にも注意が必要です。