claude-security-check 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/LICENSE +21 -0
  3. package/README.md +50 -2
  4. package/bin/cli.mjs +61 -0
  5. package/examples/hmac-empty.mjs +12 -0
  6. package/examples/hono-mount.mjs +20 -0
  7. package/package.json +30 -4
  8. package/skill/SKILL.md +49 -0
  9. package/skill/incidents/INDEX.md +38 -0
  10. package/skill/incidents/_template.md +24 -0
  11. package/skill/incidents/access-sharing/internal-share-org-team-auto-attached.md +22 -0
  12. package/skill/incidents/authz-idor/copied-admin-wiring.md +24 -0
  13. package/skill/incidents/authz-idor/fail-closed-on-empty-admin-table.md +23 -0
  14. package/skill/incidents/authz-idor/ownership-check-inside-branch.md +25 -0
  15. package/skill/incidents/client-side-action/liff-send-posts-to-wrong-chat.md +25 -0
  16. package/skill/incidents/db-privileges/security-definer-open-to-anon.md +27 -0
  17. package/skill/incidents/external/2026-10-arcadia-traversal-config-secret.md +31 -0
  18. package/skill/incidents/fail-open/idp-client-id-unset.md +23 -0
  19. package/skill/incidents/fail-open/webhook-hmac-empty-secret.md +26 -0
  20. package/skill/incidents/logging-forensics/webhook-body-console-log-pii.md +23 -0
  21. package/skill/incidents/process/break-guard-to-see-red.md +22 -0
  22. package/skill/incidents/unauth-route/internal-route-and-subpath-bypass.md +32 -0
  23. package/skill/procedure/01-scope.md +17 -0
  24. package/skill/procedure/02-surface-map.md +35 -0
  25. package/skill/procedure/03-auth-checklist.md +29 -0
  26. package/skill/procedure/04-forensics-readiness.md +51 -0
  27. package/skill/procedure/05-report.md +16 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+ - 初版。手順 5 本・観点表 18 項目・事例 12 件(他社 1・匿名の実例 11)・再現スクリプト 2 本
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kanketsu-jp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # claude-security-check
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Claude Code の Skill です。本番システムを**読み取りだけで**点検し、次の 2 つに実測で答えます。
4
+
5
+ - どこから入れるか(外部公開面・認証の入口・認可の穴)
6
+ - 入られたら、何時・どこから・何をされたかを後から並べられるか(ログの保存・除外・監査ログ・検知)
7
+
8
+ 観点表の各行は、実際に起きたインシデント・ヒヤリハットと、他社の公表事件から作っています。
9
+ 事例は `incidents/` にカテゴリ別のディレクトリで足していけます。
10
+
11
+ ## インストール
12
+
13
+ ```bash
14
+ npx claude-security-check install # ~/.claude/skills/security-check へ
15
+ npx claude-security-check install --project # ./.claude/skills/security-check へ
16
+ ```
17
+
18
+ Claude Code で `/security-check` と打つと開きます(`disable-model-invocation: true` なので、自動では読み込まれません)。
19
+
20
+ 更新するときは `--force` を付けます。`SKILL.md` と `procedure/` は上書きし、自分で足した事例と `incidents/INDEX.md` は残します。
21
+
22
+ **自分の組織の事例を足したディレクトリは、公開リポジトリに置かないでください。** 未対策の穴の一覧は、攻撃者にとっての地図になります。
23
+
24
+ ## 中身
25
+
26
+ ```
27
+ skill/
28
+ ├─ SKILL.md 点検の安全ルールと 6 段階の手順
29
+ ├─ procedure/
30
+ │ ├─ 01-scope.md 範囲と、先に読む前提
31
+ │ ├─ 02-surface-map.md 構成と外部公開面の地図
32
+ │ ├─ 03-auth-checklist.md 認証・認可の観点表(18 項目)
33
+ │ ├─ 04-forensics-readiness.md 攻撃者の行動をログで追えるか(読み取りの測定コマンドと判定表)
34
+ │ └─ 05-report.md 報告の型と、出し先の決め方
35
+ └─ incidents/
36
+ ├─ INDEX.md / _template.md
37
+ └─ <カテゴリ>/<事例>.md 他社 1 件・匿名の実例 11 件
38
+ examples/
39
+ ├─ hono-mount.mjs app.use の bare パスとサブパスの違いを再現
40
+ └─ hmac-empty.mjs 空の鍵の HMAC 検証が通ることを再現
41
+ ```
42
+
43
+ ## 前提
44
+
45
+ - Claude Code(Skill が使えるバージョン)
46
+ - 測定コマンドの例は Google Cloud(Cloud Run + Cloud Logging)と `gcloud` です。観点は他のクラウドでも同じです。
47
+ - `examples/` を動かすには Node.js 18.17 以上(`hono-mount.mjs` は `npm i hono` が要ります)。
48
+
49
+ ## License
50
+
51
+ MIT
package/bin/cli.mjs ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const SKILL_NAME = "security-check";
8
+ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..", "skill");
9
+
10
+ const USAGE = `usage: claude-security-check <command> [options]
11
+
12
+ commands:
13
+ install スキルを ~/.claude/skills/${SKILL_NAME} にコピーする
14
+ path 同梱しているスキルの場所を表示する
15
+
16
+ options (install):
17
+ --project ./.claude/skills/${SKILL_NAME} にコピーする(プロジェクト単位)
18
+ --dir <p> コピー先の skills ディレクトリを指定する
19
+ --force 既にあるとき、SKILL.md と procedure/ を上書きし、無い事例だけ足す
20
+ (incidents/ の既存ファイルと INDEX.md は上書きしない)`;
21
+
22
+ function walk(dir) {
23
+ return readdirSync(dir).flatMap((name) => {
24
+ const p = join(dir, name);
25
+ return statSync(p).isDirectory() ? walk(p) : [p];
26
+ });
27
+ }
28
+
29
+ function install(args) {
30
+ const i = args.indexOf("--dir");
31
+ const base = i >= 0 ? resolve(args[i + 1] ?? "") :
32
+ args.includes("--project") ? resolve(".claude", "skills") : join(homedir(), ".claude", "skills");
33
+ const dest = join(base, SKILL_NAME);
34
+
35
+ if (!existsSync(dest)) {
36
+ mkdirSync(base, { recursive: true });
37
+ cpSync(SRC, dest, { recursive: true });
38
+ console.log(`installed: ${dest}`);
39
+ return;
40
+ }
41
+ if (!args.includes("--force")) {
42
+ console.error(`already exists: ${dest}\n上書きするなら --force(自分で足した事例と INDEX.md は残ります)`);
43
+ process.exit(1);
44
+ }
45
+ let updated = 0, added = 0, kept = 0;
46
+ for (const file of walk(SRC)) {
47
+ const rel = relative(SRC, file);
48
+ const target = join(dest, rel);
49
+ const isIncident = rel.startsWith("incidents") && !rel.endsWith("_template.md");
50
+ if (isIncident && existsSync(target)) { kept++; continue; }
51
+ mkdirSync(dirname(target), { recursive: true });
52
+ existsSync(target) ? updated++ : added++;
53
+ copyFileSync(file, target);
54
+ }
55
+ console.log(`updated: ${dest} (上書き ${updated} / 追加 ${added} / 残した事例 ${kept})`);
56
+ }
57
+
58
+ const [cmd, ...rest] = process.argv.slice(2);
59
+ if (cmd === "install") install(rest);
60
+ else if (cmd === "path") console.log(SRC);
61
+ else { console.log(USAGE); process.exit(cmd ? 1 : 0); }
@@ -0,0 +1,12 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const secret = process.env.WEBHOOK_SECRET ?? ""; // 未設定なら空文字
3
+ const body = JSON.stringify({ orderId: 1, status: "shipped" });
4
+
5
+ // 攻撃者側: 空の鍵で署名を作る
6
+ const forged = createHmac("sha256", "").update(body).digest("hex");
7
+
8
+ // サーバ側: 受け取った署名を検証する
9
+ const expected = createHmac("sha256", secret).update(body).digest("hex");
10
+ const ok = timingSafeEqual(Buffer.from(forged), Buffer.from(expected));
11
+ console.log("WEBHOOK_SECRET set?", process.env.WEBHOOK_SECRET !== undefined);
12
+ console.log("forged signature accepted?", ok);
@@ -0,0 +1,20 @@
1
+ import { Hono } from "hono";
2
+ const auth = async (c, next) => c.req.header("x-api-key") === "secret" ? next() : c.text("unauthorized", 401);
3
+
4
+ const app = new Hono();
5
+ app.use("/api/visit-logs", auth); // bare パスだけに掛けた
6
+ app.get("/api/visit-logs", (c) => c.json({ list: [] }));
7
+ app.get("/api/visit-logs/lookup", (c) => c.json({ name: "山田 太郎" }));
8
+
9
+ const fixed = new Hono();
10
+ fixed.use("/api/visit-logs", auth);
11
+ fixed.use("/api/visit-logs/*", auth); // サブパスにも掛ける
12
+ fixed.get("/api/visit-logs", (c) => c.json({ list: [] }));
13
+ fixed.get("/api/visit-logs/lookup", (c) => c.json({ name: "山田 太郎" }));
14
+
15
+ for (const [label, a] of [["bare のみ", app], ["bare + /*", fixed]]) {
16
+ for (const p of ["/api/visit-logs", "/api/visit-logs/lookup"]) {
17
+ const r = await a.request(p);
18
+ console.log(label.padEnd(10), p.padEnd(24), r.status);
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "claude-security-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Claude Code の Skill: 本番システムを読み取りだけで点検し、認可の穴と「攻撃されたら後から追えるか」を実測で答える手順と、実際の事例集",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-security-check": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "skill",
12
+ "examples",
13
+ "README.md",
14
+ "LICENSE",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18.17"
19
+ },
20
+ "keywords": [
21
+ "claude-code",
22
+ "claude-skill",
23
+ "security",
24
+ "forensics",
25
+ "authorization",
26
+ "cloud-run",
27
+ "supabase",
28
+ "hono"
29
+ ],
30
+ "license": "MIT",
31
+ "author": "kanketsu-jp"
32
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: security-check
3
+ description: 本番システムのセキュリティ点検を、読み取りだけで行う。構成と外部公開面の地図づくり、認証・認可の観点チェック、攻撃者の行動をログで後から追えるかの点検をして、実測とコード上を分けて報告する。実際に起きたインシデント・ヒヤリハット・他社の公表事件を incidents/ にカテゴリ別で貯め、点検のたびに照らす。「セキュリティチェックして」「この事件みたいなことがうちで起きたら追える?」「認証の穴を洗って」「インシデント事例を追加して」と言われたときに使う。
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # security-check
8
+
9
+ 本番システムを**壊さずに**点検し、「どこから入れるか」「入られたら後から追えるか」を実測で答えるための手順と事例集です。
10
+ 観点表の各行は、実際に起きた事例から作っています。事例は `incidents/` にカテゴリ別で足していけます。
11
+
12
+ ## 0. 絶対に守ること(点検の安全)
13
+
14
+ 1. **読むだけ。** 編集・commit・push・本番の設定変更・デプロイをしない(利用者の明示の指示があるまで)。
15
+ 本番クラウドは**設定のメタデータと件数**だけ読む(バケット一覧・sink 一覧・IAM ポリシー・ステータス別の件数)。
16
+ 2. **秘密の値・個人情報を出さない。** 出してよいのは Secret の**名前**、件数、フィールド名まで。
17
+ IP・メール・氏名はログから引いても報告に載せない(必要なら伏せ字)。`.env*` は開かない。
18
+ 3. **アカウントを取り違えない。** 複数の組織・アカウントを持つ環境では、`--account` / `--project` を**コマンドごとに**付ける。
19
+ `gcloud config set account` や `gh auth switch` で既定を変えない(同じ PC の別セッションまで巻き込む)。
20
+ 4. **実測とコード上を書き分ける。** 各断定に `実測` / `コード上` / `未確認` を付ける。
21
+ 「0 件」は「異常が無い 0」と「見ていない 0」を区別して書く。
22
+ 5. **報告の置き場の公開範囲を先に決める。** 穴の一覧は攻撃者にとっての地図です。
23
+ 社内の共有サービスには「全社チームが自動で付いて外せない」ものがある(incidents/access-sharing/)。
24
+ 作る前に、誰が読めるかを確かめる。
25
+
26
+ ## 1. 手順(この順で進める)
27
+
28
+ | # | やること | 詳細 |
29
+ |---|---|---|
30
+ | 1 | 範囲と前提を読む(AGENTS.md / CLAUDE.md / 認証・M2M・公開エンドポイントに関するルール文書) | [procedure/01-scope.md](procedure/01-scope.md) |
31
+ | 2 | 構成と外部公開面の地図(ingress・invoker・認証の入口・公開エンドポイント・DB・Secret) | [procedure/02-surface-map.md](procedure/02-surface-map.md) |
32
+ | 3 | 認証・認可の観点チェック(事例から作った観点表) | [procedure/03-auth-checklist.md](procedure/03-auth-checklist.md) |
33
+ | 4 | 攻撃者の行動をログで追えるか(保存期間・除外・監査ログ・WAF・検知) | [procedure/04-forensics-readiness.md](procedure/04-forensics-readiness.md) |
34
+ | 5 | **incidents/ を照らす**(同じ型が今のコードに無いか) | [incidents/INDEX.md](incidents/INDEX.md) |
35
+ | 6 | 報告(結論→表→穴→対策の優先度→次の一手) | [procedure/05-report.md](procedure/05-report.md) |
36
+
37
+ 広い読み取り(全 route の棚卸し等)はサブエージェントへ**1 リポジトリ 1 体**で並列に振り、
38
+ 「読み取り専用」と明記して結論だけ受け取る。サブエージェントの「気になった点」は疑問として扱い、
39
+ 報告前に自分で根拠の行を開いて確かめる。
40
+
41
+ ## 2. 事例を足す
42
+
43
+ - 新しく踏んだ・見つけた・他社で公表された → **その場で 1 件足す**。型は [incidents/_template.md](incidents/_template.md)。
44
+ - 置き場: `incidents/<カテゴリ>/<slug>.md`(自社の事例は `YYYY-MM-DD-<system>-<slug>.md` にすると並べやすい)。
45
+ カテゴリが無ければディレクトリを作り、INDEX のカテゴリ表にも足す。
46
+ - **INDEX.md の一覧とキーワードに 1 行足す**(索引に無いものは引かれない)。
47
+ - リポジトリのルール文書に詳しい記録があるものは**要約+出典パス**にとどめる(一つの事実は一か所)。
48
+ - 「起こりそう」は書かない。**実際に起きた・実測で見つけた・公表された**ものだけ。
49
+ - 🚨 自社の事例を書いたこのディレクトリを公開リポジトリやパッケージに載せない。未対策の穴が含まれる。
@@ -0,0 +1,38 @@
1
+ # 事例の索引(インシデント・ヒヤリハット・他社事例)
2
+
3
+ 点検の手順 5 で全行を照らす。足したら**この表とキーワードに 1 行足す**(索引に無いものは引かれない)。
4
+ grep の例: `grep -rli '<語>' ~/.claude/skills/security-check/incidents/`
5
+
6
+ 同梱の事例は、他社の公表事件 1 件と、ある組織で実際に起きて**直した**事例 11 件です(組織名・システム名・日付は伏せています)。
7
+ 自分の組織の事例を足したら、このディレクトリは公開しないでください。
8
+
9
+ ## カテゴリ(= ディレクトリ)
10
+ | ディレクトリ | 何を入れるか | 観点表(procedure/03)の # |
11
+ |---|---|---|
12
+ | `external/` | 他社の公表事件 | X1〜X3 |
13
+ | `unauth-route/` | 認証の掛かっていない口・公開判定の誤り | A1〜A4 |
14
+ | `fail-open/` | 設定や依存が欠けたときに開いてしまう作り | F1・F2 |
15
+ | `authz-idor/` | 認可の対象・基準の誤り(IDOR・管理者判定) | Z1〜Z4 |
16
+ | `db-privileges/` | DB の権限・RLS・関数の実行権 | D1 |
17
+ | `client-side-action/` | 利用者の端末から利用者の権限で外部へ書き込む動作 | C1 |
18
+ | `logging-forensics/` | ログの漏えい・欠落・保存・検知 | L1・04 |
19
+ | `access-sharing/` | 資料・成果物の公開範囲 | SKILL §0-5 |
20
+ | `process/` | 作り方・テスト・レビューの手順が原因のもの | T1 |
21
+
22
+ 列挙・総当たり(E1)のように、まだ事例が無い観点は 03 にだけ載せています。事例ができたらディレクトリを作ってください。
23
+
24
+ ## 一覧
25
+ | 組織 | 種別 | 重大度 | 事例 | キーワード |
26
+ |---|---|---|---|---|
27
+ | 他社 | 他社事例 | CRITICAL | [アルカディア: トラバーサル→設定ファイルの鍵→横展開](external/2026-10-arcadia-traversal-config-secret.md) | トラバーサル, 設定ファイル, シークレット, 検知遅れ |
28
+ | 匿名 | インシデント | HIGH | [LIFF からの送信が無関係なトークに本人名義で出た](client-side-action/liff-send-posts-to-wrong-chat.md) | LIFF, sendMessages, 誤送信 |
29
+ | 匿名 | インシデント相当 | HIGH | [「サーバー間だから認証不要」+サブパス素通りで氏名が引けた](unauth-route/internal-route-and-subpath-bypass.md) | ingress, app.use, サブパス, 連番 |
30
+ | 匿名 | インシデント相当 | HIGH | [HMAC 鍵が未設定で空文字検証(誰でも署名を作れる)](fail-open/webhook-hmac-empty-secret.md) | HMAC, 空鍵, ?? "" |
31
+ | 匿名 | ヒヤリ | HIGH | [RED を見るために認可を if (true) に壊した](process/break-guard-to-see-red.md) | RED, if (true), テスト |
32
+ | 匿名 | インシデント相当 | CRITICAL | [店舗作成 API の IDOR(所有権チェックが分岐の中だけ)](authz-idor/ownership-check-inside-branch.md) | IDOR, 所有権, service_role |
33
+ | 匿名 | インシデント相当 | CRITICAL | [SECURITY DEFINER が anon に開いていた(ルール化の後に再発)](db-privileges/security-definer-open-to-anon.md) | SECURITY DEFINER, anon, REVOKE |
34
+ | 匿名 | ヒヤリ | MEDIUM | [別アプリの認可の配線をコピーして管理者テーブルを取り違え](authz-idor/copied-admin-wiring.md) | 管理者テーブル, コピー配線 |
35
+ | 匿名 | ヒヤリ | MEDIUM | [LINE チャネル ID が未設定で LIFF の検証が素通り](fail-open/idp-client-id-unset.md) | LIFF, client_id, fail-open |
36
+ | 匿名 | インシデント | MEDIUM | [空の管理者テーブルで fail-closed → bot が全員 403](authz-idor/fail-closed-on-empty-admin-table.md) | fail-closed, 403 |
37
+ | 匿名 | インシデント相当 | LOW〜MED | [Webhook の本文を console.log でそのまま出力(個人情報)](logging-forensics/webhook-body-console-log-pii.md) | ログ, 個人情報, redact |
38
+ | 匿名 | ヒヤリ | LOW〜MED | [穴の一覧を置いた社内共有に全社チームが付いて外せない](access-sharing/internal-share-org-team-auto-attached.md) | 共有, 公開範囲 |
@@ -0,0 +1,24 @@
1
+ # <一行の見出し:何が起きたか>
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 日付 | YYYY-MM-DD |
6
+ | 組織 | 自社 / 他社(社名・公表されている場合のみ) |
7
+ | システム | 例: 管理画面の BFF(Hono・Cloud Run) |
8
+ | 種別 | インシデント(本番で起きた) / ヒヤリハット(本番前・実害なし・点検で発見) / 他社事例 |
9
+ | 重大度 | CRITICAL / HIGH / MEDIUM / LOW |
10
+ | 見つけ方 | 例: 定期監査・コミット監視のレビュー・利用者の報告・点検 |
11
+ | 出典 | リポジトリのルール文書(パス:節)・公表資料の URL |
12
+ | キーワード | INDEX と同じ語を 3〜6 個 |
13
+
14
+ ## 何が起きたか
15
+ (事実だけ。実測した数値・ステータスコードがあれば書く)
16
+
17
+ ## なぜ起きたか
18
+ (作り込みの型。「誰が悪いか」ではなく「どんな前提が崩れたか」)
19
+
20
+ ## どう直したか
21
+ (直していなければ「未対応」と書く)
22
+
23
+ ## 点検で見ること
24
+ (同じ型を見つけるための grep・コマンド・観点。procedure/03-auth-checklist.md の # と対応させる)
@@ -0,0 +1,22 @@
1
+ # 点検結果(穴の一覧)を社内の共有サービスに置いたら、全社向けのチームが自動で付いて外せなかった
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 社内の静的ページ共有サービス |
7
+ | 種別 | ヒヤリハット(外部公開はしていない。閲覧できるようになったのは社内の数名) |
8
+ | 重大度 | LOW〜MEDIUM(穴の一覧は攻撃者の地図。閲覧者を絞る意図が通らなかった) |
9
+ | 見つけ方 | 作成した後の閲覧権限の確認 |
10
+ | キーワード | 共有, 公開範囲, 全社チーム, 閲覧権限, 報告の置き場 |
11
+
12
+ ## 何が起きたか
13
+ 依頼に応じて非公開のページとして作ったが、既定で全社向けのチームが閲覧できる設定になった。「自分だけに絞る」の指示で外そうとしたが、「全社アクセス用のチームは外せません」と拒否された。
14
+
15
+ ## なぜ起きたか
16
+ 作る前に、共有サービスの既定の閲覧範囲と、それを外せるかを確かめなかった。
17
+
18
+ ## どう直したか
19
+ 本人だけが見られる置き場(Basic 認証を掛ける・本人限定の場所に移す)を選び直す。
20
+
21
+ ## 点検で見ること
22
+ - SKILL.md §0-5。セキュリティの報告は**作る前に**置き場の閲覧範囲を決める
@@ -0,0 +1,24 @@
1
+ # 別アプリの認可の配線をコピーして、照合する管理者テーブルを取り違えかけた
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 複数の小さな管理アプリが同じ BFF を使う構成 |
7
+ | 種別 | ヒヤリハット(作業中に発見) |
8
+ | 重大度 | MEDIUM(人と bot で認可の基準が食い違う。逆向きなら穴) |
9
+ | 見つけ方 | 移設作業のレビュー |
10
+ | キーワード | admin テーブル, コピー配線, actor, M2M |
11
+
12
+ ## 何が起きたか
13
+ アプリ B の管理 API を移すとき、アプリ A の「管理者テーブル `a_admins` で actor を判定する」ミドルウェアをそのまま使った。
14
+ アプリ B で通行を決めているのは `b_admins` なので、そちらにしか居ない管理者が 403 になる。
15
+
16
+ ## なぜ起きたか
17
+ 同じ形の名前(`*_admins`)でも役割が違った。アプリ B では通行の可否を決める表、アプリ A では LINE ID の置き場(判定はドメイン一致)。
18
+ しかもアプリ A ではそのミドルウェアが**唯一のゲート**なので、外すと穴になる。前提が逆だった。
19
+
20
+ ## どう直したか
21
+ アプリごとの管理者テーブルの役割表をルール文書に置き、配線する前に表で確かめる運用にした。
22
+
23
+ ## 点検で見ること
24
+ - 03 の Z2。アプリごとに「誰を管理者とみなすか」を表にし、人(UI)と bot(M2M)で同じ基準か
@@ -0,0 +1,23 @@
1
+ # 空の管理者テーブルで fail-closed の照合を足し、bot 経由の操作が全員 403 になった
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 社内 bot から呼ばれる M2M の書き込み API |
7
+ | 種別 | インシデント(本番・可用性側) |
8
+ | 重大度 | MEDIUM(守りを足して業務が止まった。人の UI は無事なので気づきにくい) |
9
+ | 見つけ方 | 利用者からの報告(bot 経由の操作が 403) |
10
+ | キーワード | fail-closed, 管理者テーブル, 403, M2M, 空テーブル |
11
+
12
+ ## 何が起きたか
13
+ 管理者テーブルとの照合(fail-closed)を足したが、本番も検証環境もテーブルは空で、埋める管理画面も無かった。
14
+ 本番に 1 行を手で入れても、他のメンバーは 403 のままだった。
15
+
16
+ ## なぜ起きたか
17
+ 照合先にデータが入っているかを確かめずに fail-closed にした。人はドメイン制限、bot は管理者テーブルと、基準が食い違っていた。
18
+
19
+ ## どう直したか
20
+ テーブルが空ならドメインでの許可に戻し、1 件でも入れば厳格に照合する。照合を足すなら、埋める手段とセットにする。
21
+
22
+ ## 点検で見ること
23
+ - 03 の Z3。セキュリティを強める提案をするときは「止まる人がいないか」を本番の件数(count のみ)で先に確かめる
@@ -0,0 +1,25 @@
1
+ # 店舗作成 API が他社の company.id をそのまま受け取り、他社に店舗を紐付けられた(IDOR)
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | Next.js の route handler + Supabase(service_role で書き込み) |
7
+ | 種別 | インシデント相当(本番コードに存在・監査で発見) |
8
+ | 重大度 | CRITICAL(紐付けを経由して、被害企業の個人情報が RLS 上は正規に閲覧できる状態になる) |
9
+ | 見つけ方 | 定期のセキュリティ監査 |
10
+ | キーワード | IDOR, 所有権, company.id, service_role, 分岐の中だけの検証 |
11
+
12
+ ## 何が起きたか
13
+ 所有権のチェックが「`company.id` が無いときに既存の会社を探して使い回す」分岐の中にしか無かった。
14
+ クライアントが最初から `company.id` を送ると分岐を通らず、他社の ID がそのまま service_role の INSERT に使われた。
15
+
16
+ ## なぜ起きたか
17
+ - body を zod で検証せずに分解していた
18
+ - ID を決める処理と所有権のチェックを同じ分岐に置き、ID が無いときしか検証が効かなかった
19
+ - 後続の書き込みが service_role(RLS の外)だった
20
+
21
+ ## どう直したか
22
+ `id` があれば必ず先頭で所有権を検証する。
23
+
24
+ ## 点検で見ること
25
+ - 03 の Z1。body から他エンティティの ID を受ける書込 route を列挙し、検証の位置を見る
@@ -0,0 +1,25 @@
1
+ # 顧客画面(LIFF)からの送信で、無関係な人とのトークに本人の発言としてメッセージが出た
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | LINE LIFF で動くアンケートの完了画面 |
7
+ | 種別 | インシデント(本番・実機) |
8
+ | 重大度 | HIGH(第三者とのトークへ本人名義で投稿・情報の誤送信) |
9
+ | 見つけ方 | 実機テスト(他人とのトークで QR を読み取った) |
10
+ | 出典 | https://developers.line.biz/ja/reference/liff/#send-messages |
11
+ | キーワード | LIFF, sendMessages, 誤送信, getContext, utou, push |
12
+
13
+ ## 何が起きたか
14
+ アンケートの完了時に `liff.sendMessages()` でお礼のメッセージを送っていた。このメソッドは、**LIFF を開いたトークに本人の発言として**投稿する。
15
+ 他人とのトークで QR を開けば、そのトークに出る。さらにサーバ側の push と重なり、2 通届いていた。
16
+
17
+ ## なぜ起きたか
18
+ 「公式アカウントとの 1:1 トークで開く」ことを前提にしていた。`liff.getContext().type === "utou"` は他人との 1:1 でも同じ値なので、判定に使えない。
19
+
20
+ ## どう直したか
21
+ 顧客画面からは送らない。すべてサーバからの push にし、冪等キーで 1 回答 1 通にした。
22
+ クライアントのコードに送信 API が無いことを pre-push と CI で検査する。ラッパ関数も置かない。
23
+
24
+ ## 点検で見ること
25
+ - 03 の C1。クライアント側のコードで「利用者の権限で外部へ書き込む」API(送信・投稿・共有)を grep
@@ -0,0 +1,27 @@
1
+ # SECURITY DEFINER 関数が anon に開いていた(REVOKE FROM PUBLIC だけでは anon が残る)
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | Supabase(PostgreSQL の関数を RPC で公開) |
7
+ | 種別 | インシデント相当(本番。監査で 3 件 → ルール化した後に新しい関数で再発) |
8
+ | 重大度 | CRITICAL(請求の根拠になる価格のスナップショットを、anon が書き換えられた) |
9
+ | 見つけ方 | Supabase の security advisor / 本番での権限の読み取りクエリ |
10
+ | 出典 | https://supabase.com/docs/guides/troubleshooting/how-can-i-revoke-execution-of-a-postgresql-function-2GYb0A / https://supabase.com/docs/guides/troubleshooting/custom-role-inherits-privileges-that-were-not-explicitly-granted-ddaa1c |
11
+ | キーワード | SECURITY DEFINER, RPC, anon, REVOKE, ALTER DEFAULT PRIVILEGES, /rest/v1/rpc |
12
+
13
+ ## 何が起きたか
14
+ - 監査: 注文の確定・プロフィール作成・検索の関数が anon/authenticated に EXECUTE で開いており、関数の中に呼び出し元の検証が無かった
15
+ - 再発: ルールを書いた後に作った関数が、また anon に開いたままデプロイされた。本番で anon が実行できる関数は 13 件、そのうち 3 件は中で検証していなかった。ルールにあった仕組みの説明が不正確だったのが原因
16
+
17
+ ## なぜ起きたか
18
+ Supabase は既定の権限設定で、`public` スキーマに作った関数の EXECUTE を anon / authenticated に**個別に**付ける。
19
+ `REVOKE ... FROM PUBLIC` は PUBLIC の分しか外さないので、`has_function_privilege('anon', …)` は true のまま残る。
20
+ 「正規の呼び出しは service_role だから GRANT は無害」も誤りで、誰でも `POST /rest/v1/rpc/<fn>` で直接呼べる。
21
+
22
+ ## どう直したか
23
+ `REVOKE ... FROM PUBLIC, anon, authenticated` を必須にした。anon が実行できる関数の台帳を置き、台帳に無いものが増えたら CI で落とす。
24
+
25
+ ## 点検で見ること
26
+ - 03 の D1。本番で anon が実行できる SECURITY DEFINER を**読み取りで数え**、台帳と突き合わせる
27
+ - DB に直結する構成(Drizzle 等)なら、同じ観点を「接続ユーザーの権限」で見る
@@ -0,0 +1,31 @@
1
+ # 他社: ディレクトリトラバーサルで設定ファイルを取られ、中のサービス間の鍵で侵入が広がった
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 日付 | 2026-10-11〜15(公表 2026-10-19、報道 2026-10-20) |
6
+ | 組織 | 他社(株式会社アルカディア。防災情報配信サービス等) |
7
+ | 種別 | 他社事例 |
8
+ | 重大度 | CRITICAL(メールアドレス最大約 313.6 万件などが漏えいした可能性、約 142 万通の不正メール送信。いずれも同社の公表値) |
9
+ | 見つけ方 | アクセス開始から約 17 時間後、DB の異常負荷を社内の監視が検知 |
10
+ | 出典 | https://www.itmedia.co.jp/news/article/2610/20/2000002426/ / 同社「不正アクセスの経過と当社の対処について」(2026-10-19) |
11
+ | キーワード | トラバーサル, 設定ファイル, シークレット, 横展開, 検知遅れ, アクセスログ, 時系列 |
12
+
13
+ ## 何が起きたか(公表資料の時系列・抜粋)
14
+ - 06:26 アクセス開始 → 06:54 ログイン用 JS 取得 → 07:31 モバイル用パス探索 → 09:23 ログイン API に気づき試行
15
+ - 10:52 トラバーサルのチェック → 11:04 セッション固定のチェック → 15:49 SQLi のチェック
16
+ - 15:51〜16:26 JS 大量取得・ログイン大量試行・パス総当たり
17
+ - **16:39 config.ini の取得に成功**(メディアサーバのディレクトリトラバーサル)
18
+ - その後、設定ファイル内の**サービス間ログイン用シークレットキー**でアカウント ID を総当たり→侵入→WordPress プラグイン追加・バックドア設置
19
+ - 23:40 DB の異常負荷で検知。以降、遮断・パスワード変更・メール配信停止・復旧に 4 日
20
+
21
+ ## なぜ起きたか
22
+ - ファイルパスを受け取る口にトラバーサルの脆弱性があった
23
+ - サービス間の鍵を設定ファイルに平文で置いていた(1 つ漏れて横に広がった)
24
+ - 探索・連打の段階で気づく仕組みが無かった(気づいたのは DB 負荷=被害の後)
25
+
26
+ ## 学び(自分のシステムに当てはめる)
27
+ - 公表できる分単位の時系列は、**アクセスログが全部残っていたから**作れた。成功リクエスト(2xx)を捨てていると「取得に成功」の行が残らない。
28
+ - 共用の鍵は、漏れたときの範囲(何サービスに届くか)を先に把握しておく。
29
+
30
+ ## 点検で見ること
31
+ - 03 の X1〜X3、04 の判定表を全部埋める
@@ -0,0 +1,23 @@
1
+ # LINE のチャネル ID が未設定だと、どのチャネルのトークンでも LIFF の検証を通っていた
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | LINE LIFF のアクセストークン検証 |
7
+ | 種別 | ヒヤリハット(設定は入っていたため実害なし) |
8
+ | 重大度 | MEDIUM(設定ミス 1 回で、他社の LINE アプリのトークンでなりすましできる) |
9
+ | 見つけ方 | 定期のセキュリティ監査 |
10
+ | キーワード | LIFF, client_id, channelId, fail-open, IdP, audience |
11
+
12
+ ## 何が起きたか
13
+ `if (options?.channelId && verifyData.client_id !== options.channelId)` の形だったため、
14
+ チャネル ID の環境変数が空だと条件全体が偽になり、照合が飛ばされていた。
15
+
16
+ ## なぜ起きたか
17
+ 「設定値があるか」と「照合」を 1 つの条件式に混ぜた。同じファイル群の Origin 検証は「両方無ければ拒否」の fail-closed で、作りが食い違っていた。
18
+
19
+ ## どう直したか
20
+ 設定が無ければ先に拒否し、そのあとで照合する 2 段に分けた。
21
+
22
+ ## 点検で見ること
23
+ - 03 の F1。IdP の audience / client_id / redirect_uri の検証を全部開き、設定が空のときの分岐を確かめる
@@ -0,0 +1,26 @@
1
+ # Webhook の HMAC 鍵が未設定のとき空文字で検証していた(空の鍵は誰でも知っているので、誰でも署名を作れる)
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 外部システムからの Webhook 受信口 |
7
+ | 種別 | インシデント相当(鍵が未設定のまま検証環境にデプロイ済み) |
8
+ | 重大度 | HIGH |
9
+ | 見つけ方 | コミット監視のセキュリティレビュー(作った本人は気づいていない) |
10
+ | キーワード | HMAC, 空鍵, ?? "", fail-open, Webhook, requireEnv |
11
+
12
+ ## 何が起きたか
13
+ `webhookSecret: process.env.WEBHOOK_SECRET ?? ""` と書き、仕様にもコミットにも「未設定なら絶対に一致しないので全部 401」と書いていた。
14
+ 実際には空文字でも HMAC は計算でき、空の鍵は攻撃者も知っている。つまり誰でも正しい署名を作れる。
15
+ ガードの 1 行を消して確かめると、**空文字の署名で 200** が返った。
16
+
17
+ ## なぜ起きたか
18
+ 「たまたま失敗するはず」を守りとして数えた。
19
+
20
+ ## どう直したか
21
+ 秘密を `string | null` で持ち、ミドルウェアの最初の文で `if (!secret) return 401`。
22
+ 受信検証に使う他の鍵は、未設定なら起動時に落ちる `requireEnv` にそろえた。
23
+
24
+ ## 点検で見ること
25
+ - 03 の F1。`?? ""` の grep は 30 件出て危険なのは 1 件だった → **受信の検証に使っているか**で切り分ける
26
+ - 再現: このパッケージの `examples/hmac-empty.mjs`
@@ -0,0 +1,23 @@
1
+ # Webhook の受信ボディ(署名者のメール入り)を console.log でそのまま出力し、マスクを迂回していた
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 電子契約サービスからの Webhook 受信口 |
7
+ | 種別 | インシデント相当(本番ログに個人情報) |
8
+ | 重大度 | LOW〜MEDIUM |
9
+ | 見つけ方 | 定期のセキュリティ監査 |
10
+ | キーワード | ログ, 個人情報, console.log, redact, Webhook, logger |
11
+
12
+ ## 何が起きたか
13
+ `console.log("Webhook Body:", JSON.stringify(body))` で、署名者のメールを含むペイロード全体を出力していた。
14
+ 同じ種類の他のルートは共通の logger 経由で、`redact` がメールを自動でマスクしていた。
15
+
16
+ ## なぜ起きたか
17
+ logger を import しない 1 ファイルだけがマスクを迂回した。`JSON.stringify` した後の文字列には、キー名で判定するマスクも効かない。
18
+
19
+ ## どう直したか
20
+ 共通の logger 経由に統一し、オブジェクトのまま渡す。
21
+
22
+ ## 点検で見ること
23
+ - 03 の L1。**ログは攻撃の証拠であると同時に漏えい元にもなる**。セキュリティイベントログを足す提案(04 §4-3 ⑤)でも redact を通す
@@ -0,0 +1,22 @@
1
+ # 認可のテストで RED を見るためにガードを if (true) に壊し、そのまま残りかけた
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | BFF の認可ミドルウェア |
7
+ | 種別 | ヒヤリハット(自動レビューが CRITICAL として検出) |
8
+ | 重大度 | HIGH(残れば認可が全開) |
9
+ | 見つけ方 | 自動レビュー |
10
+ | キーワード | RED, テスト, if (true), 認可, 受入基準, AI エージェント |
11
+
12
+ ## 何が起きたか
13
+ AI エージェントへ渡した受入基準の「実装を壊して RED を見る」を、エージェントが認可の判定そのものを `if (true)` に書き換えることで満たした。
14
+
15
+ ## なぜ起きたか
16
+ 「壊して赤を見る」を、認証・認可のファイルにも同じように当てた。
17
+
18
+ ## どう直したか
19
+ 認可のテストは、入力(セッション・トークン・ヘッダ)をモックで変えて RED を見る。受入基準にその 1 行を足す。コミット前に `grep -rc 'if (true)'` が 0 であることを確かめる。
20
+
21
+ ## 点検で見ること
22
+ - 03 の T1。コードを直す段になったら、作業者への仕様にこの 1 行を入れる
@@ -0,0 +1,32 @@
1
+ # 「サーバー間だから認証不要」の口と、サブパスに掛からない認証で、連番の予約番号から氏名が引けた
2
+
3
+ | 項目 | 値 |
4
+ |---|---|
5
+ | 組織 | 自社(匿名化) |
6
+ | システム | 管理・顧客向けの BFF(Hono・Cloud Run・ingress は公開) |
7
+ | 種別 | インシデント相当(本番コードに存在。検証環境で到達を実測) |
8
+ | 重大度 | HIGH(認証なしで氏名を順に引ける形) |
9
+ | 見つけ方 | 認証の棚卸し(全 endpoint に展開)+ OpenAPI の記述との突き合わせ |
10
+ | キーワード | ingress, internal, app.use, bare パス, サブパス, 影の経路, 連番, 棚卸し |
11
+
12
+ ## 何が起きたか
13
+ 1. 「サーバー間通信。認証不要」とコメントされた `*-internal` のマウントに `app.use` が 1 行も無かった。
14
+ 検証環境で空の JSON を送ると **400**(handler まで届いている)。対照で、存在しないパスは 404、認証のある口は 401。
15
+ 2. `app.use("/api/visit-logs", 認証)` だけで `/api/visit-logs/*` が無く、`/lookup` などのサブパスが素通りした(bare は 401、`/lookup` は 400)。
16
+ 公開 API から取れる ID と**連番の**予約番号で、予約 1 件(氏名を含む)を返す口だった。
17
+ 3. 同じハンドラを 2 か所にマウントしたことで、認証の掛からない「影の経路」ができていた(同じ型の 1 度目)。
18
+ 4. サブパスの欠落は OpenAPI に「実装のまま記載」と書かれていた。**気づいていた人がいたのに直っていなかった**。
19
+
20
+ ## なぜ起きたか
21
+ - ingress が公開なのに、「内部用」という願望を前提にした
22
+ - Hono の `app.use("/x")` は `/x` にしか掛からず、`/x/lookup` には掛からない
23
+ - 棚卸しを `app.route(` の数で数え、さらに自前のパーサが型注釈の `{` を誤認して 30 本以上を見落とした
24
+
25
+ ## どう直したか
26
+ - bare と `/*` の 2 行に統一し、マウントごとのテストで「鍵なし 401/鍵あり 非 401」を固定
27
+ - 全 endpoint を展開して認証の有無を照合する検査スクリプトを CI に入れた。ルートが 1 本も取れないマウントは検査自体を落とす
28
+ - 実測: endpoint 162 本・認証あり 139・無し 23(意図的 17・塞ぐべき 6)
29
+
30
+ ## 点検で見ること
31
+ - 03 の A1・A2・A4。02 §2-3 の数え方(全 endpoint に展開→別の資料と突き合わせ)
32
+ - 再現: このパッケージの `examples/hono-mount.mjs`
@@ -0,0 +1,17 @@
1
+ # 01. 範囲と前提
2
+
3
+ ## 対象を確かめる
4
+ - リポジトリの場所・リモート・ブランチを `git -C <repo> remote -v` / `git -C <repo> branch --show-current` で実測する(推測で書かない)。
5
+ - 本番と検証環境のクラウドのプロジェクト ID は、Terraform の変数ファイル(`*.tfvars` の `project_id` など)から取る。
6
+ - worktree(`git worktree list`)は対象から外す。同じコードを 2 回数えることになる。
7
+
8
+ ## 先に読む(そのリポジトリの前提・過去事故)
9
+ - `AGENTS.md` / `CLAUDE.md`(ルートと `src/` `infra/` `supabase/` などの配下)
10
+ - `.claude/rules/` などのルール文書のうち、認証・M2M・公開エンドポイント・DB 権限・ログ・測り方の罠に関するもの
11
+ - チームのヒヤリハット記録があれば、点検する機能の語で grep する
12
+ - このスキルの `incidents/INDEX.md`
13
+
14
+ ## 範囲外にすること(報告に書いておく)
15
+ - `.env*` は開かない(秘密)。開いていないことを報告に書く。
16
+ - 各 route の全数精読をしていないなら「全数は未確認」と書く。
17
+ - 本番への書き込みを伴う確認(実際に攻撃してみる等)はしない。到達性は検証環境で確かめる。
@@ -0,0 +1,35 @@
1
+ # 02. 構成と外部公開面の地図
2
+
3
+ 目的は「どこから入れるか」を一枚にすること。脆弱性探しはまだしない。
4
+ 例は Google Cloud(Cloud Run)+ Terraform。他のクラウドでも見る観点は同じです。
5
+
6
+ ## 2-1. インフラ(Terraform を読む+本番で確かめる)
7
+ | 見るもの | 場所の例 | 意味 |
8
+ |---|---|---|
9
+ | Cloud Run の `ingress` | `infra/cloud_run*.tf` | `INGRESS_TRAFFIC_ALL` =インターネットから直接届く。**「サーバー間だから認証不要」は成立しない**(incidents/unauth-route/) |
10
+ | `allUsers` の `roles/run.invoker` | IAM の定義 | 付いていれば認証は全部アプリ層の責任 |
11
+ | Scheduler / Tasks の OIDC | `cloud_scheduler.tf` `cloud_tasks.tf` | audience とサービスアカウント。アプリ側で検証しているかは別に見る |
12
+ | CI のデプロイ権限(Workload Identity 等) | `workload_identity.tf` | リポジトリ・ブランチ条件、デプロイ用の権限がプロジェクト全体に付いていないか |
13
+ | Secret の一覧と配り先 | `secrets.tf` と各サービスの `secret_key_ref` | 名前だけ控える。**同じ鍵が何サービスに配られているか**(共用の鍵は漏れたときの範囲が広い) |
14
+ | 前段(WAF/CDN) | `dig +short <host>` / `curl -sI https://<host>` の `server` ヘッダ | クラウドのフロントに直結していれば WAF は無い |
15
+
16
+ ## 2-2. 認証の入口を種類で分ける
17
+ 1. 人(管理者): どの IdP・どのドメイン制限・どこで判定しているか(middleware / proxy / route 内 / RLS)
18
+ 2. 人(顧客): OTP・ソーシャルログイン・LINE LIFF など。トークンを誰がどう検証するか(audience / client_id の照合)
19
+ 3. M2M: 共有キーか署名か OIDC か。**actor(誰の代理か)は自己申告か検証済みか**
20
+ 4. Webhook: 署名 / トークン / IP 制限のどれか。**鍵が未設定のときの挙動**
21
+ 5. ジョブ: OIDC をアプリで検証しているか、IAM だけか
22
+ 6. 開発用の抜け道(`NODE_ENV === "development"` のときだけ通るトークン等)が本番で閉じているか
23
+
24
+ ## 2-3. 公開エンドポイントの棚卸し
25
+ - Next.js: `src/app/**/api/**/route.ts` を全列挙し、`middleware.ts` / `proxy.ts` の公開パターン・matcher と突き合わせる。
26
+ **前方一致の公開パターン**は配下の将来のルートまで無認証にする。
27
+ - Hono 等: `app.route(` の数で数えない。**全 endpoint に展開してから** middleware のパターンと 1 本ずつ照合する
28
+ (`app.use("/x")` と `app.use("/x/*")` は別。マウント側のファイルや factory 内の `route.use` も見る)。
29
+ - 無認証で残すものは「ルート内で何を検証しているか」を 1 行で書けるかで判定する。
30
+ - 自分の棚卸しを**別の資料(OpenAPI の記述等)と突き合わせて**検証する。自前の検査スクリプトは型注釈の `{` などで取りこぼす。
31
+
32
+ ## 2-4. DB
33
+ - RLS の使い方(テナント分離か、特権ロール以外の締め出しだけか)
34
+ - 特権クライアント(Supabase の service_role 等)の生成箇所。正規のヘルパー以外の**直書き**も grep する
35
+ - `SECURITY DEFINER` 関数の anon 実行可否(Supabase は `REVOKE ... FROM PUBLIC` だけでは anon が残る)
@@ -0,0 +1,29 @@
1
+ # 03. 認証・認可の観点表
2
+
3
+ 右端に事例があるものは、**実際に起きた事例**から作った観点です。事例を開くと、何が起きたか・どう見つけたかが分かります。
4
+ 新しい事例を足したら、ここにも観点を 1 行足す。
5
+
6
+ | # | 観点 | 確かめ方 | 事例 |
7
+ |---|---|---|---|
8
+ | A1 | ingress が公開なのに「内部用だから」と認証の無いルートが無いか | 公開ルートの棚卸し(02 §2-3)で「内部」「internal」「サーバー間」を grep | unauth-route/internal-route-and-subpath-bypass |
9
+ | A2 | ミドルウェアが bare パスだけに掛かり、サブパスが素通りしていないか(Hono は `/x` と `/x/*` の 2 行) | `app.use(` と `app.route(` を対で照合 | 同上 |
10
+ | A3 | 公開判定が前方一致で、将来のサブルートまで無認証になっていないか | 公開パターン(`startsWith` で判定している配列など)を列挙し、配下の route 全部に自前の認証があるか | (観点のみ) |
11
+ | A4 | 同じハンドラを 2 か所にマウントして「影の経路」ができていないか | 同じ factory を複数の path で `route` していないか | unauth-route/internal-route-and-subpath-bypass |
12
+ | F1 | 受信検証の鍵・許可リスト・client_id が**未設定のときに通る**作りになっていないか(`?? ""`、`if (cfg && ...)`) | `grep -rnE '(SECRET\|TOKEN\|KEY\|CHANNEL_ID)[^\n]*\?\? ""'` と `if \(options\?\.` を、**受信の検証に使っているもの**に絞る | fail-open/webhook-hmac-empty-secret, fail-open/idp-client-id-unset |
13
+ | F2 | レート制限・外部判定が障害時に fail-open で、公開 OTP 等の守りが外れないか | レート制限の実装の catch 節 | (観点のみ) |
14
+ | Z1 | 他エンティティの ID を受け取る書込 API が、**ID の有無に関わらず**所有権チェックを先頭で通すか | body から `*.id` を受ける route を列挙し、検証が `if (!id)` 分岐の中だけにないか | authz-idor/ownership-check-inside-branch |
15
+ | Z2 | 認可テーブルを別アプリの配線からコピーしていないか(同じ形の名前 `*_admins` でも役割が違う) | アプリごとの管理者テーブルの役割表と、API 側の照合先を突き合わせる | authz-idor/copied-admin-wiring |
16
+ | Z3 | fail-closed の照合を足すとき、照合先テーブルが空でないか・埋める手段があるか(人と bot で基準が食い違わないか) | 本番の件数(`count(*)` のみ) | authz-idor/fail-closed-on-empty-admin-table |
17
+ | Z4 | M2M の共有鍵が何サービスで共用されているか・actor が自己申告か | Secret の配り先表(02 §2-1) | (観点のみ) |
18
+ | D1 | 状態を変える `SECURITY DEFINER` 関数が anon/authenticated に開いていないか(`FROM PUBLIC` だけでは anon が残る) | `has_function_privilege('anon', oid, 'EXECUTE')` を本番で**読み取りで**数える | db-privileges/security-definer-open-to-anon |
19
+ | C1 | 顧客の画面(LIFF 等)から、利用者の権限で外部へ送信・投稿していないか | 送信 API の呼び出し・ラッパの存在を grep | client-side-action/liff-send-posts-to-wrong-chat |
20
+ | E1 | 登録の有無を返す公開 API に per-IP + per-email のレート制限があるか(reCAPTCHA だけにしない) | 公開の auth 系 route ごとに「レート制限・失敗ログ・障害時の挙動」を表にする | (観点のみ) |
21
+ | L1 | Webhook・外部 API のログが redact を通っているか(`console.*` 直呼びで個人情報を生出力していないか) | `console\.(log\|error)` を webhook / 外部クライアント配下で grep | logging-forensics/webhook-body-console-log-pii |
22
+ | T1 | 認可のテストで RED を見るとき、ガード自体を `if (true)` 等に壊していないか | `grep -rc 'if (true)'` = 0 を確認 | process/break-guard-to-see-red |
23
+
24
+ ## 外部の事件から来た観点
25
+ | # | 観点 | 事例 |
26
+ |---|---|---|
27
+ | X1 | パス操作(`../`)でサーバ上のファイルを返す口が無いか(ファイル名・パスを受け取って読む/配信するルート) | external/2026-10-arcadia-traversal-config-secret |
28
+ | X2 | 設定ファイル・リポジトリ・ビルド成果物にサービス間の鍵が平文で入っていないか(一つ漏れると横に広がる) | 同上 |
29
+ | X3 | DB の異常負荷より前に、探索(404 の山)やログイン連打で気づけるか | 同上 → 04 の検知 |
@@ -0,0 +1,51 @@
1
+ # 04. 攻撃者の行動をログで追えるか
2
+
3
+ 「事件が起きたとき、何時・どこから・何をされたかを分単位で並べられるか」を点検する。
4
+ 手本: external/2026-10-arcadia-traversal-config-secret(公表資料が分単位の時系列を出している)。
5
+ コマンドは Google Cloud(Cloud Run + Cloud Logging)の例です。
6
+
7
+ ## 4-1. 測るコマンド(すべて読み取り。`--account` を毎回付ける)
8
+ ```bash
9
+ A=<your-account@example.com>; P=<project_id>
10
+ gcloud logging buckets list --project=$P --account=$A --format="table(name.basename(),retentionDays,locked)"
11
+ gcloud logging sinks list --project=$P --account=$A --format="table(name,destination,filter)"
12
+ gcloud logging sinks describe _Default --project=$P --account=$A --format="value(exclusions[].name)"
13
+ gcloud projects get-iam-policy $P --account=$A --format=json \
14
+ | python3 -c "import json,sys;print(json.load(sys.stdin).get('auditConfigs','(none)'))"
15
+ # ステータス別件数(値は出さない)
16
+ gcloud logging read 'logName:"run.googleapis.com%2Frequests"' --project=$P --account=$A --freshness=24h --limit=20000 \
17
+ --format="value(httpRequest.status)" | awk '{c[substr($1,1,1)"xx"]++;n++}END{for(k in c)print k,c[k];print "total",n}'
18
+ # 1 件のフィールド名だけ
19
+ gcloud logging read 'logName:"run.googleapis.com%2Frequests"' --project=$P --account=$A --freshness=24h --limit=1 --format=json \
20
+ | python3 -c "import json,sys;e=json.load(sys.stdin)[0];print(sorted(e.get('httpRequest',{}).keys()))"
21
+ # 最古のログ(=実効の保存期間)
22
+ gcloud logging read 'logName:"run.googleapis.com%2Frequests"' --project=$P --account=$A --freshness=400d --order=asc --limit=1 --format="value(timestamp)"
23
+ ```
24
+ - `gcloud logging exclusions` というサブコマンドは無い(Google Cloud SDK 583.0.0 で実測)。除外は `_Default` sink の `exclusions` で見る。
25
+ - 件数が `--limit` に達したら「打ち切り」と書く。
26
+ - **2xx が 4xx より少ない**ときは、成功ログを除外・サンプリングしていないかを `sinks describe` で確かめる。
27
+
28
+ ## 4-2. 判定表(各行を 追える / 一部 / 追えない で埋める)
29
+ | 攻撃者の行動 | 残る場所 | 追えない典型原因 |
30
+ |---|---|---|
31
+ | パス探索(404 の山) | リクエストログ | 保存期間切れ |
32
+ | トラバーサル・SQLi の試行 | リクエストログの URL(クエリ含む) | POST 本文は残らない |
33
+ | ログイン連打 | リクエストログ(4xx)+ IdP 側ログ | 狙われたアカウントは本文に入るので残らない |
34
+ | **攻撃が成功した瞬間(2xx)** | リクエストログ | **コスト対策で 2xx を除外・サンプリングしている** |
35
+ | ログイン後に見た・持ち出した | アプリの構造化ログ(userId 付き)・DB 監査 | アクセスログにユーザー ID が無い |
36
+ | 盗んだ鍵の使用 | リクエストログ+M2M の呼び出し元ログ | 共用鍵だと正規の呼び出しと区別できない |
37
+ | 秘密を誰が読んだか | Data Access 監査ログ | `auditConfigs` が無い=無効 |
38
+ | 設定変更・デプロイ・IAM 変更 | Admin Activity 監査ログ(`_Required` バケット・400 日・ロック済み) | -- |
39
+ | 攻撃中に気づく | ログベース指標+アラート | 5xx しか見ていない |
40
+ | 1 か月以上前 | 長期保管(別プロジェクトの BigQuery/GCS) | `_Default` バケットの既定 30 日のみ |
41
+ | 証拠を消されない | ロック済みバケット・別プロジェクト | 同じプロジェクト内・未ロック |
42
+
43
+ ## 4-3. 対策の優先度(目安。プロジェクトごとに見直す)
44
+ 1. 2xx を含むリクエストログを全部残す(除外をやめる、または捨てずに安い保存先へ回す)
45
+ 2. 1 年以上・別プロジェクト・保存期間ロック(BigQuery なら IP 単位の時系列が SQL 一本で出る)
46
+ 3. Data Access 監査ログ(Secret Manager・IAM)
47
+ 4. 検知アラート(同じ IP の 4xx 急増/ログイン失敗の連打/1 ユーザーの異常な 200 件数/管理者権限の付与/秘密の読み取り)
48
+ 5. アプリのセキュリティイベントログ(認証の成否・権限付与・401/403・レート制限・M2M の呼び出し元・一括出力。userId・role・IP・trace ID 付き。redact を通す)
49
+ 6. DB 側の記録(マネージド DB のプラン別の保存期間・pgaudit 等)
50
+ 7. 前段の WAF(ロードバランサ+Cloud Armor、Cloudflare 等)
51
+ 8. 「疑わしい IP の時系列を出す」クエリを手順書に保存しておく
@@ -0,0 +1,16 @@
1
+ # 05. 報告の型
2
+
3
+ 1. **結論 1〜3 行**(できる/できない、いちばん大きな穴)
4
+ 2. 手本の事件の各段階 × 自社システムの表(追える/一部/追えない)
5
+ 3. 実測した事実の表(プロジェクト別・数値・日時)
6
+ 4. 穴の一覧(# / 穴 / 根拠ラベル `実測`・`コード上`・`未確認` / 何が困るか)
7
+ 5. 対策(優先度・何が残るようになるか・手間と費用の目安。見積もっていなければそう書く)
8
+ 6. 気になった点(疑問の段階。断定しない)
9
+ 7. 次の一手(候補を 2〜3)
10
+ 8. 調査方法と、やっていないこと(編集なし・`.env` 未読・全数は未確認 など)
11
+
12
+ ## 出し先
13
+ - まず会話の中で文章として返す。メモに残すならリポジトリの外(チームに共有されない場所)に置く。
14
+ - HTML や共有ページにするなら、作る**前に**公開範囲を決める(SKILL.md §0-5)。
15
+ - 読み手が知らなそうな概念(ingress・OIDC・RLS・fail-open 等)は、初出で 1 行説明する。
16
+ - 新しく分かった事例は incidents/ に足してから報告を閉じる。