はじめに
前回の記事(え!?一週間でWebサービスを!?出来らあっ!!全自動開発のススめ)は3,300viewを超えました。ありがとうございます。
……が、実際にアカウント登録してくれたのは友達だけでした。
原因を考えてみると、
- Qiitaという場所柄「作り方」には興味があっても、サービスそのものには興味がなかった
- 見知らぬ個人が(しかもAIに全自動で)作ったサービスに、メールアドレスを登録するのはリスクだと感じた
の2つがありそうです。特に2つ目は、自分が読者でも同じことを思うはずです。「AIが全部書いたコード」と聞いた瞬間、「え、セキュリティは大丈夫なの?」となるのは自然な反応です。
そこでこの記事では、その不安に正面から答えるために導入した、Claude Codeでサービス開発するときのセキュリティ多層防御スタックを紹介します。「AIが書くから危ない」ではなく「AIが書くからこそ、機械的なチェックを何層も挟める」というのが結論です。
全体像: 4層の多層防御
単一の対策に頼らず、性質の異なるチェックを重ねる「多層防御(Defense in Depth)」で構成しました。
| 層 | 対策 | 種類 | 動くタイミング |
|---|---|---|---|
| 1 | security-guidance プラグイン | パターン検知+LLMレビュー | コード編集時・ターン終了時・コミット時 |
| 2 | Semgrep(無料CLI+自作フック) | 静的解析(SAST・決定論的) | コード書き込み直後 |
| 3 | permissions.deny | シークレットへのアクセス遮断 | 常時(設定で強制) |
| 4 | /security-review | ブランチ全体のセキュリティレビュー | マージ前 |
| 5 | Dependabot+監査CI(bundler-audit/Brakeman/yarn audit) | 依存パッケージの脆弱性検知 | PR時・週次(後述の経緯で追加) |
ポイントは、LLMによるレビュー(1, 4)と、LLMではない機械的チェック(2, 3, 5)を混ぜていることです。LLMは文脈をまたぐ脆弱性(認可漏れなど)に強く、静的解析は既知パターンの取りこぼしゼロに強い。相互に補完させます。5層目は当初なく、この記事を書いている途中で穴に気づいて追加したものです(正直な経緯は後述)。
Layer 1: security-guidance プラグイン(Anthropic製)
/plugin install security-guidance@claude-plugins-official
Claude Code公式マーケットプレイスにある、Anthropic製のセキュリティレビュープラグインです。Claudeが生成したコードを、Claude自身とは別のレイヤーで検査するのがミソで、内部は3段構えになっています。
1-1. パターン警告(即時・正規表現ベース)
Edit/Writeのたびにフックが走り、既知の危険パターン約25種を正規表現で即時検知して、Claudeに警告を注入します。検知例:
-
yaml.load()(任意コード実行につながる)→yaml.safe_load()を促す -
pickle.load/torch.load(weights_only=False)などの安全でないデシリアライズ - 生の
innerHTML代入(XSS) - ハードコードされたシークレット
- GitHub Actionsワークフローへの入力インジェクション(issueタイトル等を
run:に直接埋め込むやつ)
面白いのは、これが「ブロック」ではなく「リマインド」であること。正当な用途なら、Claudeはコメントで理由を明記した上で続行します。誤検知で開発が止まらない設計です。
1-2. LLM差分レビュー(ターン終了時)
Claudeが作業を終える(Stopフックが発火する)たびに、そのターンの差分を別のLLM呼び出し(既定はOpus 4.7)に送ってセキュリティレビューし、重大度の高い指摘があればClaudeにフィードバックして、ユーザーが結果を見る前に修正させます。
「書いた本人が自己申告でレビューする」のではなく、差分だけを見る別視点のレビュアーを挟むイメージです。
1-3. エージェント型コミットレビュー(git commit時)
git commit すると、SDK駆動のレビューエージェントが起動し、差分だけでなく関連ファイルをRead/Grepで読みに行ってデータフローを追跡します。これにより、パターンマッチでは絶対に見つからない複数ファイルにまたがる脆弱性——IDOR(他人のリソースにIDを差し替えてアクセスできる問題)、認可バイパス、ファイル横断のSSRFなど——を検出します。
検知対象のクラスは、インジェクション、XSS、SSRF、ハードコードシークレット、IDOR、認可バイパス、安全でないデシリアライズ、パストラバーサルなど。
補足
- フックはハーネス側で動くため、常時のコンテキスト(トークン)コストはほぼゼロ
-
~/.claude/claude-security-guidance.mdや<project>/.claude/claude-security-guidance.mdにプロジェクト固有のセキュリティポリシーを書くと、LLMレビューのプロンプトに合成されます(例:「ユーザー固有データを共有キャッシュに乗せるな」のような自プロジェクトのルールを機械に監視させられる) - 前提: Python 3.8+ が必要
Layer 2: Semgrep(自作フックでリアルタイムSAST)
Semgrepは、業界標準のOSS静的解析(SAST)ツールです。言語ごとの豊富なセキュリティルールを持ち、LLMと違って決定論的——同じコードには必ず同じ指摘を返します。SaaS版(有料のAppSec Platform)もありますが、CLI本体とコミュニティルールはオープンソースで無料・アカウント不要。スキャンはローカル完結で、コードが外部に送信されることもありません(--metrics=offで利用統計の送信もオフにできます)。
Layer 1のLLMレビューが「文脈を理解して怪しさを指摘する」のに対し、こちらは「既知の脆弱パターンを網羅的に、確実に拾う」役割。この2つの性質の違いが多層防御の核です。
公式プラグインをやめて自作フックにした話
実は最初、公式マーケットプレイスの semgrep(Semgrep Guardian)プラグインを入れました。が、これはSemgrepアカウントへの OAuth ログインが前提で、セッション開始時に突然ブラウザでログインページが開き、しかも未ログインのままだとコード編集そのものをブロックする挙動でした({"decision":"block","reason":"Not logged into Semgrep Guardian..."})。
「無料・ローカル完結・アカウント不要」で運用したかったので、プラグインはアンインストールし、無料CLIを呼ぶ30行のフックを自作しました。~/.claude/hooks/semgrep-scan.sh:
#!/bin/bash
# PostToolUse (Write|Edit): 編集ファイルをsemgrepでスキャンし、検出をClaudeへフィードバック
set -u
SEMGREP="$(command -v semgrep || true)"
[ -z "$SEMGREP" ] && exit 0
f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty' 2>/dev/null)"
[ -z "$f" ] || [ ! -f "$f" ] && exit 0
case "$f" in
*.rb) cfg="p/ruby" ;;
*.ts|*.tsx) cfg="p/typescript" ;;
*.js|*.jsx|*.mjs|*.cjs) cfg="p/javascript" ;;
*.py) cfg="p/python" ;;
*) exit 0 ;;
esac
out="$("$SEMGREP" scan --config "$cfg" --metrics=off --quiet --json --timeout 20 "$f" 2>/dev/null \
| jq -c '[.results[] | {rule: .check_id, line: .start.line, msg: (.extra.message | .[0:300])}]')"
if [ -n "$out" ] && [ "$out" != "[]" ]; then
jq -n --arg r "Semgrepが以下を検出。脆弱性なら修正、誤検知なら理由を明示して: $out" \
'{"decision":"block","reason":$r}'
fi
exit 0
~/.claude/settings.json に登録:
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/semgrep-scan.sh", "timeout": 60 }]
}]
}
}
これで、Claudeがファイルを書いた直後に毎回Semgrepが走り、検出があればその場でClaudeに差し戻されるようになりました(1ファイルあたり約2秒)。動作確認として eval(params[:code]) を含むRubyファイルを書かせたところ、書き込みと同時に ruby.lang.security.no-eval の指摘がClaudeへ返り、修正を促されることを確認しています。ハードブロックではなく「修正するか、誤検知なら理由を明示せよ」という設計にしたのは、Layer 1のパターン警告と同じく誤検知で開発を止めないためです。
Layer 3: permissions.deny — AIからシークレットを物理的に隔離する
コードレビュー系の対策と別軸で重要なのが、そもそもAIにシークレットを読ませないことです。~/.claude/settings.json に deny ルールを追加しました。
{
"permissions": {
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/*.pem)",
"Read(**/id_rsa*)",
"Read(**/id_ed25519*)",
"Read(**/master.key)",
"Read(**/credentials.yml.enc)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)",
"Read(~/.config/gcloud/**)"
]
}
}
これで .env、SSH秘密鍵、Railsの master.key、クラウド認証情報などをClaudeは読めなくなります。deny ルールは許可モードに関係なく常に強制されるので、自動許可モードで走らせていても効きます。
なぜ必要か。LLMエージェントには「プロンプトインジェクション」(読み込んだWebページやファイルに仕込まれた指示に従ってしまう)というリスククラスがあります。万一AIが騙されても、シークレットに手が届かなければ漏れようがない。これも「検知」ではなく「遮断」なので決定論的です。
シークレットは「AIの作業場」に置かない — デプロイサービス側にENVを置く
denyルールと対になる、もっと根本的な対策がこれです。本番のシークレットは、そもそもローカルにもリポジトリにも置かない。 置き場所はVercel・Render・AWSなどデプロイサービス側の環境変数設定(ダッシュボード)です。
- 本番のAPIキー・DBパスワード・Stripeキーは、私が自分でダッシュボードに直接入力します。AIには値を渡さない(チャットに貼らない)。AIには「このENV名で参照するコードを書いて」とだけ伝えます
- こうすると、AIの作業環境(=ローカルマシン)には本番シークレットが最初から存在しない。denyルールが万一すり抜けられても、そこにあるのは開発用のダミー値だけです
- 副次効果として、リポジトリにも残らないのでgit履歴からの漏えいも構造的に起きない。実際、今回フロントの
.envが初回コミットからgit追跡されているのを見つけましたが(前述)、この運用のおかげで中身は開発用のポート設定だけで無害でした - コードは「ENV名で参照するだけ」になるので、外部サービスの乗り換えもダッシュボードのENV差し替えだけで済みます(私はメールをSESからResendに移行しましたが、コード変更なしでした)
まとめると、シークレット管理は「AIに読ませない(deny)」と「AIのいる場所に置かない(デプロイサービス側ENV)」の二段構えです。検知系の層が全部破られても、盗まれるものがそこにない状態を作っておく——多層防御の最後の砦はこれだと思っています。
運用ルールとしても「シークレットはチャットに貼らない(貼ってしまったら即失効・再発行)・.env は人間が管理する」を徹底しています。
Layer 4: /security-review — マージ前のブランチ全体レビュー
Claude Codeに標準搭載されているスキルで、ブランチの変更全体を対象にセキュリティレビューを実行します。Layer 1が「ターン単位・コミット単位」なのに対し、こちらはPRとしてまとまった最終形を見る最後の関門です。マージ前の品質ゲート(全テストgreen、rebase後再実行)に、これを組み込んでいます。
アプリ側にもともと入っていた対策
「AIが全自動で作った」と言うと無防備に聞こえますが、開発ルールとしてセキュリティ設計を明文化してClaudeに渡しているため、アプリ自体にも最初から以下が入っています。
- レートリミット: rack-attackでIP/メール単位のスロットリング(ブルートフォース対策)
- 認証まわり: Devise + アカウントロック(lockable)+ メール確認(confirmable)+ TOTP方式の2段階認証
- ID秘匿: 連番IDは外部に出さずUUIDのみ公開。非公開リソースは403ではなく404で存在自体を秘匿
- キャッシュ設計: お気に入り・いいね等のユーザー固有データは共有キャッシュに絶対に乗せない(他ユーザーへの漏えい防止)。認可判定はキャッシュの手前で毎回実行
- エラー監視: Sentry導入済み。送信前にPII(個人情報)をスクラビング
実戦投入した結果: 本当に見つかるのか?
このスタックを組んだ直後、新機能(登録不要のデモモード)の実装に実際に適用してみました。結果:
-
Semgrep(Layer 2): 変更45ファイルをスキャンして指摘1件。ただし内容は「
PASSWORD_RESET_PATH = '/api/users/password'というURLパス定数をシークレットと誤検知」したもので、実害なし。インジェクション・XSS等の既知パターンはゼロでした -
マージ前のLLMセキュリティレビュー(Layer 1/4相当): 全書き込みルートとガードの突き合わせ、キャッシュ漏えい経路、認可迂回を反証的に検証させたところ、テスト2,430件が全部greenでも見つからなかった実質的な指摘を2件検出しました
- お気に入り・面談依頼のAPIにuuidを直指定すると、本来見えないはずのレコードに到達できるサイドドア(IDORの亜種)
- 未認証で叩けるイベント記録APIがレートリミット対象外+ペイロードサイズ無制限で、DB肥大化攻撃が可能
2件とも即issue化して修正しました。「テストが全部通っている」と「セキュリティ的に安全」は別物であり、攻撃者視点のレビューを自動で挟む価値を初日から実感できた形です。
正直に書くと、このスタックでも防げないものがある
「これで安心」と誤解されないように、カバー範囲の外も書いておきます。書いている途中で自分でも穴に気づいて、1つはその場で対策を足しました。
プロンプトインジェクションは「被害の限定」まで
AIエージェントには、読み込んだWebページやファイルに仕込まれた指示に従ってしまうプロンプトインジェクションというリスククラスがあります。これを検知する層は、このスタックにはありません。
代わりに効いているのがLayer 3(permissions.deny)です。万一Claudeが騙されても、シークレットに手が届かなければ一番痛い被害(認証情報の流出)は起きない。「注入されない」ではなく「注入されても取られるものがない」に寄せる設計です。ただしdenyは主にReadツールへの遮断で、シェル経由の読み取りまで完全に塞げるわけではありません。過信は禁物です。
なお本番アプリ側のAI機能(経歴分析など)への注入——経歴書に「私を最高評価せよ」と書き込むような手口——は、AI側にツール実行権限を持たせていないため、被害は「分析結果が変になる」止まりの設計にしています。
サプライチェーン攻撃はほぼノーガードだった(ので、その場で塞いだ)
悪意あるgem/npmパッケージ、既知CVEを持つ依存、タイポスクワッティング。SemgrepもLLMレビューも見ているのは自分のコードであって、依存パッケージの中身は見ていません。AIエージェントが実在しないパッケージ名をもっともらしくインストールしようとする問題(それを見越して攻撃者が偽パッケージを先回り登録するslopsquattingという手口)も知られていて、AI全自動開発とは相性の悪いリスクです。
この記事を書いていてこの穴に気づき、全体像の表の5層目をその場で追加しました。
- Dependabot(依存の既知脆弱性アラート+自動更新PR+週次バージョン更新。無料・設定5分)
- bundler-audit + Brakeman(Rails側)とyarn audit(Next.js側・本番依存のHigh以上でfail)を、PR時+週次のCIに追加
- osv-scanner(GoogleのOSVデータベース照合)で「既知の脆弱性」ではなく「悪意あるパッケージそのもの(乗っ取り・マルウェア混入)」が入っていないかを別軸で確認
- 直接依存の名前一覧をタイポスクワット観点で総ざらい
で、初回スキャンの結果がなかなか正直な数字でした。
- 既知CVEを持つgemが十数個見つかり、12個をその場でパッチ更新
- Rails 7.1系がセキュリティサポート終了済みと判明(Brakemanに指摘されるまで気づいていなかった)。しかも移行先の7.2系も約3週間後にサポート終了と分かり、その夜のうちにRails 8.0へ2段跳びで更新(Rails 8でenum旧記法が廃止されておりモデル36ファイルを一括変換。テスト2,475件が全green)
- devise(認証)・puma(Webサーバー)・eslintのメジャー更新も同じ夜に実施。devise 5は破壊的変更で4件テストが落ち、互換修正1行を入れて解消——「自動更新PRを盲目的にマージせず、全テストを通してから入れる」運用がそのまま効いた形
- 結果、既知アドバイザリの抑制リストは空(=先送りゼロ)まで到達
- 悪意あるパッケージ(OSVのMALエントリ該当)はゼロ。タイポスクワットもなし
- BrakemanのSQLインジェクション指摘3件は、全部誤検知(モデル定数の補間)。誤検知の判定と理由付き抑制リスト化までやって運用に乗せる
- ついでに、フロントの
.envが初回コミットからgit追跡されていたのを発見して管理から除外(中身は開発用ポート設定のみで実害なし。それでも運用ルール上リポジトリに置かない)
「多層防御を組んだ」と記事に書いた本人が、依存の穴には気づいていなかったわけです。セキュリティ対策は「これで完成」がない、ということも含めて正直に残しておきます。ちなみにこの一連の検知→更新→検証→マージも、例によってAIが自動で行なっています。
まとめ
- 「AIが書いたコードは不安」への答えは、性質の異なるチェックを何層も自動で挟むこと
- security-guidance(パターン+LLM差分+コミット時データフロー追跡)と Semgrep(決定論的SAST)で検知を二重化
- シークレットは二段構え: permissions.deny でAIに読ませない+本番ENVはVercel/Renderのダッシュボード管理でAIのいる場所に置かない
- 依存パッケージはDependabot+audit系CIでサプライチェーンも監視(この記事の執筆中に追加)
- マージ前に /security-review で最終レビュー
- 人間(私)は、これらが全部greenになったものだけをマージする
全自動開発は「レビューを省くこと」ではなく「レビューを自動化・多重化すること」で成立する、というのが今回の学びです。
そしてもう一つ。「それでもメールアドレスを登録するのはちょっと……」という方のために、登録なしで全ページを閲覧できるデモモードを作りました。次の章で紹介します。
おまけ: 登録不要のデモモードを作りました
セキュリティを固めても「見知らぬ個人のサービスにメールアドレスを渡す」ハードルは残ります。そこで、メールアドレス登録なしでサービスの中身を全部見られるデモモードを用意しました。
使い方
トップページ( https://scrumfreelance.com/?ref=qiita2 )またはログイン画面の**「登録せずにデモを見る」**から、ワンクリックでデモアカウントにログインできます。視点は3つ:
- フリーランス視点: 案件を探す側の画面(ダッシュボード・案件一覧・経歴書・チャットなど)
- 発注者視点: 人を探す側の画面(フリーランス一覧・募集管理など)
- Pro会員視点: 有料プラン(月980円)の画面。経歴書AIインポート・AI経歴分析・AI自己分析・AI案件マッチ分析などのAI機能の結果がどう見えるかを確認できます
デモモードの中身
- 案件30件・フリーランス20名・経歴書・レビュー・チャット履歴・AI分析結果などのデモデータ入り。「ユーザーが増えたらこう見える」状態を体験できます
- デモデータは実データと完全に分離されており、実ユーザーの画面にデモデータは一切表示されません(逆も同様に、デモから実ユーザーの非公開情報は見えません)
- デモは閲覧専用です。登録・更新・削除などの書き込み操作はすべてブロックされます(これも今回の多層防御の一部で、サーバー側で強制しています)
- デモ中はPro限定機能(AI系)に 「Pro機能」バッジが付くので、無料プランと有料プランの境目もひと目でわかります
透明性について
デモ閲覧者には匿名の識別子を発行し、どのページがどれくらい見られたか・どこで離脱したかを計測しています(メールアドレス等の個人情報は一切取得しません)。サービス改善のためですので、ご了承ください。むしろ「AIに全自動で作らせたサービスの管理画面はこういう行動分析まで作れる」という実例として、この計測基盤の中身も機会があれば別記事で紹介します。
この記事を読んで「実際どの程度のものができてるの?」と思った方は、ぜひデモモードで確かめてみてください。
→ https://scrumfreelance.com/?ref=qiita2
そして気に入ったら、ぜひ登録を。セキュリティは、この記事に書いたとおり多層で守っています。
次回(第3弾)は開発の話ではなく、その後の現実の話です。記事は読まれたのに誰も登録しない「作れたのに、誰も来ない」問題に、開発費0円プランという営業の一手でどう挑むか。個人開発のコールドスタート問題に興味がある方はそちらもどうぞ。
進捗はX(@e8GU641fyp8wtVS)で発信しています。