Cloudflare が公開しているセキュリティ監査のスキル(security-audit-skill)を、Metronome.fm で実行してみました。確かめが要るもの(要確認)として挙がったのは 2 件で、どちらも手で確かめたうえで、その日のうちに改善しました。
1 件目は、オフライン用に端末に置くページの中身と、その消し方です。2 件目は、エラーのログを送るためのトークンの置き場所です。どちらも、利用者のアカウントの情報や支払いの情報に関わるものではありません。
この記事では、スキルがどういうものか、どう回したか、挙がった 2 件をどう確かめて直したかを書きます。最後に、やってみる価値があったかの見立てを書きます。
security-audit-skill とは
コーディングエージェントにセキュリティ監査をさせるための Agent Skill(エージェントに読ませる手順と資料のまとまり)です。2026 年 6 月 18 日に、Cloudflare のブログ記事「Build your own vulnerability harness」と同じ日に公開されました。Cloudflare 社内の脆弱性探しの仕組み(ハーネス)は、このスキルから育ったものだそうです。9 月に作業の流れと検証の部分が作り直され、ライセンスは MIT です。

- 社内のハーネスの元になったスキルだと書かれている
- ライセンスは MIT
- スターは約 2.7 万
特徴は、見つける役と確かめる役を別のエージェントに分けることです。
- 偵察: 製品・権限・入口・実行環境を 4 体のエージェントが調べ、入口(外からの入力を受けるところ)× 信頼の境界(権限が変わるところ)× 攻撃の種類の組み合わせを、1 つずつ「単位」として「カバレッジの台帳」(JSON)に並べる
- ハンティング: 台帳の単位を、別々のエージェント(ハンター)に割り振って探させる
- 検証: ハンターが出した候補を、見つけていない別のエージェントが反証しようとする
- 報告: 結果を決まった形の JSON に書き、付属のスクリプトで形を検証してから、レポートを作る
候補は 3 つに分けます。手元で動かして結果を観測できたものだけが「確定(confirmed)」です。ソースから経路は追えても、決め手の事実(本番の設定など)がわからないものは「要確認(needs_validation)」として、確かめ方と一緒に残します。反証できたものは「却下(rejected)」です。
取り込み方
スキルは、リポジトリの .claude/skills/ に置いて使います。Metronome.fm には、自前で作ったセキュリティ点検のスキルがすでにあり、名前が同じ security-audit でした。そのままだと、頼み方によってどちらが選ばれるかわからないので、名前を変えて取り込みました。
---
name: cloudflare-security-audit # 元は security-audit
description: Security guidance and vulnerability review for codebases, ...
---変えたのは名前だけです。取り込んだ版は、元のリポジトリの決まったコミットのものに固定しています。取り込む前に、付属の検証スクリプトがファイルを書いたり外へ通信したりしないことも読んで確かめました。
quick で回した
スキルには、回す範囲の段階(プロファイル)が quick・standard・deep の 3 つあり、既定は standard です。初めてなので、いちばん軽い quick にしました。ハンティングは 1 回だけで、最後にカバレッジの点検を 1 回します。エージェントを呼ぶ回数の上限(スキルでは予算と呼びます)は 25 にして、Rust で書いている CLI は対象から外しました。
工程 | エージェント |
|---|---|
偵察 | 4 |
ハンター | 11 |
最後のカバレッジの点検 | 1 |
候補の検証 | 2 |
合計 | 18 |
エージェントは並べて動かします。1 体あたりの時間は、偵察が長いもので 3 分半、ハンターが 1〜3 分ほどでした。これらのエージェントが使ったトークンは、合わせて約 270 万です。
スキルは、対象のコードを動かすなら、OS に強制させるサンドボックスを求めます。外へのネットワークなし、空の環境変数、メモリやディスクの上限などです。macOS では、メモリとディスクの上限とネットワークの隔離を OS に強制させる手段がなかったので、今回はコードを一切動かさず、ソースを読むだけにしました。その結果、「確定」は出ない作りになり、出てくるのは「要確認」か「却下」だけです。
台帳の結果は、問題なしが 34 単位、候補が 2 単位、先送りが 2 単位でした。先送りは、最後のカバレッジの点検で調べるべきだとされたものの、quick ではハンターを出し直さないため、次に回した単位です。脆弱性ではない強化の提案(レート制限の追加など)も 16 件出ました。候補の 2 件は、別のエージェントが読み直してどちらも「要確認」と判定しました。
1 件目: オフライン用に端末に置くページの中身と消し方
Metronome.fm は、一度開けばオフラインでも使えるように、Service Worker がページの HTML を端末に置いています。このページは、サインインしている人の画面をそのまま置いたものでした。保存した設定の一覧などが HTML に入っています。
置いたページは、サインインしている人が変わったら消すようにしていました。ただ、その処理は画面の中で動き、Clerk(サインインの仕組み)の読み込みを待ちます。スキルが挙げたのは、別の端末からセッションを取り消したあとにその端末をオフラインで開くと、この処理では消えずに、取り消す前のページが出るのではないか、という点でした。共有の端末でサインインしたまま離れた場合にだけ当たる、限られた条件です。
E2E のテスト(ブラウザを自動で操作して確かめるテスト)で確かめました。テスト用のユーザーでサインインし、非公開の設定を開いて、端末にページが置かれるのを待ちます。続いて、Clerk の管理用の API でそのセッションを取り消し、オフラインにして開き直します。
const { data: sessions } = await clerk.sessions.getSessionList({
userId: user.clerkId,
status: "active",
});
await Promise.all(
sessions.map((session) => clerk.sessions.revokeSession(session.id)),
);
await context.setOffline(true);
await page.goto(`/${setting.id}`);結果は、取り消す前のページがそのまま表示されました。Service Worker が端末に置いていた HTML です。
直し方は 3 つ考えました。
- 利用者ごとの内容をキャッシュしない
- サインイン中に置いたページに期限を付ける
- 直さず、既知の制約として残す
オフラインの端末には、別の端末でセッションが取り消されたことを知る手段がありません。期限を付けても、期限の内は同じことが起きます。そこで、利用者ごとの内容をそもそも置かないことにしました。
Service Worker は、端末に置くページを Cookie を送らずにサーバーから取ってくるようにしました。サインインしていない状態のページになるので、だれが開いても同じ中身です。Cookie で渡していた言語は届かなくなるので、今の画面の言語を Accept-Language で渡します。
export const pageShellRequestInit = (locale: string): RequestInit => ({
credentials: "omit",
headers: { "accept-language": locale },
});端末に置くページの保存先(キャッシュ)の名前も変えて、利用者の端末に残っていた古いページは、更新のときに消えるようにしました。その代わり、オフラインでは自分の設定の一覧と非公開の設定の画面が開けなくなります。メトロノームを鳴らすことは、今までどおりできます。
2 件目: ログを送るトークンの置き場所
エラーのログは Better Stack に送っています。サーバーのログとブラウザのログはソース(ログの受け口)を分けていて、アラートはサーバーのソースのエラーだけを数えます。
サーバーのソースのトークンは、NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN という環境変数に入れていました。Next.js は NEXT_PUBLIC_ で始まる環境変数を、ビルドのときにブラウザの JavaScript に書き込みます。使っているライブラリ(@logtail/next)は、付けない名前(BETTER_STACK_SOURCE_TOKEN)も読みますが、NEXT_PUBLIC_ の名前があればそちらを先に使います。もとはブラウザのログも同じソースに送っていたので、書き込み専用のトークンをブラウザに渡すのは設計どおりでした。ブラウザのログを別のソースに分けたあとも、この名前のままでした。
つまり、ページの JavaScript からトークンを読み取れば、サーバーのソースにログを書き込めました。トークンにはログを読む権限はなく、利用者のデータにも触れられません。ただ、偽のエラーのログでアラートを鳴らすことはできます。
手元で本番ビルドを作り、ブラウザ用の JavaScript を調べると、トークンがそのまま入っていました。直し方は次のとおりです。
- トークンはサーバーだけが読む
BETTER_STACK_SOURCE_TOKENに置く - ブラウザはトークンを持たずに、同じドメインの中継の API へ送る。中継がサーバーのトークンを付けて Better Stack に送る
- ブラウザがログを送るかどうかは、トークンがあるかどうかだけを表す値(秘密を含まない)で決める
env: {
NEXT_PUBLIC_BETTER_STACK_CUSTOM_ENDPOINT: "/api/betterstack/logs",
NEXT_PUBLIC_BETTER_STACK_ENABLED: String(
Boolean(
process.env.BETTER_STACK_SOURCE_TOKEN &&
process.env.BETTER_STACK_INGESTING_URL,
),
),
},デプロイしたあと、本番のページが読み込む JavaScript 41 本を取ってきて調べ、古いトークンが入っていないことを確かめました。
ただ、一度公開した JavaScript は、CDN や利用者のブラウザに残ります。古いトークンも無効にする必要がありますが、Better Stack のソースの画面には、トークンを作り直す操作がありませんでした。並んでいるのは、止める(Pause)と消す(Remove)だけです。

- ソースに対する操作は、関連を見る・止める(止めたあとは再開)・消すだけ
そこで、サーバー用のソースを新しく作り、新しいトークンを本番の環境変数に入れ直しました。古いソースは、ログの受け付けを止めました(Pause)。止めたソースには書き込めなくなり、過去のログは読めるまま残ります。

- 新しく作ったサーバー用のソース
- ログの受け付けを止めた古いソース
アラートは、どのソースのログを数えるかを、アラートごとに持っています。集計先を新しいソースに付け替えました。しきい値(5 分で 3 件以上)はそのままです。

- しきい値は変えていない
- 集計先を新しいソースに替えた
最後に、付け替えたあとの届き先を確かめました。本番の CSP(ページが読み込んでよい通信先などの決まり)の違反を受ける API は、届いた違反をサーバーのログとして Better Stack に送ります。この API にテスト用のレポートを 1 件送り、新しいソースに警告が届き、古いソースには何も届かないことを確かめています。
やってみる価値はあったか
あったと考えています。理由は、自前のスキルでは拾えていなかった改善点が 2 件挙がり、どちらも手で確かめられたからです。
自前のスキルに見落としがあった理由は、2 件とも説明できます。自前のスキルの点検項目には「NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN は書き込み専用のトークンで、公開は設計どおり」と書いてありました。ブラウザのログを別のソースに分けたあとも、点検項目のこの前提を見直していませんでした。Cloudflare のスキルは、こうした点検項目を持たず、入口・信頼の境界・攻撃の種類の組み合わせから調べます。前提に引きずられなかったので、アラートが数えるソースに外からログを書き込める点に気づけたのだと思います。Service Worker の件は、自前のスキルの点検項目がサーバー側に寄っていて、端末に残るデータとセッションの取り消しの組み合わせを見ていなかったのが原因です。
一方で、限界もはっきりしていました。macOS ではサンドボックスの条件を満たせないので、「確定」は出ません。2 件が実際に起きると決まったのは、E2E で再現し、ビルドの成果物を調べてからです。スキルが出すのは確かめ方つきの候補までで、確かめるのは人の仕事だと割り切ると使いやすくなります。かかったのは、quick で約 270 万トークンでした。
次は、全体を standard で回し直すより、先送りにした 2 単位と CLI だけを範囲にして回すつもりです。スキルは前回の台帳を読み、すでに見た単位を踏まえて計画を立てるので、範囲を絞って少しずつ調べる範囲を広げる使い方が合っていそうです。