Claude CodeでCodeQLの誤検知アラートを仕分けて理由つきで閉じる
CodeQLのアラートをgh apiで取り出し、claude -pに該当コードを読ませて真陽性と誤検知を分け、dismissed_reasonに合わせた280字以内のコメントを下書きさせる手順です。
CodeQLのアラートが溜まったとき、1件ずつ画面を開いてコードを読み、閉じる理由を書く作業は地味に重くなります。この作業のうち「コードを読んで判定し、理由の文面を下書きする」部分だけをclaude -pに任せ、閉じる操作は人が確認してからgh apiで行う、という分担の手順をまとめます。
流れは3段です。gh apiでアラートを取り出し、claude -pが該当コードを読んで判定をJSONで返し、人が目を通してからPATCHで閉じます。
アラートを閉じるまでの3段
- 1
取り出す
gh apiでopenなアラートを取得し、jqで番号・ルール・場所・メッセージだけに絞ります。 - 2
判定を下書きさせる
claude -pにJSONを渡し、該当コードを読ませます。判定・根拠・dismissed_reason・コメント案をスキーマ付きで返させます。 - 3
人が確認して閉じる
下書きを読み、納得した分だけ
gh apiのPATCHで閉じます。Claude側には書き込みの権限を渡しません。
セキュリティレビューのコマンドとCodeQLの役割の違いはsecurity-reviewとCodeQLの違いにあります。ここでは、CodeQLが出したアラートの後始末に絞ります。
openなアラートをgh apiで取り出して絞る
リポジトリのアラート一覧はGET /repos/{owner}/{repo}/code-scanning/alertsで取れます。stateにはopen・closed・dismissed・fixedを指定でき、per_pageの上限は100です。severityやtool_name、ref、prでも絞り込めます。
gh apiはエンドポイント中の{owner}と{repo}を、カレントディレクトリのリポジトリの値に置き換えます。--paginateを付けると、次のページがなくなるまで順に取得します。
gh api --paginate \
"repos/{owner}/{repo}/code-scanning/alerts?state=open&per_page=100" \
--jq '.[] | {number, rule: .rule.id,
severity: .rule.security_severity_level,
path: .most_recent_instance.location.path,
line: .most_recent_instance.location.start_line,
message: .most_recent_instance.message.text}' \
> alerts.jsonlレスポンスの各アラートには、number、rule(id・name・description・security_severity_levelなど)、tool、most_recent_instanceが入っています。場所はmost_recent_instance.locationのpathとstart_line、end_lineで、メッセージはmost_recent_instance.message.textです。上のjqはこの5項目だけを1行1件で書き出します。
most_recent_instanceは、既定ブランチ(またはrefで指定した参照)での直近の検出を指します。PRブランチの検出を仕分けたいときは、refかprを付けて取り直します。
仕分けの対象を絞る
最初から全件を渡す必要はありません。同じルールが大量に出ている場合は、ルール単位でjqのselectをかけて少数ずつ流すと、判定のぶれを確認しやすくなります。
# ルール別の件数を数える
jq -s 'group_by(.rule) | map({rule: .[0].rule, n: length})' alerts.jsonl
# 1ルールだけ取り出す
jq -c 'select(.rule == "js/path-injection")' alerts.jsonl > batch.jsonljs/path-injectionは例として置いたルールIDです。手元のalerts.jsonlに出てくるIDに読み替えてください。
claude -pに該当コードを読ませて判定をJSONで返させる
claude -pは標準入力を読めるので、アラートのJSONをパイプで渡せます。入力は10MBが上限です。1ルールずつ流す運用なら、まず超えません。
判定の基準は、プロンプトをファイルに分けておくと使い回せます。CLAUDE.mdに書いてもよいのですが、仕分けにだけ使う規則なので別ファイルにしておくほうが、普段の作業に混ざりません。
あなたはCodeQLアラートの仕分け担当です。標準入力のJSON Linesを1件ずつ処理します。
各件について path と line のコードを実際に読み、次を判断してください。
- 入力が外部から来るか。来ないなら、その根拠となる呼び出し元を示す
- アラートが指す危険な処理の手前に、検証・エスケープ・許可リスト照合があるか
- 対象がテスト・フィクスチャ・サンプルのコードか
判断に迷う件は verdict を needs_human にしてください。
推測で false_positive にしないこと。根拠には読んだファイルと行番号を書くこと。
dismissed_comment は280字以内の日本語で、第三者が読んで理由が分かる文にすること。スキーマで形を固定する
--output-format jsonと--json-schemaを組み合わせると、スキーマに沿った出力がstructured_outputフィールドに入ります。dismissed_reasonはGitHubが受け付ける4つの値だけにenumで絞っておきます。
cat batch.jsonl | claude -p "$(cat triage-prompt.txt)" \
--permission-mode dontAsk \
--output-format json \
--json-schema '{
"type":"object",
"properties":{"alerts":{"type":"array","items":{
"type":"object",
"properties":{
"number":{"type":"integer"},
"verdict":{"enum":["true_positive","false_positive","needs_human"]},
"evidence":{"type":"string"},
"dismissed_reason":{"enum":["false positive","won'"'"'t fix",
"used in tests","mitigated",null]},
"dismissed_comment":{"type":["string","null"],"maxLength":280}
},
"required":["number","verdict","evidence"]}}},
"required":["alerts"]}' \
| jq '.structured_output' > triage.json--permission-mode dontAskは、承認が必要になる呼び出しをすべて拒否するモードです。作業ディレクトリ内のファイル読み取りは承認なしで通るため、コードを読ませる用途には足ります。書き込みやネットワークを伴うコマンドは、許可ルールを渡さない限り実行されません。仕分けの段階でClaudeにPATCHを打たせないという分担を、権限の側でも担保できます。ツールの許可と拒否を起動時に絞る方法はallowedTools/disallowedToolsの記事にまとめています。
出力の形を確かめる
返ってくるJSONは、たとえば次のような形になります(スキーマに沿った例示で、実際の判定内容は対象のコード次第です)。
{
"alerts": [
{
"number": 142,
"verdict": "false_positive",
"evidence": "src/files.ts:38 のpathは直前の resolveUnderRoot() で基準ディレクトリ配下に正規化済み。呼び出し元は src/api/download.ts:21 のみ。",
"dismissed_reason": "false positive",
"dismissed_comment": "resolveUnderRoot() が基準ディレクトリ外のパスを拒否するため、ユーザー入力が任意パスに到達しない。検証はtests/files.test.tsで担保。"
},
{
"number": 187,
"verdict": "needs_human",
"evidence": "外部入力かどうかが呼び出し元の設定に依存し、コードだけでは断定できない。",
"dismissed_reason": null,
"dismissed_comment": null
}
]
}--json-schemaで返らなかった場合、レスポンスにstructured_outputフィールドは付きません。後続のjqが空を書き出すだけなので、スクリプトに組み込むときはtriage.jsonが空でないかを先に確かめます。
dismissed_reasonの4択とコメントの書き方
アラートを閉じるPATCHでstateをdismissedにするとき、dismissed_reasonは必須です。選べる値はfalse positive、won't fix、used in tests、mitigatedの4つ。コメントのdismissed_commentは任意で、280字までです。
| dismissed_reason | 使う場面の目安 | コメントに書くこと |
|---|---|---|
false positive | 使う場面の目安検出が前提を誤っている。危険な値が実際には到達しない | コメントに書くこと到達しない根拠(検証関数・型・呼び出し元)と該当行 |
used in tests | 使う場面の目安テスト・フィクスチャ・サンプルのコード | コメントに書くこと対象がテスト専用で本番に含まれない根拠 |
mitigated | 使う場面の目安問題は成立するが、別の層の対策で影響が抑えられている | コメントに書くこと対策の場所(WAF・権限・サンドボックスなど)と効く範囲 |
won't fix | 使う場面の目安成立するが直さない判断をした | コメントに書くこと直さない理由と、見直す条件や期限 |
使い分けの目安は私見を含みます。重要なのは、閉じた後に読む人が「なぜ閉じたか」を再現できることです。false positiveとmitigatedは混ざりやすく、前者は「そもそも成立していない」、後者は「成立するが別の手段で抑えている」と分けると、コメントの書き方が決まります。
コメントは根拠が辿れる形にする
読んでも再現できない
問題なし。誤検知。
根拠が辿れる
resolveUnderRoot()が基準ディレクトリ外のパスを拒否するため、入力が任意パスに到達しない。src/files.ts:38、検証はtests/files.test.ts。
コメントの上限はmaxLength: 280です。下書きがこの長さを超えていないかは、PATCHの前にjqで数えて確かめます。超えた件は、コメントを短く書き直させてから流します。
jq -r '.alerts[] | select((.dismissed_comment // "") | length > 280)
| .number' triage.json人が確認してからPATCHで閉じる
下書きができたら、verdictがfalse_positiveの件だけを人が読みます。確認するのはevidenceに挙がったファイルと行番号で、これを開いて検証が本当に存在するかを見ます。Claudeの言う根拠が実在しない行を指していることもあり得るので、行番号を辿る手間を省くと、この手順の安全性は崩れます。
納得した件を1つずつ閉じるコマンドは次のとおりです。gh apiは-fでパラメータを渡すと、メソッドが自動でPOSTに切り替わるため、--method PATCHを明示します。
gh api --method PATCH \
"repos/{owner}/{repo}/code-scanning/alerts/142" \
-f state=dismissed \
-f dismissed_reason="false positive" \
-f dismissed_comment="resolveUnderRoot()が基準ディレクトリ外のパスを拒否するため、入力が任意パスに到達しない。src/files.ts:38"多数の件を流すときは、triage.jsonから確認済みの番号だけを選び、ループで回します。確認済みの番号を人が書いたファイルから読む形にしておけば、未確認の件を誤って閉じることがありません。
while read -r n; do
jq -c --argjson n "$n" '.alerts[] | select(.number == $n)' triage.json |
while read -r a; do
gh api --method PATCH "repos/{owner}/{repo}/code-scanning/alerts/$n" \
-f state=dismissed \
-f dismissed_reason="$(jq -r '.dismissed_reason' <<<"$a")" \
-f dismissed_comment="$(jq -r '.dismissed_comment' <<<"$a")"
done
done < approved.txtapproved.txtには、確認を終えたアラート番号を1行に1つ書きます。
閉じたあとに取り消すとき
stateにはopenも指定できるので、誤って閉じたアラートは-f state=openで再び開けます。dismissed_reasonが必要なのはdismissedにするときだけです。
PATCHのボディにはcreate_requestもあります。trueにすると、アラートを直接閉じる代わりに、却下リクエストを作ろうとします。閉じる権限を持つ人が別にいるチームでは、仕分けた結果をリクエストとして上げる運用に向きます。なお、このリクエストの扱いはリポジトリや組織の設定に依存するため、使う前に自分の環境での挙動を確かめてください。
途中で止まるときの切り分け
APIが返すステータスと、考えられる原因を並べます。
| ステータス | 意味 | 見る場所 |
|---|---|---|
| 403 | 意味リポジトリがアーカイブされている、またはGitHub Advanced Securityが有効でない | 見る場所リポジトリの設定でコードスキャンが有効か |
| 404 | 意味リソースが見つからない | 見る場所alert_numberとowner/repoの綴り、トークンの権限 |
| 400 | 意味リクエスト不正 | 見る場所dismissed_reasonの綴りと、dismissedのときに指定しているか |
| 503 | 意味サービス利用不可 | 見る場所時間を置いて再実行 |
クラシックのパーソナルアクセストークンやOAuthアプリのトークンでは、プライベートリポジトリにも公開リポジトリにも触れるにはsecurity_eventsスコープが要ります。公開リポジトリだけならpublic_repoスコープで足ります。
claude -pの側が止まるときは、承認待ちの呼び出しがdontAskで拒否されている可能性があります。--output-format stream-jsonで実行すると、拒否はpermission_deniedのシステムメッセージとして出て、最終結果のpermission_denialsにも一覧されます。意図しない拒否があれば、読ませたいディレクトリが作業ディレクトリの外にないかを疑います。
任せてはいけないアラートの見分け方
この手順で判定をClaudeに寄せてよいのは、コードを読めば根拠が出せる種類のアラートです。次の場合はneeds_humanに倒し、最終判断は人が持ちます。
- 外部入力かどうかが、デプロイ設定や別サービスの挙動に依存する
- 検証関数が別のリポジトリや外部パッケージにあり、手元のコードから中身を読めない
security_severity_levelがhighまたはcriticalのアラート
3つ目は私の運用上の線引きで、GitHubの規則ではありません。重大度の高いアラートは、判定が正しくても閉じた理由が後から監査される可能性が高く、人が一度は自分の目で読む価値があります。プロンプトにこの条件を書き、jqでも重大度が高い件は最初から別ファイルに分けておくと、取り違えが起きにくくなります。
もう1つの注意点は、仕分けが「消す方向」に偏りやすいことです。誤検知と判定した根拠の確認が甘いまま閉じると、本物の脆弱性がアラートから消えます。閉じる判断を下書きする側と、実際に閉じる側を分けているのはそのためで、approved.txtを人が書く段を飛ばさないことが、この手順の核になります。
セキュリティレビュー側で誤検知を減らす設定については、security-reviewのHaiku廃止で誤検知フィルタが無効化する理由に、フィルタが働かなくなる条件の整理があります。
まとめ
CodeQLのアラートを、取り出す、読んで判定する、確認して閉じるの3段に分けると、Claudeに任せる範囲と人が持つ範囲がはっきりします。dismissed_reasonは4択、コメントは280字までという制約を、スキーマとプロンプトに先に入れておくのが肝です。
最初は件数の少ないルール1つで試し、evidenceの行番号を人が辿って、判定が実際のコードと合っているかを見てください。合っている割合が見えてから、対象のルールを広げるほうが安全です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。