SLOPSHOPPER

security-scan

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

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · security-scan
│ ┃ Security scan ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Security scan · scan failed │ ⏺ Read(src/auth.ts) │ ┃ │ Could not run Semgrep: semgrep exited │ ⎿ Read 6 lines │ ┃ │ with 0 │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /scan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Security scan
╭──────────────────────────────────────────────────────────╮ │ Security scan · scan failed │ │ Could not run Semgrep: semgrep exited with 0 │ ╰──────────────────────────────────────────────────────────╯
README

security-scan

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)

  1. このリポジトリを marketplace(プラグインの配布元)として登録
   /plugin marketplace add shumatsumonobu/claude-mods-bench
  1. security-scan を入れる
   /plugin install security-scan@claude-mods-bench
  1. Claude Code を起動し直す ── 次の起動から有効、初回に作業フォルダの信頼を求められたら承認 開いたままのセッションなら、/reload-plugins でも読み込める(公式の mods の概要のページ)

Semgrep を PATH の通らない場所に入れた場合は、/config で場所を入れる(下の設定の節)

動作

起動すると画面の右が開き、変更中のファイル(git status に出るもの)を Semgrep で調べる 見つかったものを、深刻な順に1件ずつカードで表示 同じ行に当たったルールは、1枚のカードにまとめる

変更中のファイルで見つかった脆弱性のカード

カードのキー

キーボタン動き
fAsk Claude to fixClaude に「本物か確かめて、本物なら最小の変更で直して」と送り、一覧から外す
xNot a problem誤検知として覚え、次に調べたときも出さない
lSkip for now一覧の最後に回す
p / nBack / Next前と次のカードへ
uUndo直前の Not a problem を取り消す(押した直後だけ出る)

下は、SQL の文字列連結のカードで f を押したあと Claude は指摘を本物と判断し、9行目を ? のプレースホルダに直した 同じ返事の中で、Semgrep が拾わなかった5行目のパスワードの直書きにも触れていた(1回)

Claude が SQL インジェクションを直したあとの画面

/scan で、いつでも調べ直せる /scan reset で、Not a problem で覚えたものを全部忘れて調べ直す プロンプトの上の1行には、残っている件数を表示(v で画面の右を開く)

設定

/config の次の行から変更

行既定中身
Semgrep command · security-scansemgrepSemgrep の実行ファイル、PATH に無ければフルパス
Rule sets · security-scanp/nodejsscan,p/owasp-top-tenSemgrep のルールの組、カンマ区切り

ユーザーの ~/.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/nodejsscanSQL の文字列連結、コマンドの組み立て、XSS、eval
p/expressjsSQL の文字列連結、XSS、eval
p/owasp-top-tenXSS、eval
p/security-auditコマンドの組み立て

パスワードの直書きは、どの組でも拾えなかった

設計上の理由

mod は Claude Code の中で動くため、shell hook(settings.json に定義する従来のフック)では書けない次のことができる

  • 人がふるい分けてから Claude に渡せる ── shell hook でもスキャナを回して結果を Claude に返せるが、誤検知も含めて全部渡すことになる mod なら、画面の右にカードを出し、人が選んだものだけを $.prompt.submit で Claude に送れる
  • 一覧を見える所に置いておける ── shell hook は画面に描けない(公式の mods の概要のページ) カードは会話と別の場所に残り、会話が流れても消えない
  • 誤検知を覚えておける ── Not a problem にしたものは $.store(mod 専用の保存領域)に覚え、次のセッションでも出さない
  • Claude に頼まずに調べられる ── 起動時と /scan で mod が自分で Semgrep を動かすので、Claude のトークンを使わない /scan は Claude の作業中でも打てる作り(公式の Use the mods API のページ、未実測)

仕様上の注意

実測(Claude Code 2.1.295、Windows)と、公式ドキュメントを読んだ範囲

  • 調べるのは変更中のファイルだけ ── git status に出るコードのファイルを渡す git の管理下でないフォルダでは、調べるファイルが無い扱いになる(コードの作り、未実測)
  • 変更中のファイルが数百あると、Semgrep が起動できないことがある ── ファイルの名前を並べて渡すため、Windows のコマンドの長さの上限(約3万2千文字)を超えうる(未実測)
  • 調べるのに数秒かかる ── 小さなファイル2つで6〜10秒(ルールの組ごとに各1回)、その間は画面の右に scanning と出る
  • 会社の PC では、Semgrep が止められることがある ── 作者の PC では、uv tool install で入れた Semgrep が「アプリケーション制御ポリシー」で起動できなかった(1回) そのときは、止められない場所に入れて、/config で場所を入れる
  • Claude が直す前に確認を求めることがある ── CLAUDE.md にファイルを変える前の確認を書いていると、Claude は直し方を示して確認を待つ(作者の環境で1回)
  • 画面の右が自動で開くのは、端末の横幅が144文字以上のとき ── mod が自分で開く表示の制限(公式のリファレンス) /scan で開けば、どの幅でも表示
Source 1 files
hooks/register.ts 363 lines
1import 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