セキュリティ監査エージェントのアーキテクチャ
AIセキュリティ監査パイプラインは、3つの専門エージェントで構成します。
全体構成
┌─────────────────────────────────────────┐
│ Manager Surface │
│ (セキュリティ監査オーケストレーター) │
├──────────┬──────────┬───────────────────┤
│ Agent 1 │ Agent 2 │ Agent 3 │
│ 依存関係 │ コード │ インフラ │
│ スキャナ │ レビュア │ チェッカー │
└──────────┴──────────┴───────────────────┘
各エージェントの役割を明確に分離することで、並列実行による高速化と、専門領域ごとの深い分析を両立させます。
agents.md の定義
まず、プロジェクトルートに .antigravity/agents.md を作成します。
# Security Audit Manager
あなたはセキュリティ監査のオーケストレーターです。
以下の3つの専門エージェントを管理し、包括的なセキュリティレポートを生成してください。
## 委任ルール
1. 依存関係の脆弱性チェックは `dependency-scanner` に委任
2. ソースコードのセキュリティレビューは `code-reviewer` に委任
3. インフラ設定・環境変数の監査は `infra-checker` に委任
4. 全エージェントの結果を統合し、重要度順にソートしたレポートを生成
---
# dependency-scanner
あなたは依存関係セキュリティの専門家です。
- package.json / package-lock.json を分析
- 既知のCVEデータベースと照合
- 修正バージョンが利用可能か確認
- 重要度(Critical / High / Medium / Low)を判定
---
# code-reviewer
あなたはアプリケーションセキュリティの専門家です。
OWASP Top 10 に基づいてソースコードをレビューしてください。
- SQLインジェクション
- クロスサイトスクリプティング(XSS)
- 認証・認可の不備
- 機密情報のハードコーディング
- 安全でないデシリアライゼーション
---
# infra-checker
あなたはインフラセキュリティの専門家です。
- 環境変数の管理状況を確認
- CORS設定の妥当性を検証
- HTTPSの強制設定を確認
- セキュリティヘッダー(CSP, HSTS等)の設定状況を監査
依存関係スキャンの自動化
npm audit との統合
Antigravityのエージェントに npm audit の結果を解析させ、具体的な修正アクションを提案させます。
// scripts/security-audit.ts
// セキュリティ監査の自動実行スクリプト
import { execSync } from "child_process" ;
import * as fs from "fs" ;
interface AuditVulnerability {
name : string ;
severity : "critical" | "high" | "moderate" | "low" ;
title : string ;
url : string ;
fixAvailable : boolean ;
}
function runDependencyAudit () : AuditVulnerability [] {
try {
// npm audit を JSON形式で実行
const result = execSync ( "npm audit --json" , {
encoding: "utf-8" ,
timeout: 30000 ,
});
const audit = JSON . parse (result);
return Object. entries (audit.vulnerabilities || {}). map (
([ name , data ] : [ string , any ]) => ({
name,
severity: data.severity,
title: data.title || "Unknown vulnerability" ,
url: data.url || "" ,
fixAvailable: !! data.fixAvailable,
})
);
} catch ( error : any ) {
// npm audit はexit code 1で脆弱性を報告する
if (error.stdout) {
const audit = JSON . parse (error.stdout);
return Object. entries (audit.vulnerabilities || {}). map (
([ name , data ] : [ string , any ]) => ({
name,
severity: data.severity,
title: data.title || "Unknown vulnerability" ,
url: data.url || "" ,
fixAvailable: !! data.fixAvailable,
})
);
}
throw error;
}
}
// 実行と結果出力
const vulnerabilities = runDependencyAudit ();
const critical = vulnerabilities. filter (( v ) => v.severity === "critical" );
const high = vulnerabilities. filter (( v ) => v.severity === "high" );
console. log ( ` \n 🔍 依存関係セキュリティレポート` );
console. log ( `${"=" . repeat ( 50 ) }` );
console. log ( `Critical: ${ critical . length } 件` );
console. log ( `High: ${ high . length } 件` );
console. log ( `Total: ${ vulnerabilities . length } 件` );
// 期待する出力例:
// 🔍 依存関係セキュリティレポート
// ==================================================
// Critical: 0 件
// High: 2 件
// Total: 8 件
エージェントへの解析依頼
Antigravityのターミナルで以下のようにエージェントに依頼します。
@dependency-scanner package.jsonとpackage-lock.jsonを分析し、
既知の脆弱性を一覧にしてください。各脆弱性について、
修正バージョンの有無と推奨アクションを含めてください。
エージェントは npm audit の結果を解析し、次のような構造化レポートを生成します。
## 依存関係脆弱性レポート
| パッケージ | 重要度 | CVE | 到達可能性 | 修正バージョン | 推奨アクション |
|-----------|--------|-----|-----------|---------------|--------------|
| lodash | High | (CVE 番号) | 直接依存・呼び出しあり | 4.17.22 | `npm update lodash` |
| express | Medium | (CVE 番号) | 推移的依存・経路なし | 4.19.3 | 次回の定期更新で吸収 |
CVE 番号はプロジェクトごとに変わるためここでは伏せています。むしろ注目していただきたいのは、テンプレートに 到達可能性 の列を足している点です。
npm audit が返す severity は、そのパッケージ単体での深刻度であって、あなたのアプリでの深刻度ではありません。ビルド時にしか動かないツールチェーンの脆弱性と、リクエストごとにユーザー入力を通す経路上の脆弱性が、同じ「High」として並びます。この2つを同じ温度で扱おうとすると件数に押し潰されます。
そこで dependency-scanner への依頼文には、必ず到達可能性の判定を含めるようにしています。
@dependency-scanner npm audit の各項目について、
その脆弱な関数がこのリポジトリのソースから実際に呼ばれる経路があるかを
import グラフを辿って判定し、「経路あり / 経路なし / 判定不能」の3値で分類してください。
経路ありのものだけを先頭にまとめてください。
この一文を足すだけで、最初に目を通すべき行が数件まで絞れます。残りが安全になるわけではありませんが、「今日直す分」と「次の定期更新で吸収する分」を分けられるようになります。
OWASPベースのコードセキュリティレビュー
SQLインジェクション検出パターン
エージェントに以下のようなパターンマッチングルールを学習させます。
// lib/security-patterns.ts
// セキュリティパターン検出ライブラリ
export const SQL_INJECTION_PATTERNS = [
// 文字列連結によるクエリ構築(危険)
/`SELECT . * \$\{ . * \} `/ g ,
/ ['"] SELECT . * ['"] \+ / g ,
/query \( . * \+ . * \) / g ,
// テンプレートリテラルの直接挿入(危険)
/ \. query \( ` [ ^ `] * \$\{ [ ^ }] + \} [ ^ `] * ` \) / g ,
// eval や Function コンストラクタ(極めて危険)
/eval \s * \( / g ,
/new \s + Function \s * \( / g ,
] as const ;
export const XSS_PATTERNS = [
// innerHTML への未サニタイズ入力
/ \. innerHTML \s * = \s * (?! ['"`] )/ g ,
// dangerouslySetInnerHTML(React)
/dangerouslySetInnerHTML \s * = \s * \{ \s * \{ \s * __html: \s * (?!DOMPurify)/ g ,
// document.write
/document \. write \s * \( / g ,
] as const ;
export const AUTH_PATTERNS = [
// ハードコードされたシークレット
/(?:password | secret | api [_-] ? key | token) \s * [:=]\s * ['"][ ^ '"] {8,} ['"] / gi ,
// JWT検証のスキップ
/verify \s * : \s * false/ g ,
] as const ;
// Math.random() は「トークン生成に使うと危険」だが、
// アニメーションのゆらぎ・リトライのジッタ・ランダム表示でも同じだけ当たる。
// critical に混ぜると本当に見るべき行が埋もれるため、severity: "low" の別枠に分ける。
export const WEAK_RANDOM_PATTERNS = [
/Math \. random \(\) / g ,
] as const ;
interface SecurityIssue {
type : string ;
severity : "critical" | "high" | "medium" | "low" ;
file : string ;
line : number ;
message : string ;
recommendation : string ;
}
/** ルール表。ここに1行足すだけで検査項目が増える形にしておく */
const RULES = [
{
patterns: SQL_INJECTION_PATTERNS ,
type: "SQL_INJECTION" ,
severity: "critical" ,
message: "SQL文に未サニタイズの入力が直接挿入されています" ,
recommendation: "パラメータ化クエリまたはORMを使用してください" ,
},
{
patterns: XSS_PATTERNS ,
type: "XSS" ,
severity: "high" ,
message: "未サニタイズの入力がDOMに挿入される可能性があります" ,
recommendation: "DOMPurifyまたはテキストノードを使用してください" ,
},
{
// ここを配線し忘れるとシークレットのハードコードが素通りする
patterns: AUTH_PATTERNS ,
type: "AUTH" ,
severity: "critical" ,
message: "認証情報のハードコード、または検証のスキップが疑われます" ,
recommendation: "環境変数へ退避し、検証フラグを有効化してください" ,
},
{
patterns: WEAK_RANDOM_PATTERNS ,
type: "WEAK_RANDOM" ,
severity: "low" ,
message: "Math.random() は暗号用途に使えません" ,
recommendation:
"トークン生成なら crypto.randomUUID() / randomBytes() へ。演出用途なら無視して構いません" ,
},
] as const satisfies readonly {
patterns : readonly RegExp [];
type : string ;
severity : SecurityIssue [ "severity" ];
message : string ;
recommendation : string ;
}[];
/** 改行位置を1度だけ index 化し、二分探索で行番号を引く */
function buildLineIndex ( content : string ) : number [] {
const starts = [ 0 ];
for ( let i = 0 ; i < content. length ; i ++ ) {
if (content[i] === " \n " ) starts. push (i + 1 );
}
return starts;
}
function lineFromIndex ( starts : number [], pos : number ) : number {
let lo = 0 ;
let hi = starts. length - 1 ;
while (lo < hi) {
const mid = (lo + hi + 1 ) >> 1 ;
if (starts[mid] <= pos) lo = mid;
else hi = mid - 1 ;
}
return lo + 1 ;
}
/**
* ファイルをスキャンしてセキュリティ問題を検出する
*/
export function scanFile (
content : string ,
filePath : string
) : SecurityIssue [] {
const issues : SecurityIssue [] = [];
const lineStarts = buildLineIndex (content);
for ( const rule of RULES ) {
for ( const pattern of rule.patterns) {
// matchAll は g フラグ必須。元の正規表現の lastIndex を汚さないよう複製する
const re = new RegExp (
pattern.source,
pattern.flags. includes ( "g" ) ? pattern.flags : pattern.flags + "g"
);
for ( const match of content. matchAll (re)) {
issues. push ({
type: rule.type,
severity: rule.severity,
file: filePath,
line: lineFromIndex (lineStarts, match.index ! ),
message: rule.message,
recommendation: rule.recommendation,
});
}
}
}
const order = { critical: 0 , high: 1 , medium: 2 , low: 3 } as const ;
return issues. sort (( a , b ) => order[a.severity] - order[b.severity]);
}
// 実行例(4行のサンプルをスキャンした結果を severity 順に整形):
// critical SQL_INJECTION line 1
// critical AUTH line 2
// high XSS line 3
// low WEAK_RANDOM line 4
最初の実装で取りこぼしていた3点
上のコードは、素直に書いた版を実際に回してから3回直したものです。直す前に何が起きていたかを残しておきます。同じ形の検査スクリプトを書く方は、おそらく同じ場所で足をすくわれます。
1. 定義したのに配線し忘れたルールは、静かに存在しないのと同じ
最初の版では AUTH_PATTERNS を定義したところで満足してしまい、scanFile の中で SQL と XSS しかループしていませんでした。型エラーも lint 警告も出ません。テストのつもりでハードコードされた sk_live_... を含むファイルを食わせたところ、検出結果は SQL と XSS の2件だけで、肝心のシークレットが素通りしていました。
これに気づいてから、パターン配列を直接ループするのをやめ、RULES という表にまとめる形に変えました。ルールを増やす作業が「配列に1行足す」だけになり、配線し忘れの余地がなくなります。
2. 行番号の算出が、ファイルサイズの二乗で効いてくる
content.substring(0, index).split("\n").length は読みやすい書き方です。ただしマッチのたびにファイル先頭からの文字列を作り直すため、マッチ数が増えるほど加速度的に重くなります。
2万行・2万マッチのファイルで両方を計測しました。
行番号の求め方 2万マッチの所要時間
substring().split() を毎回5,791 ms
改行位置を index 化して二分探索 22 ms
普段の1ファイル数百行なら差は体感できません。効いてくるのは CI でリポジトリ全体を回したときです。ここを直す前は、スキャンだけでワークフローが数分単位で伸びていました。
3. Math.random() を critical に混ぜると、レポートが読まれなくなる
暗号用途に Math.random() を使うのは確かに危険です。ただ実際のコードでは、アニメーションのゆらぎ・リトライのジッタ・ランダム表示といった無害な用途で登場する回数のほうがはるかに多くなります。手元のコードで数えたところ、4件のマッチのうち本当に危険だったのは1件でした。
危険度の高い指摘に紛れて偽陽性が3件並ぶと、レポート全体の信頼が落ちます。そこで WEAK_RANDOM_PATTERNS を別枠に切り出し、severity: "low" として最後にまとめて出すことにしました。消してしまうのではなく、順番を下げる。この判断は、検査ルールを足すときの基本方針として今も使っています。
エージェントによる自動修正
検出された脆弱性に対して、エージェントが自動修正を提案します。
@code-reviewer 以下のファイルにSQLインジェクションの脆弱性が検出されました。
パラメータ化クエリに修正してください。
修正前:
const user = await db.query(`SELECT * FROM users WHERE id = ${userId}`);
修正後(エージェントが生成):
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
インフラセキュリティの自動監査
セキュリティヘッダーチェッカー
// scripts/check-security-headers.ts
// Webアプリのセキュリティヘッダーを検証するスクリプト
interface SecurityHeaderCheck {
header : string ;
expected : string | RegExp ;
severity : "critical" | "high" | "medium" ;
description : string ;
}
const REQUIRED_HEADERS : SecurityHeaderCheck [] = [
{
header: "Strict-Transport-Security" ,
expected: /max-age= \d {8,} / ,
severity: "critical" ,
description: "HSTS: HTTPSの強制(max-ageは1年以上推奨)" ,
},
{
header: "Content-Security-Policy" ,
expected: /default-src/ ,
severity: "high" ,
description: "CSP: XSSやデータインジェクション攻撃の防御" ,
},
{
header: "X-Content-Type-Options" ,
expected: "nosniff" ,
severity: "medium" ,
description: "MIMEタイプスニッフィングの防止" ,
},
{
header: "X-Frame-Options" ,
expected: /DENY | SAMEORIGIN/ ,
severity: "medium" ,
description: "クリックジャッキング攻撃の防止" ,
},
{
header: "Referrer-Policy" ,
expected: /strict-origin | no-referrer/ ,
severity: "medium" ,
description: "リファラ情報の漏洩防止" ,
},
];
async function checkSecurityHeaders ( url : string ) : Promise < number > {
// HEAD ではなく GET を使う。
// CSP を HTML 応答の組み立て経路でのみ付与している構成では、
// HEAD がその経路を通らず「CSP 未設定」と誤判定されるため。
const response = await fetch (url, { method: "GET" });
await response. arrayBuffer (); // 本文は捨てるが、必ず読み切ってソケットを解放する
console. log ( ` \n 🛡️ セキュリティヘッダー監査: ${ url }` );
console. log ( "=" . repeat ( 60 ));
let issues = 0 ;
for ( const check of REQUIRED_HEADERS ) {
const value = response.headers. get (check.header);
const passed = value
? typeof check.expected === "string"
? value === check.expected
: check.expected. test (value)
: false ;
const status = passed ? "✅" : "❌" ;
console. log ( `${ status } ${ check . header }` );
if ( ! passed) {
console. log ( ` 重要度: ${ check . severity }` );
console. log ( ` 説明: ${ check . description }` );
console. log ( ` 現在の値: ${ value || "(未設定)"}` );
issues ++ ;
}
}
console. log ( ` \n 合計: ${ issues } 件の問題が見つかりました` );
return issues;
}
// CI から実行される入口。
// TARGET_URL を読み、問題があれば非ゼロ終了させる(ここが無いとジョブが常に成功する)
const targetUrl = process.env. TARGET_URL ;
if ( ! targetUrl) {
console. error ( "TARGET_URL が未設定です" );
process. exit ( 2 );
}
const found = await checkSecurityHeaders (targetUrl);
process. exit (found > 0 ? 1 : 0 );
// 期待する出力例:
// 🛡️ セキュリティヘッダー監査: https://your-app.example.com
// ============================================================
// ✅ Strict-Transport-Security
// ❌ Content-Security-Policy
// 重要度: high
// 説明: CSP: XSSやデータインジェクション攻撃の防御
// 現在の値: (未設定)
// ✅ X-Content-Type-Options
// ✅ X-Frame-Options
// ✅ Referrer-Policy
//
// 合計: 1 件の問題が見つかりました
このスクリプトで最初に踏んだのが HEAD の落とし穴でした。手元で挙動を再現できる最小のサーバを立てて確かめたものを載せておきます。
// csp-head-vs-get.mjs — HEAD と GET で返るヘッダが変わることの再現
import http from "node:http" ;
const srv = http. createServer (( req , res ) => {
const headers = {
"Strict-Transport-Security" : "max-age=31536000" ,
"X-Content-Type-Options" : "nosniff" ,
};
// CSP を「HTML を組み立てる経路」でのみ付与している構成の再現
if (req.method === "GET" ) headers[ "Content-Security-Policy" ] = "default-src 'self'" ;
res. writeHead ( 200 , headers);
res. end (req.method === "GET" ? "<html></html>" : undefined );
});
await new Promise (( r ) => srv. listen ( 0 , r));
const url = `http://127.0.0.1:${ srv . address (). port }/` ;
for ( const method of [ "HEAD" , "GET" ]) {
const r = await fetch (url, { method });
if (method === "GET" ) await r. arrayBuffer ();
console. log (method. padEnd ( 4 ), "CSP =" , r.headers. get ( "content-security-policy" ) ?? "(未設定と判定される)" );
}
srv. close ();
// 実行結果:
// HEAD CSP = (未設定と判定される)
// GET CSP = default-src 'self'
本番では CSP を正しく返しているのに、監査スクリプトだけが「CSP 未設定」を報告し続ける。原因にたどり着くまで、CDN の設定を疑って半日ほど無駄にしました。ヘッダ検査は必ず GET で行い、本文は読み切って捨てる。この2点だけ覚えておいていただければ、同じ半日を使わずに済みます。
環境変数の安全性チェック
// scripts/check-env-security.ts
// 環境変数と機密情報の管理状態を監査するスクリプト
import * as fs from "fs" ;
import * as path from "path" ;
import { execSync } from "child_process" ;
const SENSITIVE_PATTERNS = [
/API [_-] ? KEY/ i ,
/SECRET/ i ,
/PASSWORD/ i ,
/TOKEN/ i ,
/PRIVATE [_-] ? KEY/ i ,
/DATABASE [_-] ? URL/ i ,
/STRIPE [_-] ? SECRET/ i ,
];
function auditEnvSecurity ( projectRoot : string ) : number {
const issues : string [] = [];
// .gitignore の記述チェック。
// includes(".env") だと ".env.example" にも当たり、無視されていないのに
// 「安全」と誤判定する。行単位で完全一致を見る。
const gitignorePath = path. join (projectRoot, ".gitignore" );
const ignoredLines = fs. existsSync (gitignorePath)
? fs
. readFileSync (gitignorePath, "utf-8" )
. split ( " \n " )
. map (( l ) => l. trim ())
: [];
const envIgnored = ignoredLines. some (( l ) =>
[ ".env" , ".env*" , "*.env" , ".env.local" ]. includes (l)
);
if ( ! envIgnored) {
issues. push ( "⚠️ CRITICAL: .env が .gitignore で無視されていません" );
}
// .gitignore の記述よりも強い証拠は「実際に追跡されているか」。
// 一度コミットされたファイルは .gitignore を後から足しても追跡され続ける。
const tracked = execSync ( "git ls-files" , { cwd: projectRoot, encoding: "utf-8" })
. split ( " \n " )
. filter (( f ) => /( ^| \/ ) \. env( $| \. )/ . test (f) && ! / \. example $ / . test (f));
for ( const f of tracked) {
issues. push ( `🚨 CRITICAL: ${ f } が Git に追跡されています(履歴からの削除が必要)` );
}
// ソースコード内のハードコードされたシークレットを検出
const srcDir = path. join (projectRoot, "src" );
if (fs. existsSync (srcDir)) {
scanDirectory (srcDir, issues);
}
console. log ( " \n 🔐 環境変数セキュリティ監査レポート" );
console. log ( "=" . repeat ( 50 ));
if (issues. length === 0 ) {
console. log ( "✅ 問題は検出されませんでした" );
} else {
issues. forEach (( issue ) => console. log (issue));
}
return issues. length ;
}
function scanDirectory ( dir : string , issues : string []) : void {
const files = fs. readdirSync (dir, { withFileTypes: true });
for ( const file of files) {
const fullPath = path. join (dir, file.name);
if (file. isDirectory () && file.name !== "node_modules" ) {
scanDirectory (fullPath, issues);
} else if (
file. isFile () &&
/ \. (ts | js | tsx | jsx) $ / . test (file.name)
) {
const content = fs. readFileSync (fullPath, "utf-8" );
const lines = content. split ( " \n " );
lines. forEach (( line , index ) => {
SENSITIVE_PATTERNS . forEach (( pattern ) => {
if (
pattern. test (line) &&
/ ['"][ ^ '"] {8,} ['"] / . test (line)
) {
issues. push (
`⚠️ HIGH: ${ fullPath }:${ index + 1 } — ` +
`機密情報がハードコードされている可能性`
);
}
});
});
}
}
}
// CI から実行される入口。検出したら非ゼロで終了する
process. exit ( auditEnvSecurity (process. cwd ()) > 0 ? 1 : 0 );
// 出力例:
// 🔐 環境変数セキュリティ監査レポート
// ==================================================
// ⚠️ HIGH: src/config/database.ts:15 — 機密情報がハードコードされている可能性
// ⚠️ CRITICAL: .env が .gitignore に含まれていません
CI/CDパイプラインへの統合
GitHub Actionsワークフロー
セキュリティ監査をCI/CDに組み込むことで、プルリクエストごとに自動チェックを実行します。
# .github/workflows/security-audit.yml
name : Security Audit
on :
pull_request :
branches : [ main ]
push :
branches : [ main ]
schedule :
# 毎週月曜 9:00 JST に定期実行
- cron : "0 0 * * 1"
jobs :
dependency-scan :
name : Dependency Vulnerability Scan
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : "20"
- run : npm ci
- name : Run npm audit
run : |
# --json は監査結果があると非ゼロで終わるため、レポート保存側で吸収する
npm audit --json > audit-report.json || true
# 判定はこちらで行う。`|| true` を付けると検出しても常に成功し、
# ゲートとして機能しなくなる
npm audit --audit-level=high
- name : Upload audit report
uses : actions/upload-artifact@v4
with :
name : audit-report
path : audit-report.json
code-security-review :
name : Code Security Review
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : "20"
- run : npm ci
- name : Run security pattern scan
# scanFile が critical を返したらここで落とす
run : npx tsx scripts/security-audit.ts
- name : Check for hardcoded secrets
run : |
# 機密情報のハードコードを検出
if grep -rn \
-e "API_KEY\s*=\s*['\"][^'\"]*['\"]" \
-e "SECRET\s*=\s*['\"][^'\"]*['\"]" \
--include="*.ts" --include="*.js" \
--exclude-dir=node_modules \
src/; then
echo "::error::Hardcoded secrets detected!"
exit 1
fi
security-headers :
name : Security Headers Check
runs-on : ubuntu-latest
if : github.event_name == 'schedule'
steps :
- uses : actions/checkout@v4
- uses : actions/setup-node@v4
with :
node-version : "20"
- run : npm ci
- name : Check production security headers
# スクリプト側で TARGET_URL を読み、未検出=0 / 検出=1 / 未設定=2 を返す
run : npx tsx scripts/check-security-headers.ts
env :
TARGET_URL : ${{ secrets.PRODUCTION_URL }}
Antigravityエージェントとの連携フロー
CI/CDの結果をAntigravityエージェントにフィードバックし、自動修正を実行する流れを構築します。
1. PR作成 → GitHub Actions 実行
2. セキュリティスキャン結果をPRコメントに投稿
3. Antigravity で修正ブランチを開く
4. @code-reviewer にスキャン結果を共有
5. エージェントが修正コードを生成
6. 修正をコミット → 再スキャン → マージ
個人開発者の視点から(実体験メモ)
このパイプラインを自分のリポジトリで回し始めてから、思っていたのとは違う場所に効果が出ました。
期待していたのは「見落としていた脆弱性が見つかること」でした。実際に効いたのは、判断を保留する回数が減ったこと のほうです。以前は監査結果を見るたびに、これは直すべきか、後回しでいいか、そもそも自分のコードに関係があるのかを毎回ゼロから考えていました。到達可能性と severity で並べ替えられた状態で出てくるようになってからは、上から3件だけ見て、あとは次回に回すという判断が数分で済みます。監査そのものが速くなったというより、監査結果と向き合う心理的な重さが減りました。
一方で、エージェントの自動修正提案をそのまま信じて痛い目を見たこともあります。
code-reviewer に SQL インジェクションの修正を任せたとき、こういう箇所がありました。
// 検出された箇所(並び替え順をクエリに埋め込んでいる)
const rows = await db. query (
`SELECT * FROM wallpapers ORDER BY ${ sortColumn } DESC LIMIT 50`
);
エージェントは迷いなく、値をプレースホルダに置き換える修正を返してきました。
// エージェントの提案(動きません)
const rows = await db. query (
"SELECT * FROM wallpapers ORDER BY $1 DESC LIMIT 50" ,
[sortColumn]
);
プレースホルダで束縛できるのは値 であって、カラム名やテーブル名といった識別子ではありません。この修正を当てると、SQL としては受理されるものの、並び替えが定数文字列に対して行われるため実質的に無効化されます。エラーにならず、ただ結果の順序が壊れる。テストを書いていなければ、本番に出るまで気づけない類の壊れ方です。
正しい対処は、識別子を許可リストで畳むことでした。
const SORTABLE = {
created: "created_at" ,
downloads: "download_count" ,
rating: "rating_avg" ,
} as const ;
// 未知のキーは既定値に落とす。ここを通った値だけが SQL に入る
const column = SORTABLE [sortColumn as keyof typeof SORTABLE ] ?? "created_at" ;
const rows = await db. query (
`SELECT * FROM wallpapers ORDER BY ${ column } DESC LIMIT 50`
);
この経験から、エージェントへの依頼文をひとつ変えました。修正案を出させるときは、必ず「なぜその修正で安全になるのかを、攻撃者の入力例つきで説明してください 」を添えるようにしています。説明を書かせると、識別子とリテラルの区別のような、モデルが取り違えやすい前提が文章の側に露出します。おかしい修正は、たいてい説明の段階で破綻します。
自動修正は、レビューを省略するための機能ではありません。レビューすべき箇所を先に見つけて、たたき台まで用意してくれる機能です。この線引きを自分の中で引けてから、ようやくエージェントを安心して走らせられるようになりました。
まとめ — 次にやること
セキュリティ監査を自動化する作業の本体は、検出ルールを増やすことではありませんでした。出てきた指摘を人間が処理しきれる量まで削ること でした。到達可能性で絞り、severity で並べ、偽陽性の多いルールは消さずに順番を下げる。この3つを入れたことで、レポートが読まれるようになりました。
もし今日ひとつだけ手をつけるとしたら、checkSecurityHeaders の HEAD を GET に変え、終了コードを返すようにするところをおすすめします。数行の変更で、CI が「常に成功するだけの飾り」から実際のゲートに変わります。ここが動き出してから、残りのエージェントを足していけば十分間に合います。
私自身、まだこのパイプラインを育てている途中です。読んでくださった方の環境で、ここに書いた3つの取りこぼしを踏まずに済んだなら嬉しく思います。
マルチエージェントの設計パターンについてさらに詳しく知りたい方は、マルチエージェントオーケストレーション実践ガイドもあわせてご覧ください。環境変数の安全な管理については、環境変数・シークレット管理ガイド が参考になります。