変更中のファイルを Semgrep で調べ、見つかった脆弱性を画面の右で1件ずつふるい分けて、本物だけ Claude に直させる

Claude Code の mod(Claude Code の動きや画面を変えられるプラグイン) 変更中のファイルを Semgrep で調べ、見つかった脆弱性を画面の右で1件ずつふるい分けて、本物だけ Claude に直させる
脆弱性のスキャナは、本物と一緒に誤検知も大量に出す 作者の個人リポジトリ32個に Semgrep をかけると合計253件出て、14件を読んで確かめたら本物らしいのは3件、誤検知が9件だった(2026-10-09、1回) 結果を全部 Claude に渡すと、誤検知まで直そうとして要らない書き換えが増える かといって自分で一覧を読んで選ぶのは手間がかかり、結局見なくなる
Claude Code 2.1.287 以降が必要(claude --version で確認) 早期公開のときに CLAUDE_CODE_ENABLE_FUNCTION_HOOKS を設定していたら削除(2.1.287 以降は無視される、公式の mods の概要のページ) Semgrep が必要 ── uv か pipx で、ほかの Python のパッケージと分けて入れる(uv tool install semgrep)
/plugin marketplace add shumatsumonobu/claude-mods-bench
/plugin install security-scan@claude-mods-bench
/reload-plugins でも読み込める(公式の mods の概要のページ)Semgrep を PATH の通らない場所に入れた場合は、/config で場所を入れる(下の設定の節)
起動すると画面の右が開き、変更中のファイル(git status に出るもの)を Semgrep で調べる 見つかったものを、深刻な順に1件ずつカードで表示 同じ行に当たったルールは、1枚のカードにまとめる

カードのキー
| キー | ボタン | 動き |
|---|---|---|
f | Ask Claude to fix | Claude に「本物か確かめて、本物なら最小の変更で直して」と送り、一覧から外す |
x | Not a problem | 誤検知として覚え、次に調べたときも出さない |
l | Skip for now | 一覧の最後に回す |
p / n | Back / Next | 前と次のカードへ |
u | Undo | 直前の Not a problem を取り消す(押した直後だけ出る) |
下は、SQL の文字列連結のカードで f を押したあと Claude は指摘を本物と判断し、9行目を ? のプレースホルダに直した 同じ返事の中で、Semgrep が拾わなかった5行目のパスワードの直書きにも触れていた(1回)

/scan で、いつでも調べ直せる /scan reset で、Not a problem で覚えたものを全部忘れて調べ直す プロンプトの上の1行には、残っている件数を表示(v で画面の右を開く)
/config の次の行から変更
| 行 | 既定 | 中身 |
|---|---|---|
Semgrep command · security-scan | semgrep | Semgrep の実行ファイル、PATH に無ければフルパス |
Rule sets · security-scan | p/nodejsscan,p/owasp-top-ten | Semgrep のルールの組、カンマ区切り |
ユーザーの ~/.claude/settings.json に直接書く場合(/config もここに保存する、2.1.295 で1回確認) プロジェクトの .claude/settings.json に書いても読まれない(公式の settings のページ)
{
"pluginConfigs": {
"security-scan@claude-mods-bench": {
"options": {
"semgrep": "C:/tools/semgrep/Scripts/semgrep.exe",
"rules": "p/nodejsscan,p/owasp-top-ten"
}
}
}
}
ルールの組で、拾えるものが変わる 脆弱性を4つ仕込んだ JavaScript のファイルで試した結果(各1回)
| ルールの組 | 拾えたもの |
|---|---|
p/nodejsscan | SQL の文字列連結、コマンドの組み立て、XSS、eval |
p/expressjs | SQL の文字列連結、XSS、eval |
p/owasp-top-ten | XSS、eval |
p/security-audit | コマンドの組み立て |
パスワードの直書きは、どの組でも拾えなかった
mod は Claude Code の中で動くため、shell hook(settings.json に定義する従来のフック)では書けない次のことができる
$.prompt.submit で Claude に送れるNot a problem にしたものは $.store(mod 専用の保存領域)に覚え、次のセッションでも出さない/scan で mod が自分で Semgrep を動かすので、Claude のトークンを使わない /scan は Claude の作業中でも打てる作り(公式の Use the mods API のページ、未実測)実測(Claude Code 2.1.295、Windows)と、公式ドキュメントを読んだ範囲
git status に出るコードのファイルを渡す git の管理下でないフォルダでは、調べるファイルが無い扱いになる(コードの作り、未実測)scanning と出るuv tool install で入れた Semgrep が「アプリケーション制御ポリシー」で起動できなかった(1回) そのときは、止められない場所に入れて、/config で場所を入れるCLAUDE.md にファイルを変える前の確認を書いていると、Claude は直し方を示して確認を待つ(作者の環境で1回)/scan で開けば、どの幅でも表示hooks/register.ts 363 lines1import type { On, PluginOptions } from 'claude-code'
2
3/** 画面の右に開く表示の ID、`ui.render` で自分の表示かを見分けるのにも使う */
4const PANE_ID = 'security-scan'
5
6/** 調べるファイルの拡張子、ここに無いもの(文書や画像など)は Semgrep に渡さない */
7const CODE_EXT = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|py|php|rb|go|java|kt|cs|rs|swift|scala|vue|svelte|html|yml|yaml|tf|sh)$/i
8
9/** 見つかった問題1件 ── 同じ行に当たったルールは1件にまとめる */
10export type Finding = {
11 /** 誤検知として覚えるときのキー、ルールごとに1つ ── ルール・ファイル・その行の中身(行番号がずれても同じになる) */
12 keys: string[]
13 path: string
14 line: number
15 /** 当たったルールの短い名前(`node_sqli_injection` など) */
16 rules: string[]
17 /** 当たったルールのうち、いちばん高い深刻度 */
18 severity: string
19 /** いちばん深刻なルールの説明 */
20 message: string
21 /** その行のソース */
22 code: string
23}
24
25type Action = 'fix' | 'ignore' | 'later' | 'next' | 'prev' | 'undo'
26
27/** 今ふるい分けている一覧と、表示しているカードの位置 */
28let findings: Finding[] = []
29let cur = 0
30/** 直前に誤検知にしたカードと、その位置 ── Undo で戻す */
31let lastIgnored: { f: Finding; at: number } | null = null
32/** 調べている最中か、調べたファイルの数、最後のエラー */
33let scanning = false
34let scannedFiles = 0
35let lastError = ''
36/** Semgrep が起動できなかったか ── 入れ方を案内する */
37let missing = false
38/** 設定 ── Semgrep の実行ファイルとルールの組 */
39let semgrep = 'semgrep'
40let rules: string[] = []
41/** プロジェクトの場所 */
42let root = ''
43
44/** 深刻度の順位、小さいほど深刻 */
45const rank = (s: string) => (s === 'ERROR' ? 0 : s === 'WARNING' ? 1 : 2)
46
47/** パスの区切りを `/` にそろえ、プロジェクトからの相対にする */
48export function relPath(base: string, path: string): string {
49 const p = path.replace(/\\/g, '/')
50 const b = base.replace(/\\/g, '/').replace(/\/+$/, '')
51 return b !== '' && p.toLowerCase().startsWith(b.toLowerCase() + '/') ? p.slice(b.length + 1) : p
52}
53
54/** `git status --porcelain` の出力から、変更中・未追跡のコードのファイルを取り出す(消したファイルは除く) */
55export function changedFiles(porcelain: string): string[] {
56 const files: string[] = []
57 for (const line of porcelain.split('\n')) {
58 if (line.length < 4) continue
59 const status = line.slice(0, 2)
60 if (status.includes('D')) continue
61 let path = line.slice(3).trim()
62 // 名前の変更は `旧 -> 新` の形で来るので、新しいほうを取る
63 if (path.includes(' -> ')) path = path.split(' -> ')[1]
64 path = path.replace(/^"|"$/g, '')
65 if (CODE_EXT.test(path)) files.push(path)
66 }
67 return files
68}
69
70/**
71 * Semgrep の JSON を、ふるい分けの一覧にする
72 * 同じ行に当たったルールは1件にまとめ、誤検知として覚えたルールは除く
73 */
74export function parseResults(json: string, base: string, readLine: (path: string, line: number) => string, ignored: Set<string> = new Set()): Finding[] {
75 const out = JSON.parse(json)
76 const byLine = new Map<string, Finding>()
77 for (const r of out.results ?? []) {
78 const path = relPath(base, String(r.path ?? ''))
79 const line = Number(r.start?.line ?? 0)
80 const rule = String(r.check_id ?? '').split('.').pop() ?? ''
81 const code = readLine(path, line)
82 const key = `${rule}|${path}|${code.trim()}`
83 if (ignored.has(key)) continue
84 const severity = String(r.extra?.severity ?? 'INFO')
85 const message = firstSentence(String(r.extra?.message ?? ''))
86 const f = byLine.get(`${path}:${line}`)
87 if (!f) {
88 byLine.set(`${path}:${line}`, { keys: [key], path, line, rules: [rule], severity, message, code })
89 continue
90 }
91 if (f.keys.includes(key)) continue
92 f.keys.push(key)
93 f.rules.push(rule)
94 // いちばん深刻なルールの深刻度と説明を出す
95 if (rank(severity) < rank(f.severity)) {
96 f.severity = severity
97 f.message = message
98 }
99 }
100 // 深刻度の高い順、同じなら場所の順
101 return [...byLine.values()].sort((a, b) => rank(a.severity) - rank(b.severity) || a.path.localeCompare(b.path) || a.line - b.line)
102}
103
104/** 説明の最初の1文、長ければ切る */
105export function firstSentence(text: string): string {
106 const s = text.replace(/\s+/g, ' ').trim()
107 const end = s.search(/[.。](\s|$)/)
108 const one = end > 0 ? s.slice(0, end + 1) : s
109 return one.length > 160 ? one.slice(0, 157) + '...' : one
110}
111
112/** Claude に渡す文 ── 誤検知もありうることを添えて、本物なら直してもらう */
113export function fixPrompt(f: Finding): string {
114 return [
115 'Semgrep reported this possible security issue. Check whether it is real, and if it is, fix it with the smallest change.',
116 `${f.path}:${f.line} (${f.rules.join(', ')}, ${f.severity})`,
117 f.message,
118 `Code: ${f.code.trim()}`,
119 ].join('\n')
120}
121
122/** 深刻度を、使う人に伝わる言葉にする */
123export function riskLabel(severity: string): string {
124 return severity === 'ERROR' ? 'High risk' : severity === 'WARNING' ? 'Medium risk' : 'Low risk'
125}
126
127/** 件数の言い方 */
128const issues = (n: number) => `${n} possible issue${n === 1 ? '' : 's'}`
129
130/** 画面の右に出すカード */
131export function drawPane(t: any, onAction: (a: Action) => void) {
132 const { Box, Text, Button } = t
133 const head = (text: string) => Text({ key: 'title', bold: true, color: 'red', children: text })
134 const frame = (children: any[]) => Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'red', paddingX: 1, children })
135 const undo = lastIgnored ? [Button({ key: 'undo', label: 'Undo', hotkey: 'u', plain: true, onPress: () => onAction('undo') })] : []
136
137 if (scanning) return frame([head('Security scan · scanning'), Text({ key: 'msg', dimColor: true, children: 'Running Semgrep on the changed files...' })])
138 if (missing)
139 return frame([
140 head('Security scan · Semgrep not found'),
141 Text({ key: 'install', children: 'Install it once: uv tool install semgrep (or: pipx install semgrep)' }),
142 Text({ key: 'path', children: 'Installed somewhere else? Set the path in /config:' }),
143 Text({ key: 'row', dimColor: true, children: ` Semgrep command · security-scan (now: ${semgrep})` }),
144 Text({ key: 'then', children: 'Then run /scan.' }),
145 ])
146 if (lastError !== '') return frame([head('Security scan · scan failed'), Text({ key: 'msg', children: lastError })])
147 if (findings.length === 0) {
148 const msg = scannedFiles === 0 ? 'No changed code files. Run /scan after editing code.' : `Nothing found in ${scannedFiles} changed file${scannedFiles === 1 ? '' : 's'}.`
149 return frame([head(`Security scan · ${issues(0)}`), Text({ key: 'msg', dimColor: true, children: msg }), ...(undo.length ? [Box({ key: 'buttons', marginTop: 1, children: undo })] : [])])
150 }
151
152 const f = findings[cur]
153 const sevColor = f.severity === 'ERROR' ? { color: 'red' } : f.severity === 'WARNING' ? { color: 'yellow' } : {}
154 return frame([
155 head(`Security scan · ${issues(findings.length)}`),
156 Box({
157 key: 'card',
158 flexDirection: 'column',
159 marginTop: 1,
160 children: [
161 Text({ key: 'where', bold: true, children: `${cur + 1} of ${findings.length} · ${f.path} line ${f.line}` }),
162 Text({ key: 'risk', bold: true, ...sevColor, children: riskLabel(f.severity) }),
163 Text({ key: 'code', dimColor: true, wrap: 'truncate-end', children: ` ${f.code.trim()}` }),
164 Text({ key: 'msg', children: f.message }),
165 Text({ key: 'rule', dimColor: true, children: `Semgrep rule: ${f.rules.join(', ')}` }),
166 ],
167 }),
168 // ボタンは、このカードへの操作と、カードの移動の2行に分ける(折り返すと行の間に空行が入るため)
169 Box({
170 key: 'actions',
171 marginTop: 1,
172 gap: 2,
173 children: [
174 Button({ key: 'fix', label: 'Ask Claude to fix', hotkey: 'f', plain: true, onPress: () => onAction('fix') }),
175 Button({ key: 'ignore', label: 'Not a problem', hotkey: 'x', plain: true, onPress: () => onAction('ignore') }),
176 Button({ key: 'later', label: 'Skip for now', hotkey: 'l', plain: true, onPress: () => onAction('later') }),
177 ],
178 }),
179 Box({
180 key: 'moves',
181 gap: 2,
182 children: [
183 Button({ key: 'prev', label: 'Back', hotkey: 'p', plain: true, onPress: () => onAction('prev') }),
184 Button({ key: 'next', label: 'Next', hotkey: 'n', plain: true, onPress: () => onAction('next') }),
185 ...undo,
186 ],
187 }),
188 Text({ key: 'hint', dimColor: true, children: '"Not a problem" hides this in later scans. /scan reset brings them back.' }),
189 ])
190}
191
192/** プロンプトの上の1行 ── ふるい分けが残っているときだけ */
193export function drawBand(t: any, onOpen: () => void) {
194 if (findings.length === 0) return null
195 const { Box, Text, Button } = t
196 return Box({
197 gap: 2,
198 paddingX: 1,
199 children: [
200 Text({ key: 'label', bold: true, color: 'red', children: `Security scan · ${issues(findings.length)}` }),
201 Button({ key: 'open', label: 'Open', hotkey: 'v', plain: true, onPress: onOpen }),
202 ],
203 })
204}
205
206/** 誤検知として覚えたキーの一覧 */
207async function ignoredKeys($: any): Promise<string[]> {
208 const v = await $.store.get('ignored')
209 return Array.isArray(v) ? v : []
210}
211
212/** カードのボタンの処理 */
213export async function act($: any, a: Action) {
214 if (a === 'undo') {
215 if (!lastIgnored) return
216 const { f, at } = lastIgnored
217 const keys = await ignoredKeys($)
218 await $.store.set('ignored', keys.filter((k) => !f.keys.includes(k)))
219 findings.splice(Math.min(at, findings.length), 0, f)
220 cur = Math.min(at, findings.length - 1)
221 lastIgnored = null
222 $.ui.invalidate('ui.render')
223 return
224 }
225 if (findings.length === 0) return
226 const f = findings[cur]
227 if (a === 'next') cur = (cur + 1) % findings.length
228 else if (a === 'prev') cur = (cur - 1 + findings.length) % findings.length
229 else if (a === 'later') {
230 findings.splice(cur, 1)
231 findings.push(f)
232 if (cur >= findings.length) cur = 0
233 } else {
234 const at = cur
235 findings.splice(cur, 1)
236 if (cur >= findings.length) cur = 0
237 if (a === 'ignore') {
238 const keys = await ignoredKeys($)
239 await $.store.set('ignored', [...keys, ...f.keys.filter((k) => !keys.includes(k))])
240 lastIgnored = { f, at }
241 } else {
242 // 返事を待つと Claude の作業が終わるまで止まるので、待たずに送る
243 void $.prompt.submit({ text: fixPrompt(f) })
244 $.ui.toast(`Sent to Claude: ${f.path}:${f.line}`)
245 }
246 }
247 $.ui.invalidate('ui.render')
248}
249
250/** 誤検知として覚えたものを全部忘れる */
251export async function resetIgnored($: any) {
252 await $.store.set('ignored', [])
253 lastIgnored = null
254}
255
256/** 変更中のファイルを Semgrep で調べ、誤検知として覚えたものを除いて一覧にする */
257export async function scan($: any) {
258 scanning = true
259 lastError = ''
260 missing = false
261 lastIgnored = null
262 $.ui.invalidate('ui.render')
263 try {
264 // git status のパスはリポジトリの一番上からの相対なので、Semgrep もそこを起点に動かす(サブフォルダで起動しても取り違えない)
265 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
266 if (top.exitCode !== 0) {
267 scannedFiles = 0
268 findings = []
269 cur = 0
270 return
271 }
272 const base = String(top.stdout ?? '').trim()
273 const st = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=all'], { cwd: base })
274 const files = changedFiles(String(st.stdout ?? ''))
275 scannedFiles = files.length
276 if (files.length === 0) {
277 findings = []
278 cur = 0
279 return
280 }
281 const argv = [semgrep, 'scan', '--json', '--quiet', '--metrics=off', ...rules.flatMap((r) => ['--config', r]), ...files]
282 const r = await $.process.run(argv, { cwd: base, timeoutMs: 5 * 60_000 })
283 if (String(r.stdout ?? '').trim() === '') throw new Error(String(r.stderr ?? '').trim().split('\n').pop() || `semgrep exited with ${r.exitCode}`)
284 // その行の中身を読むため、見つかったファイルだけ先に読んでおく
285 const sources = new Map<string, string[]>()
286 const raw = JSON.parse(String(r.stdout))
287 for (const p of new Set<string>((raw.results ?? []).map((x: any) => relPath(base, String(x.path ?? ''))))) {
288 try {
289 sources.set(p, String(await $.fs.read(`${base}/${p}`)).split(/\r?\n/))
290 } catch {
291 sources.set(p, [])
292 }
293 }
294 const readLine = (path: string, line: number) => sources.get(path)?.[line - 1] ?? ''
295 findings = parseResults(String(r.stdout), base, readLine, new Set(await ignoredKeys($)))
296 cur = 0
297 } catch (err: any) {
298 const text = String(err?.message ?? err)
299 // 起動できなかったときは入れ方を案内し、それ以外の失敗はエラーの文を出す
300 if (/ENOENT|not found/i.test(text)) missing = true
301 else lastError = `Could not run Semgrep: ${text.slice(0, 200)}`
302 } finally {
303 scanning = false
304 $.ui.invalidate('ui.render')
305 }
306}
307
308/**
309 * 画面の右を開く
310 * コマンドやボタンで開くときだけキーボードを渡し、自動で開くときはプロンプトへの入力を奪わない
311 */
312async function openPane($: any, asked: boolean) {
313 const pane = { id: PANE_ID, title: 'Security scan', closeOnEscape: true, columns: 60 }
314 await $.ui.open(asked ? { ...pane, focus: true } : pane)
315}
316
317/** mod の入口 ── 変更中のファイルの脆弱性を Semgrep で調べ、画面の右でふるい分けて、本物だけ Claude に直させる */
318export function register(on: On, options?: PluginOptions) {
319 semgrep = String(options?.semgrep ?? 'semgrep') || 'semgrep'
320 rules = String(options?.rules ?? 'p/nodejsscan,p/owasp-top-ten')
321 .split(',')
322 .map((s) => s.trim())
323 .filter((s) => s !== '')
324
325 on('session.start', async ($: any, e: any, next: any) => {
326 root = String(e.cwd ?? '')
327 const r = await next(e)
328 await $.command.register({ name: 'scan', description: 'Security scan: scan changed files with Semgrep and review the issues', argumentHint: '[reset]', immediate: true })
329 // 画面の無いセッション(claude -p)では結果を見る人がいないので、自動では調べない
330 if (e.isInteractive === false) return r
331 // 起動を待たせないよう、開いて調べるのはタイマーで後から
332 $.clock.after(500, async () => {
333 await openPane($, false)
334 await scan($)
335 })
336 return r
337 })
338
339 on('command.run', { command: 'scan' }, async ($: any, e: any) => {
340 if (root === '') root = String(await $.session.cwd())
341 if (String(e.args ?? '').trim() === 'reset') await resetIgnored($)
342 await openPane($, true)
343 await scan($)
344 // 文字を返すと会話に入り Claude が読むので、何も返さず画面に描くだけにする
345 return {}
346 })
347
348 on('ui.render', { component: 'Pane' }, ($: any, e: any, next: any) => {
349 if (e.requestId !== PANE_ID) return next(e)
350 return drawPane($.ui.resolve(e), (a) => void act($, a))
351 })
352
353 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
354 const t = $.ui.resolve(e)
355 const ours = drawBand(t, () => void openPane($, true))
356 const theirs = await next(e)
357 if (ours === null) return theirs
358 if (!theirs) return ours
359 // ほかの mod の表示を消さないよう、自分の1行の下に並べる
360 return t.Box({ flexDirection: 'column', children: [ours, theirs] })
361 })
362}
363