diff --git a/.agents/rules/common.md b/.agents/rules/common.md index 2a2c3009..46a3fd62 100644 --- a/.agents/rules/common.md +++ b/.agents/rules/common.md @@ -37,8 +37,8 @@ **E2E テストは実装と同時に書く**: バグ修正・UI 挙動の変更時はコミット前に該当ケースの E2E を追加する。後回し禁止。 -**push 前に必須**: `npm run test`(ユニット)/ `node_modules/.bin/astro check`(型)/ `npm run test:e2e`(E2E)。 -post-PR 代行は不要、CI が最終ゲート。 +**push 前に必須**: `npm run format:check`(整形)/ `npm run test`(ユニット)/ `node_modules/.bin/astro check`(型)/ `npm run test:e2e`(E2E)。 +post-PR 代行は不要、CI が最終ゲート。**`format:check` を含める理由**: CI の `test` ジョブは `format:check` を最初に走らせるため、`npm run test` だけでは整形崩れ(特に `Write` / `Edit` で作成した Markdown)を検出できず CI が赤になる(PR #753 実例)。 **ガード / バリデータ / 検知機構には陽性対照を必須**: 検出する・拒否する・違反したら fail させる仕組み(CSP 違反検知 / 入力 validator / lint / セキュリティヘッダ assert / E2E ガード / regex マッチ系)を追加 / 修正する場合は **`Skill` tool で `test-gates` skill を必ず呼ぶ**。陰性対照のみでは「検知能力ゼロで green」と区別不能(PR #233 `applyProductionCsp` 空回り事故)。詳細・チェックリストは skill 本体に集約してこの doc では肥大化させない。 @@ -70,9 +70,10 @@ post-PR 代行は不要、CI が最終ゲート。 1. `src/components/tools/ToolName.tsx` を作成 2. `src/pages/tools/tool-slug.astro` を作成(`client:load` で React コンポーネントをマウント) 3. `src/data/tools.ts` の `toolEntries` 配列にエントリを追加(slug / name / description / category / yomi)。`yomi` は並び替え用の読み仮名(ひらがな)で、表示順はこの `yomi` の五十音順に自動ソートされる(手動で位置を決める必要はない) -4. `tests/e2e/visual-regression-pages.ts` の `PAGES` 配列に `/tools/` を追加(VRT 対象に登録)。baseline は CI Linux runner で `Update Visual Regression Baseline` workflow を `workflow_dispatch` trigger して生成(mac との font 描画差を回避するためローカル生成は不可)。**漏れた場合は `tests/meta/vrt-pages-coverage.test.ts` が `npm run test` で fail させる**ため CI で必ず検知される(issue #355 で導入)。※ この `workflow_dispatch` をエージェント自身が起動できるかは実行環境のトークン権限に依存する(Claude Code on the web では `actions: write` が無く起動不可・手動トリガー必須 → `.claude/rules/github-web-session.md`。他エージェントは各固有ルール参照)。 -5. 4 章「ドキュメント更新ルール」に従い `README.md` / `SPEC.md` / `docs/decisions.md` を更新 -6. 候補リスト(`docs/tool-candidates.md`)由来のツールの場合、PR マージ時に該当行の「状態」列へ ✅ と PR 番号を記載する +4. `src/components/ui/ToolIcon.astro` にツールアイコン(SVG)を追加する。既存アイコンと同じ `{...attrs}` 展開・`currentColor` 方式に従う。**漏れた場合は `tests/meta/tool-icon-coverage.test.ts` が `npm run test` で fail させる**(PR #746 で漏れが発生し手戻りになった実例あり) +5. `tests/e2e/visual-regression-pages.ts` の `PAGES` 配列に `/tools/` を追加(VRT 対象に登録)。baseline は CI Linux runner で `Update Visual Regression Baseline` workflow を `workflow_dispatch` trigger して生成(mac との font 描画差を回避するためローカル生成は不可)。**漏れた場合は `tests/meta/vrt-pages-coverage.test.ts` が `npm run test` で fail させる**ため CI で必ず検知される(issue #355 で導入)。※ この `workflow_dispatch` をエージェント自身が起動できるかは実行環境のトークン権限に依存する(Claude Code on the web では `actions: write` が無く起動不可・手動トリガー必須 → `.claude/rules/github-web-session.md`。他エージェントは各固有ルール参照)。**baseline 生成 workflow は対象ブランチへ直接コミットを push する**ため、実行後にローカルから push する場合は先に `git pull --rebase origin ` で取り込むこと(取り込まないと non-fast-forward で拒否される) +6. 4 章「ドキュメント更新ルール」に従い `README.md` / `SPEC.md` / `docs/decisions.md` を更新 +7. 候補リスト(`docs/tool-candidates.md`)由来のツールの場合、PR マージ時に該当行の「状態」列へ ✅ と PR 番号を記載する 新しい入力欄・ボタン・エラー表示等を実装する前に、`src/components/ui/` の既存共通コンポーネント(`InputField`, `CopyButton`, `DownloadButton` 等)を確認すること。一覧と用途は `.agents/rules/ui-conventions.md` を参照。 diff --git a/.agents/rules/ui-conventions.md b/.agents/rules/ui-conventions.md index 7cd5470f..1edd56dd 100644 --- a/.agents/rules/ui-conventions.md +++ b/.agents/rules/ui-conventions.md @@ -105,6 +105,14 @@ input / textarea / button などのフォーカス可能要素の className に | 操作の種類が変わる(エンコード/デコード等) | する | 入力の期待形式が変わる | | 同じ操作のサブバリアント(標準/URL-safe 等) | しない | 出力比較のために保持が便利 | +### 2.5 live region(`aria-live` / `role="status"`)は小さい要素に限定する + +リアルタイム変換系ツールで **結果領域全体**(サマリ・詳細・テーブルを含む大きな div)に `aria-live` / `role="status"` を付けない。入力を 1 文字編集するたびに領域全体が変化し、スクリーンリーダーに膨大な再アナウンスが走る。 + +- ✅ 推奨: 「変換ステップ行」「結果の 1 行要約」など**小さく安定した要素**だけを live region にし、詳細領域は通常のセクションにする +- `role="status"` は暗黙で `aria-live="polite"` を持つため、両方を併記しない(冗長) +- 過去事例: PR #746 のレビューで検出(JwtDecoder の既存パターンを踏襲した結果の再発。既存分の改修は別 issue 管理) + --- ## 3. Playwright での確認手順 @@ -130,3 +138,11 @@ UI 変更時は **PC (1280x800)** と **スマホ (390x844)** 両方でスクリ - `getByRole` / `getByText` / `getByLabel` を使う。`locator('[role="X"]')` のような属性セレクタは禁止(アクセシビリティ・国際化に弱く、リファクタリング耐性も低い)。 - DOM 直接操作(`page.evaluate`)より `expect` のオートリトライを優先(React の再レンダータイミングで不安定になるため)。 + +### 3.4 React island へ入力する E2E spec は hydration 待機が必須 + +React island(`client:load` でマウントされるツール本体)に `fill` / `click` 等で入力する spec は、**`beforeEach` で `await waitForReactHydration(page);`(`tests/e2e/helpers.ts`)を必ず呼ぶ**。 + +- hydration 完了前の `fill` は DOM の value だけを書き換え、React の `onChange` が発火しないため state が空のまま進む(例: URI 貼り付け分解で一部フィールドだけ空になる) +- この race は **CI では顕在化しない**(`workers: 1` の直列実行で hydration が間に合う)が、ローカルの並列実行で flaky になる。「CI green だからテストは正しい」とは判断できない +- 過去事例: issue #750(`dsn-builder.spec.ts` / `dummy-personal-data.spec.ts` が未呼び出しでローカル 8〜10 件 fail) diff --git a/.agents/skills/README.md b/.agents/skills/README.md index 3557580e..6b1f5ffd 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -8,4 +8,4 @@ | grill-me | [mattpocock/skills](https://github.com/mattpocock/skills) | MIT([LICENSE-mattpocock-skills](./LICENSE-mattpocock-skills)) | | vercel-react-best-practices | [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) | MIT(upstream に LICENSE ファイルは無く [README](https://github.com/vercel-labs/agent-skills#license) で MIT 宣言) | | frontend-design | [anthropics/claude-plugins-official](https://github.com/anthropics/claude-plugins-official)(`plugins/frontend-design`) | Apache-2.0([LICENSE-frontend-design](./LICENSE-frontend-design)) | -| dads-design-system / test-gates | 本リポジトリ自作 | 本リポジトリのライセンスに従う | +| dads-design-system / test-gates / retro | 本リポジトリ自作 | 本リポジトリのライセンスに従う | diff --git a/.agents/skills/retro/SKILL.md b/.agents/skills/retro/SKILL.md new file mode 100644 index 00000000..9d6fcc97 --- /dev/null +++ b/.agents/skills/retro/SKILL.md @@ -0,0 +1,56 @@ +--- +name: retro +description: PR マージ後の振り返り(retro / レトロ / 振り返り)。対象PRの作業から気づき(手戻り・レビュー指摘・つまずき)を、レビューコメント・docs/agent-lessons.md・会話履歴の3ソースから抽出し、.agents/rules/common.md 11 章の基準で5分類に仕分けして、承認された分だけドキュメント改善PRを作る。ユーザーが `/retro`、「振り返り」「レトロ」「retro して」等と言ったとき発動。手動起動が主で、対象PRは引数指定または直近マージPR。 +--- + +# retro: PR マージ後の振り返りをドキュメント改善に落とす + +PR マージ後に、そのPRの作業から得られた気づきを抽出し、.agents/rules/common.md 11 章の基準で仕分けして、 +**再発防止に値するものだけ**をドキュメント改善PRに落とす手順。 + +自動分析が暴走して無関係な変更を提案しないよう、判定基準は厳格に。過剰な提案は形骸化を招くため +YAGNI 寄りに倒し、Step 4 で必ず停止してユーザー承認を挟む。 + +## Step 1 — 対象PRの特定 + +- `/retro [PR番号]` の引数があればそれを対象にする。 +- 引数省略時は直近マージPR(`gh pr list --state merged --limit 1 --json number,title`)を取得し、 + **「PR #N(タイトル)を対象にします。よいですか?」と確認してから**進む(誤爆防止)。 + +## Step 2 — 3ソース収集(会話履歴は best-effort) + +- **レビューコメント(主軸)**: `gh pr view --comments` +- **既存教訓(主軸)**: `docs/agent-lessons.md` を読み、繰り返し出ている教訓を把握 +- **会話履歴(best-effort)**: 同一セッションに実装ログが残っていれば手戻り・訂正を抽出。 + 別セッション起動で空ならスキップし、「会話履歴は取得できなかった」と明示(欠落を隠さない)。 + +## Step 3 — 仕分け判定(.agents/rules/common.md 11 章準拠、最終反映先へ直接ルーティング) + +各気づきを次の5分類に振り分ける。11章の「バッファ→昇格」モデルと二重化しないよう、 +**最終反映先へ直接**振り分ける(全部を agent-lessons バッファに通さない)。 + +| 分類 | 反映先 | 判定基準 | +| -------------------------------- | ------------------------------- | ------------------------------------------- | +| (a) 再発防止に値する共通規約 | `.agents/rules/common.md` | 全エージェント・全開発に適用される | +| (b) Claude 固有の運用改善 | `CLAUDE.md` / `.claude/rules/*` | Claude Code の harness 挙動・権限に紐づく | +| (c) 手順が複雑・再利用性が高い | 新規 skill 化提案 | 3ステップ以上の定型手順、覚えにくいフラグ群 | +| (d) 特定ツール紐付きの実装メモ | `docs/agent-lessons.md` 追記 | 個別コンポーネントのリスク・実装知見 | +| (e) 一度限りの TIP/既に強制済み | 破棄 | コード・Hook・lint で既に担保 | + +## Step 4 — 提案の提示(ここで必ず停止) + +仕分け結果を表で提示する(各行: 気づき / 分類 / 反映先ファイル / 変更概要)。 +**(e) 破棄も含めて判定理由を明示**する。ここで停止し、ユーザーが承認/却下を選ぶ。 +判定に迷うものは (e) 側(破棄)に倒し、過剰提案を避ける。 + +## Step 5 — 承認分のPR作成 + +承認された変更のみ `chore/retro-` ブランチ(**origin/develop 起点**を明示)で実装し、 +`--base develop` で PR を作成する(.agents/rules/common.md 6 章/`docs/playbooks/pr-creation.md` 準拠)。 +本文は必ずファイル経由(`--body-file`)で渡す。 +(c) skill 化提案が承認された場合は `writing-skills` スキルに委譲する。 + +## やらないこと + +- マージ検知の自動化(PostToolUse フック等)は第1弾スコープ外。設計上の留保は + `docs/superpowers/specs/2026-07-05-retro-skill-design.md` を参照。 diff --git a/.claude/rules/git-and-fs.md b/.claude/rules/git-and-fs.md index 60f03ede..c3c66c43 100644 --- a/.claude/rules/git-and-fs.md +++ b/.claude/rules/git-and-fs.md @@ -16,3 +16,14 @@ ## git 操作 - `git -C ` は使わない。既に project dir に居る場合は素の `git` を使う(`git -C` は sandbox 除外パターンに合致せず SSH push が known_hosts 拒否で失敗する)。 + +## Playwright / E2E の sandbox 制約(macOS ローカルセッション向け) + +本節は macOS ローカルの sandbox-exec 環境で確認した制約。web セッション(claude.ai/code)は Chromium pre-install 済みのコンテナで動くため `playwright install` は不要で、`mach_port_rendezvous` の制約も該当しない。 + +- ブラウザ未インストール環境では `PLAYWRIGHT_BROWSERS_PATH="$PWD/tmp/claude/ms-playwright"`(リポジトリ内の sandbox 書込可能経路)を指定して `npx playwright install chromium chromium-headless-shell` する。デフォルトの `~/Library/Caches` は書込 deny。キャッシュは未追跡のまま残してよい(次セッションで再利用可)。 +- `node` スクリプトから `chromium.launch()` を直接呼ぶと `mach_port_rendezvous ... Permission denied (1100)` で起動できない。**test runner(`npm run test:e2e` / `npx playwright test`)経由なら起動できる**。スクリーンショット撮影等の単発ブラウザ操作も、一時 spec + 専用 config(起動済みサーバを `baseURL` 参照、`webServer` なし)を作って runner 経由で実行する(一時 spec はコミットしない)。 +- 環境によっては `webServer` 自動起動が `listen EPERM ::1:4321`(IPv6 bind 拒否)で失敗することがある。その場合は `astro preview --host 127.0.0.1` を別途起動して `baseURL` で参照する。 +- さらに環境によっては loopback への **connect 自体が全面 deny** される(`astro preview` の起動・listen は成功するのに、node fetch / curl / バックグラウンドタスクからの `127.0.0.1` 接続がすべて EPERM / exit 000)。この状態では上記 workaround を含め **in-session E2E は実行不能**。接続 probe(`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:/` 等)が 2〜3 回失敗した時点で workaround 探索を打ち切り、「CI を E2E の最終ゲートにする」判断へ切り替えて PR 本文にローカル E2E 未実行の旨と理由を明示する。UI の目視確認は claude-in-chrome(ユーザーの実 Chrome、sandbox 外)で代替できる。 + +(経緯: PR #746 のセッションで親・サブエージェント計 3 者が同じ制約に別々に遭遇したため記録。loopback connect 全面 deny は PR #749 のセッションで確認し、workaround 試行のラウンドトリップが無駄になったため追記) diff --git a/.claude/settings.json b/.claude/settings.json index 68b839d5..3b182b95 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { - "model": "opusplan", + "model": "opus[1m]", "env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }, @@ -91,7 +91,7 @@ } }, "permissions": { - "defaultMode": "default", + "defaultMode": "auto", "allow": [ "Read(./**)", "Read(~/.claude/**)", @@ -106,12 +106,6 @@ "Edit(/tmp/claude/**)", "Edit(/tmp/claude-[0-9a-f]*/**)", "Edit(/var/folders/*/*/T/**)", - "Write(./**)", - "Write(/private/tmp/claude/**)", - "Write(/private/tmp/claude-[0-9a-f]*/**)", - "Write(/tmp/claude/**)", - "Write(/tmp/claude-[0-9a-f]*/**)", - "Write(/var/folders/*/*/T/**)", "WebFetch(domain:ai.google.dev)", "WebFetch(domain:code.claude.com)", "WebFetch(domain:docs.anthropic.com)", @@ -187,9 +181,7 @@ ], "ask": [ "Edit(**/.claude/{*.json,*.sh,hooks/**,agents/**,skills/**,commands/**,plugins/**})", - "Write(**/.claude/{*.json,*.sh,hooks/**,agents/**,skills/**,commands/**,plugins/**})", "Edit(./.gemini/**)", - "Write(./.gemini/**)", "Bash(git push*)", "Bash(git reset --hard*)", "Bash(git commit --amend*)", diff --git a/.claude/skills/retro b/.claude/skills/retro new file mode 120000 index 00000000..b1723279 --- /dev/null +++ b/.claude/skills/retro @@ -0,0 +1 @@ +../../.agents/skills/retro \ No newline at end of file diff --git a/.gitignore b/.gitignore index f05b7a09..7315f821 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ conductor/ .claude/tmp/ .claude/*.bak .tmp/ +# sandbox セッションの作業キャッシュ(Playwright browsers 等 → .claude/rules/git-and-fs.md) +/tmp/ # Superpowers extension (Visual brainstorming artifacts and session info) .superpowers/ diff --git a/README.md b/README.md index ac7be747..44ec2d2d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ | markdownエディタ | markdownをリアルタイムにHTMLプレビュー。GFM(表・取り消し線・コードブロック)対応。HTMLコピー・.mdダウンロード対応。全処理ブラウザ内完結 | | コントラスト比マトリクス | 任意の N 色の全組合せ(N×N)のコントラスト比を一覧表示。WCAG 2.x の AA/AAA 合否と APCA Lc を併記。計算はブラウザ内で完結 | | DDL → ER図ジェネレータ | CREATE TABLE 文(MySQL/PostgreSQL)から ER 図を描画。外部キーからリレーション線を自動生成し、Mermaid コード・SVG・PNG で出力。全処理ブラウザ内完結 | +| SAMLデコーダ | SSO の SAMLResponse / AuthnRequest を自動判定デコードし、Assertion の内容と Status・有効期限・Audience の定番チェックを表示 | ## 技術スタック diff --git a/SPEC.md b/SPEC.md index 233363f5..f5841112 100644 --- a/SPEC.md +++ b/SPEC.md @@ -92,6 +92,7 @@ | `asn1js` | ASN.1 DER のデコード(pkijs の基盤。拡張領域の生バイト取得にも使用) | SSL/TLS証明書デコーダ | | `marked` | Markdown パース・HTML 変換(GFM 対応。`gfm: true`, `breaks: true`)。出力は既存 `sanitizeHtml` でサニタイズ | markdownエディタ | | `node-sql-parser` | DDL(`CREATE TABLE`)のパース。dynamic import で遅延ロード | DDL → ER図ジェネレータ | +| `fflate` | SAML HTTP-Redirect binding の raw deflate 展開(`decompressSync` は raw deflate/zlib/gzip を自動判定) | SAMLデコーダ | ※ すべて Tree-shakable で軽量なものを選定。バンドルサイズ最小化を優先。 @@ -220,6 +221,7 @@ devtools/ ├── key/ # 鍵フォーマット変換(types.ts / detect.ts / convert.ts / index.ts) ├── har/ # HARビューア&サニタイザ(types.ts / rules.ts / parse.ts / sanitize.ts / index.ts、__tests__ colocated) ├── csr/ # CSR・鍵ペアジェネレータ(types.ts / generate.ts / parse.ts / index.ts) + ├── saml/ # SAMLデコーダ(types.ts / decode.ts / parse.ts / checks.ts / format.ts / index.ts) ├── dummy-personal-data/ # 日本語ダミー個人データ生成(types.ts / dictionaries.ts / generate.ts / serialize.ts、__tests__ colocated) ├── dataTransferSnapshot.ts # DataTransfer 捕捉・フレーバー列挙(clipboard-inspector が利用) ├── sanitizeHtml.ts # 許可リスト方式 HTML サニタイザ(clipboard-inspector が利用) @@ -291,7 +293,7 @@ devtools/ --- -## 4. ツール一覧(全31ツール) +## 4. ツール一覧(全33ツール) ### カテゴリ A: 生成ツール(`generate`) @@ -322,6 +324,7 @@ devtools/ | 11 | Base64エンコード/デコード | `base64` | テキスト⇔Base64 相互変換。通常の Base64 と URL-safe Base64 に対応 | | 12 | JWTデコーダー | `jwt-decoder` | JWTトークン貼り付け → Header/Payload/署名を分解表示。HS/RS/ES署名検証対応 | | 25 | SSL/TLS証明書デコーダ | `cert-decoder` | PEM/DER/PKCS#7/PKCS#12(.pfx/.p12)証明書を解析し Subject/SAN/有効期限/署名アルゴリズム/SCT を表示。複数証明書のチェーン並べ替え・署名検証(pkijs + Web Crypto)対応。PKCS#12 はパスワード復号・秘密鍵(メタ情報常時/PKCS#8 PEM トグル開示)含む。全処理ブラウザ内完結 | +| 32 | SAMLデコーダ | `saml-decoder` | SSO の SAMLResponse / AuthnRequest を URL/URLエンコード/base64/base64+deflate/生XML の5形式から自動判定デコードし、Assertion の内容と Status・有効期限・Audience の定番チェックを表示。XMLDSig 署名検証・EncryptedAssertion 復号は非対応。全処理ブラウザ内完結 | ### カテゴリ D: 変換・解析ツール(`convert`) @@ -1361,6 +1364,56 @@ SQL のプレースホルダにJSON形式のパラメータを埋め込み、人 --- +### 5.33 SAMLデコーダ(`saml-decoder`) + +**概要:** SSO の SAMLResponse / SAMLRequest(AuthnRequest)を貼り付けると入力形式を自動判定してデコードし、Assertion の構造表示と定番チェック(Status / 有効期間 / Audience / NameID)を行うツール。Assertion は氏名・メールアドレス等の PII を含むため、外部サービスに貼れない現場向けに全処理をブラウザ内で完結させる。 + +**入力:** SAMLResponse / SAMLRequest のテキスト(URL 全体・URLエンコード済み・base64・base64+deflate・生 XML のいずれか)。SP entityID の任意入力欄(Audience 照合用)。 + +**処理(自動判定デコードチェーン、`decodeSamlInput`):** + +1. URL 全体の場合は `SAMLResponse` / `SAMLRequest` クエリパラメータを自前パースで抽出する(`URLSearchParams` は `+` を空白に変換し base64 を破壊するため、生クエリ文字列から抽出し percent エンコードのまま保持する) +2. 生 XML(`<` で始まる)ならそのまま採用 +3. `%xx` を含む場合は URL デコード(失敗時はそのまま続行)し、再度生 XML 判定 +4. base64 デコード +5. UTF-8 として XML と解釈できれば HTTP-POST binding と判定 +6. 解釈できなければ `fflate` の `decompressSync`(raw deflate/zlib/gzip 自動判定)で展開し HTTP-Redirect binding と判定 + +適用した変換ステップを UI に表示する。 + +**パース(`parseSamlXml`):** `DOMParser` で名前空間 URI ベース(`getElementsByTagNameNS` 等)に解決し prefix 非依存でパースする。Response は Issuer / Status(StatusMessage 含む)/ Destination / InResponseTo / IssueInstant と、Assertion ごとの NameID・属性テーブル・Conditions・AuthnStatement・SubjectConfirmationData を構造化する。AuthnRequest は Issuer / Destination / AssertionConsumerServiceURL / ProtocolBinding / NameIDPolicy / RequestedAuthnContext を構造化する。Response・Assertion 直下の `ds:Signature` 有無、`EncryptedAssertion` の件数も検出する(署名検証・復号は非対応、存在表示のみ)。 + +**定番チェック(`runResponseChecks`、Response のみ・現在時刻基準):** + +1. Status が Success か(Success 以外はエラー表示 + StatusMessage) +2. Conditions の NotBefore / NotOnOrAfter が有効期間内か(`NotOnOrAfter` は仕様通り排他境界として判定)。タイムゾーン指定(`Z` / `±hh:mm`)のない日時はこの端末のローカル時刻として解釈し警告を付記する。パース不能な日時は「有効期間内」と誤判定せず警告表示に倒す +3. Audience / Recipient の値表示。SP entityID 入力時のみ厳密一致で照合結果を表示 +4. NameID の有無 + +各項目を成功/警告/エラー/情報で色分け表示(`StatusBadge` / `NotificationBanner`)。 + +**出力:** + +- 適用した変換ステップの表示 +- Response/AuthnRequest のサマリと定番チェックリスト(Response のみ) +- Assertion ごとの構造表示(NameID・属性テーブル・Conditions・AuthnStatement・SubjectConfirmationData) +- 整形済み生 XML の折りたたみ表示 + `CopyButton`。「生 XML」/「マスク XML(共有用)」の `ToggleGroup` 切替に対応し、マスク XML は `maskSamlXml` で NameID・全 AttributeValue を値ベース一貫トークン化 + `secret-scrubber`(`HIGH_ENTROPY` 除外)で URL 埋め込みメール等を除去した PII 削減版を表示・コピーできる + +**モジュール構成:** `src/utils/saml/`(`types.ts` 型定義 / `decode.ts` 自動判定デコードチェーン / `parse.ts` XML → 構造化モデル / `checks.ts` 定番チェックリスト / `format.ts` XML 整形 / `mask.ts` 共有用マスク出力 / `index.ts`)/ `src/components/tools/SamlDecoder.tsx` / `src/pages/tools/saml-decoder.astro` + +**追加依存:** `fflate`(raw deflate 展開)。 + +**既知の制限:** + +- XMLDSig 署名検証・`EncryptedAssertion` の復号・LogoutRequest/LogoutResponse 等の他メッセージ型は非対応(署名・暗号化は存在の有無のみ表示) +- ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない +- 全処理はブラウザ内で完結し、入力(Assertion に含まれる PII を含む)を外部サーバーに送信しない +- 「共有用マスク XML」は構造上の PII フィールドと `secret-scrubber` の既知パターンの除去であり、完全な匿名化を保証しない(共有前の目視確認が必要) + +**スコープ外(v1):** XMLDSig 署名検証(C14N 実装)・EncryptedAssertion 復号・LogoutRequest/LogoutResponse 等の他メッセージ型 + +--- + ## 6. 各ツール共通仕様 ### 6.1 共通UIパターン @@ -1531,6 +1584,7 @@ Phase 2 でアクセシビリティ要件(コントラスト比 4.5:1)を満 - [x] コントラスト比マトリクス(`contrast-matrix`) - [x] 日本語ダミー個人データ生成(`dummy-personal-data`) - [x] DDL → ER図ジェネレータ(`ddl-er-diagram`) + - [x] SAMLデコーダ(`saml-decoder`) - [ ] Diff、パスワード生成、ハッシュ等 - [ ] 全文検索 - [ ] お気に入り(localStorage) diff --git a/docs/agent-lessons.md b/docs/agent-lessons.md index dd1204fc..ffab69d3 100644 --- a/docs/agent-lessons.md +++ b/docs/agent-lessons.md @@ -253,3 +253,26 @@ PR #450 で `databarlimitedcomposite` (GS1 DataBar Limited Composite) が以下 - PR #450(本件、`height` + `injectCompositeText` の 2 段修正) - `src/components/tools/Gs1Databar.tsx:113-117` (`bwip-js v4.9.0` の挙動依存をコメントで明示) + +--- + +## [2026-07-19] 重量フィクスチャの陽性対照テストは CI ランナーで vitest デフォルト 5s を超過する + +### 現象 + +PR #749 の deflate 展開上限(zip bomb 対策)の陽性対照テスト(ゼロ埋め 40MB を `zlibSync` 圧縮 → 展開で上限超過を検証)が、ローカルでは pass するのに CI runner で vitest デフォルトタイムアウト 5000ms を超過して fail した。 + +### 対処(PR #749 の 4186b81 で実施した組み合わせ) + +1. **フィクスチャを上限超過の最小限に縮小**: 40MB → 34MB(上限 32MB を確実に超える最小級) +2. **圧縮レベルを最小化**: `zlibSync(huge, { level: 1 })`(生成時間を短縮、圧縮率は検証に無関係) +3. **明示タイムアウトを設定**: `it('...', { timeout: 30_000 }, () => ...)`(CI 実測に基づく余裕値) + +### 教訓 + +- test-gates 系の陽性対照で MB 級データの生成・変換を伴う場合、ローカル pass だけで CI の時間予算を判断しない。**明示 timeout + フィクスチャ最小化**を最初から入れる +- 検証したい境界(上限 32MB)に対しフィクスチャは「確実に超える最小」を選ぶ。余裕を盛るほど CI 時間を浪費する + +### 関連 + +- PR #749(`src/utils/__tests__/saml-decode.test.ts` の deflate 上限テスト) diff --git a/docs/decisions.md b/docs/decisions.md index b8314915..3dac8698 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4636,3 +4636,63 @@ blob URL / srcdoc iframe 内での描画も検討したが、Chromium は親ド - ✅ `withProductionCsp` ガートが通過する(CSP 違反ゼロ) - ✅ mermaid の追加バンドルなし(`node-sql-parser` のみ追加) - ⚠️ テーブルレイアウトのアルゴリズムは自前実装のため、複雑なスキーマでのレイアウト最適化に限界がある + +--- + +## [124] SAMLデコーダ: deflate 展開に fflate を採用 + +**2026-07-17 | ステータス: 採用** + +### 背景 + +SAMLデコーダの HTTP-Redirect binding では、SAMLResponse/SAMLRequest が raw deflate 圧縮 → base64 → URL エンコードの順で URL クエリに埋め込まれる(SAML 2.0 Bindings)。ブラウザの標準 API には deflate 展開手段がなく、展開用ライブラリの選定が必要だった。 + +### 決断 + +**deflate 展開ライブラリとして `fflate` を採用する。** 定番の `pako` と比較し、バンドルサイズが小さく TypeScript 型定義を同梱するため型定義用の追加パッケージが不要。`decompressSync` が raw deflate / zlib / gzip を自動判定するため、SAML の仕様上は raw deflate のみ想定されていても、仕様外の zlib ラッパー付き実装で送出する IdP にも耐性がある。 + +XMLDSig 署名検証は C14N(正規化)の実装コストが大きく、初版スコープ外とした(詳細は `docs/superpowers/specs/2026-07-17-saml-decoder-design.md` 参照。第2版候補として別 issue 化)。 + +### 却下した選択肢 + +- **`pako`**: 実績のある定番だが、バンドルサイズが `fflate` より大きく、型定義は `@types/pako` を別途追加する必要がある +- **XMLDSig 署名検証の同時実装**: C14N(正規化)の実装が山場で難度が高く、初版のスコープ(貼り付け→自動判定デコード→構造表示→定番チェック)に対して過大。EncryptedAssertion 復号・他メッセージ型対応と合わせて第2版候補とした + +### 結果・トレードオフ + +- ✅ 追加依存は `fflate` のみ。型定義同梱でパッケージ追加が最小限 +- ✅ zlib ラッパー付き実装の IdP にも `decompressSync` の自動判定で耐性がある +- ⚠️ 署名検証・復号非対応のため、SAMLデコーダの出力はあくまで「中身の確認・トラブルシュート用」であり、正当性の証明には使えない(UI 上に非対応を明記) + +--- + +## [125] SAMLデコーダ: 共有用マスク出力は構造ベースを主・secret-scrubber を副で併用 + +**2026-07-20 | ステータス: 採用** + +### 背景 + +SAMLデコーダのデコード結果 XML には NameID や属性値として社員の氏名・メールアドレス等の PII が含まれる。issue #745 で、Slack や issue コメント等への貼り付け共有を想定した「PII を除去した共有用マスク XML」の出力機能が要求された。 + +マスク方式として、既存の `secret-scrubber`(パターンベースの正規表現マッチ)をそのまま適用する案と、SAML の構造(NameID・AttributeValue 要素)を対象に置換する案があった。 + +### 決断 + +**構造ベース(NameID/AttributeValue のテキスト置換)を主、`secret-scrubber` を副で併用する二段構えを採用する。** `secret-scrubber` は `HIGH_ENTROPY` カテゴリを除外して適用する。 + +- フェーズ1(構造ベース): 再パースした DOM 上で `saml:NameID` / `saml:AttributeValue` のテキストを値ベース一貫トークン `[REDACTED:PII_n]` に置換する。同一値は同一トークンにすることで NameID とマッチする属性値(例: `mail` 属性)の相関を保つ。 +- フェーズ2(scrubber 併用): 再シリアライズ後の文字列に `scrubText` を適用し、`Destination` の URL クエリに埋め込まれたメールアドレス等、構造では拾えない残余を救済する。 + +理由: `secret-scrubber` 単独(パターンベースのみ)では正規表現で拾えない日本語氏名(例: `displayName` 属性の「山田 太郎」)等の意味的 PII を検出できない。逆に `HIGH_ENTROPY` を有効にしたまま適用すると、`ds:SignatureValue` / `ds:X509Certificate` の base64(非 PII・公開情報)を over-mask してしまう。SAML の構造を理解した上で意味的 PII フィールドを確実に除去しつつ、URL 埋め込み等の構造外の残余を scrubber で補完する二段構えが最も過不足が少ない。 + +### 却下した選択肢 + +- **`secret-scrubber` 単独適用(`HIGH_ENTROPY` 込み)**: 日本語氏名等パターンで拾えない PII を見逃す一方、署名値・証明書の base64 を過剰にマスクし、共有先での有効性確認(署名有無の目視)を妨げる +- **完全な匿名化の保証**: XML の構造は無限に多様であり、独自拡張属性やカスタムスキーマまで含めた完全網羅は非現実的。UI 上に「共有前に必ず目視で確認」の注記を出し、過信を防ぐ設計とした + +### 結果・トレードオフ + +- ✅ 意味的 PII(NameID・全属性値)を構造的に確実に除去できる +- ✅ 署名値・証明書等の非 PII base64 を over-mask しない +- ✅ 値ベース一貫トークンにより NameID とメール属性等の相関が UI 上で確認できる +- ⚠️ 構造外(要素名・属性名自体に PII が含まれる等の非定型なケース)は対象外。完全な匿名化は保証しない diff --git a/docs/playbooks/e2e-validation.md b/docs/playbooks/e2e-validation.md index fe0a5ac0..57bcdf00 100644 --- a/docs/playbooks/e2e-validation.md +++ b/docs/playbooks/e2e-validation.md @@ -34,9 +34,12 @@ | --- | --------------------- | ------------------------------------------------------------------------------------------ | | 0 | node_modules 整備 | `npm ci`(新規作成 worktree では必須。fresh worktree なら 5〜10 秒で完了。詳細は下記参照) | | 1 | develop ベース確認 | `git rev-parse origin/develop` と `git merge-base HEAD origin/develop` が一致 | -| 2 | ユニットテスト全 pass | `npm run test` | -| 3 | 型チェック | `node_modules/.bin/astro check`(0 errors) | -| 4 | E2E テスト | `npm run test:e2e`(env 不備で走らない場合は未完了の旨を明記して親に引き継ぐ) | +| 2 | 整形チェック | `npm run format:check`(CI の `test` ジョブが最初に走らせる。下記補足参照) | +| 3 | ユニットテスト全 pass | `npm run test` | +| 4 | 型チェック | `node_modules/.bin/astro check`(0 errors) | +| 5 | E2E テスト | `npm run test:e2e`(env 不備で走らない場合は未完了の旨を明記して親に引き継ぐ) | + +> **`format:check` を push 前必須にする理由**: CI の `test` ジョブは `format:check`(`prettier --check .`)→ lint → 型 → vitest の順で実行するため、`npm run test`(vitest のみ)がローカルで green でも `format:check` 崩れで CI が赤になる。特に **`Write` / `Edit` ツールで新規作成・編集した Markdown(設計 doc・実装計画・docs)** は、サブエージェントが prettier をかけるコードと違い整形漏れが起きやすい(PR #753 で設計 doc / 実装計画の整形崩れにより CI 赤 → 修正 → 再 push のラウンドトリップが発生)。`prettier --check .` は数秒で完了するため push 前に必ず通す。 > **ステップ 0 の補足**: fresh subagent isolation worktree では node_modules が存在しないため、素の `npm ci` のみで十分(過去の `scripts/agent-worktree-setup.sh` は不要と判明し、issue #241 / decisions [062] で廃止)。`.claude/settings.json` の SessionStart hook は session 開始時のみ fire するため、mid-session で `git worktree add` した worktree には適用されない。新規作成 worktree では作成直後に手動で `npm ci` を実行する必須ステップとして扱うこと。 > diff --git a/docs/playbooks/pr-creation.md b/docs/playbooks/pr-creation.md index cec47339..f4ce81fb 100644 --- a/docs/playbooks/pr-creation.md +++ b/docs/playbooks/pr-creation.md @@ -59,13 +59,14 @@ git rebase --onto origin/develop $(git merge-base HEAD origin/develop) HEAD 親セッションが直接 push する際は、以下をすべて確認する。 -| # | チェック項目 | コマンド | -| --- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| 0 | node_modules 整備 | `npm ci`(新規作成 worktree では必須。SessionStart hook は session 開始時のみ fire し mid-session 作成 worktree には適用されない) | -| 1 | develop ベース確認 | `git rev-parse origin/develop` と `git merge-base HEAD origin/develop` が一致 | -| 2 | スコープ外差分の確認 | `git diff origin/develop --name-only` で想定外ファイルがないか確認。aria-\* 削除行(`git diff` の `-` 行)がないか確認 | -| 3 | E2E 直列実行 | `npm run test:e2e`(preview 経由・複数 worktree がある場合は同時実行しない、詳細は `e2e-validation.md` 3 章) | -| 4 | PR ベース | `gh pr create --base develop` | +| # | チェック項目 | コマンド | +| --- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | node_modules 整備 | `npm ci`(新規作成 worktree では必須。SessionStart hook は session 開始時のみ fire し mid-session 作成 worktree には適用されない) | +| 1 | develop ベース確認 | `git rev-parse origin/develop` と `git merge-base HEAD origin/develop` が一致 | +| 2 | 整形チェック | `npm run format:check`(CI の `test` ジョブが最初に走らせる。`Write` / `Edit` で作成した Markdown の整形漏れを防ぐ。詳細は `e2e-validation.md` 2.1 節) | +| 3 | スコープ外差分の確認 | `git diff origin/develop --name-only` で想定外ファイルがないか確認。aria-\* 削除行(`git diff` の `-` 行)がないか確認 | +| 4 | E2E 直列実行 | `npm run test:e2e`(preview 経由・複数 worktree がある場合は同時実行しない、詳細は `e2e-validation.md` 3 章) | +| 5 | PR ベース | `gh pr create --base develop` | --- diff --git a/docs/superpowers/plans/2026-07-05-retro-skill.md b/docs/superpowers/plans/2026-07-05-retro-skill.md new file mode 100644 index 00000000..a48f332d --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-retro-skill.md @@ -0,0 +1,198 @@ +# retro スキル Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** PR マージ後の振り返りを構造化する手動起動スキル `retro`(第1弾 MVP)を、全エージェント共通のスキルとして追加する。 + +**Architecture:** 単一 `SKILL.md`(A案)に5ステップ手順・分類基準・停止ゲートを直書きし、`.claude/skills/retro` symlink で Claude Code から discoverable にする。`.agents/skills/README.md` の自作スキル行に登録。コード変更なし(Markdown + symlink のみ)。 + +**Tech Stack:** Markdown(スキル本体)、シンボリックリンク、既存自作スキル test-gates と同一構成。 + +**前提:** 作業ブランチ `chore/retro-skill`(origin/develop 起点、作成済み)。設計スペック = `docs/superpowers/specs/2026-07-05-retro-skill-design.md`。未コミットの `.claude/settings.json` 変更(`model`/`defaultMode`、ユーザー承認済み)が同ブランチに存在し、最終PRに同梱する。 + +--- + +## File Structure + +- Create: `.agents/skills/retro/SKILL.md` — スキル本体(フロントマター + 5ステップ手順 + 分類表) +- Create: `.claude/skills/retro` — symlink → `../../.agents/skills/retro`(Claude Code discovery 用) +- Modify: `.agents/skills/README.md` — 自作スキル行に `retro` を追記 + +--- + +## Task 1: スキル本体 SKILL.md を作成 + +**Files:** + +- Create: `.agents/skills/retro/SKILL.md` + +参考にする既存フォーマット: `.agents/skills/test-gates/SKILL.md`(フロントマターは `name` + `description` の2キーのみ、`---` で囲む)。 + +- [ ] **Step 1: `.agents/skills/retro/SKILL.md` を以下の内容で作成** + +```markdown +--- +name: retro +description: PR マージ後の振り返り(retro / レトロ / 振り返り)。対象PRの作業から気づき(手戻り・レビュー指摘・つまずき)を、レビューコメント・docs/agent-lessons.md・会話履歴の3ソースから抽出し、.agents/rules/common.md 11 章の基準で5分類に仕分けして、承認された分だけドキュメント改善PRを作る。ユーザーが `/retro`、「振り返り」「レトロ」「retro して」等と言ったとき発動。手動起動が主で、対象PRは引数指定または直近マージPR。 +--- + +# retro: PR マージ後の振り返りをドキュメント改善に落とす + +PR マージ後に、そのPRの作業から得られた気づきを抽出し、.agents/rules/common.md 11 章の基準で仕分けして、 +**再発防止に値するものだけ**をドキュメント改善PRに落とす手順。 + +自動分析が暴走して無関係な変更を提案しないよう、判定基準は厳格に。過剰な提案は形骸化を招くため +YAGNI 寄りに倒し、Step 4 で必ず停止してユーザー承認を挟む。 + +## Step 1 — 対象PRの特定 + +- `/retro [PR番号]` の引数があればそれを対象にする。 +- 引数省略時は直近マージPR(`gh pr list --state merged --limit 1 --json number,title`)を取得し、 + **「PR #N(タイトル)を対象にします。よいですか?」と確認してから**進む(誤爆防止)。 + +## Step 2 — 3ソース収集(会話履歴は best-effort) + +- **レビューコメント(主軸)**: `gh pr view --comments` +- **既存教訓(主軸)**: `docs/agent-lessons.md` を読み、繰り返し出ている教訓を把握 +- **会話履歴(best-effort)**: 同一セッションに実装ログが残っていれば手戻り・訂正を抽出。 + 別セッション起動で空ならスキップし、「会話履歴は取得できなかった」と明示(欠落を隠さない)。 + +## Step 3 — 仕分け判定(.agents/rules/common.md 11 章準拠、最終反映先へ直接ルーティング) + +各気づきを次の5分類に振り分ける。11章の「バッファ→昇格」モデルと二重化しないよう、 +**最終反映先へ直接**振り分ける(全部を agent-lessons バッファに通さない)。 + +| 分類 | 反映先 | 判定基準 | +| -------------------------------- | ------------------------------- | ------------------------------------------- | +| (a) 再発防止に値する共通規約 | `.agents/rules/common.md` | 全エージェント・全開発に適用される | +| (b) Claude 固有の運用改善 | `CLAUDE.md` / `.claude/rules/*` | Claude Code の harness 挙動・権限に紐づく | +| (c) 手順が複雑・再利用性が高い | 新規 skill 化提案 | 3ステップ以上の定型手順、覚えにくいフラグ群 | +| (d) 特定ツール紐付きの実装メモ | `docs/agent-lessons.md` 追記 | 個別コンポーネントのリスク・実装知見 | +| (e) 一度限りの TIP/既に強制済み | 破棄 | コード・Hook・lint で既に担保 | + +## Step 4 — 提案の提示(ここで必ず停止) + +仕分け結果を表で提示する(各行: 気づき / 分類 / 反映先ファイル / 変更概要)。 +**(e) 破棄も含めて判定理由を明示**する。ここで停止し、ユーザーが承認/却下を選ぶ。 +判定に迷うものは (e) 側(破棄)に倒し、過剰提案を避ける。 + +## Step 5 — 承認分のPR作成 + +承認された変更のみ `chore/retro-` ブランチ(**origin/develop 起点**を明示)で実装し、 +`--base develop` で PR を作成する(.agents/rules/common.md 6 章/`docs/playbooks/pr-creation.md` 準拠)。 +本文は必ずファイル経由(`--body-file`)で渡す。 +(c) skill 化提案が承認された場合は `writing-skills` スキルに委譲する。 + +## やらないこと + +- マージ検知の自動化(PostToolUse フック等)は第1弾スコープ外。設計上の留保は + `docs/superpowers/specs/2026-07-05-retro-skill-design.md` を参照。 +``` + +- [ ] **Step 2: フロントマターの妥当性を確認** + +Run: `head -3 .agents/skills/retro/SKILL.md` +Expected: 1行目 `---`、2行目 `name: retro`、3行目が `description:` で始まる(test-gates と同一構造)。 + +- [ ] **Step 3: コミット** + +```bash +git add .agents/skills/retro/SKILL.md +git commit -m "feat: retro スキル本体を追加(PR マージ後の振り返り手順) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Claude Code discovery 用 symlink を作成 + +**Files:** + +- Create: `.claude/skills/retro` → `../../.agents/skills/retro` + +既存の symlink 実例: `.claude/skills/test-gates -> ../../.agents/skills/test-gates`(相対パス)。 + +- [ ] **Step 1: symlink を作成** + +```bash +ln -s ../../.agents/skills/retro .claude/skills/retro +``` + +- [ ] **Step 2: symlink が本体を指すことを検証** + +```bash +ls -l .claude/skills/retro +cat .claude/skills/retro/SKILL.md | head -3 +``` + +Expected: `retro -> ../../.agents/skills/retro` と表示され、`head` が Task 1 のフロントマター(`name: retro`)を出力する(=解決成功)。 + +- [ ] **Step 3: コミット** + +```bash +git add .claude/skills/retro +git commit -m "feat: retro スキルの Claude Code discovery 用 symlink を追加 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: 自作スキル出典表に retro を追記 + +**Files:** + +- Modify: `.agents/skills/README.md` + +- [ ] **Step 1: 該当行を確認** + +Run: `grep -n "本リポジトリ自作" .agents/skills/README.md` +Expected: `dads-design-system / test-gates | 本リポジトリ自作 | 本リポジトリのライセンスに従う` の行が見つかる。 + +- [ ] **Step 2: その行のスキル名リストに `retro` を追記** + +`dads-design-system / test-gates` を `dads-design-system / test-gates / retro` に変更する(Edit tool で該当セル文字列のみ置換。表の桁揃えは Markdown レンダリング上不問だが、既存の空白パディングは崩さない)。 + +- [ ] **Step 3: 追記を確認** + +Run: `grep -n "retro" .agents/skills/README.md` +Expected: 上記行に `retro` が含まれる。 + +- [ ] **Step 4: コミット** + +```bash +git add .agents/skills/README.md +git commit -m "docs: 自作スキル出典表に retro を追記 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: 最終検証 + +**Files:** なし(検証のみ) + +- [ ] **Step 1: Markdown 整形チェック** + +Run: `npm run format:check` +Expected: PASS(新規 SKILL.md・README 追記・spec/plan が prettier 準拠)。失敗したら `npm run format` で整形し、`git add` + `git commit -m "style: prettier 整形"` する。 + +- [ ] **Step 2: meta テストが壊れていないこと** + +Run: `npm run test` +Expected: PASS(スキル参照の meta テストは無いが、docs-section-references 等の既存 meta テストに影響がないことを確認)。 + +- [ ] **Step 3: symlink 経由でスキル本体が読めることを最終確認** + +Run: `test -f .claude/skills/retro/SKILL.md && echo OK` +Expected: `OK` + +--- + +## Self-Review 結果 + +- **Spec coverage**: 配置3成果物(本体/symlink/README)= Task 1-3、インターフェース(フロントマター description・引数省略時確認)= Task 1 Step 1 本文、5ステップ手順 = Task 1 本文、検証方針 = Task 4。settings.json 同梱は既存の未コミット変更で本plan外(PR段階で同梱)。網羅済み。 +- **Placeholder scan**: TBD/TODO/「適切に」等なし。全 Step に実コマンド/実内容を記載。 +- **Type consistency**: パス `.agents/skills/retro/SKILL.md` / symlink `../../.agents/skills/retro` は全 Task で一貫。 diff --git a/docs/superpowers/plans/2026-07-17-saml-decoder.md b/docs/superpowers/plans/2026-07-17-saml-decoder.md new file mode 100644 index 00000000..4673ca14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-saml-decoder.md @@ -0,0 +1,1882 @@ +# SAMLデコーダ(saml-decoder)実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **実装サブエージェントは `model: sonnet` で dispatch すること(ユーザー指示)。** + +**Goal:** SAMLResponse / AuthnRequest を貼り付けると入力形式(URL / URLエンコード / base64 / base64+deflate / 生XML)を自動判定してデコードし、Assertion の構造表示と定番チェック(Status / 有効期間 / Audience / NameID)を行うブラウザ完結ツールを追加する。 + +**Architecture:** ロジックは `src/utils/saml/`(cert / har と同じディレクトリ分割: types / decode / parse / checks / format / index)に置き、UI は `src/components/tools/SamlDecoder.tsx` から利用する。XML は `DOMParser` で名前空間 URI ベースにパースし prefix 非依存。deflate 展開のみ新規依存 `fflate` を使う。 + +**Tech Stack:** React + TypeScript, DOMParser, fflate(新規・唯一の追加依存), Vitest(`@vitest-environment jsdom`), Playwright。 + +**Spec:** `docs/superpowers/specs/2026-07-17-saml-decoder-design.md` + +--- + +## ファイル構成 + +| 種別 | パス | 責務 | +| :----- | :-------------------------------------------------------------- | :------------------------------------------------------- | +| Create | `src/utils/saml/types.ts` | 型定義のみ | +| Create | `src/utils/saml/decode.ts` | 入力形式の自動判定とデコードチェーン | +| Create | `src/utils/saml/parse.ts` | XML → 構造化モデル(Response / AuthnRequest) | +| Create | `src/utils/saml/checks.ts` | Response の定番チェックリスト(検知機構 → 陽性対照必須) | +| Create | `src/utils/saml/format.ts` | XML 整形(表示用) | +| Create | `src/utils/saml/index.ts` | re-export | +| Create | `src/utils/__tests__/saml-fixtures.ts` | テスト用 XML フィクスチャ | +| Create | `src/utils/__tests__/saml-decode.test.ts` | decode のユニットテスト | +| Create | `src/utils/__tests__/saml-parse.test.ts` | parse のユニットテスト | +| Create | `src/utils/__tests__/saml-checks.test.ts` | checks のユニットテスト(陽性対照含む) | +| Create | `src/utils/__tests__/saml-format.test.ts` | format のユニットテスト | +| Create | `src/components/tools/SamlDecoder.tsx` | ツール UI | +| Create | `src/pages/tools/saml-decoder.astro` | ページ | +| Create | `tests/e2e/saml-decoder.spec.ts` | E2E(陽性対照含む) | +| Modify | `src/data/tools.ts` | ツールエントリ追加 | +| Modify | `tests/e2e/visual-regression-pages.ts` | `PAGES` に追加 | +| Modify | `package.json` / `package-lock.json` | fflate 追加 | +| Modify | `README.md` / `SPEC.md` / `docs/tools.md` / `docs/decisions.md` | ドキュメント更新 | + +前提: ブランチ `feat/saml-decoder` 上で作業(作成済み)。コミットメッセージは Conventional Commits + 日本語。`git add` は明示 pathspec のみ(`-A` 禁止)。 + +--- + +### Task 1: fflate 依存追加 + +**Files:** + +- Modify: `package.json`, `package-lock.json` + +- [ ] **Step 1: インストール** + +```bash +npm install fflate --no-audit --no-fund --cache "$TMPDIR/npm-cache" +``` + +- [ ] **Step 2: 確認** + +```bash +grep '"fflate"' package.json && node -e "const {deflateSync,decompressSync}=require('fflate');const d=deflateSync(new TextEncoder().encode(''));console.log(new TextDecoder().decode(decompressSync(d)))" +``` + +Expected: `"fflate": "^0.8.x"` の行と `` が出力される。 + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "build: SAMLデコーダ用に fflate を追加" +``` + +--- + +### Task 2: 型定義とデコードチェーン(`decode.ts`) + +**Files:** + +- Create: `src/utils/saml/types.ts` +- Create: `src/utils/saml/decode.ts` +- Create: `src/utils/__tests__/saml-decode.test.ts` + +- [ ] **Step 1: 型定義を作成**(テスト不要、次ステップの前提) + +`src/utils/saml/types.ts`: + +```ts +/** 入力の由来バインディング */ +export type SamlBinding = 'redirect' | 'post' | 'xml'; + +export interface DecodedInput { + xml: string; + /** 適用した変換ステップ(UI 表示用、適用順) */ + steps: string[]; + binding: SamlBinding; +} + +export interface SamlAttribute { + name: string; + friendlyName?: string; + values: string[]; +} + +export interface SamlConditions { + notBefore?: string; + notOnOrAfter?: string; + audiences: string[]; +} + +export interface SamlSubjectConfirmation { + method?: string; + recipient?: string; + notOnOrAfter?: string; + inResponseTo?: string; +} + +export interface SamlAuthnStatement { + authnInstant?: string; + sessionIndex?: string; + authnContextClassRef?: string; +} + +export interface SamlAssertion { + id?: string; + issuer?: string; + nameId?: string; + nameIdFormat?: string; + attributes: SamlAttribute[]; + conditions?: SamlConditions; + authnStatements: SamlAuthnStatement[]; + subjectConfirmations: SamlSubjectConfirmation[]; + /** Assertion 直下に ds:Signature を持つか(存在表示のみ、検証はしない) */ + signed: boolean; +} + +export interface SamlResponseData { + type: 'response'; + issuer?: string; + statusCode?: string; + statusMessage?: string; + destination?: string; + inResponseTo?: string; + issueInstant?: string; + /** Response 直下に ds:Signature を持つか */ + signed: boolean; + assertions: SamlAssertion[]; + encryptedAssertionCount: number; +} + +export interface SamlAuthnRequestData { + type: 'authnRequest'; + issuer?: string; + destination?: string; + acsUrl?: string; + protocolBinding?: string; + issueInstant?: string; + nameIdPolicyFormat?: string; + allowCreate?: string; + authnContextClassRefs: string[]; + signed: boolean; +} + +export type SamlMessage = SamlResponseData | SamlAuthnRequestData; + +export type CheckStatus = 'success' | 'warning' | 'error' | 'info'; + +export interface CheckItem { + id: string; + label: string; + status: CheckStatus; + detail: string; +} +``` + +- [ ] **Step 2: フィクスチャを作成** + +`src/utils/__tests__/saml-fixtures.ts`: + +```ts +/** + * SAML テスト用フィクスチャ。 + * 日時は固定(2026-07-17 00:00Z 周辺)。checks のテストは now を注入して有効/期限切れを切り替える。 + */ +export const SAMPLE_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + + taro.yamada@example.com + + + + + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + taro.yamada@example.com + 山田 太郎 + devadmin + + +`; + +export const FAILED_STATUS_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + Authentication failed + +`; + +export const ENCRYPTED_ASSERTION_RESPONSE_XML = ` + + https://idp.example.com/metadata + + +`; + +export const AUTHN_REQUEST_XML = ` + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + +`; + +/** UTF-8 → base64(マルチバイト対応。btoa 直呼びは日本語で例外になるため必須) */ +export function toBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} +``` + +- [ ] **Step 3: decode の失敗するテストを書く** + +`src/utils/__tests__/saml-decode.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { deflateSync } from 'fflate'; +import { decodeSamlInput } from '@/utils/saml'; +import { SAMPLE_RESPONSE_XML, AUTHN_REQUEST_XML, toBase64 } from './saml-fixtures'; + +function deflateBase64(xml: string): string { + const compressed = deflateSync(new TextEncoder().encode(xml)); + let bin = ''; + for (const b of compressed) bin += String.fromCharCode(b); + return btoa(bin); +} + +describe('decodeSamlInput', () => { + it('生 XML をそのまま返す', () => { + const r = decodeSamlInput(SAMPLE_RESPONSE_XML); + expect(r.binding).toBe('xml'); + expect(r.xml).toBe(SAMPLE_RESPONSE_XML); + }); + + it('base64(HTTP-POST binding)をデコードする', () => { + const r = decodeSamlInput(toBase64(SAMPLE_RESPONSE_XML)); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + const b64 = toBase64(SAMPLE_RESPONSE_XML).replace(/(.{60})/g, '$1\n'); + expect(decodeSamlInput(b64).binding).toBe('post'); + }); + + it('base64 + deflate(HTTP-Redirect binding)を展開する', () => { + const r = decodeSamlInput(deflateBase64(AUTHN_REQUEST_XML)); + expect(r.binding).toBe('redirect'); + expect(r.xml).toContain(' { + const r = decodeSamlInput(encodeURIComponent(deflateBase64(AUTHN_REQUEST_XML))); + expect(r.binding).toBe('redirect'); + }); + + it('URL 全体から SAMLRequest パラメータを抽出する', () => { + const url = `https://idp.example.com/sso?SAMLRequest=${encodeURIComponent(deflateBase64(AUTHN_REQUEST_XML))}&RelayState=abc`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('redirect'); + expect(r.steps[0]).toBe('URL からパラメータ抽出'); + expect(r.xml).toContain(' { + const url = `https://sp.example.com/acs?SAMLResponse=${encodeURIComponent(toBase64(SAMPLE_RESPONSE_XML))}`; + expect(decodeSamlInput(url).xml).toContain(' { + expect(() => decodeSamlInput('https://example.com/?foo=bar')).toThrow( + /SAMLResponse \/ SAMLRequest/ + ); + }); + + it('base64 でない文字列はエラー', () => { + expect(() => decodeSamlInput('これはSAMLではない')).toThrow(); + }); + + it('base64 だが中身が XML でない場合はエラー', () => { + expect(() => decodeSamlInput(toBase64('hello world'))).toThrow(/XML ではありません/); + }); + + it('空入力はエラー', () => { + expect(() => decodeSamlInput(' ')).toThrow(/入力が空/); + }); +}); +``` + +- [ ] **Step 4: 失敗を確認** + +Run: `npm run test -- src/utils/__tests__/saml-decode.test.ts` +Expected: FAIL(`@/utils/saml` が存在しない) + +- [ ] **Step 5: decode を実装** + +`src/utils/saml/decode.ts`: + +```ts +import { decompressSync } from 'fflate'; +import type { DecodedInput } from './types'; + +const utf8 = new TextDecoder('utf-8', { fatal: true }); + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +/** + * SAML メッセージ入力の自動判定デコード。 + * URL 全体 / URL エンコード base64 / base64(POST)/ base64+deflate(Redirect)/ 生 XML に対応。 + */ +export function decodeSamlInput(raw: string): DecodedInput { + const steps: string[] = []; + let text = raw.trim(); + if (!text) throw new Error('入力が空です'); + + // 1. URL 全体 → SAMLResponse / SAMLRequest パラメータ抽出(searchParams.get は URL デコード済みを返す) + if (/^https?:\/\//i.test(text)) { + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error('URL として解釈できません'); + } + const param = url.searchParams.get('SAMLResponse') ?? url.searchParams.get('SAMLRequest'); + if (!param) throw new Error('URL に SAMLResponse / SAMLRequest パラメータが見つかりません'); + steps.push('URL からパラメータ抽出'); + text = param; + } + + // 2. 生 XML + if (text.startsWith('<')) { + return { xml: text, steps: [...steps, '生 XML と判定'], binding: 'xml' }; + } + + // 3. URL エンコード解除(%xx を含む場合のみ。復号失敗はそのまま続行) + if (/%[0-9a-fA-F]{2}/.test(text)) { + try { + text = decodeURIComponent(text); + steps.push('URL デコード'); + } catch { + /* %xx が偶然含まれる base64 の可能性があるため無視 */ + } + } + + // 4. base64 + let bytes: Uint8Array; + try { + bytes = base64ToBytes(text.replace(/\s+/g, '')); + } catch { + throw new Error( + 'base64 として解釈できません(SAMLResponse / SAMLRequest の値か確認してください)' + ); + } + steps.push('base64 デコード'); + + // 5. そのまま XML → HTTP-POST binding + try { + const asText = utf8.decode(bytes); + if (asText.trimStart().startsWith('<')) { + return { xml: asText, steps, binding: 'post' }; + } + } catch { + /* UTF-8 でない → deflate 圧縮の可能性 */ + } + + // 6. deflate 展開 → HTTP-Redirect binding(decompressSync は raw deflate / zlib / gzip を自動判定) + let inflated: string; + try { + inflated = utf8.decode(decompressSync(bytes)); + } catch { + throw new Error('デコード結果が XML ではありません(deflate 展開にも失敗しました)'); + } + if (!inflated.trimStart().startsWith('<')) { + throw new Error('デコード結果が XML ではありません(SAML メッセージか確認してください)'); + } + return { xml: inflated, steps: [...steps, 'deflate 展開'], binding: 'redirect' }; +} +``` + +`src/utils/saml/index.ts`(この時点では decode と types のみ。Task 3〜4 で追記): + +```ts +export * from './types'; +export { decodeSamlInput } from './decode'; +``` + +- [ ] **Step 6: テストが通ることを確認** + +Run: `npm run test -- src/utils/__tests__/saml-decode.test.ts` +Expected: PASS(全ケース) + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/saml/types.ts src/utils/saml/decode.ts src/utils/saml/index.ts src/utils/__tests__/saml-fixtures.ts src/utils/__tests__/saml-decode.test.ts +git commit -m "feat: SAML 入力の自動判定デコードチェーンを追加" +``` + +--- + +### Task 3: XML パーサ(`parse.ts`) + +**Files:** + +- Create: `src/utils/saml/parse.ts` +- Modify: `src/utils/saml/index.ts` +- Create: `src/utils/__tests__/saml-parse.test.ts` + +- [ ] **Step 1: 失敗するテストを書く** + +`src/utils/__tests__/saml-parse.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { parseSamlXml } from '@/utils/saml'; +import { + SAMPLE_RESPONSE_XML, + FAILED_STATUS_RESPONSE_XML, + ENCRYPTED_ASSERTION_RESPONSE_XML, + AUTHN_REQUEST_XML, +} from './saml-fixtures'; + +describe('parseSamlXml: Response', () => { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.destination).toBe('https://sp.example.com/acs'); + expect(m.inResponseTo).toBe('_req1'); + expect(m.issueInstant).toBe('2026-07-17T00:00:00Z'); + expect(m.signed).toBe(false); + expect(m.encryptedAssertionCount).toBe(0); + }); + + it('Assertion の Subject / Conditions / AuthnStatement を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.assertions).toHaveLength(1); + const a = m.assertions[0]; + expect(a.nameId).toBe('taro.yamada@example.com'); + expect(a.nameIdFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(a.conditions?.notBefore).toBe('2026-07-16T23:55:00Z'); + expect(a.conditions?.notOnOrAfter).toBe('2026-07-17T00:05:00Z'); + expect(a.conditions?.audiences).toEqual(['https://sp.example.com/metadata']); + expect(a.authnStatements[0].sessionIndex).toBe('_s1'); + expect(a.authnStatements[0].authnContextClassRef).toContain('PasswordProtectedTransport'); + expect(a.subjectConfirmations[0].recipient).toBe('https://sp.example.com/acs'); + expect(a.subjectConfirmations[0].method).toBe('urn:oasis:names:tc:SAML:2.0:cm:bearer'); + }); + + it('属性(複数値・FriendlyName 含む)を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + const attrs = m.assertions[0].attributes; + expect(attrs).toHaveLength(3); + expect(attrs[1]).toEqual({ + name: 'displayName', + friendlyName: '表示名', + values: ['山田 太郎'], + }); + expect(attrs[2].values).toEqual(['dev', 'admin']); + }); + + it('Status 失敗レスポンスの StatusMessage を抽出する', () => { + const m = parseSamlXml(FAILED_STATUS_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Responder'); + expect(m.statusMessage).toBe('Authentication failed'); + expect(m.assertions).toHaveLength(0); + }); + + it('EncryptedAssertion を数える', () => { + const m = parseSamlXml(ENCRYPTED_ASSERTION_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.encryptedAssertionCount).toBe(1); + expect(m.assertions).toHaveLength(0); + }); +}); + +describe('parseSamlXml: AuthnRequest', () => { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(AUTHN_REQUEST_XML); + if (m.type !== 'authnRequest') throw new Error('authnRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.destination).toBe('https://idp.example.com/sso'); + expect(m.acsUrl).toBe('https://sp.example.com/acs'); + expect(m.protocolBinding).toBe('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); + expect(m.nameIdPolicyFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(m.allowCreate).toBe('true'); + expect(m.authnContextClassRefs).toEqual([ + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ]); + }); +}); + +describe('parseSamlXml: 異常系', () => { + it('壊れた XML はエラー', () => { + expect(() => parseSamlXml('')).toThrow(/XML/); + }); + + it('SAML 以外の XML はエラー', () => { + expect(() => parseSamlXml('')).toThrow(/対応していない/); + }); + + it('LogoutRequest は未対応としてエラー', () => { + const xml = + ''; + expect(() => parseSamlXml(xml)).toThrow(/LogoutRequest/); + }); +}); +``` + +- [ ] **Step 2: 失敗を確認** + +Run: `npm run test -- src/utils/__tests__/saml-parse.test.ts` +Expected: FAIL(`parseSamlXml` 未定義) + +- [ ] **Step 3: parse を実装** + +`src/utils/saml/parse.ts`: + +```ts +import type { + SamlAssertion, + SamlAttribute, + SamlAuthnRequestData, + SamlMessage, + SamlResponseData, +} from './types'; + +const NS_P = 'urn:oasis:names:tc:SAML:2.0:protocol'; +const NS_A = 'urn:oasis:names:tc:SAML:2.0:assertion'; +const NS_DS = 'http://www.w3.org/2000/09/xmldsig#'; + +/** 直下の子要素のみを名前空間 URI + localName で探す(prefix 非依存・ネスト混入防止) */ +function childNS(el: Element, ns: string, local: string): Element | undefined { + return Array.from(el.children).find((c) => c.namespaceURI === ns && c.localName === local); +} + +function childrenNS(el: Element, ns: string, local: string): Element[] { + return Array.from(el.children).filter((c) => c.namespaceURI === ns && c.localName === local); +} + +function textOf(el: Element | undefined): string | undefined { + const t = el?.textContent?.trim(); + return t || undefined; +} + +function attrOf(el: Element | undefined, name: string): string | undefined { + return el?.getAttribute(name) ?? undefined; +} + +function hasDirectSignature(el: Element): boolean { + return childNS(el, NS_DS, 'Signature') !== undefined; +} + +/** + * SAML XML を構造化モデルへパースする。 + * 対応: Response / AuthnRequest。それ以外の SAML メッセージ型はエラー。 + */ +export function parseSamlXml(xml: string): SamlMessage { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + throw new Error('XML の構文エラーがあります'); + } + const root = doc.documentElement; + if (root.namespaceURI === NS_P && root.localName === 'Response') return parseResponse(root); + if (root.namespaceURI === NS_P && root.localName === 'AuthnRequest') + return parseAuthnRequest(root); + throw new Error( + `対応していない SAML メッセージです(${root.localName})。対応形式: Response / AuthnRequest` + ); +} + +function parseResponse(root: Element): SamlResponseData { + const status = childNS(root, NS_P, 'Status'); + return { + type: 'response', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + statusCode: status ? attrOf(childNS(status, NS_P, 'StatusCode'), 'Value') : undefined, + statusMessage: status ? textOf(childNS(status, NS_P, 'StatusMessage')) : undefined, + destination: attrOf(root, 'Destination'), + inResponseTo: attrOf(root, 'InResponseTo'), + issueInstant: attrOf(root, 'IssueInstant'), + signed: hasDirectSignature(root), + assertions: childrenNS(root, NS_A, 'Assertion').map(parseAssertion), + encryptedAssertionCount: childrenNS(root, NS_A, 'EncryptedAssertion').length, + }; +} + +function parseAssertion(el: Element): SamlAssertion { + const subject = childNS(el, NS_A, 'Subject'); + const nameId = subject && childNS(subject, NS_A, 'NameID'); + const conditions = childNS(el, NS_A, 'Conditions'); + const attrStatement = childNS(el, NS_A, 'AttributeStatement'); + return { + id: attrOf(el, 'ID'), + issuer: textOf(childNS(el, NS_A, 'Issuer')), + nameId: textOf(nameId), + nameIdFormat: attrOf(nameId, 'Format'), + attributes: attrStatement + ? childrenNS(attrStatement, NS_A, 'Attribute').map(parseAttribute) + : [], + conditions: conditions + ? { + notBefore: attrOf(conditions, 'NotBefore'), + notOnOrAfter: attrOf(conditions, 'NotOnOrAfter'), + audiences: childrenNS(conditions, NS_A, 'AudienceRestriction').flatMap((ar) => + childrenNS(ar, NS_A, 'Audience').flatMap((a) => textOf(a) ?? []) + ), + } + : undefined, + authnStatements: childrenNS(el, NS_A, 'AuthnStatement').map((s) => { + const ctx = childNS(s, NS_A, 'AuthnContext'); + return { + authnInstant: attrOf(s, 'AuthnInstant'), + sessionIndex: attrOf(s, 'SessionIndex'), + authnContextClassRef: ctx ? textOf(childNS(ctx, NS_A, 'AuthnContextClassRef')) : undefined, + }; + }), + subjectConfirmations: subject + ? childrenNS(subject, NS_A, 'SubjectConfirmation').map((sc) => { + const data = childNS(sc, NS_A, 'SubjectConfirmationData'); + return { + method: attrOf(sc, 'Method'), + recipient: attrOf(data, 'Recipient'), + notOnOrAfter: attrOf(data, 'NotOnOrAfter'), + inResponseTo: attrOf(data, 'InResponseTo'), + }; + }) + : [], + signed: hasDirectSignature(el), + }; +} + +function parseAttribute(el: Element): SamlAttribute { + return { + name: attrOf(el, 'Name') ?? '(名前なし)', + friendlyName: attrOf(el, 'FriendlyName'), + values: childrenNS(el, NS_A, 'AttributeValue').map((v) => v.textContent?.trim() ?? ''), + }; +} + +function parseAuthnRequest(root: Element): SamlAuthnRequestData { + const nameIdPolicy = childNS(root, NS_P, 'NameIDPolicy'); + const requestedCtx = childNS(root, NS_P, 'RequestedAuthnContext'); + return { + type: 'authnRequest', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + destination: attrOf(root, 'Destination'), + acsUrl: attrOf(root, 'AssertionConsumerServiceURL'), + protocolBinding: attrOf(root, 'ProtocolBinding'), + issueInstant: attrOf(root, 'IssueInstant'), + nameIdPolicyFormat: attrOf(nameIdPolicy, 'Format'), + allowCreate: attrOf(nameIdPolicy, 'AllowCreate'), + authnContextClassRefs: requestedCtx + ? childrenNS(requestedCtx, NS_A, 'AuthnContextClassRef').flatMap((e) => textOf(e) ?? []) + : [], + signed: hasDirectSignature(root), + }; +} +``` + +`src/utils/saml/index.ts` に追記: + +```ts +export { parseSamlXml } from './parse'; +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `npm run test -- src/utils/__tests__/saml-parse.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/saml/parse.ts src/utils/saml/index.ts src/utils/__tests__/saml-parse.test.ts +git commit -m "feat: SAML Response / AuthnRequest の構造化パーサを追加" +``` + +--- + +### Task 4: 定番チェックリスト(`checks.ts`)— 検知機構・陽性対照必須 + +> ⚠️ このタスクは検知機構の実装。**`.agents/rules/common.md` 3 章に従い test-gates skill の陽性対照ルールを適用**する。「fail するべき入力で実際に error になる」テストを必ず含める(下記テストの `陽性対照` describe)。 + +**Files:** + +- Create: `src/utils/saml/checks.ts` +- Modify: `src/utils/saml/index.ts` +- Create: `src/utils/__tests__/saml-checks.test.ts` + +- [ ] **Step 1: 失敗するテストを書く** + +`src/utils/__tests__/saml-checks.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { parseSamlXml, runResponseChecks } from '@/utils/saml'; +import type { SamlResponseData } from '@/utils/saml'; +import { + SAMPLE_RESPONSE_XML, + FAILED_STATUS_RESPONSE_XML, + ENCRYPTED_ASSERTION_RESPONSE_XML, +} from './saml-fixtures'; + +function parseResponse(xml: string): SamlResponseData { + const m = parseSamlXml(xml); + if (m.type !== 'response') throw new Error('response expected'); + return m; +} + +// フィクスチャの有効期間: 2026-07-16T23:55:00Z 〜 2026-07-17T00:05:00Z +const IN_WINDOW = new Date('2026-07-17T00:02:00Z'); +const AFTER_WINDOW = new Date('2026-07-17T01:00:00Z'); +const BEFORE_WINDOW = new Date('2026-07-16T23:00:00Z'); + +function byId(items: ReturnType, id: string) { + const item = items.find((i) => i.id === id); + if (!item) throw new Error(`check item not found: ${id}`); + return item; +} + +describe('runResponseChecks: 正常系', () => { + const res = parseResponse(SAMPLE_RESPONSE_XML); + + it('Status Success は success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'status').status).toBe('success'); + }); + + it('有効期間内は success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'validity-0').status).toBe('success'); + }); + + it('SP entityID 未入力の Audience は info(表示のみ)', () => { + const item = byId(runResponseChecks(res, { now: IN_WINDOW }), 'audience'); + expect(item.status).toBe('info'); + expect(item.detail).toContain('https://sp.example.com/metadata'); + }); + + it('SP entityID 一致は success', () => { + const item = byId( + runResponseChecks(res, { now: IN_WINDOW, spEntityId: 'https://sp.example.com/metadata' }), + 'audience' + ); + expect(item.status).toBe('success'); + }); + + it('NameID ありは success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'nameid').status).toBe('success'); + }); +}); + +describe('runResponseChecks: 陽性対照(fail 側の検知能力を実証)', () => { + it('Status Responder は error になり StatusMessage を含む', () => { + const item = byId(runResponseChecks(parseResponse(FAILED_STATUS_RESPONSE_XML)), 'status'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('Responder'); + expect(item.detail).toContain('Authentication failed'); + }); + + it('期限切れ(NotOnOrAfter 経過)は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { now: AFTER_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('期限切れ'); + }); + + it('NotOnOrAfter ちょうどは仕様通り期限切れ(境界値: NotOnOrAfter は排他)', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { + now: new Date('2026-07-17T00:05:00Z'), + }), + 'validity-0' + ); + expect(item.status).toBe('error'); + }); + + it('有効期間前(NotBefore 未到達)は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { now: BEFORE_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('有効期間前'); + }); + + it('SP entityID 不一致は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { + now: IN_WINDOW, + spEntityId: 'https://other.example.com', + }), + 'audience' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('不一致'); + }); + + it('EncryptedAssertion のみの Response は warning になる', () => { + const item = byId( + runResponseChecks(parseResponse(ENCRYPTED_ASSERTION_RESPONSE_XML)), + 'assertion' + ); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('暗号化'); + }); + + it('Assertion なし(失敗レスポンス)は error になる', () => { + const item = byId(runResponseChecks(parseResponse(FAILED_STATUS_RESPONSE_XML)), 'assertion'); + expect(item.status).toBe('error'); + }); +}); +``` + +- [ ] **Step 2: 失敗を確認** + +Run: `npm run test -- src/utils/__tests__/saml-checks.test.ts` +Expected: FAIL(`runResponseChecks` 未定義) + +- [ ] **Step 3: checks を実装** + +`src/utils/saml/checks.ts`: + +```ts +import type { CheckItem, SamlResponseData } from './types'; + +const STATUS_SUCCESS = 'urn:oasis:names:tc:SAML:2.0:status:Success'; + +export interface CheckOptions { + /** テスト用に注入可能な現在時刻(省略時は実時刻) */ + now?: Date; + /** SP entityID。入力時のみ Audience と厳密一致で照合する */ + spEntityId?: string; +} + +/** Response の定番チェックリストを実行する(AuthnRequest には適用しない) */ +export function runResponseChecks(res: SamlResponseData, opts: CheckOptions = {}): CheckItem[] { + const now = opts.now ?? new Date(); + const items: CheckItem[] = []; + + // 1. Status + if (res.statusCode === STATUS_SUCCESS) { + items.push({ id: 'status', label: 'Status', status: 'success', detail: 'Success' }); + } else { + const code = res.statusCode?.split(':').pop() ?? '不明'; + items.push({ + id: 'status', + label: 'Status', + status: 'error', + detail: res.statusMessage + ? `${code}(StatusMessage: ${res.statusMessage})` + : `${code}(Success ではありません)`, + }); + } + + // 2. Assertion 有無(無ければ以降のチェックは打ち切り) + if (res.assertions.length === 0) { + items.push({ + id: 'assertion', + label: 'Assertion', + status: res.encryptedAssertionCount > 0 ? 'warning' : 'error', + detail: + res.encryptedAssertionCount > 0 + ? '暗号化されており内容を確認できません(復号は非対応)' + : 'Assertion が含まれていません', + }); + return items; + } + + // 3. 有効期間(NotOnOrAfter は SAML 仕様上その時刻自体を含まない排他境界) + res.assertions.forEach((a, i) => { + const label = res.assertions.length > 1 ? `有効期間 (Assertion ${i + 1})` : '有効期間'; + const c = a.conditions; + if (!c || (!c.notBefore && !c.notOnOrAfter)) { + items.push({ + id: `validity-${i}`, + label, + status: 'warning', + detail: 'Conditions に有効期間の指定がありません', + }); + return; + } + const notBefore = c.notBefore ? new Date(c.notBefore) : undefined; + const notOnOrAfter = c.notOnOrAfter ? new Date(c.notOnOrAfter) : undefined; + if (notBefore && now < notBefore) { + items.push({ + id: `validity-${i}`, + label, + status: 'error', + detail: `有効期間前です(NotBefore: ${c.notBefore})。IdP / SP の時刻ずれ(クロックスキュー)の可能性があります`, + }); + } else if (notOnOrAfter && now >= notOnOrAfter) { + items.push({ + id: `validity-${i}`, + label, + status: 'error', + detail: `期限切れです(NotOnOrAfter: ${c.notOnOrAfter})`, + }); + } else { + items.push({ + id: `validity-${i}`, + label, + status: 'success', + detail: `有効期間内です(${c.notBefore ?? '-'} 〜 ${c.notOnOrAfter ?? '-'})`, + }); + } + }); + + // 4. Audience(SP entityID 入力時のみ照合、未入力は表示のみ) + const audiences = [...new Set(res.assertions.flatMap((a) => a.conditions?.audiences ?? []))]; + const sp = opts.spEntityId?.trim(); + if (audiences.length === 0) { + items.push({ + id: 'audience', + label: 'Audience', + status: 'warning', + detail: 'AudienceRestriction がありません', + }); + } else if (!sp) { + items.push({ id: 'audience', label: 'Audience', status: 'info', detail: audiences.join(', ') }); + } else if (audiences.includes(sp)) { + items.push({ + id: 'audience', + label: 'Audience', + status: 'success', + detail: `SP entityID と一致します(${sp})`, + }); + } else { + items.push({ + id: 'audience', + label: 'Audience', + status: 'error', + detail: `SP entityID と不一致です(Audience: ${audiences.join(', ')})`, + }); + } + + // 5. Recipient(表示のみ) + const recipients = [ + ...new Set( + res.assertions.flatMap((a) => + a.subjectConfirmations.flatMap((s) => (s.recipient ? [s.recipient] : [])) + ) + ), + ]; + items.push({ + id: 'recipient', + label: 'Recipient', + status: recipients.length > 0 ? 'info' : 'warning', + detail: + recipients.length > 0 + ? recipients.join(', ') + : 'SubjectConfirmationData に Recipient がありません', + }); + + // 6. NameID + const hasNameId = res.assertions.some((a) => a.nameId); + items.push({ + id: 'nameid', + label: 'NameID', + status: hasNameId ? 'success' : 'warning', + detail: hasNameId + ? 'NameID が含まれています' + : 'NameID が含まれていません(SP 側でユーザを特定できない可能性があります)', + }); + + return items; +} +``` + +`src/utils/saml/index.ts` に追記: + +```ts +export { runResponseChecks, type CheckOptions } from './checks'; +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `npm run test -- src/utils/__tests__/saml-checks.test.ts` +Expected: PASS(陽性対照 7 ケース含む全ケース) + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/saml/checks.ts src/utils/saml/index.ts src/utils/__tests__/saml-checks.test.ts +git commit -m "feat: SAML Response の定番チェックリストを追加(陽性対照テスト付き)" +``` + +--- + +### Task 5: XML 整形(`format.ts`) + +**Files:** + +- Create: `src/utils/saml/format.ts` +- Modify: `src/utils/saml/index.ts` +- Create: `src/utils/__tests__/saml-format.test.ts` + +- [ ] **Step 1: 失敗するテストを書く** + +`src/utils/__tests__/saml-format.test.ts`: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { formatXml } from '@/utils/saml'; + +describe('formatXml', () => { + it('1 行 XML をインデント付きに整形する', () => { + const out = formatXml('v'); + expect(out).toBe(['', ' v', ' ', ''].join('\n')); + }); + + it('XML 宣言を保持する', () => { + const out = formatXml(''); + expect(out.split('\n')[0]).toBe(''); + }); + + it('テキストと属性をエスケープする', () => { + const out = formatXml('<tag> & more'); + expect(out).toBe('<tag> & more'); + }); + + it('parse 不能な入力はそのまま返す', () => { + expect(formatXml(' 0) return xml; + const lines: string[] = []; + const decl = xml.match(/^\s*<\?xml[^?]*\?>/); + if (decl) lines.push(decl[0].trim()); + serializeEl(doc.documentElement, 0, lines); + return lines.join('\n'); +} + +function escapeText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttr(s: string): string { + return escapeText(s).replace(/"/g, '"'); +} + +function openTag(el: Element): string { + const attrs = Array.from(el.attributes) + .map((a) => ` ${a.name}="${escapeAttr(a.value)}"`) + .join(''); + return `<${el.tagName}${attrs}`; +} + +function serializeEl(el: Element, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const children = Array.from(el.children); + if (children.length === 0) { + const text = (el.textContent ?? '').trim(); + lines.push( + text + ? `${indent}${openTag(el)}>${escapeText(text)}` + : `${indent}${openTag(el)}/>` + ); + return; + } + lines.push(`${indent}${openTag(el)}>`); + for (const c of children) serializeEl(c, depth + 1, lines); + lines.push(`${indent}`); +} +``` + +`src/utils/saml/index.ts` に追記(最終形): + +```ts +export * from './types'; +export { decodeSamlInput } from './decode'; +export { parseSamlXml } from './parse'; +export { runResponseChecks, type CheckOptions } from './checks'; +export { formatXml } from './format'; +``` + +- [ ] **Step 4: テストが通ることを確認** + +Run: `npm run test -- src/utils/__tests__/saml-format.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/saml/format.ts src/utils/saml/index.ts src/utils/__tests__/saml-format.test.ts +git commit -m "feat: SAML 生 XML の表示用整形を追加" +``` + +--- + +### Task 6: UI コンポーネント(`SamlDecoder.tsx`) + +**Files:** + +- Create: `src/components/tools/SamlDecoder.tsx` + +規約リマインド: + +- 色は semantic class / `@theme` auto-utility のみ(primitive scale 禁止) +- `dangerouslySetInnerHTML` 禁止(全て React 要素として組み立て済みの設計) +- `aria-live` / `role` は既存パターン(JwtDecoder の `role="status" aria-live="polite"`)を踏襲 + +- [ ] **Step 1: コンポーネントを実装** + +`src/components/tools/SamlDecoder.tsx`: + +```tsx +import { useMemo, useState } from 'react'; +import { InputField } from '@/components/ui/InputField'; +import { ClearButton } from '@/components/ui/ClearButton'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { StatusBadge } from '@/components/ui/StatusBadge'; +import { NotificationBanner } from '@/components/ui/NotificationBanner'; +import { ResultTable, type TableColumn } from '@/components/ui/ResultTable'; +import { + decodeSamlInput, + parseSamlXml, + runResponseChecks, + formatXml, + type CheckItem, + type DecodedInput, + type SamlAssertion, + type SamlAttribute, + type SamlBinding, + type SamlMessage, +} from '@/utils/saml'; + +const BINDING_LABEL: Record = { + redirect: 'HTTP-Redirect binding(base64 + deflate)', + post: 'HTTP-POST binding(base64)', + xml: '生 XML', +}; + +/** サンプル: 現在時刻を挟む有効期間の Response を POST binding(base64)で生成 */ +function buildSampleInput(): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + const xml = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + + taro.yamada@example.com + + + + + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + taro.yamada@example.com + 山田 太郎 + devadmin + + +`; + const bytes = new TextEncoder().encode(xml); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +function SummaryRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +const CHECK_TONE: Record = { + success: 'success', + warning: 'warning', + error: 'error', + info: 'info', +}; + +const CHECK_TONE_LABEL: Record = { + success: 'OK', + warning: '注意', + error: 'エラー', + info: '情報', +}; + +function CheckList({ items }: { items: CheckItem[] }) { + return ( +
+

チェックリスト

+
    + {items.map((item) => ( +
  • + + + {CHECK_TONE_LABEL[item.status]} + + {item.label} + + {item.detail} +
  • + ))} +
+

+ 有効期間はこの端末の現在時刻で判定しています。IdP / SP + 間の時刻ずれ(クロックスキュー)により実環境の判定と異なる場合があります。 +

+
+ ); +} + +const ATTR_COLUMNS: TableColumn[] = [ + { + key: 'name', + header: '属性名', + className: 'font-mono break-all', + render: (a) => ( + <> + {a.name} + {a.friendlyName && ({a.friendlyName})} + + ), + }, + { + key: 'values', + header: '値', + className: 'font-mono break-all', + render: (a) => a.values.join(', '), + }, +]; + +function AssertionSection({ + assertion, + index, + total, +}: { + assertion: SamlAssertion; + index: number; + total: number; +}) { + return ( +
+
+

Assertion{total > 1 ? ` ${index + 1}` : ''}

+ + {assertion.signed ? '署名あり(未検証)' : '署名なし'} + +
+
+ + + + + + {assertion.subjectConfirmations.map((sc, i) => ( + 1 ? ` ${i + 1}` : ''}`} + value={[ + sc.recipient && `Recipient: ${sc.recipient}`, + sc.notOnOrAfter && `NotOnOrAfter: ${sc.notOnOrAfter}`, + sc.inResponseTo && `InResponseTo: ${sc.inResponseTo}`, + ] + .filter(Boolean) + .join(' / ')} + /> + ))} + {assertion.authnStatements.map((st, i) => ( + 1 ? ` ${i + 1}` : ''}`} + value={[ + st.authnInstant && `AuthnInstant: ${st.authnInstant}`, + st.sessionIndex && `SessionIndex: ${st.sessionIndex}`, + st.authnContextClassRef && `AuthnContext: ${st.authnContextClassRef}`, + ] + .filter(Boolean) + .join(' / ')} + /> + ))} +
+ {assertion.attributes.length > 0 && ( +
+

属性({assertion.attributes.length} 件)

+ a.name} /> +
+ )} +
+ ); +} + +interface ParsedOk { + decoded: DecodedInput; + message: SamlMessage; + error?: undefined; +} +interface ParsedNg { + error: string; +} + +export function SamlDecoderTool() { + const [input, setInput] = useState(''); + const [spEntityId, setSpEntityId] = useState(''); + + const result: ParsedOk | ParsedNg | null = useMemo(() => { + if (!input.trim()) return null; + try { + const decoded = decodeSamlInput(input); + return { decoded, message: parseSamlXml(decoded.xml) }; + } catch (e) { + return { error: e instanceof Error ? e.message : '解析に失敗しました' }; + } + }, [input]); + + const ok = result && !result.error ? (result as ParsedOk) : null; + const response = ok && ok.message.type === 'response' ? ok.message : null; + const authnRequest = ok && ok.message.type === 'authnRequest' ? ok.message : null; + + const checks = useMemo( + () => (response ? runResponseChecks(response, { spEntityId }) : null), + [response, spEntityId] + ); + + const prettyXml = useMemo(() => (ok ? formatXml(ok.decoded.xml) : ''), [ok]); + + return ( +
+ setInput(buildSampleInput())} + mono + /> + + {response && ( + + SP entityID + (任意・入力すると Audience と照合) + + } + value={spEntityId} + onChange={setSpEntityId} + placeholder="https://sp.example.com/metadata" + mono + /> + )} + + {ok && ( +
+ {/* デコード過程 */} +

+ 変換: {ok.decoded.steps.join(' → ')}({BINDING_LABEL[ok.decoded.binding]}) +

+ + {/* サマリ */} +
+

+ {response ? 'Response サマリ' : 'AuthnRequest サマリ'} +

+ {response && ( +
+ + + + + + + a.signed) + ? 'あり(このツールでは検証しません)' + : 'なし' + } + /> +
+ )} + {authnRequest && ( +
+ + + + + + + + + +
+ )} +
+ + {/* チェックリスト(Response のみ) */} + {checks && } + + {/* EncryptedAssertion 案内 */} + {response && response.encryptedAssertionCount > 0 && ( + + EncryptedAssertion が {response.encryptedAssertionCount}{' '} + 件含まれています。復号(秘密鍵の入力)には対応していません。 + + )} + + {/* Assertion 詳細 */} + {response?.assertions.map((a, i) => ( + + ))} + + {/* 生 XML */} +
+ + 整形済み XML + +
+
+ +
+
{prettyXml}
+
+
+ + + XMLDSig 署名の検証・EncryptedAssertion + の復号は行いません。表示内容の改ざん有無は保証されないため、署名検証が必要な場合は IdP / + SP 側のログと突き合わせてください。入力データはブラウザ外に送信しません。 + +
+ )} + + {input && ( +
+ { + setInput(''); + setSpEntityId(''); + }} + /> +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: 型チェック** + +Run: `npx astro check --filter SamlDecoder.tsx` +Expected: エラー 0 件。`InputField` / `ResultTable` / `StatusBadge` の props が実際の定義と食い違う場合は**コンポーネント側の定義を読み**、この計画のコードを props 定義に合わせて修正する(既存コンポーネントは変更しない)。 + +- [ ] **Step 3: Commit** + +```bash +git add src/components/tools/SamlDecoder.tsx +git commit -m "feat: SAMLデコーダの UI コンポーネントを追加" +``` + +--- + +### Task 7: ページ・ツール登録・VRT 登録 + +**Files:** + +- Create: `src/pages/tools/saml-decoder.astro` +- Modify: `src/data/tools.ts`(`toolEntries` 配列の末尾に追加) +- Modify: `tests/e2e/visual-regression-pages.ts`(`PAGES` の `'/tools/ddl-er-diagram'` の後に追加) + +- [ ] **Step 1: Astro ページを作成** + +`src/pages/tools/saml-decoder.astro`: + +```astro +--- +import ToolLayout from '@/layouts/ToolLayout.astro'; +import ToolInfoSection from '@/components/ui/ToolInfoSection.astro'; +import { SamlDecoderTool } from '@/components/tools/SamlDecoder'; +import { tools } from '@/data/tools'; + +const tool = tools.find((t) => t.slug === 'saml-decoder')!; +--- + + + + + +

+ SSO の SAMLResponse / SAMLRequest(AuthnRequest)をデコードして内容を表示します。 URL + 全体・URLエンコード・base64(HTTP-POST binding)・base64 + deflate(HTTP-Redirect + binding)・生 XML を自動判定します。Assertion には氏名・メールアドレス等の個人情報が + 含まれますが、全処理はブラウザ内で完結し、データを外部へ送信しません。 XMLDSig 署名の検証と + EncryptedAssertion の復号には対応していません。 +

+

ユースケース

+
    +
  • SSO ログイン失敗時に IdP からの SAMLResponse の Status・有効期限を確認したい
  • +
  • Assertion に含まれる NameID・属性が SP の期待と一致しているか確認したい
  • +
  • Audience / Destination の設定ミス(entityID 不一致)を切り分けたい
  • +
+
+
+``` + +- [ ] **Step 2: `src/data/tools.ts` の `toolEntries` 末尾(`ddl-er-diagram` エントリの後)に追加** + +```ts + { + slug: 'saml-decoder', + name: 'SAMLデコーダ', + description: + 'SSO の SAMLResponse / AuthnRequest を base64・deflate 自動判定でデコードし、Assertion の内容と Status・有効期限・Audience の定番チェックを表示します。データはブラウザ外に送信しません', + category: 'encode', + yomi: 'さむるでこーだ', + }, +``` + +- [ ] **Step 3: `tests/e2e/visual-regression-pages.ts` の `PAGES` 配列(`'/tools/ddl-er-diagram'` の後)に追加** + +```ts + '/tools/saml-decoder', +``` + +- [ ] **Step 4: 検証** + +```bash +node_modules/.bin/astro check +npm run test -- tests/meta/vrt-pages-coverage.test.ts +npm run build +``` + +Expected: すべて成功。`npm run dev` で `http://localhost:4321/tools/saml-decoder` が表示され、サンプルボタンでデコード結果が出ることを確認。 + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/tools/saml-decoder.astro src/data/tools.ts tests/e2e/visual-regression-pages.ts +git commit -m "feat: SAMLデコーダのページ・ツール登録・VRT 対象を追加" +``` + +--- + +### Task 8: E2E テスト(陽性対照含む) + +**Files:** + +- Create: `tests/e2e/saml-decoder.spec.ts` + +規約リマインド: ロケーターは `getByRole` / `getByText` / `getByLabel` のみ。属性セレクタ禁止。`expect` のオートリトライを優先。 + +- [ ] **Step 1: E2E テストを書く** + +`tests/e2e/saml-decoder.spec.ts`: + +```ts +import { test, expect } from '@playwright/test'; + +/** 有効期間を現在時刻基準で生成する Response XML(E2E は実時刻でチェックが走るため動的に組む) */ +function responseXml(opts: { notOnOrAfterOffsetMs: number; statusCode?: string }): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + const status = opts.statusCode ?? 'urn:oasis:names:tc:SAML:2.0:status:Success'; + return ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + taro@example.com + + https://sp.example.com/metadata + + + taro@example.com + + +`; +} + +const AUTHN_REQUEST_XML = ` + + https://sp.example.com/metadata +`; + +test.describe('SAMLデコーダ', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/saml-decoder'); + }); + + test('有効な Response を貼ると内容とチェックリストが表示される', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: 300_000 })); + await expect(page.getByText('Response サマリ')).toBeVisible(); + await expect(page.getByText('https://idp.example.com/metadata').first()).toBeVisible(); + await expect(page.getByText('taro@example.com').first()).toBeVisible(); + await expect(page.getByText('有効期間内です', { exact: false })).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeVisible(); + }); + + test('サンプルボタンでデコード結果が表示される', async ({ page }) => { + await page.getByRole('button', { name: 'サンプル' }).click(); + await expect(page.getByText('Response サマリ')).toBeVisible(); + await expect(page.getByText('HTTP-POST binding', { exact: false })).toBeVisible(); + }); + + test('陽性対照: 期限切れ Response はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: -300_000 })); + await expect(page.getByText('期限切れです', { exact: false })).toBeVisible(); + }); + + test('陽性対照: Status Responder はエラー表示になる', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill( + responseXml({ + notOnOrAfterOffsetMs: 300_000, + statusCode: 'urn:oasis:names:tc:SAML:2.0:status:Responder', + }) + ); + await expect(page.getByText('Success ではありません', { exact: false })).toBeVisible(); + }); + + test('陽性対照: SP entityID 不一致はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: 300_000 })); + await page.getByLabel(/SP entityID/).fill('https://other.example.com/metadata'); + await expect(page.getByText('SP entityID と不一致です', { exact: false })).toBeVisible(); + }); + + test('AuthnRequest はサマリのみ表示されチェックリストは出ない', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill(AUTHN_REQUEST_XML); + await expect(page.getByText('AuthnRequest サマリ')).toBeVisible(); + await expect(page.getByText('https://sp.example.com/acs').first()).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeHidden(); + }); + + test('不正な入力はエラーメッセージが表示される', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill('これはSAMLではない'); + await expect(page.getByText('base64 として解釈できません', { exact: false })).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: 実行して確認** + +Run: `npm run test:e2e -- saml-decoder.spec.ts` +Expected: 全ケース PASS。ラベル文言・ボタン名(「サンプル」)が実際の `InputField` 実装とずれて fail した場合は、実 DOM の文言に合わせて **テスト側** を修正する。 + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/saml-decoder.spec.ts +git commit -m "test: SAMLデコーダの E2E テストを追加(陽性対照含む)" +``` + +--- + +### Task 9: ドキュメント更新 + +**Files:** + +- Modify: `README.md`(ツール一覧に追加。既存行の形式に合わせる) +- Modify: `SPEC.md`(2.3 章: fflate 追加 / 2.4 章: `src/utils/saml/` / 4・5 章: ツール一覧 / 9 章: チェックリスト。各章の既存記述形式に合わせる) +- Modify: `docs/tools.md`(技術解説を追加) +- Modify: `docs/decisions.md`(採用理由を追記。既存の `[NNN]` 連番形式に従い次番号を使う) + +- [ ] **Step 1: README.md のツール一覧に追加** + +既存エントリの形式(表 or リスト)に合わせ、以下の内容で追加: + +> **SAMLデコーダ** (`/tools/saml-decoder`) — SSO の SAMLResponse / AuthnRequest を自動判定デコードし、Assertion の内容と Status・有効期限・Audience の定番チェックを表示 + +- [ ] **Step 2: SPEC.md を更新** + +- 2.3 章(ライブラリ一覧): `fflate` を追加(用途: SAML HTTP-Redirect binding の raw deflate 展開) +- 2.4 章(ディレクトリ構成): `src/utils/saml/` を追加 +- 4・5 章(ツール一覧): saml-decoder を追加 +- 9 章(チェックリスト): 該当フェーズに saml-decoder 完了項目を追加 + +- [ ] **Step 3: docs/tools.md に技術解説を追加** + +既存ツールの見出し形式に合わせ、以下の内容で追加: + +> **SAMLデコーダ(saml-decoder)** +> +> - 仕組み: 入力を URL パラメータ抽出 → URL デコード → base64 → raw deflate 展開(fflate `decompressSync`)の順に自動判定し、`DOMParser` で名前空間 URI ベース(prefix 非依存)にパース。Response は Status / Conditions / Audience / NameID の定番チェックを現在時刻基準で実行 +> - 準拠仕様: SAML 2.0 Core / Bindings(HTTP-POST・HTTP-Redirect)。`NotOnOrAfter` は仕様通り排他境界として判定 +> - 制限: XMLDSig 署名検証・EncryptedAssertion 復号・LogoutRequest 等の他メッセージ型は非対応(第2版候補)。ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない + +- [ ] **Step 4: docs/decisions.md に追記** + +次の連番で追加: + +> **[NNN] SAMLデコーダ: deflate 展開に fflate を採用** +> pako と比較し、バンドルサイズが小さく TypeScript 型定義を同梱する fflate を採用。`decompressSync` が raw deflate / zlib / gzip を自動判定するため、仕様外の zlib ラッパー付き実装の IdP にも耐性がある。XMLDSig 署名検証は C14N(正規化)実装が重く初版スコープ外とした(`docs/superpowers/specs/2026-07-17-saml-decoder-design.md` 参照)。 + +- [ ] **Step 5: 整形と確認** + +```bash +npm run format +git diff --stat +``` + +- [ ] **Step 6: Commit** + +```bash +git add README.md SPEC.md docs/tools.md docs/decisions.md +git commit -m "docs: SAMLデコーダのドキュメントを更新" +``` + +--- + +### Task 10: スコープ外項目の issue 化 + +**Files:** なし(GitHub 操作のみ) + +- [ ] **Step 1: 第2版候補の issue を作成**(本文はファイル経由必須・`--body` 直接埋め込み禁止) + +`/tmp/claude/saml-v2-issue.md` を作成: + +```markdown +## 概要 + +SAMLデコーダ(#PR番号)の初版でスコープ外とした機能の第2版候補。 + +## 候補 + +- [ ] XMLDSig 署名検証(C14N 実装が山場。`xmldsigjs` 等の導入検討) +- [ ] EncryptedAssertion の復号(秘密鍵入力) +- [ ] LogoutRequest / LogoutResponse 等の他メッセージ型 +- [ ] 共有用マスク出力(secret-scrubber との連携) + +## 経緯 + +`docs/superpowers/specs/2026-07-17-saml-decoder-design.md` のスコープ外セクション参照。 +``` + +```bash +gh issue create --title "SAMLデコーダ第2版: 署名検証・復号・他メッセージ型対応" --body-file /tmp/claude/saml-v2-issue.md +bash scripts/rm-tmp.sh /tmp/claude/saml-v2-issue.md +``` + +--- + +### Task 11: 最終検証と PR 作成 + +- [ ] **Step 1: push 前必須チェック(`.agents/rules/common.md` 3 章)** + +```bash +npm run format:check +npm run lint +npm run test +node_modules/.bin/astro check +npm run test:e2e +``` + +Expected: すべて PASS。失敗があれば修正してから進む(修正は該当タスクの流儀で TDD)。 + +- [ ] **Step 2: PR 作成(`docs/playbooks/pr-creation.md` 3〜5 章参照)** + +`/tmp/claude/pr_body.md` に PR 本文を作成(概要 / 変更内容 / スクリーンショット欄 / テスト結果 / スコープ外 issue 番号)し: + +```bash +git push -u origin feat/saml-decoder +gh pr create --base develop --title "feat: SAMLデコーダを追加" --body-file /tmp/claude/pr_body.md +bash scripts/rm-tmp.sh /tmp/claude/pr_body.md +``` + +- [ ] **Step 3: UI 目視確認** + +PC (1280x800) / スマホ (390x844) 両方でスクリーンショットを撮影し、`.agents/rules/ui-conventions.md` 3.1 章のチェックリストで目視確認。結果を PR に添付(撮影手順は同 3.2 章)。 + +- [ ] **Step 4: VRT baseline 再生成の手動トリガーを依頼** + +`Update Visual Regression Baseline` workflow の `workflow_dispatch` を **ユーザーに手動トリガー依頼**する(web セッションでは 403 のため自分で起動を試みない。CLI セッションでも先に可否確認せず提案から入る)。branch は `feat/saml-decoder` を指定。 + +- [ ] **Step 5: マージ時の後処理(マージ後に実施)** + +- `docs/tool-candidates.md` S2-2 の状態列に ✅ と PR 番号を記載(別コミットまたは同 PR 内最終コミット) +- feature PR は `--squash` でマージ。**squash コミット件名が Conventional Commits 形式(`feat: SAMLデコーダを追加 (#NNN)`)になっているか確認**(`docs/playbooks/pr-creation.md` 6 章) + +--- + +## Self-Review 済み確認事項 + +- spec の全要件(5 入力形式 / Response・AuthnRequest / チェックリスト 4 項目+α / 署名存在表示 / EncryptedAssertion 案内 / 整形 XML / test-gates 陽性対照 / VRT / docs 更新 / スコープ外 issue 化)に対応するタスクがあることを確認 +- `NotOnOrAfter` の排他境界(`now >= notOnOrAfter` で期限切れ)は SAML Core 仕様準拠。テスト・実装・docs の記述が一致 +- 型名・関数名はタスク間で一貫(`decodeSamlInput` / `parseSamlXml` / `runResponseChecks` / `formatXml`、`SamlMessage` 判別は `type: 'response' | 'authnRequest'`) +- UI コードは既存 props 定義(InputField / ResultTable / StatusBadge / NotificationBanner / ClearButton / CopyButton)を確認済みの範囲で記述。Task 6 Step 2 に食い違い時の解決手順を明記 diff --git a/docs/superpowers/plans/2026-07-19-e2e-hydration-wait.md b/docs/superpowers/plans/2026-07-19-e2e-hydration-wait.md new file mode 100644 index 00000000..f09c35bf --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-e2e-hydration-wait.md @@ -0,0 +1,397 @@ +# E2E hydration 待ち漏れ修正 + meta テスト 実装計画 (issue #750) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** hydration race で flaky な 3 つの E2E spec に `waitForReactHydration` を追加し、新規 spec での漏れを CI で fail させる meta テストを導入する。 + +**Architecture:** 既存ヘルパー `tests/e2e/helpers.ts` の `waitForReactHydration` を各 spec の goto 直後に挟む(既存 spec の踏襲パターン)。漏れ防止は Vitest meta テスト(`tests/meta/` の allowlist + 純粋関数パターン、`vrt-pages-coverage.test.ts` 踏襲)で、`goto('/tools/...')` を含む spec にヘルパー参照を必須化する。 + +**Tech Stack:** Playwright (E2E) / Vitest (meta テスト) / TypeScript + +**Spec:** `docs/superpowers/specs/2026-07-19-e2e-hydration-wait-design.md` + +**前提知識:** + +- `waitForReactHydration(page)` は `tests/e2e/helpers.ts:19` に定義済み。astro-island 配下の要素に React の `__react*` キーが付くまで待つ(デフォルト 10s timeout)。 +- `withProductionCsp` ラッパは内部で hydration 待ちを行うため、これを使う spec は追加不要。 +- コミットメッセージは日本語 + Conventional Commits(`.githooks/commit-msg` が検証。`test(e2e):` のようなスコープ付き可)。 +- E2E 実行: `npm run test:e2e -- `(preview build 経由)。ユニット: `npm run test`。 + +--- + +### Task 1: dsn-builder.spec.ts に hydration 待ちを追加 + +**Files:** + +- Modify: `tests/e2e/dsn-builder.spec.ts:1-8` + +- [ ] **Step 1: import と beforeEach を修正** + +現状(1〜8 行目): + +```ts +import { test, expect } from '@playwright/test'; + +const PG_URI = 'postgresql://app:s3cret@db.example.com:5432/app_db?sslmode=require'; + +test.describe('DSN/接続文字列ビルダ', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/dsn-builder'); + }); +``` + +以下に変更: + +```ts +import { test, expect } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; + +const PG_URI = 'postgresql://app:s3cret@db.example.com:5432/app_db?sslmode=require'; + +test.describe('DSN/接続文字列ビルダ', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/dsn-builder'); + // fill → React onChange パースが hydration 完了前に走ると DOM のみ更新され + // flaky になるため、island の hydration 完了を待つ (issue #750) + await waitForReactHydration(page); + }); +``` + +- [ ] **Step 2: 型チェック** + +Run: `npx astro check --filter tests/e2e/dsn-builder.spec.ts`(filter が効かない場合は `node_modules/.bin/astro check`) +Expected: エラー 0 件(既存 warning は無視) + +- [ ] **Step 3: 該当 E2E を実行して pass を確認** + +Run: `npm run test:e2e -- tests/e2e/dsn-builder.spec.ts` +Expected: 全件 PASS + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/dsn-builder.spec.ts +git commit -m "test(e2e): dsn-builder spec に hydration 待ちを追加" +``` + +--- + +### Task 2: dummy-personal-data.spec.ts に hydration 待ちを追加 + +**Files:** + +- Modify: `tests/e2e/dummy-personal-data.spec.ts:1-6` + +- [ ] **Step 1: import と beforeEach を修正** + +現状(1〜6 行目): + +```ts +import { test, expect } from '@playwright/test'; + +test.describe('日本語ダミー個人データ生成', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/dummy-personal-data'); + }); +``` + +以下に変更: + +```ts +import { test, expect } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; + +test.describe('日本語ダミー個人データ生成', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/dummy-personal-data'); + // click → React handler が hydration 完了前に走ると無反応になり + // flaky になるため、island の hydration 完了を待つ (issue #750) + await waitForReactHydration(page); + }); +``` + +- [ ] **Step 2: 該当 E2E を実行して pass を確認** + +Run: `npm run test:e2e -- tests/e2e/dummy-personal-data.spec.ts` +Expected: 全件 PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/dummy-personal-data.spec.ts +git commit -m "test(e2e): dummy-personal-data spec に hydration 待ちを追加" +``` + +--- + +### Task 3: har-viewer.spec.ts にローカルヘルパー経由で hydration 待ちを追加 + +**Files:** + +- Modify: `tests/e2e/har-viewer.spec.ts` + +**注意:** 2 つの test(135 行付近「固定文言カラム…」、258 行付近「タイミング列…」)は goto **前**に `setViewportSize` を呼ぶ。この順序を保つため `beforeEach` 集約はせず、ローカルヘルパーで置換する。 + +- [ ] **Step 1: import 修正とローカルヘルパー追加** + +1 行目の import を修正: + +```ts +import { test, expect, type Page } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; +``` + +既存の `uploadHar` ヘルパー定義(85〜99 行付近)の直後・`test.describe('HAR ビューア', ...)` の直前に追加: + +```ts +/** + * HAR ビューアを開き React island の hydration 完了まで待つ。 + * setInputFiles → React onChange が hydration 前に走ると file が処理されず + * flaky になるため、各 test の goto はこのヘルパー経由に統一する (issue #750)。 + * ※ viewport を変える test は本ヘルパー呼び出し前に setViewportSize すること。 + */ +async function openHarViewer(page: Page): Promise { + await page.goto('/tools/har-viewer'); + await waitForReactHydration(page); +} +``` + +- [ ] **Step 2: 8 箇所の goto を置換** + +`await page.goto('/tools/har-viewer');` (8 箇所: 103, 121, 140, 167, 188, 224, 260, 318 行付近)をすべて以下に置換: + +```ts +await openHarViewer(page); +``` + +置換後に `grep -n "page.goto" tests/e2e/har-viewer.spec.ts` で 0 件になることを確認。 + +- [ ] **Step 3: 該当 E2E を実行して pass を確認** + +Run: `npm run test:e2e -- tests/e2e/har-viewer.spec.ts` +Expected: 全件 PASS + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/har-viewer.spec.ts +git commit -m "test(e2e): har-viewer spec に hydration 待ちを追加" +``` + +--- + +### Task 4: 漏れ防止 meta テストの追加 + +**Files:** + +- Create: `tests/meta/e2e-hydration-wait-coverage.test.ts` + +- [ ] **Step 1: meta テストを作成** + +以下の内容で新規作成: + +```ts +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * meta test: E2E spec の hydration 待ち漏れ検出 (issue #750 再発防止策) + * + * Playwright の fill / click / setInputFiles が React island の hydration + * 完了前に実行されると、DOM だけ書き換わり React の onChange が発火しない + * hydration race で flaky になる(CI は workers:1 で顕在化せずローカル並列 + * 実行でのみ落ちるため発見が遅れる)。 + * + * 検知ルール: `tests/e2e/*.spec.ts` のうちソースに `goto('/tools/...')` を + * 含むファイルは `waitForReactHydration` または `withProductionCsp`(内部で + * hydration 待ちを実施)への参照を必須とする。 + * + * 除外基準: React のイベントハンドラ発火に依存しない spec(computed style + * 読取のみ等)は ALLOWLIST に理由付きで登録する。`/test-fixtures/*` や + * 静的ページのみへ goto する spec は検知対象外(gate spec 等は自然に除外)。 + * + * 注意: 参照検出はソース文字列ベースの heuristic(コメント内の言及でも + * 通過しうる)。厳密性より「新規 spec 作成時の完全な失念」の検知を目的とする。 + */ + +const E2E_DIR = join(__dirname, '../e2e'); + +/** hydration 待ち不要と判断した spec の allowlist(除外理由を必ず併記) */ +const ALLOWLIST: ReadonlyMap = new Map([ + [ + 'prefers-reduced-motion.spec.ts', + 'computed style の読取のみで React イベントハンドラの発火に依存しない', + ], +]); + +const TOOLS_GOTO_RE = /goto\(\s*['"`]\/tools\//; +const HYDRATION_HELPER_RE = /waitForReactHydration|withProductionCsp/; + +interface SpecSource { + name: string; + content: string; +} + +/** hydration 待ちが漏れている spec 名を返す純粋関数(陰性/陽性両対照で共有) */ +function findSpecsMissingHydrationWait( + specs: readonly SpecSource[], + allowlist: ReadonlyMap +): string[] { + return specs + .filter((s) => !allowlist.has(s.name)) + .filter((s) => TOOLS_GOTO_RE.test(s.content)) + .filter((s) => !HYDRATION_HELPER_RE.test(s.content)) + .map((s) => s.name) + .sort(); +} + +/** + * allowlist の腐敗(orphan)を返す純粋関数。 + * 実在しない・既にヘルパー使用済み・/tools/ へ goto しない spec が + * allowlist に残っている場合に検出する(vrt-pages-coverage の orphan 検出踏襲)。 + */ +function findOrphanAllowlistEntries( + specs: readonly SpecSource[], + allowlist: ReadonlyMap +): string[] { + const byName = new Map(specs.map((s) => [s.name, s])); + return [...allowlist.keys()] + .filter((name) => { + const spec = byName.get(name); + if (!spec) return true; + return HYDRATION_HELPER_RE.test(spec.content) || !TOOLS_GOTO_RE.test(spec.content); + }) + .sort(); +} + +function loadSpecs(): SpecSource[] { + return readdirSync(E2E_DIR) + .filter((f) => f.endsWith('.spec.ts')) + .map((name) => ({ name, content: readFileSync(join(E2E_DIR, name), 'utf8') })); +} + +describe('E2E hydration 待ちカバレッジ', () => { + it('goto(/tools/*) する全 spec が waitForReactHydration か withProductionCsp を使用している', () => { + const missing = findSpecsMissingHydrationWait(loadSpecs(), ALLOWLIST); + expect(missing).toEqual([]); + }); + + it('ALLOWLIST に orphan エントリがない', () => { + const orphans = findOrphanAllowlistEntries(loadSpecs(), ALLOWLIST); + expect(orphans).toEqual([]); + }); +}); + +// 陽性対照: 検知機構が空回りしていないことを保証 (test-gates skill 準拠)。 +// fixture を注入し、検知ロジックが実際に違反を列挙することを確認する。 +describe('[陽性対照] E2E hydration 待ちカバレッジ検知機構', () => { + const emptyAllowlist: ReadonlyMap = new Map(); + + it('hydration 待ちなしで /tools/ へ goto する fixture を検出する', () => { + const fixture: SpecSource = { + name: 'fake-missing.spec.ts', + content: `await page.goto('/tools/fake-tool');\nawait page.fill('#x', 'y');`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([ + 'fake-missing.spec.ts', + ]); + }); + + it('waitForReactHydration 使用済み fixture は検出しない(過検知なし)', () => { + const fixture: SpecSource = { + name: 'fake-ok.spec.ts', + content: `await page.goto('/tools/fake-tool');\nawait waitForReactHydration(page);`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('withProductionCsp 使用済み fixture は検出しない(過検知なし)', () => { + const fixture: SpecSource = { + name: 'fake-csp.spec.ts', + content: `await withProductionCsp(browser, '/tools/fake-tool', async (page) => {});`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('/tools/ 以外へ goto する fixture は検出しない(gate spec 等の除外)', () => { + const fixture: SpecSource = { + name: 'fake-fixture-page.spec.ts', + content: `await page.goto('/test-fixtures/hydration-broken');`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('allowlist 登録済み fixture は検出しない', () => { + const fixture: SpecSource = { + name: 'fake-allowed.spec.ts', + content: `await page.goto('/tools/fake-tool');`, + }; + const allowlist = new Map([['fake-allowed.spec.ts', 'テスト用の除外理由']]); + expect(findSpecsMissingHydrationWait([fixture], allowlist)).toEqual([]); + }); + + it('orphan 検出: 実在しない allowlist エントリを検出する', () => { + const allowlist = new Map([['no-such-file.spec.ts', '理由']]); + expect(findOrphanAllowlistEntries([], allowlist)).toEqual(['no-such-file.spec.ts']); + }); + + it('orphan 検出: ヘルパー使用済みなのに allowlist に残るエントリを検出する', () => { + const fixture: SpecSource = { + name: 'fake-migrated.spec.ts', + content: `await page.goto('/tools/x');\nawait waitForReactHydration(page);`, + }; + const allowlist = new Map([['fake-migrated.spec.ts', '理由']]); + expect(findOrphanAllowlistEntries([fixture], allowlist)).toEqual(['fake-migrated.spec.ts']); + }); +}); +``` + +- [ ] **Step 2: meta テストを実行して pass を確認** + +Run: `npm run test -- tests/meta/e2e-hydration-wait-coverage.test.ts` +Expected: 全件 PASS(Task 1〜3 で 3 spec 修正済みのため陰性対照が green) + +- [ ] **Step 3: 旧実装相当で fail することを実機確認(陽性対照の実地検証)** + +一時的に dsn-builder の修正を外して本体テストが fail することを確認し、直後に復元する: + +```bash +git stash push tests/e2e/dsn-builder.spec.ts +npm run test -- tests/meta/e2e-hydration-wait-coverage.test.ts +# Expected: 「goto(/tools/*) する全 spec が…」が FAIL し dsn-builder.spec.ts を列挙 +git stash pop +npm run test -- tests/meta/e2e-hydration-wait-coverage.test.ts +# Expected: 全件 PASS に戻る +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/meta/e2e-hydration-wait-coverage.test.ts +git commit -m "test(meta): E2E spec の hydration 待ち漏れ検出 meta テストを追加" +``` + +--- + +### Task 5: 全体検証 + +- [ ] **Step 1: ユニットテスト全件** + +Run: `npm run test` +Expected: 全件 PASS + +- [ ] **Step 2: 型チェック** + +Run: `node_modules/.bin/astro check` +Expected: エラー 0 件 + +- [ ] **Step 3: E2E 全件(並列 worker で hydration race の再発がないこと)** + +Run: `npm run test:e2e` +Expected: 全件 PASS + +- [ ] **Step 4: 未コミット差分がないことを確認** + +Run: `git status --short` +Expected: 出力なし diff --git a/docs/superpowers/plans/2026-07-20-saml-logout-messages.md b/docs/superpowers/plans/2026-07-20-saml-logout-messages.md new file mode 100644 index 00000000..0b4c65d8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-saml-logout-messages.md @@ -0,0 +1,924 @@ +# SAML デコーダ LogoutRequest / LogoutResponse 対応 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SAML デコーダを LogoutRequest / LogoutResponse(シングルログアウト)に対応させ、期限切れリクエスト・Status 失敗レスポンスのトラブルシュートを可能にする。 + +**Architecture:** 既存の「`parse.ts` ルート要素分岐 → 判別可能 union `SamlMessage` → 型別サマリ UI → 型別チェックリスト」パターンの最小拡張。Status 抽出とタイムゾーン注記は既存 Response ロジックから関数抽出して共有する。 + +**Tech Stack:** React + TypeScript (Astro island) / Vitest (jsdom) / Playwright。外部ライブラリ追加なし。 + +**Spec:** `docs/superpowers/specs/2026-07-20-saml-logout-messages-design.md` + +**前提:** ブランチ `feat/issue-745-saml-logout-messages`(origin/develop 起点、作成済み)で作業する。 + +--- + +### Task 1: パーサ拡張(types + parse + フィクスチャ) + +**Files:** + +- Modify: `src/utils/saml/types.ts`(union に 2 型追加) +- Modify: `src/utils/saml/parse.ts`(ルート分岐 + `parseStatus` 抽出 + 2 パーサ追加) +- Modify: `src/utils/__tests__/saml-fixtures.ts`(フィクスチャ追加) +- Test: `src/utils/__tests__/saml-parse.test.ts` + +- [ ] **Step 1: フィクスチャを追加する** + +`src/utils/__tests__/saml-fixtures.ts` の `AUTHN_REQUEST_XML` 定義の直後(`toBase64` 関数の前)に以下を追加: + +```ts +export const LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + taro.yamada@example.com + _s1 + _s2 +`; + +/** EncryptedID を含む LogoutRequest(NameID なし・復号非対応の注記確認用) */ +export const ENCRYPTED_ID_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + +`; + +/** NameID / EncryptedID / NotOnOrAfter がいずれもない LogoutRequest(チェックの error / info 分岐用) */ +export const NO_NAMEID_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata +`; + +export const LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; + +/** 二段階ステータスで失敗する LogoutResponse(Status チェックの陽性対照用) */ +export const FAILED_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + + + Session not found + +`; + +/** prefix なし(default xmlns)の LogoutRequest。prefix 非依存パースの回帰確認用 */ +export const DEFAULT_NS_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + taro.yamada@example.com + _s1 +`; + +/** prefix なし(default xmlns)の LogoutResponse。prefix 非依存パースの回帰確認用 */ +export const DEFAULT_NS_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; +``` + +- [ ] **Step 2: 失敗するパーステストを書く** + +`src/utils/__tests__/saml-parse.test.ts` の import に追加: + +```ts +import { + // ...既存 import はそのまま... + LOGOUT_REQUEST_XML, + ENCRYPTED_ID_LOGOUT_REQUEST_XML, + NO_NAMEID_LOGOUT_REQUEST_XML, + LOGOUT_RESPONSE_XML, + FAILED_LOGOUT_RESPONSE_XML, + DEFAULT_NS_LOGOUT_REQUEST_XML, + DEFAULT_NS_LOGOUT_RESPONSE_XML, +} from './saml-fixtures'; +``` + +ファイル末尾に追加: + +```ts +describe('parseSamlXml: LogoutRequest', () => { + it('サマリ情報と複数 SessionIndex を抽出する', () => { + const m = parseSamlXml(LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.destination).toBe('https://idp.example.com/slo'); + expect(m.issueInstant).toBe('2026-07-17T00:00:00Z'); + expect(m.notOnOrAfter).toBe('2026-07-17T00:05:00Z'); + expect(m.reason).toBe('urn:oasis:names:tc:SAML:2.0:logout:user'); + expect(m.nameId).toBe('taro.yamada@example.com'); + expect(m.nameIdFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(m.encryptedNameId).toBe(false); + expect(m.sessionIndexes).toEqual(['_s1', '_s2']); + expect(m.signed).toBe(false); + }); + + it('EncryptedID を検出する', () => { + const m = parseSamlXml(ENCRYPTED_ID_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.nameId).toBeUndefined(); + expect(m.encryptedNameId).toBe(true); + }); + + it('prefix なし(default xmlns)の LogoutRequest も正常にパースする(回帰)', () => { + const m = parseSamlXml(DEFAULT_NS_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.nameId).toBe('taro.yamada@example.com'); + expect(m.sessionIndexes).toEqual(['_s1']); + }); + + it('NameID / EncryptedID / NotOnOrAfter なしはいずれも undefined / false / 空になる', () => { + const m = parseSamlXml(NO_NAMEID_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.nameId).toBeUndefined(); + expect(m.encryptedNameId).toBe(false); + expect(m.notOnOrAfter).toBeUndefined(); + expect(m.sessionIndexes).toEqual([]); + }); +}); + +describe('parseSamlXml: LogoutResponse', () => { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.destination).toBe('https://sp.example.com/slo'); + expect(m.inResponseTo).toBe('_lreq1'); + expect(m.signed).toBe(false); + }); + + it('二段階ステータスの内側コードと StatusMessage を抽出する', () => { + const m = parseSamlXml(FAILED_LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Responder'); + expect(m.statusSubCode).toBe('urn:oasis:names:tc:SAML:2.0:status:RequestDenied'); + expect(m.statusMessage).toBe('Session not found'); + }); + + it('prefix なし(default xmlns)の LogoutResponse も正常にパースする(回帰)', () => { + const m = parseSamlXml(DEFAULT_NS_LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.inResponseTo).toBe('_lreq4'); + }); +}); +``` + +- [ ] **Step 3: テストが失敗することを確認する** + +Run: `npm run test -- src/utils/__tests__/saml-parse.test.ts` +Expected: FAIL(`対応していない SAML メッセージです` エラーで新規 describe 2 つが落ちる。既存テストは PASS のまま) + +- [ ] **Step 4: types.ts に 2 型を追加する** + +`src/utils/saml/types.ts` の `SamlAuthnRequestData` 定義の直後に追加し、`SamlMessage` を差し替える: + +```ts +export interface SamlLogoutRequestData { + type: 'logoutRequest'; + issuer?: string; + destination?: string; + issueInstant?: string; + /** ルート属性。リクエスト自体の有効期限(SAML 仕様上は任意) */ + notOnOrAfter?: string; + /** Reason 属性(URI) */ + reason?: string; + nameId?: string; + nameIdFormat?: string; + /** NameID が EncryptedID で暗号化されている場合 true(内容は表示不可・復号は非対応) */ + encryptedNameId: boolean; + /** samlp:SessionIndex(複数可) */ + sessionIndexes: string[]; + signed: boolean; +} + +export interface SamlLogoutResponseData { + type: 'logoutResponse'; + issuer?: string; + statusCode?: string; + /** 外側 StatusCode の直下にネストした内側 StatusCode の Value */ + statusSubCode?: string; + statusMessage?: string; + destination?: string; + inResponseTo?: string; + issueInstant?: string; + signed: boolean; +} +``` + +```ts +export type SamlMessage = + | SamlResponseData + | SamlAuthnRequestData + | SamlLogoutRequestData + | SamlLogoutResponseData; +``` + +- [ ] **Step 5: parse.ts を拡張する** + +`src/utils/saml/parse.ts` を以下のとおり変更する。 + +import に 2 型を追加: + +```ts +import type { + SamlAssertion, + SamlAttribute, + SamlAuthnRequestData, + SamlLogoutRequestData, + SamlLogoutResponseData, + SamlMessage, + SamlResponseData, +} from './types'; +``` + +`parseSamlXml` のルート分岐と doc コメント・エラーメッセージを更新: + +```ts +/** + * SAML XML を構造化モデルへパースする。 + * 対応: Response / AuthnRequest / LogoutRequest / LogoutResponse。それ以外の SAML メッセージ型はエラー。 + */ +export function parseSamlXml(xml: string): SamlMessage { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + throw new Error('XML の構文エラーがあります'); + } + const root = doc.documentElement; + if (root.namespaceURI === NS_P && root.localName === 'Response') return parseResponse(root); + if (root.namespaceURI === NS_P && root.localName === 'AuthnRequest') + return parseAuthnRequest(root); + if (root.namespaceURI === NS_P && root.localName === 'LogoutRequest') + return parseLogoutRequest(root); + if (root.namespaceURI === NS_P && root.localName === 'LogoutResponse') + return parseLogoutResponse(root); + throw new Error( + `対応していない SAML メッセージです(${root.namespaceURI ?? '名前空間なし'} の ${root.localName})。SAML 2.0 の Response / AuthnRequest / LogoutRequest / LogoutResponse のみ対応しています` + ); +} +``` + +Status 抽出を `parseStatus` ヘルパーに抽出し、`parseResponse` を差し替える: + +```ts +interface ParsedStatus { + statusCode?: string; + statusSubCode?: string; + statusMessage?: string; +} + +/** samlp:Status から外側/内側 StatusCode と StatusMessage を抽出する(Response / LogoutResponse 共通) */ +function parseStatus(root: Element): ParsedStatus { + const status = childNS(root, NS_P, 'Status'); + const outerStatusCode = status ? childNS(status, NS_P, 'StatusCode') : undefined; + // 二段階ステータス(外側 StatusCode の子にもう1つ StatusCode)の内側コード + const innerStatusCode = outerStatusCode + ? childNS(outerStatusCode, NS_P, 'StatusCode') + : undefined; + return { + statusCode: attrOf(outerStatusCode, 'Value'), + statusSubCode: attrOf(innerStatusCode, 'Value'), + statusMessage: status ? textOf(childNS(status, NS_P, 'StatusMessage')) : undefined, + }; +} + +function parseResponse(root: Element): SamlResponseData { + return { + type: 'response', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + ...parseStatus(root), + destination: attrOf(root, 'Destination'), + inResponseTo: attrOf(root, 'InResponseTo'), + issueInstant: attrOf(root, 'IssueInstant'), + signed: hasDirectSignature(root), + assertions: childrenNS(root, NS_A, 'Assertion').map(parseAssertion), + encryptedAssertionCount: childrenNS(root, NS_A, 'EncryptedAssertion').length, + }; +} +``` + +ファイル末尾に 2 パーサを追加: + +```ts +function parseLogoutRequest(root: Element): SamlLogoutRequestData { + const nameId = childNS(root, NS_A, 'NameID'); + return { + type: 'logoutRequest', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + destination: attrOf(root, 'Destination'), + issueInstant: attrOf(root, 'IssueInstant'), + notOnOrAfter: attrOf(root, 'NotOnOrAfter'), + reason: attrOf(root, 'Reason'), + nameId: textOf(nameId), + nameIdFormat: attrOf(nameId, 'Format'), + encryptedNameId: childNS(root, NS_A, 'EncryptedID') !== undefined, + // SessionIndex は assertion 側ではなく protocol 名前空間の要素 + sessionIndexes: childrenNS(root, NS_P, 'SessionIndex').flatMap((e) => textOf(e) ?? []), + signed: hasDirectSignature(root), + }; +} + +function parseLogoutResponse(root: Element): SamlLogoutResponseData { + return { + type: 'logoutResponse', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + ...parseStatus(root), + destination: attrOf(root, 'Destination'), + inResponseTo: attrOf(root, 'InResponseTo'), + issueInstant: attrOf(root, 'IssueInstant'), + signed: hasDirectSignature(root), + }; +} +``` + +- [ ] **Step 6: テストが通ることを確認する** + +Run: `npm run test -- src/utils/__tests__/saml-parse.test.ts` +Expected: PASS(既存 Response / AuthnRequest テスト含め全件) + +- [ ] **Step 7: 型チェックとコミット** + +```bash +node_modules/.bin/astro check +git add src/utils/saml/types.ts src/utils/saml/parse.ts src/utils/__tests__/saml-fixtures.ts src/utils/__tests__/saml-parse.test.ts +git commit -m "feat: SAMLデコーダのパーサを LogoutRequest/LogoutResponse に対応" +``` + +Expected: astro check エラー 0 件(`SamlDecoder.tsx` は `SamlMessage` の union 拡張に対して網羅 switch を持たないため型エラーは出ない) + +--- + +### Task 2: チェックリスト拡張(checks + index 公開) + +**Files:** + +- Modify: `src/utils/saml/checks.ts`(`statusCheckItem` / `timezoneNote` 抽出 + 2 関数追加) +- Modify: `src/utils/saml/index.ts`(export 追加) +- Test: `src/utils/__tests__/saml-checks.test.ts` + +- [ ] **Step 1: 失敗するチェックテストを書く** + +`src/utils/__tests__/saml-checks.test.ts` の import を更新: + +```ts +import { + parseSamlXml, + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, +} from '@/utils/saml'; +import type { SamlLogoutRequestData, SamlLogoutResponseData, SamlResponseData } from '@/utils/saml'; +import { + SAMPLE_RESPONSE_XML, + FAILED_STATUS_RESPONSE_XML, + ENCRYPTED_ASSERTION_RESPONSE_XML, + NESTED_STATUS_RESPONSE_XML, + LOGOUT_REQUEST_XML, + ENCRYPTED_ID_LOGOUT_REQUEST_XML, + NO_NAMEID_LOGOUT_REQUEST_XML, + LOGOUT_RESPONSE_XML, + FAILED_LOGOUT_RESPONSE_XML, +} from './saml-fixtures'; +``` + +ファイル末尾に追加(`byId` は既存ヘルパーをそのまま利用。引数型が `runResponseChecks` の戻り値型なので `CheckItem[]` 互換でそのまま渡せる): + +```ts +function parseLogoutRequest(xml: string): SamlLogoutRequestData { + const m = parseSamlXml(xml); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + return m; +} + +function parseLogoutResponse(xml: string): SamlLogoutResponseData { + const m = parseSamlXml(xml); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + return m; +} + +// LOGOUT_REQUEST_XML の NotOnOrAfter: 2026-07-17T00:05:00Z +describe('runLogoutRequestChecks', () => { + const req = parseLogoutRequest(LOGOUT_REQUEST_XML); + + it('期限内は success', () => { + const item = byId(runLogoutRequestChecks(req, { now: IN_WINDOW }), 'notOnOrAfter'); + expect(item.status).toBe('success'); + }); + + it('陽性対照: 期限切れは error', () => { + const item = byId(runLogoutRequestChecks(req, { now: AFTER_WINDOW }), 'notOnOrAfter'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('期限切れ'); + }); + + it('NotOnOrAfter なしは info(SAML 仕様上は任意属性)', () => { + const noLimit = parseLogoutRequest(NO_NAMEID_LOGOUT_REQUEST_XML); + expect(byId(runLogoutRequestChecks(noLimit, { now: IN_WINDOW }), 'notOnOrAfter').status).toBe( + 'info' + ); + }); + + it('パース不能な NotOnOrAfter は warning', () => { + const broken = { ...req, notOnOrAfter: 'not-a-date' }; + expect(byId(runLogoutRequestChecks(broken, { now: IN_WINDOW }), 'notOnOrAfter').status).toBe( + 'warning' + ); + }); + + it('タイムゾーンなし日時は判定続行しつつ warning + 注記', () => { + const noTz = { ...req, notOnOrAfter: '2026-07-17T00:05:00' }; + const item = byId(runLogoutRequestChecks(noTz, { now: BEFORE_WINDOW }), 'notOnOrAfter'); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('ローカル時刻'); + }); + + it('NameID ありは success', () => { + expect(byId(runLogoutRequestChecks(req, { now: IN_WINDOW }), 'nameid').status).toBe('success'); + }); + + it('EncryptedID は warning(復号非対応)', () => { + const enc = parseLogoutRequest(ENCRYPTED_ID_LOGOUT_REQUEST_XML); + const item = byId(runLogoutRequestChecks(enc, { now: IN_WINDOW }), 'nameid'); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('暗号化'); + }); + + it('陽性対照: NameID / EncryptedID なしは error', () => { + const none = parseLogoutRequest(NO_NAMEID_LOGOUT_REQUEST_XML); + expect(byId(runLogoutRequestChecks(none, { now: IN_WINDOW }), 'nameid').status).toBe('error'); + }); +}); + +describe('runLogoutResponseChecks', () => { + it('Status Success は success', () => { + const res = parseLogoutResponse(LOGOUT_RESPONSE_XML); + expect(byId(runLogoutResponseChecks(res), 'status').status).toBe('success'); + }); + + it('陽性対照: Status 失敗は error になり内側コードを併記する', () => { + const res = parseLogoutResponse(FAILED_LOGOUT_RESPONSE_XML); + const item = byId(runLogoutResponseChecks(res), 'status'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('Responder / RequestDenied'); + expect(item.detail).toContain('Session not found'); + }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認する** + +Run: `npm run test -- src/utils/__tests__/saml-checks.test.ts` +Expected: FAIL(`runLogoutRequestChecks` / `runLogoutResponseChecks` が未定義) + +- [ ] **Step 3: checks.ts を実装する** + +`src/utils/saml/checks.ts` に以下の変更を加える。 + +import を更新: + +```ts +import type { + CheckItem, + SamlLogoutRequestData, + SamlLogoutResponseData, + SamlResponseData, +} from './types'; +``` + +`STATUS_SUCCESS` 定数の直後に共有ヘルパー 2 つを追加: + +```ts +interface StatusFields { + statusCode?: string; + statusSubCode?: string; + statusMessage?: string; +} + +/** Status チェック項目を組み立てる(Response / LogoutResponse 共通) */ +function statusCheckItem(res: StatusFields): CheckItem { + if (res.statusCode === STATUS_SUCCESS) { + return { id: 'status', label: 'Status', status: 'success', detail: 'Success' }; + } + const code = res.statusCode?.split(':').pop() ?? '不明'; + const subCode = res.statusSubCode?.split(':').pop(); + const codeLabel = subCode ? `${code} / ${subCode}` : code; + return { + id: 'status', + label: 'Status', + status: 'error', + detail: res.statusMessage + ? `${codeLabel}(StatusMessage: ${res.statusMessage})` + : `${codeLabel}(Success ではありません)`, + }; +} + +/** + * xs:dateTime 文字列の解釈注記を組み立てる。 + * - 日付のみ形式(YYYY / YYYY-MM / YYYY-MM-DD)は ES 仕様上 UTC (00:00Z) 解釈が確定 → 専用注記 + * - timezone designator(Z / ±hh / ±hh:mm / ±hhmm)なしはローカル時刻解釈で環境依存 → 警告注記 + * 両形式が混在する場合は注記を連結する + */ +function timezoneNote(...values: (string | undefined)[]): { + note: string; + missingTimezone: boolean; +} { + // 時刻部(T または スペース区切りの hh:mm)の存在を前提とすることで、年月のみ形式 + // (例: "2026-07")の末尾ハイフンをタイムゾーンオフセットと誤認しないようにする + const hasTimezone = (s: string) => + /[T ]\d{2}:\d{2}/.test(s) && /(?:Z|[+-]\d{2}(?::?\d{2})?)$/.test(s); + const isDateOnly = (s: string) => /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(s); + const present = values.filter((v): v is string => !!v); + const dateOnly = present.some(isDateOnly); + const missingTimezone = present.some((v) => !isDateOnly(v) && !hasTimezone(v)); + let note = ''; + if (dateOnly) note += '\n※ 日付のみのため、UTC (00:00Z) として解釈しています'; + if (missingTimezone) + note += '\n※ タイムゾーン指定がないため、この端末のローカル時刻として解釈しています'; + return { note, missingTimezone }; +} +``` + +`runResponseChecks` の Status ブロック(`// 1. Status` から最初の `}` まで)を差し替え: + +```ts +// 1. Status +items.push(statusCheckItem(res)); +``` + +`runResponseChecks` の有効期間ループ内の `hasTimezone` / `isDateOnly` / `dateOnly` / `missingTimezone` / `tzNote` の組み立て(`const hasTimezone = ...` から `if (missingTimezone) ...` の行まで)を差し替え: + +```ts +const { note: tzNote, missingTimezone } = timezoneNote(c.notBefore, c.notOnOrAfter); +``` + +(以降の `${tzNote}` 参照と `missingTimezone ? 'warning' : 'success'` はそのまま) + +ファイル末尾に 2 関数を追加: + +```ts +/** LogoutRequest の定番チェックリストを実行する */ +export function runLogoutRequestChecks( + req: SamlLogoutRequestData, + opts: CheckOptions = {} +): CheckItem[] { + const now = opts.now ?? new Date(); + const items: CheckItem[] = []; + + // 1. NotOnOrAfter(LogoutRequest では任意属性のため、なしは info) + if (!req.notOnOrAfter) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'info', + detail: '期限指定はありません(SAML 仕様上は任意)', + }); + } else { + const limit = new Date(req.notOnOrAfter); + if (isNaN(limit.getTime())) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'warning', + detail: `日時を解釈できません(NotOnOrAfter: ${req.notOnOrAfter})`, + }); + } else { + const { note, missingTimezone } = timezoneNote(req.notOnOrAfter); + if (now >= limit) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'error', + detail: `期限切れです(NotOnOrAfter: ${req.notOnOrAfter})${note}`, + }); + } else { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: missingTimezone ? 'warning' : 'success', + detail: `期限内です(NotOnOrAfter: ${req.notOnOrAfter})${note}`, + }); + } + } + } + + // 2. NameID(SAML 2.0 Core 仕様上 BaseID / NameID / EncryptedID のいずれかが必須) + if (req.nameId) { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'success', + detail: 'NameID が含まれています', + }); + } else if (req.encryptedNameId) { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'warning', + detail: '暗号化されており内容を確認できません(復号は非対応)', + }); + } else { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'error', + detail: + 'NameID が含まれていません(LogoutRequest には NameID / EncryptedID のいずれかが必要です)', + }); + } + + return items; +} + +/** LogoutResponse の定番チェックリストを実行する(Status のみ) */ +export function runLogoutResponseChecks(res: SamlLogoutResponseData): CheckItem[] { + return [statusCheckItem(res)]; +} +``` + +`src/utils/saml/index.ts` の checks export を差し替え: + +```ts +export { + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, + type CheckOptions, +} from './checks'; +``` + +- [ ] **Step 4: テストが通ることを確認する** + +Run: `npm run test -- src/utils/__tests__/saml-checks.test.ts` +Expected: PASS(既存 `runResponseChecks` テスト含め全件。リファクタで挙動が変わっていないことの回帰確認を兼ねる) + +- [ ] **Step 5: 全ユニットテスト・型チェック・コミット** + +```bash +npm run test +node_modules/.bin/astro check +git add src/utils/saml/checks.ts src/utils/saml/index.ts src/utils/__tests__/saml-checks.test.ts +git commit -m "feat: LogoutRequest/LogoutResponse のチェックリストを追加" +``` + +Expected: 全テスト PASS / astro check エラー 0 件 + +--- + +### Task 3: UI 拡張(SamlDecoder.tsx) + +**Files:** + +- Modify: `src/components/tools/SamlDecoder.tsx` + +- [ ] **Step 1: import と型別分岐を追加する** + +`@/utils/saml` の import に `runLogoutRequestChecks, runLogoutResponseChecks` を追加: + +```ts +import { + decodeSamlInput, + parseSamlXml, + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, + formatXml, + type CheckItem, + type DecodedInput, + type SamlAssertion, + type SamlAttribute, + type SamlBinding, + type SamlMessage, +} from '@/utils/saml'; +``` + +`SamlDecoderTool` 内の型別変数と checks useMemo を差し替え(`const authnRequest = ...` の行の直後に 2 行追加し、`const checks = ...` を差し替え): + +```ts +const logoutRequest = ok && ok.message.type === 'logoutRequest' ? ok.message : null; +const logoutResponse = ok && ok.message.type === 'logoutResponse' ? ok.message : null; + +const checks = useMemo(() => { + if (response) return runResponseChecks(response, { spEntityId }); + if (logoutRequest) return runLogoutRequestChecks(logoutRequest); + if (logoutResponse) return runLogoutResponseChecks(logoutResponse); + return null; +}, [response, logoutRequest, logoutResponse, spEntityId]); +``` + +- [ ] **Step 2: サマリ見出しと型別 dl を追加する** + +サマリセクションの見出しを差し替え: + +```tsx +

+ {response && 'Response サマリ'} + {authnRequest && 'AuthnRequest サマリ'} + {logoutRequest && 'LogoutRequest サマリ'} + {logoutResponse && 'LogoutResponse サマリ'} +

+``` + +`{authnRequest && (...)}` ブロックの直後(サマリ `` の直前)に追加: + +```tsx +{ + logoutRequest && ( +
+ + + + + + + + + +
+ ); +} +{ + logoutResponse && ( +
+ + + + + + + + +
+ ); +} +``` + +注: チェックリストの `{checks && }` は既存のまま変更不要(checks の useMemo 差し替えで Logout 2 型にも表示される)。コメント `{/* チェックリスト(Response のみ) */}` は `{/* チェックリスト(Response / Logout 2 型) */}` に更新する。 + +- [ ] **Step 3: 型チェック・ユニットテスト・コミット** + +```bash +node_modules/.bin/astro check +npm run test +git add src/components/tools/SamlDecoder.tsx +git commit -m "feat: SAMLデコーダ UI に LogoutRequest/LogoutResponse サマリとチェックリストを追加" +``` + +Expected: astro check エラー 0 件 / 全テスト PASS + +--- + +### Task 4: E2E テスト追加 + +**Files:** + +- Modify: `tests/e2e/saml-decoder.spec.ts` + +- [ ] **Step 1: E2E spec を追加する** + +`tests/e2e/saml-decoder.spec.ts` の `AUTHN_REQUEST_XML` 定義の直後に追加: + +```ts +/** NotOnOrAfter を現在時刻基準で生成する LogoutRequest XML(実時刻でチェックが走るため動的に組む) */ +function logoutRequestXml(opts: { notOnOrAfterOffsetMs: number }): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + return ` + + https://sp.example.com/metadata + taro@example.com + _s1 +`; +} + +/** 二段階ステータスで失敗する LogoutResponse(時刻非依存のため静的でよい) */ +const FAILED_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + + + Session not found + +`; +``` + +`test.describe('SAMLデコーダ', ...)` ブロック内の末尾に追加: + +```ts +test('LogoutRequest を貼るとサマリとチェックリストが表示される', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(logoutRequestXml({ notOnOrAfterOffsetMs: 300_000 })); + await expect(page.getByText('LogoutRequest サマリ')).toBeVisible(); + await expect(page.getByText('https://sp.example.com/metadata').first()).toBeVisible(); + await expect(page.getByText('_s1').first()).toBeVisible(); + await expect(page.getByText('期限内です', { exact: false })).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeVisible(); +}); + +test('陽性対照: 期限切れ LogoutRequest はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(logoutRequestXml({ notOnOrAfterOffsetMs: -300_000 })); + await expect(page.getByText('期限切れです', { exact: false })).toBeVisible(); +}); + +test('陽性対照: Status 失敗の LogoutResponse はエラー表示になる', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill(FAILED_LOGOUT_RESPONSE_XML); + await expect(page.getByText('LogoutResponse サマリ')).toBeVisible(); + await expect(page.getByText('Responder / RequestDenied', { exact: false })).toBeVisible(); + await expect(page.getByText('Session not found', { exact: false })).toBeVisible(); +}); +``` + +注: `beforeEach` の `waitForReactHydration(page)` は既存 spec で対応済みのため追加不要。 + +- [ ] **Step 2: E2E を実行する** + +Run: `npm run test:e2e -- saml-decoder` +Expected: PASS(既存 + 新規 3 件) + +※ sandbox の loopback connect 全面 deny 環境では実行不能。接続 probe が 2〜3 回失敗したら `.claude/rules/git-and-fs.md` に従い workaround 探索を打ち切り、「CI を最終ゲートにする」判断へ切り替えて完了報告に明記する。 + +- [ ] **Step 3: コミット** + +```bash +git add tests/e2e/saml-decoder.spec.ts +git commit -m "test(e2e): LogoutRequest/LogoutResponse の表示と陽性対照を追加" +``` + +--- + +### Task 5: ドキュメント更新 + +**Files:** + +- Modify: `docs/tools.md`(SAMLデコーダ節) + +- [ ] **Step 1: docs/tools.md を更新する** + +`docs/tools.md` の SAMLデコーダ節(366 行目付近)で以下 3 箇所を変更する。 + +(1) 「仕組み・アルゴリズム」の `parse.ts` の段落(`- \`parse.ts\` が ...` の行)の「Response は Issuer/Status/...」の文に LogoutRequest / LogoutResponse を追記し、次のとおり差し替える: + +```markdown +- `parse.ts` が `DOMParser` で XML をパースし、`getElementsByTagNameNS` 等の名前空間 URI ベースの解決で prefix(`saml:` / `samlp:` 等)非依存に構造化する。Response は Issuer/Status/Destination と Assertion ごとの NameID・属性・Conditions・AuthnStatement・SubjectConfirmationData、AuthnRequest は Issuer/Destination/AssertionConsumerServiceURL/ProtocolBinding/NameIDPolicy/RequestedAuthnContext、LogoutRequest は Issuer/Destination/NotOnOrAfter/Reason/NameID(EncryptedID は存在検出のみ)/SessionIndex(複数可)、LogoutResponse は Issuer/Status/Destination/InResponseTo を抽出する。`ds:Signature` の有無・`EncryptedAssertion` の件数も検出する(存在表示のみ、検証・復号はしない) +``` + +(2) `checks.ts` の段落の箇条書き(`- \`checks.ts\` の \`runResponseChecks\` が ...` のサブ項目群)の末尾に追加: + +```markdown +- `runLogoutRequestChecks` は LogoutRequest の NotOnOrAfter(任意属性のため未指定は info、期限切れは error)と NameID の存在(EncryptedID は復号非対応のため warning、いずれもなしは仕様違反として error)を、`runLogoutResponseChecks` は Status を同じ規則で判定する +``` + +(3) 「制限・エッジケース」の 1 つ目の箇条書きを差し替え: + +```markdown +- XMLDSig 署名検証・EncryptedAssertion / EncryptedID の復号・ArtifactResolve 等のその他メッセージ型は非対応(署名・暗号化は存在の有無のみ表示。第2版候補) +``` + +- [ ] **Step 2: 整形チェックとコミット** + +```bash +npm run format:check +git add docs/tools.md +git commit -m "docs: SAMLデコーダの LogoutRequest/LogoutResponse 対応を技術解説に反映" +``` + +Expected: format:check PASS(fail した場合は `npm run format` で整形してから commit) + +--- + +## 完了後(親セッションが実施) + +1. push 前必須チェック: `npm run test` / `node_modules/.bin/astro check` / `npm run test:e2e`(sandbox 不能なら CI ゲート判断を PR 本文に明記) +2. `git push -u origin feat/issue-745-saml-logout-messages` +3. PR 作成: `gh pr create --base develop --body-file /tmp/claude/pr_body.md`(`docs/playbooks/pr-creation.md` 3〜5 章に従う) +4. マージ後: issue #745 のチェックボックス「LogoutRequest / LogoutResponse 等の他メッセージ型」を更新 + +## スコープ外 + +- 署名検証 / EncryptedAssertion・EncryptedID の復号 / マスク出力 / その他メッセージ型(issue #745 に残置) +- サンプルボタンの Logout 対応・VRT ページ追加・README / SPEC.md 更新(ツール追加ではないため不要) diff --git a/docs/superpowers/plans/2026-07-20-saml-mask-output.md b/docs/superpowers/plans/2026-07-20-saml-mask-output.md new file mode 100644 index 00000000..7299ac3e --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-saml-mask-output.md @@ -0,0 +1,542 @@ +# SAMLデコーダ 共有用マスク出力 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SAML デコーダのデコード結果 XML から社員 PII(NameID・属性値)と機密文字列を除去した「共有用マスク XML」を生成し、既存の整形 XML 表示ブロック内でトグル切替・コピーできるようにする。 + +**Architecture:** 2 フェーズのマスク。フェーズ1 は DOM を再パースして `saml:NameID` / `saml:AttributeValue` のテキストを値ベース一貫トークン `[REDACTED:PII_n]` に置換(構造ベース)。フェーズ2 は再シリアライズ後の XML 文字列に既存 `scrubText` を `HIGH_ENTROPY` 除外で適用し、URL 埋め込みメール等を救済。UI は既存 `details` 内に `ToggleGroup` を追加して表示・コピー対象を切替。 + +**Tech Stack:** TypeScript / Astro / React island / Vitest(jsdom)/ Playwright。既存 `src/utils/saml/*` と `src/utils/secret-scrubber` を再利用。 + +--- + +## File Structure + +- `src/utils/saml/ns.ts`(新規)— SAML 名前空間定数(`NS_P` / `NS_A` / `NS_DS`)の単一の真実源。parse.ts と mask.ts が import。 +- `src/utils/saml/parse.ts`(変更)— ローカル定義の名前空間定数を `ns.ts` from の import に置換。 +- `src/utils/saml/mask.ts`(新規)— `maskSamlXml(xml): SamlMaskResult` を実装。 +- `src/utils/saml/index.ts`(変更)— `maskSamlXml` / `SamlMaskResult` を re-export。 +- `src/utils/__tests__/saml-mask.test.ts`(新規)— マスクのユニットテスト(陽性対照・陰性対照・不変条件)。 +- `src/components/tools/SamlDecoder.tsx`(変更)— ToggleGroup 追加・マスク結果 useMemo・表示切替。 +- `tests/e2e/saml-decoder.spec.ts`(変更)— トグル切替の E2E を追記。 +- `docs/tools.md` / `docs/decisions.md` / `SPEC.md`(変更)— ドキュメント更新。 + +--- + +### Task 1: 名前空間定数を ns.ts へ切り出す(リファクタ) + +**Files:** + +- Create: `src/utils/saml/ns.ts` +- Modify: `src/utils/saml/parse.ts:11-13` + +- [ ] **Step 1: ns.ts を作成** + +```ts +/** SAML 2.0 の名前空間 URI(parse.ts / mask.ts の単一の真実源) */ +export const NS_P = 'urn:oasis:names:tc:SAML:2.0:protocol'; +export const NS_A = 'urn:oasis:names:tc:SAML:2.0:assertion'; +export const NS_DS = 'http://www.w3.org/2000/09/xmldsig#'; +``` + +- [ ] **Step 2: parse.ts の定数定義を import に置換** + +`src/utils/saml/parse.ts` の 11〜13 行目 + +```ts +const NS_P = 'urn:oasis:names:tc:SAML:2.0:protocol'; +const NS_A = 'urn:oasis:names:tc:SAML:2.0:assertion'; +const NS_DS = 'http://www.w3.org/2000/09/xmldsig#'; +``` + +を次に置き換える(import 文はファイル冒頭の既存 import 群の直後に置く): + +```ts +import { NS_P, NS_A, NS_DS } from './ns'; +``` + +- [ ] **Step 3: 型チェックと既存テストを実行** + +Run: `node_modules/.bin/astro check && npm run test -- saml` +Expected: PASS(parse/decode/checks/format の既存テストが全て緑。挙動は不変) + +- [ ] **Step 4: Commit** + +```bash +git add src/utils/saml/ns.ts src/utils/saml/parse.ts +git commit -m "refactor: SAML 名前空間定数を ns.ts へ切り出し parse/mask で共有" +``` + +--- + +### Task 2: maskSamlXml を実装(構造ベース+scrubber 併用) + +**Files:** + +- Create: `src/utils/saml/mask.ts` +- Test: `src/utils/__tests__/saml-mask.test.ts` + +- [ ] **Step 1: 失敗するテストを書く** + +`src/utils/__tests__/saml-mask.test.ts` を新規作成: + +```ts +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { maskSamlXml } from '@/utils/saml'; +import { SAMPLE_RESPONSE_XML, LOGOUT_REQUEST_XML } from './saml-fixtures'; + +/** 署名付き Response。X509Certificate / SignatureValue の base64 が over-mask されないことの陰性対照用。 */ +const SIGNED_RESPONSE_XML = ` + + https://idp.example.com/metadata + + Qm9ndXNTaWduYXR1cmVWYWx1ZUJhc2U2NEhpZ2hFbnRyb3B5QUJDREVGMTIzNDU2Nzg5MA== + Rml4dHVyZUNlcnRpZmljYXRlQmFzZTY0SGlnaEVudHJvcHlaWVhXVlUwOTg3NjU0MzIxUVJTVA== + + +`; + +/** Destination の URL クエリにメールを埋め込み、フェーズ2 の scrubber 救済を実証する。 */ +const RECIPIENT_EMAIL_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; + +describe('maskSamlXml: フェーズ1 構造ベースマスク(陽性対照)', () => { + it('NameID のメールがマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).not.toContain('taro.yamada@example.com'); + expect(xml).toContain('[REDACTED:PII_'); + }); + + it('パターンでは拾えない日本語氏名(displayName)がマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).not.toContain('山田 太郎'); + }); + + it('複数 AttributeValue(groups の dev / admin)がすべてマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + // Attribute 値として単独出現する dev / admin が消える(要素名 groups は残る) + expect(xml).not.toMatch(/>devadmin { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + const tokens = xml.match(/\[REDACTED:PII_\d+\]/g) ?? []; + // NameID と mail 属性が同じメールを持つため、同一トークンが 2 回以上出現する + const counts = tokens.reduce>((acc, t) => { + acc[t] = (acc[t] ?? 0) + 1; + return acc; + }, {}); + expect(Object.values(counts).some((c) => c >= 2)).toBe(true); + }); + + it('piiCount は occurrence 数(NameID 1 + mail 1 + displayName 1 + groups 2 = 5)', () => { + const { piiCount } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(piiCount).toBe(5); + }); + + it('LogoutRequest の NameID もマスクされる', () => { + const { xml, piiCount } = maskSamlXml(LOGOUT_REQUEST_XML); + expect(xml).not.toContain('taro@example.com'); + expect(piiCount).toBeGreaterThanOrEqual(1); + }); +}); + +describe('maskSamlXml: フェーズ2 scrubber 併用(陽性対照)', () => { + it('Destination URL に埋め込まれたメールが scrubber でマスクされる', () => { + const { xml, secretCount } = maskSamlXml(RECIPIENT_EMAIL_RESPONSE_XML); + expect(xml).not.toContain('leaked@corp.example'); + expect(xml).toContain('[REDACTED:EMAIL_'); + expect(secretCount).toBeGreaterThanOrEqual(1); + }); +}); + +describe('maskSamlXml: over-mask していないこと(陰性対照)', () => { + it('X509Certificate / SignatureValue の base64(HIGH_ENTROPY)は残る', () => { + const { xml } = maskSamlXml(SIGNED_RESPONSE_XML); + expect(xml).toContain( + 'Qm9ndXNTaWduYXR1cmVWYWx1ZUJhc2U2NEhpZ2hFbnRyb3B5QUJDREVGMTIzNDU2Nzg5MA==' + ); + expect(xml).toContain( + 'Rml4dHVyZUNlcnRpZmljYXRlQmFzZTY0SGlnaEVudHJvcHlaWVhXVlUwOTg3NjU0MzIxUVJTVA==' + ); + }); + + it('タイムスタンプ・要素名・属性名・ID が保持される', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).toContain('2026-07-17T00:00:00Z'); + expect(xml).toContain('Name="mail"'); + expect(xml).toContain('ID="_resp1"'); + expect(xml).toContain('SessionIndex="_s1"'); + }); +}); + +describe('maskSamlXml: 不変条件', () => { + it('マスク後の出力は valid XML のまま(再パースできる)', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + expect(doc.getElementsByTagName('parsererror').length).toBe(0); + }); + + it('パース不能な入力は件数 0 で元の文字列を返す', () => { + const { xml, piiCount, secretCount } = maskSamlXml(' = { + API_KEY: true, + PRIVATE_KEY: true, + CREDENTIAL: true, + JWT: true, + EMAIL: true, + IP: true, + PHONE_JP: true, + CREDIT_CARD: true, + HIGH_ENTROPY: false, +}; + +/** + * デコード済み SAML XML から PII / 機密文字列を除去した共有用 XML を生成する。 + * + * フェーズ1(構造ベース): saml:NameID / saml:AttributeValue のテキストを値ベース一貫 + * トークン [REDACTED:PII_n] に置換する(同一値 → 同一トークンで相関を保つ)。 + * フェーズ2(scrubber 併用): 再シリアライズ後の文字列に scrubText を HIGH_ENTROPY 除外で + * 適用し、URL 埋め込みメール等の構造で拾えない残余を救済する。 + * + * 純関数。パース不能な入力は件数 0 で元の文字列を返す。 + */ +export function maskSamlXml(xml: string): SamlMaskResult { + try { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + return { xml, piiCount: 0, secretCount: 0 }; + } + + // フェーズ1: 構造ベースマスク(値ベース一貫トークン化) + const tokenMap = new Map(); + let counter = 0; + let piiCount = 0; + const maskElement = (el: Element): void => { + const value = el.textContent ?? ''; + if (!value.trim()) return; + let token = tokenMap.get(value); + if (!token) { + counter += 1; + token = `[REDACTED:PII_${counter}]`; + tokenMap.set(value, token); + } + el.textContent = token; + piiCount += 1; + }; + const targets: Element[] = [ + ...Array.from(doc.getElementsByTagNameNS(NS_A, 'NameID')), + ...Array.from(doc.getElementsByTagNameNS(NS_A, 'AttributeValue')), + ]; + for (const el of targets) maskElement(el); + + const serialized = new XMLSerializer().serializeToString(doc); + + // フェーズ2: secret-scrubber 残余救済 + const scrubbed = scrubText(serialized, SCRUB_ENABLED); + return { xml: scrubbed.output, piiCount, secretCount: scrubbed.findings.length }; + } catch { + return { xml, piiCount: 0, secretCount: 0 }; + } +} +``` + +- [ ] **Step 4: index.ts に re-export を追加** + +`src/utils/saml/index.ts` の末尾(`export { formatXml } from './format';` の次の行)に追加: + +```ts +export { maskSamlXml, type SamlMaskResult } from './mask'; +``` + +- [ ] **Step 5: テストと型チェックを実行して緑を確認** + +Run: `npm run test -- saml-mask && node_modules/.bin/astro check` +Expected: PASS(全テスト緑・型エラーなし) + +- [ ] **Step 6: Commit** + +```bash +git add src/utils/saml/mask.ts src/utils/saml/index.ts src/utils/__tests__/saml-mask.test.ts +git commit -m "feat: SAML デコーダに共有用マスク出力ロジックを追加 + +構造ベース(NameID/AttributeValue)+secret-scrubber 併用(HIGH_ENTROPY 除外)で +PII・機密を除去。値ベース一貫トークンで相関を保つ。陽性/陰性対照テスト付き。" +``` + +--- + +### Task 3: SamlDecoder UI にマスク表示トグルを追加 + +**Files:** + +- Modify: `src/components/tools/SamlDecoder.tsx` + +- [ ] **Step 1: import と型・state を追加** + +`src/components/tools/SamlDecoder.tsx` 冒頭付近の import に `ToggleGroup` を追加し、`@/utils/saml` の import に `maskSamlXml` を追加する。 + +`import { ResultTable, type TableColumn } from '@/components/ui/ResultTable';` の次の行に: + +```tsx +import { ToggleGroup } from '@/components/ui/ToggleGroup'; +``` + +`@/utils/saml` の import 分割代入(`formatXml,` の行付近)に `maskSamlXml,` を追加する。 + +ファイル上部(`const BINDING_LABEL` の直前)に型エイリアスを追加: + +```tsx +type XmlView = 'raw' | 'masked'; +``` + +- [ ] **Step 2: コンポーネント内に state と useMemo を追加** + +`export function SamlDecoderTool()` 内、`const [spEntityId, setSpEntityId] = useState('');` の次の行に: + +```tsx +const [xmlView, setXmlView] = useState('raw'); +``` + +`const prettyXml = useMemo(() => (ok ? formatXml(ok.decoded.xml) : ''), [ok]);` の次の行に: + +```tsx +const masked = useMemo(() => (ok ? maskSamlXml(ok.decoded.xml) : null), [ok]); +const maskedXml = useMemo(() => (masked ? formatXml(masked.xml) : ''), [masked]); +const displayedXml = xmlView === 'masked' ? maskedXml : prettyXml; +``` + +- [ ] **Step 3: 生 XML details ブロックを差し替える** + +既存の `{/* 生 XML */}` から `` までのブロックを次に置き換える: + +```tsx +{ + /* 生 XML / マスク XML */ +} +
+ + 整形済み XML(簡易整形) + +
+
+ + options={[ + { value: 'raw', label: '生 XML' }, + { value: 'masked', label: 'マスク XML(共有用)' }, + ]} + value={xmlView} + onChange={setXmlView} + ariaLabel="XML 表示モード" + size="sm" + layout="wrap" + /> + +
+ {xmlView === 'masked' && masked && ( +
+ + {masked.piiCount + masked.secretCount > 0 + ? `PII ${masked.piiCount} 件・機密 ${masked.secretCount} 件をマスク` + : 'マスク対象なし'} + +

+ 共有前に必ず目視で確認してください。構造上の PII + フィールドと既知パターンの除去であり、完全な匿名化を保証するものではありません。 +

+
+ )} +
{displayedXml}
+

+ 簡易整形のため、タグ間に混在するテキスト(mixed content)は表示されない場合があります。 +

+
+
; +``` + +- [ ] **Step 4: Clear 時に xmlView をリセット** + +`onClick={() => {` 内の `setSpEntityId('');` の次の行に: + +```tsx +setXmlView('raw'); +``` + +- [ ] **Step 5: 型チェック・lint・format を実行** + +Run: `node_modules/.bin/astro check && npm run lint && npm run format:check` +Expected: PASS(型・button type・整形すべて緑) + +- [ ] **Step 6: Commit** + +```bash +git add src/components/tools/SamlDecoder.tsx +git commit -m "feat: SAML デコーダに 生 XML / マスク XML の表示トグルを追加" +``` + +--- + +### Task 4: E2E テストを追記 + +**Files:** + +- Modify: `tests/e2e/saml-decoder.spec.ts` + +- [ ] **Step 1: テストを追記** + +`tests/e2e/saml-decoder.spec.ts` の最後の `test(...)` の後ろ(`describe` の閉じ括弧の直前)に追加: + +```ts +test('マスク XML トグルで PII がトークン化されコピー対象も切替わる', async ({ page }) => { + await page.getByRole('button', { name: 'サンプル' }).click(); + await expect(page.getByText('Response サマリ')).toBeVisible(); + + // 整形済み XML の details を開く + await page.getByText('整形済み XML(簡易整形)').click(); + + // 生 XML モードでは NameID のメールが表示される + const xmlBlock = page.locator('pre').last(); + await expect(xmlBlock).toContainText('taro.yamada@example.com'); + + // マスク XML に切替 + await page.getByRole('button', { name: 'マスク XML(共有用)' }).click(); + await expect(xmlBlock).not.toContainText('taro.yamada@example.com'); + await expect(xmlBlock).toContainText('[REDACTED:PII_'); + await expect(xmlBlock).not.toContainText('山田 太郎'); + + // 件数バッジが表示される + await expect(page.getByText(/PII \d+ 件・機密 \d+ 件をマスク/)).toBeVisible(); +}); +``` + +> 注: `pre` 要素の locator は属性セレクタではなく可視テキストで検証している。`getByRole('button', ...)` はトグルボタン(`btn-toggle`)に一致する。 + +- [ ] **Step 2: E2E を実行** + +Run: `npm run test:e2e -- saml-decoder` +Expected: PASS(新規ケース含む全ケース緑) + +> ローカル sandbox で loopback 接続が全面 deny される環境では in-session E2E 実行不能。接続 probe が 2〜3 回失敗したら打ち切り、PR 本文にローカル E2E 未実行の旨と理由を明示して CI を最終ゲートとする(`.claude/rules/git-and-fs.md`)。 + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/saml-decoder.spec.ts +git commit -m "test: SAML デコーダのマスク XML トグルの E2E を追加" +``` + +--- + +### Task 5: ドキュメント更新 + +**Files:** + +- Modify: `docs/tools.md`(SAML デコーダの節) +- Modify: `docs/decisions.md`(末尾に決定を追記) +- Modify: `SPEC.md`(SAML デコーダの機能記述に一文追記があれば) + +- [ ] **Step 1: docs/tools.md に追記** + +`docs/tools.md` の SAML デコーダの節に、マスク出力の仕組みと制限を追記する。追記内容の要点: + +- 「共有用マスク XML」トグルで NameID・全 AttributeValue を値ベース一貫トークン `[REDACTED:PII_n]` に置換 +- secret-scrubber を `HIGH_ENTROPY` 除外で併用し URL 埋め込みメール等を救済 +- 署名値・証明書・タイムスタンプ・ID・要素名は構造情報として保持 +- 完全な匿名化は保証せず共有前の目視確認が必要 + +(既存節の文体・見出しレベルに合わせる。該当節の場所は `grep -n "SAML" docs/tools.md` で特定する) + +- [ ] **Step 2: docs/decisions.md に決定を追記** + +`docs/decisions.md` の既存の最新エントリの採番形式(`[NNN]`)に合わせ、次の要旨で 1 エントリ追記: + +- 決定: SAML マスク出力は構造ベース(NameID/AttributeValue)を主とし secret-scrubber を副で併用。scrubber は `HIGH_ENTROPY` を除外。 +- 理由: パターンベースのみでは日本語氏名等を拾えず、逆に HIGH_ENTROPY を有効化すると X509Certificate/SignatureValue(非 PII・公開情報)を over-mask する。構造で意味的 PII を確実に除去しつつ、URL 埋め込み等の残余を scrubber で救済する二段構えが最も過不足が少ない。 + +- [ ] **Step 3: SPEC.md を確認・必要なら追記** + +Run: `grep -n "saml\|SAML" SPEC.md` +該当ツールの機能記述に「共有用マスク出力」を一文追記する(記述が無ければスキップしてよい。ツール追加ではないため 9 章チェックリストは対象外)。 + +- [ ] **Step 4: format:check を実行** + +Run: `npm run format:check` +Expected: PASS(Markdown 整形崩れなし。崩れていれば `npm run format` で修正) + +- [ ] **Step 5: Commit** + +```bash +git add docs/tools.md docs/decisions.md SPEC.md +git commit -m "docs: SAML デコーダ共有用マスク出力の仕組み・決定を追記" +``` + +--- + +### Task 6: 最終検証と VRT 確認 + +- [ ] **Step 1: push 前必須チェックをすべて実行** + +Run: `npm run format:check && npm run test && node_modules/.bin/astro check` +Expected: すべて PASS + +- [ ] **Step 2: ビルドして VRT 影響を確認** + +Run: `npm run build` +Expected: ビルド成功。`details` は初期折りたたみのため `/tools/saml-decoder` の VRT baseline に影響しない見込み。実際に描画差が疑われる場合のみ、対象ブランチで `Update Visual Regression Baseline` workflow を手動トリガーする(web セッションは自動起動不可)。 + +- [ ] **Step 3: E2E(可能なら)** + +Run: `npm run test:e2e -- saml-decoder` +Expected: PASS。loopback deny 環境では未実行として PR 本文に明記。 + +- [ ] **Step 4: push して PR 作成** + +`git push -u origin claude/issue-745-m0e3kx` 後、`--base develop` で PR を作成する(本文は issue #745 を参照し、実装スコープ・テスト状況を記載)。 + +--- + +## Self-Review 結果 + +- **Spec coverage:** フェーズ1(Task 2)・フェーズ2(Task 2)・UI トグル(Task 3)・全メッセージ型対応(Task 2/4、LogoutRequest テスト含む)・テスト陽性/陰性対照(Task 2)・E2E(Task 4)・docs(Task 5)・VRT(Task 6)をカバー。 +- **Placeholder scan:** コード無し記述は Task 5 の docs のみ(既存文体への追従が必要なため要点箇条書き+位置特定コマンドを明示)。他は完全コード。 +- **Type consistency:** `SamlMaskResult`(`xml` / `piiCount` / `secretCount`)と `XmlView`(`'raw' | 'masked'`)は全 Task で一貫。`maskSamlXml` シグネチャは Task 2 定義と Task 3 利用で一致。 diff --git a/docs/superpowers/specs/2026-07-05-retro-skill-design.md b/docs/superpowers/specs/2026-07-05-retro-skill-design.md new file mode 100644 index 00000000..ce9f4219 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-retro-skill-design.md @@ -0,0 +1,145 @@ +# retro スキル設計(PR マージ後の振り返り自動化)— 設計ドキュメント + +- 作成日: 2026-07-05 +- ステータス: 設計承認待ち → 実装 +- スコープ: 第1弾 = 手動起動 `/retro [PR番号]`(MVP)。フック自動リマインドは将来案。 + +--- + +## 1. 目的 + +PR マージ後に、そのPRの作業から得られた気づき(手戻り・レビュー指摘・つまずき)を +抽出し、.agents/rules/common.md 11 章の基準で仕分けして、**再発防止に値するものだけ**をドキュメント改善PRに落とす手順を、 +全エージェント共通のスキルとして構造化する。 + +自動分析が暴走して無関係な変更を提案しないよう、判定基準を厳格に本文へ埋め込み、 +過剰な提案は YAGNI 側に倒す(提案は Step 4 で必ず停止しユーザー承認を挟む)。 + +--- + +## 2. スコープ + +### やること(第1弾 MVP) + +- 手動起動スキル `/retro [PR番号]` +- 3ソース(レビューコメント・agent-lessons・会話履歴)からの気づき抽出 +- .agents/rules/common.md 11 章準拠の5分類仕分け +- 提案表の提示 → ユーザー承認 → 承認分のみ doc 改善PR作成(Step5 まで) + +### やらないこと(将来案として記載のみ) + +- PostToolUse フックによる自動リマインド層。理由(第1弾で見送る根拠): + - **留保1**: PostToolUse フックはスキルを直接起動できない。できるのは + additionalContext / systemMessage で「retro を回しては?」とリマインド注入するところまでで、 + 実際に Skill tool を呼ぶかはモデル判断(=「自動起動」ではなく「自動リマインド+モデル起動」)。 + - **留保2**: squash マージを GitHub Web 上で行うとローカルの Bash tool を通らず、 + フックが発火しない(ローカル `gh pr merge` 経由限定になる)。 + - **留保3**: 実装セッションとマージセッションは別になりがちで、同一セッション前提の + 「会話履歴」は空になりうる。 + - 手動 MVP を先に運用し、これらのリスクを実地で確かめてからフック設計する(YAGNI)。 + +--- + +## 3. 配置と成果物 + +| 部品 | パス | 説明 | +| --------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| スキル本体 | `.agents/skills/retro/SKILL.md` | 全エージェント共通の正本。手順・分類基準・停止ゲートを直書き | +| symlink(必須) | `.claude/skills/retro → ../../.agents/skills/retro` | これが無いと Claude Code の Skill tool から見えない。既存自作スキル(test-gates 等)と同じ登録方式。git 管理対象 | +| 出典表への追記 | `.agents/skills/README.md` | `dads-design-system / test-gates` の「本リポジトリ自作」行に `retro` を追記。`skills-lock.json` は外部由来専用なので登録不要 | + +**設計上の分離**: スキル本体(=全エージェント共通の正本)と、将来のトリガー層(=各エージェント固有)を分ける。 +これにより将来 Codex/Gemini 側の自動トリガーを足すときもスキル本体をそのまま再利用できる。 + +同梱する無関係な設定変更(ユーザー承認済み・本PRに同梱): + +- `.claude/settings.json`: `model: opusplan → opus[1m]`、`permissions.defaultMode: default → auto` + (retro とは独立の設定変更。`defaultMode: auto` はリポジトリ全体の権限モードを変える点を承知のうえ同梱) + +構造は A案(単一 `SKILL.md` に手順を全部埋め込む)を採用。test-gates と同じ単一ファイル構成で、 +retro の手順(5ステップ+分類表)はその分量に収まる。補助リファレンス分割(B案)は手順が育ってからで十分(YAGNI)。 + +--- + +## 4. スキルのインターフェース + +### フロントマター(`SKILL.md` 冒頭 YAML) + +- `name: retro` +- `description`: 手動起動が主。triggering 用に日本語キーワード(`retro` / `振り返り` / `レトロ` / `/retro`)を含める。 + 「PR マージ後にそのPRの作業を振り返り、会話履歴・レビューコメント・agent-lessons から気づきを抽出し、 + .agents/rules/common.md 11 章の基準で仕分けして、承認された分だけドキュメント改善PRを作る」趣旨。 + +### 起動インターフェース + +- `/retro [PR番号]` — 引数で対象PRを指定 +- 引数省略時 — 直近マージPR(`gh pr list --state merged --limit 1 --json number,title`)を対象にし、 + **「PR #N(タイトル)を対象にします。よいですか?」と確認してから進む**(誤爆防止) + +--- + +## 5. 手順(SKILL.md 本体に埋め込む5ステップ) + +### Step 1 — 対象PRの特定 + +`/retro [PR番号]` の引数を使う。省略時は直近マージPR(`gh pr list --state merged --limit 1`)を対象にし、 +確認を挟んでから進む。 + +### Step 2 — 3ソース収集(会話履歴は best-effort) + +- レビューコメント(**主軸**): `gh pr view --comments` +- 既存教訓(**主軸**): `docs/agent-lessons.md` を読み、繰り返し出ている教訓を把握 +- 会話履歴(**best-effort**): 同一セッションに実装ログが残っていれば手戻り・訂正を抽出。 + 別セッション起動で空ならスキップして明示(欠落を隠さない) + +### Step 3 — 仕分け判定(.agents/rules/common.md 11 章準拠、最終反映先へ直接ルーティング) + +| 分類 | 反映先 | 判定基準 | +| -------------------------------- | ------------------------------- | ------------------------------------------- | +| (a) 再発防止に値する共通規約 | `.agents/rules/common.md` | 全エージェント・全開発に適用される | +| (b) Claude 固有の運用改善 | `CLAUDE.md` / `.claude/rules/*` | Claude Code の harness 挙動・権限に紐づく | +| (c) 手順が複雑・再利用性が高い | 新規 skill 化提案 | 3ステップ以上の定型手順、覚えにくいフラグ群 | +| (d) 特定ツール紐付きの実装メモ | `docs/agent-lessons.md` 追記 | 個別コンポーネントのリスク・実装知見 | +| (e) 一度限りの TIP/既に強制済み | 破棄 | コード・Hook・lint で既に担保 | + +11章の「バッファ→昇格」モデルと二重化しないよう、retro は各気づきを**最終反映先へ直接**振り分ける +(全部を agent-lessons バッファに通さない)。 + +### Step 4 — 提案の提示(ここで必ず停止) + +仕分け結果を表で提示(各行: 気づき / 分類 / 反映先ファイル / 変更概要)。 +**(e) 破棄も含めて判定理由を明示**。ユーザーが承認/却下を選ぶ。 +判定基準を厳格にスキル本文へ埋め込み、過剰な提案が形骸化を招くため YAGNI 寄りに倒す。 + +### Step 5 — 承認分のPR作成 + +承認された変更のみ `chore/retro-` ブランチ(**origin/develop 起点**)で実装し、 +`--base develop` で PR 作成(.agents/rules/common.md 6 章/pr-creation playbook 準拠)。 +(c) skill 化提案が承認された場合は `writing-skills` スキルに委譲する。 + +--- + +## 6. 検証方針 + +これはスキル(Markdown)+ symlink + ドキュメント編集で、`src/` のコード変更はない。 + +### 不要 + +- `astro check`(型)・`npm run test:e2e`(E2E)・VRT baseline(ツール追加ではないため `PAGES` 登録不要) +- test-gates skill(retro はガード/バリデータ/検知機構ではなく手順スキルのため陽性対照対象外) + +### 必要 + +- symlink が正しく解決するか(`.claude/skills/retro/SKILL.md` が本体を指す) +- `SKILL.md` フロントマターが有効で、Skill tool の一覧に retro が出るか +- `tests/meta/` にスキル登録/README 整合をチェックする meta テストがあれば通す + (現状スキル参照の meta テストは無く、retro 追加で壊れる既存テストは無い) +- `npm run format:check`(Markdown 整形) + +--- + +## 7. リスクと留保 + +- MVP は手動起動のみ。マージ検知の自動化は上記留保1〜3の運用リスクを実地確認後に別PRで検討。 +- Step 2 の会話履歴は best-effort。別セッション起動時は主軸2ソース(コメント+agent-lessons)で成立させる。 +- retro が生成するのはドキュメント改善提案のみ。承認ゲート(Step4)を必ず通し、無関係変更の混入を防ぐ。 diff --git a/docs/superpowers/specs/2026-07-17-saml-decoder-design.md b/docs/superpowers/specs/2026-07-17-saml-decoder-design.md new file mode 100644 index 00000000..67a940a8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-saml-decoder-design.md @@ -0,0 +1,95 @@ +# SAMLデコーダ(saml-decoder)設計 + +- 日付: 2026-07-17 +- 出典: `docs/tool-candidates.md` S2-2(SAML レスポンスデコーダ) +- 状態: 設計承認済み(ブレインストーミングで確定) + +## 目的 + +SSO トラブルシューティングの定番作業「SAMLResponse の中身確認」をブラウザ完結で行う。 +Assertion は社員の PII(NameID・メール・氏名属性等)の塊であり、samltool.com 等の外部サービスに貼れない現場向け。 + +## スコープ + +### 対応する入力(すべて自動判定) + +対象メッセージ型は **Response と AuthnRequest** の 2 種。入力形式は以下を自動判定する: + +1. URL 全体(`SAMLResponse=` / `SAMLRequest=` クエリパラメータを抽出) +2. URL エンコードされた base64 +3. base64(HTTP-POST binding) +4. base64 + raw deflate(HTTP-Redirect binding、`fflate` で展開) +5. 生 XML + +適用した変換ステップ(URL抽出 → URLデコード → base64 → inflate → XML)を UI に表示し、 +POST / Redirect どちらのバインディング由来かが分かるようにする。 + +### 表示内容 + +**Response の場合:** + +- サマリ: メッセージ型 / Issuer / Status(Success 以外は StatusMessage も)/ Destination / InResponseTo / IssueInstant +- Assertion ごと: + - NameID(+ Format) + - 属性テーブル(Name / 値の一覧) + - Conditions(NotBefore / NotOnOrAfter / AudienceRestriction) + - AuthnStatement(AuthnInstant / SessionIndex / AuthnContextClassRef) + - SubjectConfirmationData(Recipient / NotOnOrAfter / InResponseTo) +- 署名: 存在の有無と位置(Response レベル / Assertion レベル)のみ表示。「署名検証は未対応」と明記 +- `EncryptedAssertion`: 検出して「暗号化されており復号は非対応」と案内 +- 整形済み生 XML の折りたたみ表示 + `CopyButton` + +**AuthnRequest の場合:** + +- サマリ: Issuer / Destination / AssertionConsumerServiceURL / ProtocolBinding / IssueInstant / NameIDPolicy(Format / AllowCreate)/ RequestedAuthnContext +- 定番チェックリストは適用しない(Response のみ) + +### 定番チェックリスト(Response のみ、現在時刻基準) + +1. Status が Success か(Responder / Requester 等はエラー表示 + StatusMessage) +2. Conditions の NotBefore / NotOnOrAfter が有効期間内か(クロックスキューの注意書き付き) +3. Audience / Recipient の値表示。任意入力欄に SP entityID を入れた場合は照合結果を表示(未入力なら表示のみ) +4. NameID の有無 + +各項目を成功 / 警告 / エラーで色分け表示(`StatusBadge` / `NotificationBanner` を使用)。 + +## 実装構成 + +| 項目 | 内容 | +| :--------- | :---------------------------------------------------------------------------------------- | +| ロジック | `src/utils/saml.ts` — デコードチェーン(形式自動判定)+ パーサ | +| UI | `src/components/tools/SamlDecoder.tsx` | +| ページ | `src/pages/tools/saml-decoder.astro`(`client:load`) | +| カテゴリ | `encode`(jwt-decoder / cert-decoder と同列) | +| yomi | `さむるでこーだ` | +| 新規依存 | `fflate` のみ(raw deflate 展開用。pako より小さく TypeScript 型定義同梱) | +| XML パース | `DOMParser`。名前空間 URI ベース(`getElementsByTagNameNS` 等)で要素解決し prefix 非依存 | + +### セキュリティ上の注意 + +- ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない +- `dangerouslySetInnerHTML` は使用しない(属性値・XML 表示はすべて React 要素として組み立てる) +- 入力データはブラウザ外に送信しない + +## テスト + +- ユニットテスト(Vitest): `saml.ts` の入力形式自動判定(5 形式)・Response / AuthnRequest パース・異常系 +- **陽性対照(test-gates skill 準拠、必須)**: チェックリストは検知機構のため fail 側を実証する + - 期限切れ Assertion(NotOnOrAfter 過去)→ エラー表示になること + - Status = Responder → エラー表示になること + - Audience 不一致(entityID 入力時)→ 不一致表示になること +- E2E(Playwright): 貼り付け → 構造表示・チェックリスト表示、陽性対照ケース含む +- VRT: `tests/e2e/visual-regression-pages.ts` の `PAGES` に `/tools/saml-decoder` を追加(baseline は CI の workflow_dispatch で生成・手動トリガー) + +## ドキュメント更新 + +- `README.md`(ツール一覧)/ `SPEC.md`(2.3, 2.4, 4, 5, 9 章)/ `docs/decisions.md`(fflate 採用理由等) +- `docs/tools.md`(仕組み・準拠仕様・制限) +- `docs/tool-candidates.md` S2-2 の状態列に ✅ + PR 番号(マージ時) + +## スコープ外(第2版候補として issue 化する) + +- XMLDSig 署名検証(C14N が山場、難度 High) +- EncryptedAssertion の復号 +- LogoutRequest / LogoutResponse 等の他メッセージ型 +- 共有用マスク出力(secret-scrubber との連携) diff --git a/docs/superpowers/specs/2026-07-19-e2e-hydration-wait-design.md b/docs/superpowers/specs/2026-07-19-e2e-hydration-wait-design.md new file mode 100644 index 00000000..2ea9eb8c --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-e2e-hydration-wait-design.md @@ -0,0 +1,67 @@ +# E2E hydration 待ち漏れ修正 + 漏れ防止 meta テスト 設計 (issue #750) + +日付: 2026-07-19 +対象 issue: [#750](https://github.com/fumtas1k/devtools/issues/750) +関連: #279(waitForReactHydration の拡張余地) + +## 背景 / 問題 + +ローカルの `npm run test:e2e`(並列 worker)で `dsn-builder.spec.ts` を中心に flaky な失敗が発生する。原因は **hydration race**: Playwright の `fill` / `click` / `setInputFiles` が React island の hydration 完了前に実行されると、DOM の value だけ書き換わり React の onChange が発火しない。CI は `workers: 1`(直列)のため顕在化しない。 + +既存ヘルパー `tests/e2e/helpers.ts` の `waitForReactHydration` がこのための対策で、大半の spec は `beforeEach` またはラッパ `withProductionCsp`(内部で hydration 待ちを実施)経由で使用している。 + +## 棚卸し結果(全 51 spec) + +`waitForReactHydration` / `withProductionCsp` を参照しない spec は 7 件: + +| spec | React 操作 | 対応 | +| ---------------------------------- | ---------------------------------------- | ---------------------------- | +| `dsn-builder.spec.ts` | `fill` → onChange パース | **修正**(issue 対象) | +| `dummy-personal-data.spec.ts` | `click` → React handler | **修正**(issue 対象) | +| `har-viewer.spec.ts` | `setInputFiles` → onChange | **修正**(同一リスククラス) | +| `custom-404.spec.ts` | なし(`/tools/*` 外・表示検証のみ) | 不要(検知対象外) | +| `hydration-check.gate.spec.ts` | `/test-fixtures/*`・hydration 破壊を検証 | 不要(検知対象外) | +| `hydration-check-dev.gate.spec.ts` | 同上 | 不要(検知対象外) | +| `prefers-reduced-motion.spec.ts` | なし(computed style 読取のみ) | 不要(**allowlist 登録**) | + +## 設計 + +### 1. hydration 待ちの追加(3 spec) + +- `dsn-builder.spec.ts` / `dummy-personal-data.spec.ts`: 既存 `beforeEach` の `page.goto(...)` 直後に `await waitForReactHydration(page);` を追加(`saml-decoder.spec.ts` 等の既存パターン踏襲)。 +- `har-viewer.spec.ts`: `beforeEach` が無く各 test 冒頭で同一 URL へ `page.goto('/tools/har-viewer')`(8 箇所)している。うち 2 test は goto **前**に `setViewportSize` を実行しており `beforeEach` 集約では順序が変わるため、ローカルヘルパー `openHarViewer(page)`(goto + hydration 待ち)を定義して各 goto を置換する(順序保持 + DRY)。 + +### 2. 漏れ防止 meta テスト(`tests/meta/e2e-hydration-wait-coverage.test.ts`) + +Vitest の meta テスト(`vrt-pages-coverage.test.ts` の allowlist + 純粋関数パターン踏襲)。 + +**検知ルール**: `tests/e2e/*.spec.ts` のうち、ソースに `goto('/tools/...')` を含むファイルは、`waitForReactHydration` または `withProductionCsp` への参照を必須とする。違反ファイルを列挙して fail。 + +- 判定は純粋関数 `findSpecsMissingHydrationWait(specs: { name, content }[], allowlist)` に切り出し、実ファイル走査(`fs.readdirSync` + `readFileSync`)と分離する(陽性対照で fixture 注入可能にするため)。 +- **allowlist**: `prefers-reduced-motion.spec.ts`(computed style 読取のみで React イベント発火に依存しない)。除外理由コメントを併記。 +- **allowlist の腐敗防止**: allowlist 記載ファイルが (a) 実在しない、または (b) 既にヘルパーを使用している場合も fail(orphan 検出。`findOrphanPages` パターン踏襲)。 +- `/test-fixtures/*` や `/`, `/about` 等 `/tools/` 以外への goto のみの spec は検知対象外(gate spec を自然に除外)。 + +**陽性対照**(test-gates skill 準拠・別 describe に分離): + +- hydration 待ちなしで `goto('/tools/xxx')` する fixture 文字列を注入 → 検出されることを assert +- ヘルパー使用済み fixture / `/tools/` 外 goto の fixture → 過検知しないことを assert +- allowlist orphan(実在しない名前 / ヘルパー使用済みなのに allowlist 記載)→ 検出されることを assert +- 実装前の旧状態(今回の 3 spec 修正を戻した状態)で本体テストが fail することを実機確認する + +### 3. スコープ外 + +- `waitForReactHydration` 自体の拡張(#279 で別管理) +- 既存 spec の `withProductionCsp` への移行・リファクタ +- CI の worker 数変更 + +## テスト計画 + +- `npm run test`(meta テスト含む unit) +- `node_modules/.bin/astro check` +- `npm run test:e2e`(修正 3 spec を含む全件、リモート環境の並列 worker で実行) +- 陽性対照の実機確認: 3 spec の修正を一時的に戻し、meta テストが fail することを確認してから復元 + +## ドキュメント影響 + +ツール追加・ライブラリ変更・構成変更に該当しないため `README.md` / `SPEC.md` / `docs/decisions.md` の更新は不要。meta テスト自体がルールのドキュメントを兼ねる(ファイル冒頭コメントに背景 issue と除外基準を記載)。 diff --git a/docs/superpowers/specs/2026-07-20-saml-logout-messages-design.md b/docs/superpowers/specs/2026-07-20-saml-logout-messages-design.md new file mode 100644 index 00000000..dac050ff --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-saml-logout-messages-design.md @@ -0,0 +1,98 @@ +# SAML デコーダ LogoutRequest / LogoutResponse 対応 設計 + +- 日付: 2026-07-20 +- 対象 issue: [#745](https://github.com/fumtas1k/devtools/issues/745)(第2版候補「LogoutRequest / LogoutResponse 等の他メッセージ型」) +- 初版設計: `docs/superpowers/specs/2026-07-17-saml-decoder-design.md` + +## 目的 + +SAML デコーダ(`/tools/saml-decoder`)を LogoutRequest / LogoutResponse(シングルログアウト)に対応させ、SLO のトラブルシュート(期限切れリクエスト・Status 失敗レスポンス)をブラウザ内で完結できるようにする。 + +## スコープ + +- 対応追加: `samlp:LogoutRequest` / `samlp:LogoutResponse` の 2 型のみ +- アプローチ: 既存パターン踏襲の最小拡張(union 型追加・ルート分岐追加・型別サマリ・チェックリスト拡張) + +## 設計 + +### 1. データモデル(`src/utils/saml/types.ts`) + +判別可能 union `SamlMessage` に 2 型を追加する。 + +```ts +interface SamlLogoutRequestData { + type: 'logoutRequest'; + issuer?: string; // saml:Issuer(SP または IdP) + destination?: string; // ルート属性 + issueInstant?: string; + notOnOrAfter?: string; // ルート属性(リクエスト自体の期限) + reason?: string; // Reason 属性(URI) + nameId?: string; // saml:NameID テキスト + nameIdFormat?: string; + encryptedNameId: boolean; // saml:EncryptedID の場合 true(内容は表示不可) + sessionIndexes: string[]; // samlp:SessionIndex(複数可) + signed: boolean; +} + +interface SamlLogoutResponseData { + type: 'logoutResponse'; + issuer?: string; + statusCode?: string; + statusSubCode?: string; // Response と同じ二段階ステータス対応 + statusMessage?: string; + destination?: string; + inResponseTo?: string; + issueInstant?: string; + signed: boolean; +} +``` + +### 2. パーサ(`src/utils/saml/parse.ts`) + +- ルート分岐に `LogoutRequest` / `LogoutResponse` を追加。非対応時のエラーメッセージの対応型列挙も更新する +- Status 抽出(外側/内側 StatusCode + StatusMessage)は Response と LogoutResponse で共通のため `parseStatus(root)` ヘルパーに抽出して両者で再利用する +- `EncryptedID` は `NS_A` 名前空間の直下子要素の存在で判定(復号はしない。復号は issue #745 の別項目) +- `SessionIndex` は `NS_P` 名前空間(protocol 側の要素であることに注意) + +### 3. チェックリスト(`src/utils/saml/checks.ts`) + +- **LogoutResponse**: Status チェックのみ。既存 `runResponseChecks` の Status 判定ブロックを `statusCheckItem()` に関数抽出して共有(Success → success / それ以外 → error、内側コード連結表示も同じ挙動) +- **LogoutRequest**: 2 項目 + - **NotOnOrAfter**: 属性なし → info「期限指定なし」/ パース不能 → warning / 期限切れ → error / 有効 → success。既存の `hasTimezone` / `isDateOnly` によるタイムゾーン注記ロジックを関数抽出して再利用する + - **NameID**: NameID あり → success / EncryptedID → warning「暗号化されており内容を確認できません(復号は非対応)」/ どちらもなし → error(SAML 2.0 Core 仕様上 LogoutRequest には BaseID / NameID / EncryptedID のいずれかが必須) +- 公開 API は `runLogoutRequestChecks` / `runLogoutResponseChecks` を追加。`CheckOptions.now` の注入でテスト決定性を担保する(既存と同様) + +### 4. UI(`src/components/tools/SamlDecoder.tsx`) + +- 既存パターン踏襲で型別サマリ `
` を 2 つ追加(見出し「LogoutRequest サマリ」「LogoutResponse サマリ」) +- LogoutRequest で EncryptedID の場合は NameID 行に「(暗号化・表示不可)」を表示する +- SP entityID 入力欄は Response のときだけ表示(現状維持。Logout 型には Audience がないため不要) +- 入力ラベル・placeholder は現状のまま(既に「SAMLResponse / SAMLRequest」と汎用的) +- サンプルボタンは現状の Response サンプルのまま変更しない(YAGNI) + +### 5. テスト + +- **ユニット**(`src/utils/saml/__tests__/`): + - パース: LogoutRequest / LogoutResponse を prefix あり・default xmlns の両方で。EncryptedID 判定・SessionIndex 複数も対象 + - チェック: `now` 注入で決定的に。期限切れ / 有効 / 指定なし、Status 成功 / 失敗、NameID なし +- **E2E**(`tests/e2e/saml-decoder.spec.ts` に追記): LogoutRequest の base64 貼り付け → サマリ + チェックリスト表示、LogoutResponse の Status 失敗表示。`waitForReactHydration` 必須 +- チェックリストは検知機構のため **test-gates skill に従い陽性対照必須**(期限切れ → error、Status 失敗 → error を必ず含める) + +### 6. ドキュメント + +- `docs/tools.md` の SAML デコーダ節に対応メッセージ型(LogoutRequest / LogoutResponse)と制限を追記 +- `README.md` / `SPEC.md` はツール追加ではないため基本影響なし(説明文が変わる場合のみ更新) +- issue #745 のチェックボックス更新は PR マージ後に行う + +## エラーハンドリング + +- 非対応メッセージ型のエラーメッセージは対応型の列挙を「Response / AuthnRequest / LogoutRequest / LogoutResponse」に更新 +- XML 構文エラー・デコード失敗の扱いは既存のまま(変更なし) + +## スコープ外(issue #745 に残置) + +- XMLDSig 署名検証 +- EncryptedAssertion / EncryptedID の復号 +- 共有用マスク出力(secret-scrubber 連携) +- ArtifactResolve 等のさらなるメッセージ型追加 +- VRT ページ追加(既存ページの表示バリエーションのため新規ページなし) diff --git a/docs/superpowers/specs/2026-07-20-saml-mask-output-design.md b/docs/superpowers/specs/2026-07-20-saml-mask-output-design.md new file mode 100644 index 00000000..1db807fb --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-saml-mask-output-design.md @@ -0,0 +1,172 @@ +# SAMLデコーダ 共有用マスク出力(saml-mask-output)設計 + +- 日付: 2026-07-20 +- 出典: issue #745「SAMLデコーダ第2版」の機能候補「共有用マスク出力(secret-scrubber との連携)」 +- 状態: 設計承認済み(ブレインストーミングで確定) +- 関連: `docs/superpowers/specs/2026-07-17-saml-decoder-design.md`(初版設計・スコープ外セクション) + +## 目的 + +SAML デコーダのデコード結果 XML から、社員 PII(NameID・属性値)と機密文字列を除去した +「共有用マスク XML」を生成し、コピーして安全に共有できるようにする。 + +本ツールの存在意義は「Assertion は社員の PII の塊であり samltool.com 等の外部サービスに貼れない現場向け」 +(初版設計より)。マスク出力はこの目的を出力面から完成させる機能で、 +「PII を伏せた状態なら同僚・チケットに共有したい」というトラブルシュート実務のニーズに応える。 + +## スコープ + +### この PR に含める + +- デコード済み XML の PII / 機密文字列マスク(構造ベース+secret-scrubber 併用) +- 「整形済み XML」表示ブロック内での 生 XML / マスク XML トグル切替 +- 全メッセージ型(Response / AuthnRequest / LogoutRequest / LogoutResponse)に一律適用 + +### スコープ外(別 PR) + +- XMLDSig 署名検証(issue #745 の機能候補③) +- EncryptedAssertion 復号(同②) +- マスク対象フィールドのカスタマイズ UI(フィールド選択トグル等)。初版は固定ルールとし YAGNI で見送る + +## マスク戦略(構造ベース+スクラバ併用) + +### フェーズ1: 構造ベースマスク(DOM 操作) + +`decodeSamlInput` 済みの XML を `DOMParser` で再パースし、以下の要素の**テキスト内容**を +一貫トークンで置換する。要素は名前空間 URI で解決し prefix 非依存とする(parse.ts と同方針)。 + +- `saml:NameID`(`urn:oasis:names:tc:SAML:2.0:assertion` の `NameID`) + — Subject 内・LogoutRequest 直下いずれも対象 +- `saml:AttributeValue`(全 Attribute の値) + +**一貫トークン化**: 値ベースで採番する(同一文字列 → 同一トークン)。 +トークン形式は secret-scrubber に揃えて `[REDACTED:PII_]`。 +カテゴリは NameID / AttributeValue をまたいで単一の `PII` とし、値をキーに採番する。 +これにより NameID のメールと `mail` 属性値が同一値なら同じトークンになり、 +「同一ユーザか」の相関がマスク後も追える(トラブルシュートで有益)。 + +構造ベースの要点は、パターンマッチでは拾えない値(日本語氏名 `山田 太郎`・社員番号 `E12345` 等)も +「意味的役割が PII の要素」として確実にマスクできること。 + +### フェーズ2: secret-scrubber 併用(残余救済) + +フェーズ1 で再シリアライズした XML 文字列に対し `scrubText`(`src/utils/secret-scrubber`)を実行し、 +構造では拾えない箇所(Recipient URL 等に埋め込まれたメール、埋め込み JWT、IP、認証情報)を救済する。 + +- **`HIGH_ENTROPY` カテゴリは除外する**。X509Certificate / SignatureValue / DigestValue の + base64(非 PII・公開情報)を over-mask しないため。これが「secret-scrubber をそのまま XML に + かける」案を採らない理由でもある(HIGH_ENTROPY が署名・証明書を巻き込む)。 +- 有効カテゴリ: EMAIL / JWT / CREDENTIAL / API_KEY / PRIVATE_KEY / IP / PHONE_JP / CREDIT_CARD。 +- フェーズ1 の `PII` カテゴリと scrubText が採番するカテゴリ(EMAIL 等)は別空間のため、 + トークン番号は衝突しない。scrubText は入力中の既存 `[REDACTED:CAT_n]` を走査して番号を予約する + (`reservedMax` ロジック)ため、`PII` トークンを挿入済みでも整合する。 +- プレースホルダ `[REDACTED:PII_n]` はコロン・角括弧を含み、有効カテゴリの正規表現 + (EMAIL は `@` 必須等)にはマッチしないため、フェーズ2 で二重マスクされない。 + +### マスクしない(構造情報として保持) + +- 要素名・属性名(`Name` / `FriendlyName` / `Format`) +- タイムスタンプ(`IssueInstant` / `NotBefore` / `NotOnOrAfter` / `AuthnInstant` 等) +- ID 系(Response / Assertion の `ID`・`InResponseTo`・`SessionIndex`) + — 相関用のセッション毎ランダム値。PII ではなくトラブルシュートに必要 +- エンドポイント URL(`Issuer` / `Destination` / `AssertionConsumerServiceURL` / `Recipient`) + — 議論対象そのものが多い。ただし URL 内に埋まったメール・認証情報はフェーズ2 が部分マスクする +- 署名関連(`X509Certificate` / `SignatureValue` / `DigestValue`)— 公開情報・非 PII + +## UI + +「整形済み XML(簡易整形)」の `details` ブロック内に `ToggleGroup` を追加する。 + +- 選択肢: `生 XML` / `マスク XML(共有用)` +- 選択に応じて表示 XML と `CopyButton` のコピー対象を切替 +- マスクモード時: + - 件数バッジ(例「PII 3 件・機密 1 件をマスク」)を表示 + - 注記「共有前に必ず目視確認してください。完全な匿名化を保証するものではありません」 +- 全メッセージ型に一律適用。マスク対象が 0 件の場合(AuthnRequest 等)は「マスク対象なし」を明示 + +`details` は初期状態で折りたたみのため、トグル追加による初期表示の見た目変化はない見込み。 + +## 実装構成 + +| 項目 | 内容 | +| :------------- | :----------------------------------------------------------------------------------- | +| 名前空間定数 | `src/utils/saml/ns.ts`(新規)— `NS_P` / `NS_A` / `NS_DS` を切り出し parse.ts と共有 | +| マスクロジック | `src/utils/saml/mask.ts`(新規)— `maskSamlXml(xml): { xml; piiCount; secretCount }` | +| re-export | `src/utils/saml/index.ts` に `maskSamlXml` を追加 | +| UI | `src/components/tools/SamlDecoder.tsx` に ToggleGroup・マスク結果 useMemo・表示切替 | +| スタイル | 既存 semantic class(`bg-subtle` 等)のみ使用。Tailwind primitive scale 直書きは禁止 | + +### `maskSamlXml` の返り値 + +```ts +interface SamlMaskResult { + /** マスク済み XML(シリアライズ後の文字列。表示側で formatXml して整形表示する) */ + xml: string; + /** 構造ベース(フェーズ1)でマスクした occurrence 数 */ + piiCount: number; + /** secret-scrubber(フェーズ2)でマスクした occurrence 数 */ + secretCount: number; +} +``` + +処理順: `decoded.xml` を DOM パース → フェーズ1(対象要素のテキスト置換)→ シリアライズ → +フェーズ2(`scrubText`)→ 返却。表示側は返却 XML を既存 `formatXml` で整形して表示する。 + +## エラーハンドリング + +- マスクはパース成功後(トグル表示時)のみ実行するため、DOM 再パースは通常成功する。 + 念のため try/catch し、失敗時は件数 0・元 XML 返却またはエラー表示にフォールバックする。 +- `scrubText` は純関数で例外を投げない。 + +## テスト + +test-gates skill 準拠。マスクは「PII / 機密を検出して除去する機構」であり、 +陰性対照のみでは「検出能力ゼロで green」と区別不能なため**陽性対照を必須**とする。 + +### ユニット(Vitest): `src/utils/saml/__tests__/saml-mask.test.ts` + +**陽性対照(必須・除去できることの実証):** + +- NameID のメールがマスクされる +- 日本語氏名(`displayName` 属性値 `山田 太郎`)がマスクされる(パターンでは拾えない値) +- 複数 AttributeValue がすべてマスクされる +- 同一値(NameID メール = `mail` 属性値)が同一トークンになる(相関) +- Recipient URL 等に埋め込んだメールがフェーズ2(scrubber)でマスクされる +- LogoutRequest の NameID もマスクされる + +**陰性対照(over-mask していないことの確認):** + +- X509Certificate / SignatureValue の base64 が残る(HIGH_ENTROPY 除外の実証) +- タイムスタンプ・要素名・属性名・ID 系が保持される + +**不変条件:** + +- マスク後の出力が valid XML のまま(再パースできる) + +### E2E(Playwright): `tests/e2e/saml-decoder.spec.ts` に追記 + +- サンプル入力 → トグルを「マスク XML」に切替 → NameID / 属性値がトークン化されて表示される +- 件数バッジが表示される +- `CopyButton` のコピー対象がマスク XML に切替わる +- `beforeEach` で `waitForReactHydration(page)` を必ず呼ぶ(React island 入力の hydration race 対策) + +### VRT + +`/tools/saml-decoder` は既存 PAGES に登録済み。`details` は初期折りたたみのため baseline への影響は +無い見込み。実装時に `npm run build` 後の描画で影響有無を確認し、影響があれば +`Update Visual Regression Baseline` workflow を対象ブランチで手動トリガーする +(web セッションは `actions: write` 権限が無く自動起動不可 → `.claude/rules/github-web-session.md`)。 + +## ドキュメント更新 + +- `docs/tools.md`(SAML デコーダの節にマスク機能の仕組み・制限を追記) +- `docs/decisions.md`(構造ベース+secret-scrubber 併用・HIGH_ENTROPY 除外の判断理由) +- `SPEC.md`(挙動変更のため該当箇所のみ。ツール追加ではないので 9 章チェックリストは対象外) +- issue #745 のチェックボックス「共有用マスク出力」を PR マージ時に ✅ 更新 + +## セキュリティ上の注意 + +- 入力データ・マスク処理はすべてブラウザ内で完結し外部送信しない(初版方針を踏襲) +- `dangerouslySetInnerHTML` は使用せず、表示はすべて React 要素として組み立てる +- マスクは「完全な匿名化」を保証しない(構造的 PII フィールド+既知パターンの除去であり、 + 自由記述の属性値に第三者の氏名が含まれる等のケースは残りうる)。UI に目視確認を促す注記を置く diff --git a/docs/tool-candidates.md b/docs/tool-candidates.md index 71720325..493f6779 100644 --- a/docs/tool-candidates.md +++ b/docs/tool-candidates.md @@ -66,7 +66,7 @@ Agent Teams によるブレインストーミング(2026-04-29)の結果を | # | 状態 | ツール名 | slug(案) | 概要 | 技術メモ | | ---- | ------- | ------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | S2-1 | ✅ #631 | **シークレット/ログマスキング** | `secret-scrubber` | ログ・コード・設定を貼ると API キー / トークン / JWT / メール / IP / 電話番号等を検出して一括マスク(同一値は同一プレースホルダ)。LLM や issue に貼る前の前処理 | 3視点が独立提案。gitleaks 系正規表現ルール+エントロピー判定の純 JS で Medium。**検知器のため test-gates skill(陽性対照テスト)必須** | -| S2-2 | | **SAML レスポンスデコーダ** | `saml-decoder` | `SAMLResponse` の base64 + deflate を展開し Assertion(NameID / 属性 / Conditions / Audience)をツリー表示。SSO トラブルの定番チェックを自動判定 | Assertion は社員 PII の塊で samltool.com に貼れない。`pako` + `DOMParser` で Low〜Medium。XMLDSig 署名検証まで踏むと High(初版スコープ外可) | +| S2-2 | ✅ #746 | **SAML レスポンスデコーダ** | `saml-decoder` | `SAMLResponse` の base64 + deflate を展開し Assertion(NameID / 属性 / Conditions / Audience)をツリー表示。SSO トラブルの定番チェックを自動判定 | Assertion は社員 PII の塊で samltool.com に貼れない。`pako` + `DOMParser` で Low〜Medium。XMLDSig 署名検証まで踏むと High(初版スコープ外可) | | S2-3 | | **CSV 匿名化・PII ダミー置換** | `csv-anonymizer` | CSV 列ごとに削除 / ハッシュ化 / 一貫ダミー置換(同一人物は同一偽名)/ 数値丸めを指定して匿名化版を出力。本番データから検証用データを作る定番作業 | `papaparse` + Web Crypto。日本語ダミー値(氏名・住所)はダミーテキスト生成の資産を流用。S2-1 と検出ルール・辞書を共有可能 | | S2-4 | ✅ #675 | **HAR ビューア&サニタイザ** | `har-viewer` | DevTools の HAR を D&D で閲覧(一覧 / ウォーターフォール)し、Cookie / Authorization / トークン類を自動 redact した共有用 HAR を出力 | HAR はセッション Cookie・POST ボディを丸ごと含む。HAR は単なる JSON でパース容易、ウォーターフォール UI は #674(PR #708)で追加済み。閲覧+サニタイズ一体型は空白 | diff --git a/docs/tools.md b/docs/tools.md index b5cf78fe..baf5e259 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -23,6 +23,7 @@ - [Base64 エンコード/デコード](#base64-エンコードデコード) - [JWTデコーダー](#jwtデコーダー) - [SSL/TLS証明書デコーダ](#ssltls証明書デコーダ) + - [SAMLデコーダ](#samlデコーダ) - [変換・解析](#変換解析) - [JSON / XML 変換](#json--xml-変換) - [JSON / CSV 変換](#json--csv-変換) @@ -362,6 +363,36 @@ JWT を `.` で 3 分割し、Header・Payload を base64url デコードして - SCT はタイムスタンプ・ログ ID の表示のみで、署名の暗号検証はしない(best-effort) - 全処理はブラウザ内で完結し、入力(社内 CA・本番証明書・秘密鍵を含む)は外部に送信しない +### SAMLデコーダ + +#### 仕組み・アルゴリズム + +- 入力形式を `decode.ts` の `decodeSamlInput` で自動判定する。URL 全体なら `SAMLResponse` / `SAMLRequest` クエリパラメータを抽出(`URLSearchParams` は `+` を空白に変換し base64 を破壊するため、生クエリ文字列から自前パースし percent エンコードのまま `+` を保持する。クエリキー名が percent エンコードされている場合も比較前に `decodeURIComponent` を試みる)→ 生 XML 判定 → URL デコード → base64 デコード(`-`/`_` を含む base64url 表記は標準 base64 へ変換しパディングを補完してから decode)→ UTF-8 として XML と解釈できれば HTTP-POST binding、できなければ `fflate` の `Decompress`(ストリーミング API。raw deflate/zlib/gzip 自動判定)で展開し HTTP-Redirect binding と判定する。展開後サイズが 32MB を超えた場合は zip bomb 対策としてエラーにする(圧縮データを 64KB 単位のチャンクに分けて渡すことで、上限超過を検知した時点で残りの展開処理を打ち切る)。適用した変換ステップは UI に表示する +- `parse.ts` が `DOMParser` で XML をパースし、`getElementsByTagNameNS` 等の名前空間 URI ベースの解決で prefix(`saml:` / `samlp:` 等)非依存に構造化する。Response は Issuer/Status/Destination と Assertion ごとの NameID・属性・Conditions・AuthnStatement・SubjectConfirmationData、AuthnRequest は Issuer/Destination/AssertionConsumerServiceURL/ProtocolBinding/NameIDPolicy/RequestedAuthnContext、LogoutRequest は Issuer/Destination/NotOnOrAfter/Reason/NameID(EncryptedID は存在検出のみ)/SessionIndex(複数可)、LogoutResponse は Issuer/Status/Destination/InResponseTo を抽出する。`ds:Signature` の有無・`EncryptedAssertion` の件数も検出する(存在表示のみ、検証・復号はしない) + - 二段階ステータス(外側 StatusCode の子にネストした内側 StatusCode)の内側コードも `statusSubCode` として抽出し、UI では Status 行の直後に表示する + - `AudienceRestriction` は要素ごとに `string[]`(AND される制約)として保持し、各制約内の `Audience` は OR 列挙として扱う +- `checks.ts` の `runResponseChecks` が Response の定番チェック(Status / 有効期間 / Audience・Recipient / NameID)を現在時刻基準で実行する。`NotOnOrAfter` は SAML 仕様どおりその時刻自体を含まない排他境界(`now >= notOnOrAfter` で期限切れ)として判定する + - Status が失敗の場合、内側 StatusCode があれば `外側 / 内側`(例: `Responder / RequestDenied`)の形式で併記する + - タイムゾーン指定(`Z` / `±hh(:mm)`)のない `NotBefore` / `NotOnOrAfter` はこの端末のローカル時刻として解釈されるため実行環境依存になる旨を警告として注記しつつ、判定自体は継続する。日付のみ形式(`YYYY-MM-DD`)は ES 仕様上 UTC (00:00Z) 解釈が確定するため、ローカル時刻の注記ではなく専用の注記を表示する + - `Date` でパース不能な日時は「有効期間内」と誤って表示しないよう warning 扱いにする + - SP entityID 入力時の Audience 照合は、複数の `AudienceRestriction` がある場合、空でないすべての制約に entityID が含まれる場合のみ一致とする(AND 判定) + - `runLogoutRequestChecks` は LogoutRequest の NotOnOrAfter(任意属性のため未指定は info、期限切れは error)と NameID の存在(EncryptedID は復号非対応のため warning、いずれもなしは仕様違反として error)を、`runLogoutResponseChecks` は Status を同じ規則で判定する +- `format.ts` が表示用に生 XML を簡易整形する(要素・属性・テキストのみを再構成するため、タグ間に混在するテキスト(mixed content)は表示されない場合がある旨を UI に注記) +- `mask.ts` の `maskSamlXml` が「共有用マスク XML」トグル用の 2 フェーズマスクを行う。フェーズ1(構造ベース)は再パースした DOM 上の `saml:NameID` / `saml:AttributeValue` のテキストを値ベース一貫トークン `[REDACTED:PII_n]`(同一値は同一トークン)に置換し、フェーズ2 は再シリアライズ後の文字列に `secret-scrubber` の `scrubText` を `HIGH_ENTROPY` カテゴリ除外で適用して URL クエリ埋め込みメール等の構造で拾えない残余を救済する。`HIGH_ENTROPY` を除外するのは `ds:SignatureValue` / `ds:X509Certificate` 等の base64(非 PII・公開情報)を over-mask しないため。署名値・証明書・タイムスタンプ・ID・要素名は構造情報としてそのまま残す + +#### 準拠仕様・RFC + +- SAML 2.0 Core / Bindings(HTTP-POST・HTTP-Redirect binding) + +#### 制限・エッジケース + +- XMLDSig 署名検証・EncryptedAssertion / EncryptedID の復号・ArtifactResolve 等のその他メッセージ型は非対応(署名・暗号化は存在の有無のみ表示。第2版候補) +- LogoutRequest の主体識別子は `NameID` / `EncryptedID` のみ対応(SAML 2.0 Core 3.7.1 で許容される `BaseID` は非対応。実運用例がほぼ皆無なため。`BaseID` のみのリクエストは NameID チェックが error 表示になる) +- ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない +- 全処理はブラウザ内で完結し、入力(Assertion に含まれる氏名・メール等の PII を含む)は外部に送信しない +- deflate 展開後のサイズが 32MB を超える入力はエラーにする(zip bomb 対策) +- 「共有用マスク XML」は構造上の PII フィールド(NameID・AttributeValue)と `secret-scrubber` の既知パターンの除去であり、完全な匿名化を保証するものではない。共有前に必ず目視で確認すること + ## 変換・解析 ### JSON / XML 変換 diff --git a/package-lock.json b/package-lock.json index 823cfc24..7b7153f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "bwip-js": "^4.9.0", "encoding-japanese": "2.2.0", "fast-xml-parser": "5.5.11", + "fflate": "0.8.3", "jmespath": "0.16.0", "jsbarcode": "^3.12.3", "jsonc-parser": "3.3.1", @@ -5792,6 +5793,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", diff --git a/package.json b/package.json index f4c113c6..7b0d873b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "bwip-js": "^4.9.0", "encoding-japanese": "2.2.0", "fast-xml-parser": "5.5.11", + "fflate": "0.8.3", "jmespath": "0.16.0", "jsbarcode": "^3.12.3", "jsonc-parser": "3.3.1", diff --git a/src/components/tools/SamlDecoder.tsx b/src/components/tools/SamlDecoder.tsx new file mode 100644 index 00000000..161b04aa --- /dev/null +++ b/src/components/tools/SamlDecoder.tsx @@ -0,0 +1,462 @@ +import { useMemo, useState } from 'react'; +import { InputField } from '@/components/ui/InputField'; +import { ClearButton } from '@/components/ui/ClearButton'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { StatusBadge } from '@/components/ui/StatusBadge'; +import { NotificationBanner } from '@/components/ui/NotificationBanner'; +import { ResultTable, type TableColumn } from '@/components/ui/ResultTable'; +import { ToggleGroup } from '@/components/ui/ToggleGroup'; +import { + decodeSamlInput, + parseSamlXml, + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, + formatXml, + maskSamlXml, + type CheckItem, + type DecodedInput, + type SamlAssertion, + type SamlAttribute, + type SamlBinding, + type SamlMessage, +} from '@/utils/saml'; + +const BINDING_LABEL: Record = { + redirect: 'HTTP-Redirect binding(base64 + deflate)', + post: 'HTTP-POST binding(base64)', + xml: '生 XML', +}; + +type XmlView = 'raw' | 'masked'; + +/** サンプル: 現在時刻を挟む有効期間の Response を POST binding(base64)で生成 */ +function buildSampleInput(): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + const xml = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + + taro.yamada@example.com + + + + + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + taro.yamada@example.com + 山田 太郎 + devadmin + + +`; + const bytes = new TextEncoder().encode(xml); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +/** 複数 AudienceRestriction は AND 判定のため、2 件以上ならグループが分かる表記にする */ +function formatAudienceRestrictions(restrictions: string[][] | undefined): string | undefined { + if (!restrictions) return undefined; + const nonEmpty = restrictions.filter((g) => g.length > 0); + if (nonEmpty.length === 0) return undefined; + if (nonEmpty.length === 1) return nonEmpty[0].join(', '); + return nonEmpty.map((g) => `[${g.join(', ')}]`).join(' AND '); +} + +function SummaryRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +const CHECK_TONE: Record = { + success: 'success', + warning: 'warning', + error: 'error', + info: 'info', +}; + +const CHECK_TONE_LABEL: Record = { + success: 'OK', + warning: '注意', + error: 'エラー', + info: '情報', +}; + +function CheckList({ items }: { items: CheckItem[] }) { + return ( +
+

チェックリスト

+
    + {items.map((item) => ( +
  • + + + {CHECK_TONE_LABEL[item.status]} + + {item.label} + + + {item.detail} + +
  • + ))} +
+

+ 有効期間はこの端末の現在時刻で判定しています。IdP / SP + 間の時刻ずれ(クロックスキュー)により実環境の判定と異なる場合があります。 +

+
+ ); +} + +type KeyedSamlAttribute = SamlAttribute & { key: string }; + +const ATTR_COLUMNS: TableColumn[] = [ + { + key: 'name', + header: '属性名', + className: 'font-mono break-all', + render: (a) => ( + <> + {a.name} + {a.friendlyName && ({a.friendlyName})} + + ), + }, + { + key: 'values', + header: '値', + className: 'font-mono break-all', + render: (a) => a.values.join(', '), + }, +]; + +function AssertionSection({ + assertion, + index, + total, +}: { + assertion: SamlAssertion; + index: number; + total: number; +}) { + return ( +
+
+

Assertion{total > 1 ? ` ${index + 1}` : ''}

+ + {assertion.signed ? '署名あり(未検証)' : '署名なし'} + +
+
+ + + + + + {assertion.subjectConfirmations.map((sc, i) => ( + 1 ? ` ${i + 1}` : ''}`} + value={[ + sc.recipient && `Recipient: ${sc.recipient}`, + sc.notOnOrAfter && `NotOnOrAfter: ${sc.notOnOrAfter}`, + sc.inResponseTo && `InResponseTo: ${sc.inResponseTo}`, + ] + .filter(Boolean) + .join(' / ')} + /> + ))} + {assertion.authnStatements.map((st, i) => ( + 1 ? ` ${i + 1}` : ''}`} + value={[ + st.authnInstant && `AuthnInstant: ${st.authnInstant}`, + st.sessionIndex && `SessionIndex: ${st.sessionIndex}`, + st.authnContextClassRef && `AuthnContext: ${st.authnContextClassRef}`, + ] + .filter(Boolean) + .join(' / ')} + /> + ))} +
+ {assertion.attributes.length > 0 && ( +
+

属性({assertion.attributes.length} 件)

+ ({ ...a, key: `${i}-${a.name}` }))} + columns={ATTR_COLUMNS} + getKey={(a) => a.key} + /> +
+ )} +
+ ); +} + +interface ParsedOk { + decoded: DecodedInput; + message: SamlMessage; + error?: undefined; +} +interface ParsedNg { + error: string; +} + +export function SamlDecoderTool() { + const [input, setInput] = useState(''); + const [spEntityId, setSpEntityId] = useState(''); + const [xmlView, setXmlView] = useState('raw'); + + const result: ParsedOk | ParsedNg | null = useMemo(() => { + if (!input.trim()) return null; + try { + const decoded = decodeSamlInput(input); + return { decoded, message: parseSamlXml(decoded.xml) }; + } catch (e) { + return { error: e instanceof Error ? e.message : '解析に失敗しました' }; + } + }, [input]); + + const ok = result && !result.error ? (result as ParsedOk) : null; + const response = ok && ok.message.type === 'response' ? ok.message : null; + const authnRequest = ok && ok.message.type === 'authnRequest' ? ok.message : null; + const logoutRequest = ok && ok.message.type === 'logoutRequest' ? ok.message : null; + const logoutResponse = ok && ok.message.type === 'logoutResponse' ? ok.message : null; + + const checks = useMemo(() => { + if (response) return runResponseChecks(response, { spEntityId }); + if (logoutRequest) return runLogoutRequestChecks(logoutRequest); + if (logoutResponse) return runLogoutResponseChecks(logoutResponse); + return null; + }, [response, logoutRequest, logoutResponse, spEntityId]); + + const prettyXml = useMemo(() => (ok ? formatXml(ok.decoded.xml) : ''), [ok]); + const masked = useMemo(() => (ok ? maskSamlXml(ok.decoded.xml) : null), [ok]); + const maskedXml = useMemo(() => (masked ? formatXml(masked.xml) : ''), [masked]); + const displayedXml = xmlView === 'masked' ? maskedXml : prettyXml; + + return ( +
+ setInput(buildSampleInput())} + mono + /> + + {response && ( + + SP entityID + (任意・入力すると Audience と照合) + + } + value={spEntityId} + onChange={setSpEntityId} + placeholder="https://sp.example.com/metadata" + mono + /> + )} + + {ok && ( +
+ {/* デコード過程(解析成功の簡潔なアナウンス。暗黙 polite のため aria-live は不要) */} +

+ 変換: {ok.decoded.steps.join(' → ')}({BINDING_LABEL[ok.decoded.binding]}) +

+ + {/* サマリ */} +
+

+ {response && 'Response サマリ'} + {authnRequest && 'AuthnRequest サマリ'} + {logoutRequest && 'LogoutRequest サマリ'} + {logoutResponse && 'LogoutResponse サマリ'} +

+ {response && ( +
+ + + + + + + + a.signed) + ? 'あり(このツールでは検証しません)' + : 'なし' + } + /> +
+ )} + {authnRequest && ( +
+ + + + + + + + + +
+ )} + {logoutRequest && ( +
+ + + + + + + + + +
+ )} + {logoutResponse && ( +
+ + + + + + + + +
+ )} +
+ + {/* チェックリスト(Response / Logout 2 型) */} + {checks && } + + {/* EncryptedAssertion 案内 */} + {response && response.encryptedAssertionCount > 0 && ( + + EncryptedAssertion が {response.encryptedAssertionCount}{' '} + 件含まれています。復号(秘密鍵の入力)には対応していません。 + + )} + + {/* Assertion 詳細 */} + {response?.assertions.map((a, i) => ( + + ))} + + {/* 生 XML / マスク XML */} +
+ + 整形済み XML(簡易整形) + +
+
+ + options={[ + { value: 'raw', label: '生 XML' }, + { value: 'masked', label: 'マスク XML(共有用)' }, + ]} + value={xmlView} + onChange={setXmlView} + ariaLabel="XML 表示モード" + size="sm" + layout="wrap" + /> + +
+ {xmlView === 'masked' && masked && ( +
+ + {masked.piiCount + masked.secretCount > 0 + ? `PII ${masked.piiCount} 件・機密 ${masked.secretCount} 件をマスク` + : 'マスク対象なし'} + +

+ 共有前に必ず目視で確認してください。構造上の PII + フィールドと既知パターンの除去であり、完全な匿名化を保証するものではありません。 +

+
+ )} +
{displayedXml}
+

+ 簡易整形のため、タグ間に混在するテキスト(mixed + content)は表示されない場合があります。 +

+
+
+ + + XMLDSig 署名の検証・EncryptedAssertion + の復号は行いません。表示内容の改ざん有無は保証されないため、署名検証が必要な場合は IdP / + SP 側のログと突き合わせてください。入力データはブラウザ外に送信しません。 + +
+ )} + + {input && ( +
+ { + setInput(''); + setSpEntityId(''); + setXmlView('raw'); + }} + /> +
+ )} +
+ ); +} diff --git a/src/components/ui/ToolIcon.astro b/src/components/ui/ToolIcon.astro index 9f5da2f9..23882d0e 100644 --- a/src/components/ui/ToolIcon.astro +++ b/src/components/ui/ToolIcon.astro @@ -482,3 +482,14 @@ const attrs = { ) } +{ + slug === 'saml-decoder' && ( + + {/* シールド(SSO のトラストを表現) */} + + {/* 人物シルエット(アイデンティティ連携を表現) */} + + + + ) +} diff --git a/src/data/tools.ts b/src/data/tools.ts index 379f9b8d..f1fc404e 100644 --- a/src/data/tools.ts +++ b/src/data/tools.ts @@ -252,6 +252,14 @@ const toolEntries: Tool[] = [ category: 'convert', yomi: 'でぃーでぃーえるいーあーるずせいせい', }, + { + slug: 'saml-decoder', + name: 'SAMLデコーダ', + description: + 'SSO の SAMLResponse / AuthnRequest を base64・deflate 自動判定でデコードし、Assertion の内容と Status・有効期限・Audience の定番チェックを表示します。データはブラウザ外に送信しません', + category: 'encode', + yomi: 'さむるでこーだ', + }, ]; // 表示順は yomi(読み仮名)の五十音順。category 内 filter でも相対順序が保たれる diff --git a/src/pages/tools/saml-decoder.astro b/src/pages/tools/saml-decoder.astro new file mode 100644 index 00000000..1269dc70 --- /dev/null +++ b/src/pages/tools/saml-decoder.astro @@ -0,0 +1,28 @@ +--- +import ToolLayout from '@/layouts/ToolLayout.astro'; +import ToolInfoSection from '@/components/ui/ToolInfoSection.astro'; +import { SamlDecoderTool } from '@/components/tools/SamlDecoder'; +import { tools } from '@/data/tools'; + +const tool = tools.find((t) => t.slug === 'saml-decoder')!; +--- + + + + + +

+ SSO の SAMLResponse / SAMLRequest(AuthnRequest)をデコードして内容を表示します。 URL + 全体・URLエンコード・base64(HTTP-POST binding)・base64 + deflate(HTTP-Redirect + binding)・生 XML を自動判定します。Assertion には氏名・メールアドレス等の個人情報が + 含まれますが、全処理はブラウザ内で完結し、データを外部へ送信しません。 XMLDSig 署名の検証と + EncryptedAssertion の復号には対応していません。 +

+

ユースケース

+
    +
  • SSO ログイン失敗時に IdP からの SAMLResponse の Status・有効期限を確認したい
  • +
  • Assertion に含まれる NameID・属性が SP の期待と一致しているか確認したい
  • +
  • Audience / Destination の設定ミス(entityID 不一致)を切り分けたい
  • +
+
+
diff --git a/src/utils/__tests__/saml-checks.test.ts b/src/utils/__tests__/saml-checks.test.ts new file mode 100644 index 00000000..e3005d07 --- /dev/null +++ b/src/utils/__tests__/saml-checks.test.ts @@ -0,0 +1,385 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { + parseSamlXml, + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, +} from '@/utils/saml'; +import type { SamlLogoutRequestData, SamlLogoutResponseData, SamlResponseData } from '@/utils/saml'; +import { + SAMPLE_RESPONSE_XML, + FAILED_STATUS_RESPONSE_XML, + ENCRYPTED_ASSERTION_RESPONSE_XML, + NESTED_STATUS_RESPONSE_XML, + LOGOUT_REQUEST_XML, + ENCRYPTED_ID_LOGOUT_REQUEST_XML, + NO_NAMEID_LOGOUT_REQUEST_XML, + LOGOUT_RESPONSE_XML, + FAILED_LOGOUT_RESPONSE_XML, +} from './saml-fixtures'; + +function parseResponse(xml: string): SamlResponseData { + const m = parseSamlXml(xml); + if (m.type !== 'response') throw new Error('response expected'); + return m; +} + +// フィクスチャの有効期間: 2026-07-16T23:55:00Z 〜 2026-07-17T00:05:00Z +const IN_WINDOW = new Date('2026-07-17T00:02:00Z'); +const AFTER_WINDOW = new Date('2026-07-17T01:00:00Z'); +const BEFORE_WINDOW = new Date('2026-07-16T23:00:00Z'); + +function byId(items: ReturnType, id: string) { + const item = items.find((i) => i.id === id); + if (!item) throw new Error(`check item not found: ${id}`); + return item; +} + +describe('runResponseChecks: 正常系', () => { + const res = parseResponse(SAMPLE_RESPONSE_XML); + + it('Status Success は success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'status').status).toBe('success'); + }); + + it('有効期間内は success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'validity-0').status).toBe('success'); + }); + + it('SP entityID 未入力の Audience は info(表示のみ)', () => { + const item = byId(runResponseChecks(res, { now: IN_WINDOW }), 'audience'); + expect(item.status).toBe('info'); + expect(item.detail).toContain('https://sp.example.com/metadata'); + }); + + it('SP entityID 一致は success', () => { + const item = byId( + runResponseChecks(res, { now: IN_WINDOW, spEntityId: 'https://sp.example.com/metadata' }), + 'audience' + ); + expect(item.status).toBe('success'); + }); + + it('NameID ありは success', () => { + expect(byId(runResponseChecks(res, { now: IN_WINDOW }), 'nameid').status).toBe('success'); + }); +}); + +describe('runResponseChecks: 陽性対照(fail 側の検知能力を実証)', () => { + it('Status Responder は error になり StatusMessage を含む', () => { + const item = byId(runResponseChecks(parseResponse(FAILED_STATUS_RESPONSE_XML)), 'status'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('Responder'); + expect(item.detail).toContain('Authentication failed'); + }); + + it('期限切れ(NotOnOrAfter 経過)は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { now: AFTER_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('期限切れ'); + }); + + it('NotOnOrAfter ちょうどは仕様通り期限切れ(境界値: NotOnOrAfter は排他)', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { + now: new Date('2026-07-17T00:05:00Z'), + }), + 'validity-0' + ); + expect(item.status).toBe('error'); + }); + + it('有効期間前(NotBefore 未到達)は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { now: BEFORE_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('有効期間前'); + }); + + it('SP entityID 不一致は error になる', () => { + const item = byId( + runResponseChecks(parseResponse(SAMPLE_RESPONSE_XML), { + now: IN_WINDOW, + spEntityId: 'https://other.example.com', + }), + 'audience' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('不一致'); + }); + + it('ネストした StatusCode は外側/内側コードを併記する', () => { + const item = byId(runResponseChecks(parseResponse(NESTED_STATUS_RESPONSE_XML)), 'status'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('Responder / RequestDenied'); + expect(item.detail).toContain('Authentication failed'); + }); + + it('EncryptedAssertion のみの Response は warning になる', () => { + const item = byId( + runResponseChecks(parseResponse(ENCRYPTED_ASSERTION_RESPONSE_XML)), + 'assertion' + ); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('暗号化'); + }); + + it('Assertion なし(失敗レスポンス)は error になる', () => { + const item = byId(runResponseChecks(parseResponse(FAILED_STATUS_RESPONSE_XML)), 'assertion'); + expect(item.status).toBe('error'); + }); +}); + +describe('runResponseChecks: レビュー指摘の回帰', () => { + // Conditions の NotOnOrAfter のみをパース不能な値に差し替える + // (SubjectConfirmationData 側の NotOnOrAfter はそのまま残す) + const INVALID_DATE_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="not-a-date"' + ); + + it('パース不能な NotOnOrAfter は success にならず warning になる(陽性対照)', () => { + const item = byId( + runResponseChecks(parseResponse(INVALID_DATE_RESPONSE_XML), { now: IN_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('日時を解釈できません'); + expect(item.detail).toContain('not-a-date'); + }); + + // NotBefore / NotOnOrAfter からタイムゾーン指定(Z)を除去 + const NO_TZ_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07-16T23:55:00" NotOnOrAfter="2026-07-17T00:05:00"' + ); + // 同じ「タイムゾーン指定なし」形式で now を作ることで、実行環境の TZ に依存せず + // 「窓内」の関係性を保つ(notBefore と notOnOrAfter は共にローカル解釈されるため) + const NO_TZ_IN_WINDOW = new Date('2026-07-17T00:02:00'); + const NO_TZ_AFTER_WINDOW = new Date('2026-07-17T01:00:00'); + + it('タイムゾーン指定なしの日時は期間内でも warning に降格し、ローカル時刻解釈の注記が付く(陽性対照)', () => { + const item = byId( + runResponseChecks(parseResponse(NO_TZ_RESPONSE_XML), { now: NO_TZ_IN_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('ローカル時刻'); + }); + + it('タイムゾーン指定なしでも期限切れは error のままで、ローカル時刻解釈の注記が付く', () => { + const item = byId( + runResponseChecks(parseResponse(NO_TZ_RESPONSE_XML), { now: NO_TZ_AFTER_WINDOW }), + 'validity-0' + ); + expect(item.status).toBe('error'); + expect(item.detail).toContain('ローカル時刻'); + }); +}); + +describe('runResponseChecks: タイムゾーン判定の精度(陽性対照)', () => { + // 時のみオフセット(±hh、分なし)に置換。ES 仕様上 TZ 指定ありと解釈される。 + // "T" 区切りだと `Date` 自体が bare hour offset を解釈できないため、Date が解釈可能な + // スペース区切り形式で検証する(`hasTimezone` は raw 文字列末尾の正規表現マッチのみで、 + // 区切り文字には依存しないため、判定ロジックの検証としては妥当) + const HOUR_OFFSET_TZ_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07-16 23:55:00+09" NotOnOrAfter="2026-07-17 00:05:00+09"' + ); + // 上記は UTC 換算で 2026-07-16T14:55:00Z 〜 2026-07-16T15:05:00Z の窓になる + const HOUR_OFFSET_IN_WINDOW = new Date('2026-07-16T15:00:00Z'); + + it('時のみオフセット(+09)は TZ ありと判定され注記が付かない(旧実装では TZ なし扱いになり fail する)', () => { + const item = byId( + runResponseChecks(parseResponse(HOUR_OFFSET_TZ_RESPONSE_XML), { + now: HOUR_OFFSET_IN_WINDOW, + }), + 'validity-0' + ); + expect(item.status).toBe('success'); + expect(item.detail).not.toContain('※'); + }); + + // 日付のみ形式(YYYY-MM-DD)に置換。ES 仕様上 UTC (00:00Z) 解釈が確定する + const DATE_ONLY_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07-16" NotOnOrAfter="2026-07-17"' + ); + const DATE_ONLY_IN_WINDOW = new Date('2026-07-16T12:00:00Z'); + + it('日付のみ形式は UTC (00:00Z) 解釈の専用注記になり、ローカル時刻注記にはならない(陽性対照)', () => { + const item = byId( + runResponseChecks(parseResponse(DATE_ONLY_RESPONSE_XML), { now: DATE_ONLY_IN_WINDOW }), + 'validity-0' + ); + expect(item.detail).toContain('UTC (00:00Z)'); + expect(item.detail).not.toContain('ローカル時刻'); + }); +}); + +describe('runResponseChecks: 年月のみ形式・混在注記(レビュー指摘の回帰、陽性対照)', () => { + // 年月のみ形式(YYYY-MM)に置換。末尾の "-07" が旧実装の hasTimezone 正規表現 + // (時刻部の有無を問わず末尾 ±hh にマッチ)に誤マッチし、TZ ありと誤判定されていた + const YEAR_MONTH_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07" NotOnOrAfter="2026-08"' + ); + const YEAR_MONTH_IN_WINDOW = new Date('2026-07-16T12:00:00Z'); + + it('年月のみ形式(YYYY-MM)は UTC (00:00Z) 解釈の専用注記になる(旧実装では TZ あり誤判定で注記なしになり fail する)', () => { + const item = byId( + runResponseChecks(parseResponse(YEAR_MONTH_RESPONSE_XML), { now: YEAR_MONTH_IN_WINDOW }), + 'validity-0' + ); + expect(item.detail).toContain('UTC (00:00Z)'); + expect(item.detail).not.toContain('ローカル時刻'); + }); + + // NotBefore は日付のみ(dateOnly)、NotOnOrAfter はタイムゾーン指定なし日時(missingTimezone) + // という混在ケース。now は NotOnOrAfter と同じ「タイムゾーン指定なし」形式で組み立てることで、 + // 実行環境の TZ に依存せず NotOnOrAfter との窓内関係を保つ(NO_TZ 系テストと同じ手法) + const MIXED_RESPONSE_XML = SAMPLE_RESPONSE_XML.replace( + 'NotBefore="2026-07-16T23:55:00Z" NotOnOrAfter="2026-07-17T00:05:00Z"', + 'NotBefore="2026-07-16" NotOnOrAfter="2026-07-17T00:05:00"' + ); + const MIXED_IN_WINDOW = new Date('2026-07-17T00:02:00'); + + it('dateOnly と missingTimezone が混在する場合、UTC 注記とローカル時刻注記が両方付く(旧実装では排他分岐で UTC 注記のみになり fail する)', () => { + const item = byId( + runResponseChecks(parseResponse(MIXED_RESPONSE_XML), { now: MIXED_IN_WINDOW }), + 'validity-0' + ); + expect(item.detail).toContain('UTC (00:00Z)'); + expect(item.detail).toContain('ローカル時刻'); + }); +}); + +describe('runResponseChecks: 複数 AudienceRestriction の AND 判定(陽性対照)', () => { + // 2 restriction、両方に SP entityID を含む + const TWO_RESTRICTIONS_BOTH_MATCH_XML = SAMPLE_RESPONSE_XML.replace( + 'https://sp.example.com/metadata', + 'https://sp.example.com/metadata' + + 'https://sp.example.com/metadatahttps://partner.example.com/metadata' + ); + + // 2 restriction、SP entityID は片方の restriction にしか含まれない + const TWO_RESTRICTIONS_ONE_MISMATCH_XML = SAMPLE_RESPONSE_XML.replace( + 'https://sp.example.com/metadata', + 'https://sp.example.com/metadata' + + 'https://other.example.com/metadata' + ); + + it('すべての AudienceRestriction に entityID が含まれる場合は一致する', () => { + const item = byId( + runResponseChecks(parseResponse(TWO_RESTRICTIONS_BOTH_MATCH_XML), { + now: IN_WINDOW, + spEntityId: 'https://sp.example.com/metadata', + }), + 'audience' + ); + expect(item.status).toBe('success'); + }); + + it('片方の AudienceRestriction にしか entityID が含まれない場合は不一致になる(旧 flatten 実装では誤って一致してしまい fail する)', () => { + const item = byId( + runResponseChecks(parseResponse(TWO_RESTRICTIONS_ONE_MISMATCH_XML), { + now: IN_WINDOW, + spEntityId: 'https://sp.example.com/metadata', + }), + 'audience' + ); + expect(item.status).toBe('error'); + }); +}); + +function parseLogoutRequest(xml: string): SamlLogoutRequestData { + const m = parseSamlXml(xml); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + return m; +} + +function parseLogoutResponse(xml: string): SamlLogoutResponseData { + const m = parseSamlXml(xml); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + return m; +} + +// LOGOUT_REQUEST_XML の NotOnOrAfter: 2026-07-17T00:05:00Z +describe('runLogoutRequestChecks', () => { + const req = parseLogoutRequest(LOGOUT_REQUEST_XML); + + it('期限内は success', () => { + const item = byId(runLogoutRequestChecks(req, { now: IN_WINDOW }), 'notOnOrAfter'); + expect(item.status).toBe('success'); + }); + + it('陽性対照: 期限切れは error', () => { + const item = byId(runLogoutRequestChecks(req, { now: AFTER_WINDOW }), 'notOnOrAfter'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('期限切れ'); + }); + + it('NotOnOrAfter なしは info(SAML 仕様上は任意属性)', () => { + const noLimit = parseLogoutRequest(NO_NAMEID_LOGOUT_REQUEST_XML); + expect(byId(runLogoutRequestChecks(noLimit, { now: IN_WINDOW }), 'notOnOrAfter').status).toBe( + 'info' + ); + }); + + it('パース不能な NotOnOrAfter は warning', () => { + const broken = { ...req, notOnOrAfter: 'not-a-date' }; + expect(byId(runLogoutRequestChecks(broken, { now: IN_WINDOW }), 'notOnOrAfter').status).toBe( + 'warning' + ); + }); + + it('タイムゾーンなし日時は判定続行しつつ warning + 注記', () => { + const noTz = { ...req, notOnOrAfter: '2026-07-17T00:05:00' }; + // now もタイムゾーン指定なしの同形式で組み立てることで、実行環境の TZ に依存せず + // 「期限前」の関係性を保つ(NO_TZ_IN_WINDOW と同じ手法。BEFORE_WINDOW は Z 付き絶対時刻の + // ため、TZ なし文字列と比較すると実行環境の TZ によって前後関係が反転してしまう) + const item = byId( + runLogoutRequestChecks(noTz, { now: new Date('2026-07-16T23:00:00') }), + 'notOnOrAfter' + ); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('ローカル時刻'); + }); + + it('NameID ありは success', () => { + expect(byId(runLogoutRequestChecks(req, { now: IN_WINDOW }), 'nameid').status).toBe('success'); + }); + + it('EncryptedID は warning(復号非対応)', () => { + const enc = parseLogoutRequest(ENCRYPTED_ID_LOGOUT_REQUEST_XML); + const item = byId(runLogoutRequestChecks(enc, { now: IN_WINDOW }), 'nameid'); + expect(item.status).toBe('warning'); + expect(item.detail).toContain('暗号化'); + }); + + it('陽性対照: NameID / EncryptedID なしは error', () => { + const none = parseLogoutRequest(NO_NAMEID_LOGOUT_REQUEST_XML); + expect(byId(runLogoutRequestChecks(none, { now: IN_WINDOW }), 'nameid').status).toBe('error'); + }); +}); + +describe('runLogoutResponseChecks', () => { + it('Status Success は success', () => { + const res = parseLogoutResponse(LOGOUT_RESPONSE_XML); + expect(byId(runLogoutResponseChecks(res), 'status').status).toBe('success'); + }); + + it('陽性対照: Status 失敗は error になり内側コードを併記する', () => { + const res = parseLogoutResponse(FAILED_LOGOUT_RESPONSE_XML); + const item = byId(runLogoutResponseChecks(res), 'status'); + expect(item.status).toBe('error'); + expect(item.detail).toContain('Responder / RequestDenied'); + expect(item.detail).toContain('Session not found'); + }); +}); diff --git a/src/utils/__tests__/saml-decode.test.ts b/src/utils/__tests__/saml-decode.test.ts new file mode 100644 index 00000000..4e75a466 --- /dev/null +++ b/src/utils/__tests__/saml-decode.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { deflateSync, zlibSync } from 'fflate'; +import { decodeSamlInput } from '@/utils/saml'; +import { SAMPLE_RESPONSE_XML, AUTHN_REQUEST_XML, toBase64 } from './saml-fixtures'; + +function deflateBase64(xml: string): string { + const compressed = deflateSync(new TextEncoder().encode(xml)); + let bin = ''; + for (const b of compressed) bin += String.fromCharCode(b); + return btoa(bin); +} + +describe('decodeSamlInput', () => { + it('生 XML をそのまま返す', () => { + const r = decodeSamlInput(SAMPLE_RESPONSE_XML); + expect(r.binding).toBe('xml'); + expect(r.xml).toBe(SAMPLE_RESPONSE_XML); + }); + + it('base64(HTTP-POST binding)をデコードする', () => { + const r = decodeSamlInput(toBase64(SAMPLE_RESPONSE_XML)); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + const b64 = toBase64(SAMPLE_RESPONSE_XML).replace(/(.{60})/g, '$1\n'); + expect(decodeSamlInput(b64).binding).toBe('post'); + }); + + it('base64 + deflate(HTTP-Redirect binding)を展開する', () => { + const r = decodeSamlInput(deflateBase64(AUTHN_REQUEST_XML)); + expect(r.binding).toBe('redirect'); + expect(r.xml).toContain(' { + const r = decodeSamlInput(encodeURIComponent(deflateBase64(AUTHN_REQUEST_XML))); + expect(r.binding).toBe('redirect'); + }); + + it('URL 全体から SAMLRequest パラメータを抽出する', () => { + const url = `https://idp.example.com/sso?SAMLRequest=${encodeURIComponent(deflateBase64(AUTHN_REQUEST_XML))}&RelayState=abc`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('redirect'); + expect(r.steps[0]).toBe('URL からパラメータ抽出'); + expect(r.xml).toContain(' { + const url = `https://sp.example.com/acs?SAMLResponse=${encodeURIComponent(toBase64(SAMPLE_RESPONSE_XML))}`; + expect(decodeSamlInput(url).xml).toContain(' { + expect(() => decodeSamlInput('https://example.com/?foo=bar')).toThrow( + /SAMLResponse \/ SAMLRequest/ + ); + }); + + it('base64 でない文字列はエラー', () => { + expect(() => decodeSamlInput('これはSAMLではない')).toThrow(); + }); + + it('base64 だが中身が XML でない場合はエラー', () => { + expect(() => decodeSamlInput(toBase64('hello world'))).toThrow(/XML ではありません/); + }); + + it('空入力はエラー', () => { + expect(() => decodeSamlInput(' ')).toThrow(/入力が空/); + }); +}); + +describe('decodeSamlInput: レビュー指摘の回帰', () => { + it('URL クエリ中の生の "+" を含む base64 を破壊せずデコードする(陽性対照)', () => { + const b64 = toBase64(SAMPLE_RESPONSE_XML); + // フィクスチャの base64 表現が "+" を含むことを前提にしたテスト + // (searchParams.get は "+" を空白に変換して壊すため、含まれていないと検知能力が証明できない) + expect(b64).toContain('+'); + const url = `https://sp.example.com/acs?SAMLResponse=${b64}`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + const r = decodeSamlInput(encodeURIComponent(SAMPLE_RESPONSE_XML)); + expect(r.binding).toBe('xml'); + expect(r.xml).toBe(SAMPLE_RESPONSE_XML); + expect(r.steps).toEqual(['URL デコード', '生 XML と判定']); + }); + + it('クエリ文字列断片(SAMLResponse=...&RelayState=...)だけの入力からパラメータを抽出する', () => { + const url = `SAMLResponse=${encodeURIComponent(toBase64(SAMPLE_RESPONSE_XML))}&RelayState=abc`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + const url = `SAMLRequest=${encodeURIComponent(deflateBase64(AUTHN_REQUEST_XML))}&RelayState=abc`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('redirect'); + expect(r.xml).toContain(' { + // 仕様: 実装を単純に保つため「SAMLResponse=」「SAMLRequest=」で始まる場合のみ救済する。 + // RelayState= 等が先頭に来る断片は対象外とし、通常の base64 判定にフォールスルーする。 + expect(() => decodeSamlInput('RelayState=abc&SAMLResponse=xyz')).toThrow(); + }); + + it('percent エンコードされたクエリキー名(SAML%52esponse)も SAMLResponse として抽出する', () => { + const url = `https://sp.example.com/acs?SAML%52esponse=${encodeURIComponent(toBase64(SAMPLE_RESPONSE_XML))}`; + const r = decodeSamlInput(url); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + it('base64url(- _ を含みパディングなし)を標準 base64 として解釈しデコードする', () => { + const std = toBase64(SAMPLE_RESPONSE_XML); + // フィクスチャの base64 表現が "+" を含むことを前提にしたテスト(含まれていないと + // "-" への変換が検知能力を証明できない) + expect(std).toContain('+'); + const urlSafe = std.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const r = decodeSamlInput(urlSafe); + expect(r.binding).toBe('post'); + expect(r.xml).toContain(' { + const r = decodeSamlInput(toBase64(SAMPLE_RESPONSE_XML)); + expect(r.steps).not.toContain('base64url を標準 base64 へ変換'); + }); +}); + +describe('decodeSamlInput: deflate 展開サイズ上限(陽性対照)', () => { + // 34MB のゼロ埋め圧縮 + 32MB 展開は遅い CI ランナーで vitest デフォルト 5s を超えることが + // あるため(PR #749 の CI で実測)、明示タイムアウトを設定する + it('展開後 32MB を超える入力はサイズ上限エラーになる', { timeout: 30_000 }, () => { + // ゼロ埋めは極めて高圧縮率になるため、数十 KB の入力で上限超の展開結果を作れる。 + // level 1 で圧縮時間を最小化しつつ、上限 32MB を確実に超える 34MB を展開させる + const huge = new Uint8Array(34 * 1024 * 1024); + const compressed = zlibSync(huge, { level: 1 }); + let bin = ''; + for (const b of compressed) bin += String.fromCharCode(b); + const b64 = btoa(bin); + expect(() => decodeSamlInput(b64)).toThrow(/上限(32MB)/); + }); +}); diff --git a/src/utils/__tests__/saml-fixtures.ts b/src/utils/__tests__/saml-fixtures.ts new file mode 100644 index 00000000..e2fdc5c8 --- /dev/null +++ b/src/utils/__tests__/saml-fixtures.ts @@ -0,0 +1,161 @@ +/** + * SAML テスト用フィクスチャ。 + * 日時は固定(2026-07-17 00:00Z 周辺)。checks のテストは now を注入して有効/期限切れを切り替える。 + */ +export const SAMPLE_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + + taro.yamada@example.com + + + + + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + taro.yamada@example.com + 山田 太郎 + devadmin + + +`; + +export const FAILED_STATUS_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + Authentication failed + +`; + +export const ENCRYPTED_ASSERTION_RESPONSE_XML = ` + + https://idp.example.com/metadata + + +`; + +export const NESTED_STATUS_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + + + Authentication failed + +`; + +/** prefix なし(default xmlns)の Response。prefix 非依存パースの回帰確認用 */ +export const DEFAULT_NS_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + + taro.yamada@example.com + + +`; + +/** Assertion 2 件の Response。有効期間ラベルの連番付与の回帰確認用 */ +export const TWO_ASSERTIONS_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + user1@example.com + + https://sp.example.com/metadata + + + + https://idp.example.com/metadata + user2@example.com + + https://sp.example.com/metadata + + +`; + +export const AUTHN_REQUEST_XML = ` + + https://sp.example.com/metadata + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + +`; + +export const LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + taro.yamada@example.com + _s1 + _s2 +`; + +/** EncryptedID を含む LogoutRequest(NameID なし・復号非対応の注記確認用) */ +export const ENCRYPTED_ID_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + +`; + +/** NameID / EncryptedID / NotOnOrAfter がいずれもない LogoutRequest(チェックの error / info 分岐用) */ +export const NO_NAMEID_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata +`; + +export const LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; + +/** 二段階ステータスで失敗する LogoutResponse(Status チェックの陽性対照用) */ +export const FAILED_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + + + Session not found + +`; + +/** prefix なし(default xmlns)の LogoutRequest。prefix 非依存パースの回帰確認用 */ +export const DEFAULT_NS_LOGOUT_REQUEST_XML = ` + + https://sp.example.com/metadata + taro.yamada@example.com + _s1 +`; + +/** prefix なし(default xmlns)の LogoutResponse。prefix 非依存パースの回帰確認用 */ +export const DEFAULT_NS_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; + +/** UTF-8 → base64(マルチバイト対応。btoa 直呼びは日本語で例外になるため必須) */ +export function toBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} diff --git a/src/utils/__tests__/saml-format.test.ts b/src/utils/__tests__/saml-format.test.ts new file mode 100644 index 00000000..2093894e --- /dev/null +++ b/src/utils/__tests__/saml-format.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { formatXml } from '@/utils/saml'; + +describe('formatXml', () => { + it('1 行 XML をインデント付きに整形する', () => { + const out = formatXml('v'); + expect(out).toBe(['', ' v', ' ', ''].join('\n')); + }); + + it('XML 宣言を保持する', () => { + const out = formatXml(''); + expect(out.split('\n')[0]).toBe(''); + }); + + it('テキストと属性をエスケープする', () => { + const out = formatXml('<tag> & more'); + expect(out).toBe('<tag> & more'); + }); + + it('parse 不能な入力はそのまま返す', () => { + expect(formatXml(' + + https://idp.example.com/metadata + + Qm9ndXNTaWduYXR1cmVWYWx1ZUJhc2U2NEhpZ2hFbnRyb3B5QUJDREVGMTIzNDU2Nzg5MA== + Rml4dHVyZUNlcnRpZmljYXRlQmFzZTY0SGlnaEVudHJvcHlaWVhXVlUwOTg3NjU0MzIxUVJTVA== + + +`; + +/** Destination の URL クエリにメールを埋め込み、フェーズ2 の scrubber 救済を実証する。 */ +const RECIPIENT_EMAIL_RESPONSE_XML = ` + + https://idp.example.com/metadata + +`; + +describe('maskSamlXml: フェーズ1 構造ベースマスク(陽性対照)', () => { + it('NameID のメールがマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).not.toContain('taro.yamada@example.com'); + expect(xml).toContain('[REDACTED:PII_'); + }); + + it('パターンでは拾えない日本語氏名(displayName)がマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).not.toContain('山田 太郎'); + }); + + it('複数 AttributeValue(groups の dev / admin)がすべてマスクされる', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + // Attribute 値として単独出現する dev / admin が消える(要素名 groups は残る) + expect(xml).not.toMatch(/>devadmin { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + const tokens = xml.match(/\[REDACTED:PII_\d+\]/g) ?? []; + // NameID と mail 属性が同じメールを持つため、同一トークンが 2 回以上出現する + const counts = tokens.reduce>((acc, t) => { + acc[t] = (acc[t] ?? 0) + 1; + return acc; + }, {}); + expect(Object.values(counts).some((c) => c >= 2)).toBe(true); + }); + + it('piiCount は occurrence 数(NameID 1 + mail 1 + displayName 1 + groups 2 = 5)', () => { + const { piiCount } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(piiCount).toBe(5); + }); + + it('LogoutRequest の NameID もマスクされる', () => { + const { xml, piiCount } = maskSamlXml(LOGOUT_REQUEST_XML); + // フィクスチャの NameID 実在値で検証する(空振り防止・陽性対照) + expect(xml).not.toContain('taro.yamada@example.com'); + expect(xml).toContain('[REDACTED:PII_'); + expect(piiCount).toBeGreaterThanOrEqual(1); + }); +}); + +describe('maskSamlXml: フェーズ2 scrubber 併用(陽性対照)', () => { + it('Destination URL に埋め込まれたメールが scrubber でマスクされる', () => { + const { xml, secretCount } = maskSamlXml(RECIPIENT_EMAIL_RESPONSE_XML); + expect(xml).not.toContain('leaked@corp.example'); + expect(xml).toContain('[REDACTED:EMAIL_'); + expect(secretCount).toBeGreaterThanOrEqual(1); + }); +}); + +describe('maskSamlXml: over-mask していないこと(陰性対照)', () => { + it('X509Certificate / SignatureValue の base64(HIGH_ENTROPY)は残る', () => { + const { xml } = maskSamlXml(SIGNED_RESPONSE_XML); + expect(xml).toContain( + 'Qm9ndXNTaWduYXR1cmVWYWx1ZUJhc2U2NEhpZ2hFbnRyb3B5QUJDREVGMTIzNDU2Nzg5MA==' + ); + expect(xml).toContain( + 'Rml4dHVyZUNlcnRpZmljYXRlQmFzZTY0SGlnaEVudHJvcHlaWVhXVlUwOTg3NjU0MzIxUVJTVA==' + ); + }); + + it('タイムスタンプ・要素名・属性名・ID が保持される', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + expect(xml).toContain('2026-07-17T00:00:00Z'); + expect(xml).toContain('Name="mail"'); + expect(xml).toContain('ID="_resp1"'); + expect(xml).toContain('SessionIndex="_s1"'); + }); +}); + +describe('maskSamlXml: 不変条件', () => { + it('マスク後の出力は valid XML のまま(再パースできる)', () => { + const { xml } = maskSamlXml(SAMPLE_RESPONSE_XML); + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + expect(doc.getElementsByTagName('parsererror').length).toBe(0); + }); + + it('パース不能な入力は件数 0 で元の文字列を返す', () => { + const { xml, piiCount, secretCount } = maskSamlXml(' { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.destination).toBe('https://sp.example.com/acs'); + expect(m.inResponseTo).toBe('_req1'); + expect(m.issueInstant).toBe('2026-07-17T00:00:00Z'); + expect(m.signed).toBe(false); + expect(m.encryptedAssertionCount).toBe(0); + }); + + it('Assertion の Subject / Conditions / AuthnStatement を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.assertions).toHaveLength(1); + const a = m.assertions[0]; + expect(a.nameId).toBe('taro.yamada@example.com'); + expect(a.nameIdFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(a.conditions?.notBefore).toBe('2026-07-16T23:55:00Z'); + expect(a.conditions?.notOnOrAfter).toBe('2026-07-17T00:05:00Z'); + expect(a.conditions?.audienceRestrictions).toEqual([['https://sp.example.com/metadata']]); + expect(a.authnStatements[0].sessionIndex).toBe('_s1'); + expect(a.authnStatements[0].authnContextClassRef).toContain('PasswordProtectedTransport'); + expect(a.subjectConfirmations[0].recipient).toBe('https://sp.example.com/acs'); + expect(a.subjectConfirmations[0].method).toBe('urn:oasis:names:tc:SAML:2.0:cm:bearer'); + }); + + it('属性(複数値・FriendlyName 含む)を抽出する', () => { + const m = parseSamlXml(SAMPLE_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + const attrs = m.assertions[0].attributes; + expect(attrs).toHaveLength(3); + expect(attrs[1]).toEqual({ + name: 'displayName', + friendlyName: '表示名', + values: ['山田 太郎'], + }); + expect(attrs[2].values).toEqual(['dev', 'admin']); + }); + + it('Status 失敗レスポンスの StatusMessage を抽出する', () => { + const m = parseSamlXml(FAILED_STATUS_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Responder'); + expect(m.statusMessage).toBe('Authentication failed'); + expect(m.assertions).toHaveLength(0); + }); + + it('EncryptedAssertion を数える', () => { + const m = parseSamlXml(ENCRYPTED_ASSERTION_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.encryptedAssertionCount).toBe(1); + expect(m.assertions).toHaveLength(0); + }); + + it('ネストした StatusCode の内側コードを statusSubCode として抽出する', () => { + const m = parseSamlXml(NESTED_STATUS_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Responder'); + expect(m.statusSubCode).toBe('urn:oasis:names:tc:SAML:2.0:status:RequestDenied'); + }); + + it('prefix なし(default xmlns)の Response も正常にパースする(回帰)', () => { + const m = parseSamlXml(DEFAULT_NS_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.assertions).toHaveLength(1); + expect(m.assertions[0].nameId).toBe('taro.yamada@example.com'); + }); + + it('Assertion 2 件をどちらも抽出する(回帰)', () => { + const m = parseSamlXml(TWO_ASSERTIONS_RESPONSE_XML); + if (m.type !== 'response') throw new Error('response expected'); + expect(m.assertions).toHaveLength(2); + expect(m.assertions[0].nameId).toBe('user1@example.com'); + expect(m.assertions[1].nameId).toBe('user2@example.com'); + }); +}); + +describe('parseSamlXml: AuthnRequest', () => { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(AUTHN_REQUEST_XML); + if (m.type !== 'authnRequest') throw new Error('authnRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.destination).toBe('https://idp.example.com/sso'); + expect(m.acsUrl).toBe('https://sp.example.com/acs'); + expect(m.protocolBinding).toBe('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'); + expect(m.nameIdPolicyFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(m.allowCreate).toBe('true'); + expect(m.authnContextClassRefs).toEqual([ + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ]); + }); +}); + +describe('parseSamlXml: 異常系', () => { + it('壊れた XML はエラー', () => { + expect(() => parseSamlXml('')).toThrow(/XML/); + }); + + it('SAML 以外の XML はエラー', () => { + expect(() => parseSamlXml('')).toThrow(/対応していない/); + }); + + it('ArtifactResolve など未対応の SAML メッセージ型はエラー', () => { + const xml = + ''; + expect(() => parseSamlXml(xml)).toThrow(/ArtifactResolve/); + }); + + it('SAML 1.1 namespace の Response は namespace URI と SAML 2.0 限定である旨をエラーに含む(陽性対照)', () => { + const xml = + ''; + expect(() => parseSamlXml(xml)).toThrow(/urn:oasis:names:tc:SAML:1\.0:protocol/); + expect(() => parseSamlXml(xml)).toThrow(/SAML 2\.0/); + }); +}); + +describe('parseSamlXml: LogoutRequest', () => { + it('サマリ情報と複数 SessionIndex を抽出する', () => { + const m = parseSamlXml(LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.destination).toBe('https://idp.example.com/slo'); + expect(m.issueInstant).toBe('2026-07-17T00:00:00Z'); + expect(m.notOnOrAfter).toBe('2026-07-17T00:05:00Z'); + expect(m.reason).toBe('urn:oasis:names:tc:SAML:2.0:logout:user'); + expect(m.nameId).toBe('taro.yamada@example.com'); + expect(m.nameIdFormat).toBe('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'); + expect(m.encryptedNameId).toBe(false); + expect(m.sessionIndexes).toEqual(['_s1', '_s2']); + expect(m.signed).toBe(false); + }); + + it('EncryptedID を検出する', () => { + const m = parseSamlXml(ENCRYPTED_ID_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.nameId).toBeUndefined(); + expect(m.encryptedNameId).toBe(true); + }); + + it('prefix なし(default xmlns)の LogoutRequest も正常にパースする(回帰)', () => { + const m = parseSamlXml(DEFAULT_NS_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.issuer).toBe('https://sp.example.com/metadata'); + expect(m.nameId).toBe('taro.yamada@example.com'); + expect(m.sessionIndexes).toEqual(['_s1']); + }); + + it('NameID / EncryptedID / NotOnOrAfter なしはいずれも undefined / false / 空になる', () => { + const m = parseSamlXml(NO_NAMEID_LOGOUT_REQUEST_XML); + if (m.type !== 'logoutRequest') throw new Error('logoutRequest expected'); + expect(m.nameId).toBeUndefined(); + expect(m.encryptedNameId).toBe(false); + expect(m.notOnOrAfter).toBeUndefined(); + expect(m.sessionIndexes).toEqual([]); + }); +}); + +describe('parseSamlXml: LogoutResponse', () => { + it('サマリ情報を抽出する', () => { + const m = parseSamlXml(LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.destination).toBe('https://sp.example.com/slo'); + expect(m.inResponseTo).toBe('_lreq1'); + expect(m.signed).toBe(false); + }); + + it('二段階ステータスの内側コードと StatusMessage を抽出する', () => { + const m = parseSamlXml(FAILED_LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Responder'); + expect(m.statusSubCode).toBe('urn:oasis:names:tc:SAML:2.0:status:RequestDenied'); + expect(m.statusMessage).toBe('Session not found'); + }); + + it('prefix なし(default xmlns)の LogoutResponse も正常にパースする(回帰)', () => { + const m = parseSamlXml(DEFAULT_NS_LOGOUT_RESPONSE_XML); + if (m.type !== 'logoutResponse') throw new Error('logoutResponse expected'); + expect(m.issuer).toBe('https://idp.example.com/metadata'); + expect(m.statusCode).toBe('urn:oasis:names:tc:SAML:2.0:status:Success'); + expect(m.inResponseTo).toBe('_lreq4'); + }); +}); diff --git a/src/utils/saml/checks.ts b/src/utils/saml/checks.ts new file mode 100644 index 00000000..8d7f7bb9 --- /dev/null +++ b/src/utils/saml/checks.ts @@ -0,0 +1,283 @@ +import type { + CheckItem, + SamlLogoutRequestData, + SamlLogoutResponseData, + SamlResponseData, +} from './types'; + +const STATUS_SUCCESS = 'urn:oasis:names:tc:SAML:2.0:status:Success'; + +export interface CheckOptions { + /** テスト用に注入可能な現在時刻(省略時は実時刻) */ + now?: Date; + /** SP entityID。入力時のみ Audience と厳密一致で照合する */ + spEntityId?: string; +} + +interface StatusFields { + statusCode?: string; + statusSubCode?: string; + statusMessage?: string; +} + +/** Status チェック項目を組み立てる(Response / LogoutResponse 共通) */ +function statusCheckItem(res: StatusFields): CheckItem { + if (res.statusCode === STATUS_SUCCESS) { + return { id: 'status', label: 'Status', status: 'success', detail: 'Success' }; + } + const code = res.statusCode?.split(':').pop() ?? '不明'; + const subCode = res.statusSubCode?.split(':').pop(); + const codeLabel = subCode ? `${code} / ${subCode}` : code; + return { + id: 'status', + label: 'Status', + status: 'error', + detail: res.statusMessage + ? `${codeLabel}(StatusMessage: ${res.statusMessage})` + : `${codeLabel}(Success ではありません)`, + }; +} + +/** + * xs:dateTime 文字列の解釈注記を組み立てる。 + * - 日付のみ形式(YYYY / YYYY-MM / YYYY-MM-DD)は ES 仕様上 UTC (00:00Z) 解釈が確定 → 専用注記 + * - timezone designator(Z / ±hh / ±hh:mm / ±hhmm)なしはローカル時刻解釈で環境依存 → 警告注記 + * 両形式が混在する場合は注記を連結する + */ +function timezoneNote(...values: (string | undefined)[]): { + note: string; + missingTimezone: boolean; +} { + // 時刻部(T または スペース区切りの hh:mm)の存在を前提とすることで、年月のみ形式 + // (例: "2026-07")の末尾ハイフンをタイムゾーンオフセットと誤認しないようにする + const hasTimezone = (s: string) => + /[T ]\d{2}:\d{2}/.test(s) && /(?:Z|[+-]\d{2}(?::?\d{2})?)$/.test(s); + const isDateOnly = (s: string) => /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(s); + const present = values.filter((v): v is string => !!v); + const dateOnly = present.some(isDateOnly); + const missingTimezone = present.some((v) => !isDateOnly(v) && !hasTimezone(v)); + let note = ''; + if (dateOnly) note += '\n※ 日付のみのため、UTC (00:00Z) として解釈しています'; + if (missingTimezone) + note += '\n※ タイムゾーン指定がないため、この端末のローカル時刻として解釈しています'; + return { note, missingTimezone }; +} + +/** Response の定番チェックリストを実行する(AuthnRequest には適用しない) */ +export function runResponseChecks(res: SamlResponseData, opts: CheckOptions = {}): CheckItem[] { + const now = opts.now ?? new Date(); + const items: CheckItem[] = []; + + // 1. Status + items.push(statusCheckItem(res)); + + // 2. Assertion 有無(無ければ以降のチェックは打ち切り) + if (res.assertions.length === 0) { + items.push({ + id: 'assertion', + label: 'Assertion', + status: res.encryptedAssertionCount > 0 ? 'warning' : 'error', + detail: + res.encryptedAssertionCount > 0 + ? '暗号化されており内容を確認できません(復号は非対応)' + : 'Assertion が含まれていません', + }); + return items; + } + + // 3. 有効期間(NotOnOrAfter は SAML 仕様上その時刻自体を含まない排他境界) + res.assertions.forEach((a, i) => { + const label = res.assertions.length > 1 ? `有効期間 (Assertion ${i + 1})` : '有効期間'; + const c = a.conditions; + if (!c || (!c.notBefore && !c.notOnOrAfter)) { + items.push({ + id: `validity-${i}`, + label, + status: 'warning', + detail: 'Conditions に有効期間の指定がありません', + }); + return; + } + const notBefore = c.notBefore ? new Date(c.notBefore) : undefined; + const notOnOrAfter = c.notOnOrAfter ? new Date(c.notOnOrAfter) : undefined; + + if ( + (notBefore && isNaN(notBefore.getTime())) || + (notOnOrAfter && isNaN(notOnOrAfter.getTime())) + ) { + items.push({ + id: `validity-${i}`, + label, + status: 'warning', + detail: `日時を解釈できません(NotBefore: ${c.notBefore ?? '-'} / NotOnOrAfter: ${c.notOnOrAfter ?? '-'})`, + }); + return; + } + + const { note: tzNote, missingTimezone } = timezoneNote(c.notBefore, c.notOnOrAfter); + + if (notBefore && now < notBefore) { + items.push({ + id: `validity-${i}`, + label, + status: 'error', + detail: `有効期間前です(NotBefore: ${c.notBefore})。IdP / SP の時刻ずれ(クロックスキュー)の可能性があります${tzNote}`, + }); + } else if (notOnOrAfter && now >= notOnOrAfter) { + items.push({ + id: `validity-${i}`, + label, + status: 'error', + detail: `期限切れです(NotOnOrAfter: ${c.notOnOrAfter})${tzNote}`, + }); + } else { + items.push({ + id: `validity-${i}`, + label, + status: missingTimezone ? 'warning' : 'success', + detail: `有効期間内です(${c.notBefore ?? '-'} 〜 ${c.notOnOrAfter ?? '-'})${tzNote}`, + }); + } + }); + + // 4. Audience(SP entityID 入力時のみ照合、未入力は表示のみ) + // restrictionGroups: AudienceRestriction ごとの Audience 列挙(外側 = AND、内側 = OR) + const restrictionGroups = res.assertions.flatMap((a) => a.conditions?.audienceRestrictions ?? []); + const audiences = [...new Set(restrictionGroups.flat())]; + // 空の restriction(Audience 要素なし)は AND 判定の対象外とする + const nonEmptyRestrictions = restrictionGroups.filter((g) => g.length > 0); + const sp = opts.spEntityId?.trim(); + if (audiences.length === 0) { + items.push({ + id: 'audience', + label: 'Audience', + status: 'warning', + detail: 'AudienceRestriction がありません', + }); + } else if (!sp) { + items.push({ id: 'audience', label: 'Audience', status: 'info', detail: audiences.join(', ') }); + } else if (nonEmptyRestrictions.every((g) => g.includes(sp))) { + items.push({ + id: 'audience', + label: 'Audience', + status: 'success', + detail: `SP entityID と一致します(${sp})`, + }); + } else { + items.push({ + id: 'audience', + label: 'Audience', + status: 'error', + detail: `SP entityID と不一致です(Audience: ${audiences.join(', ')})`, + }); + } + + // 5. Recipient(表示のみ) + const recipients = [ + ...new Set( + res.assertions.flatMap((a) => + a.subjectConfirmations.flatMap((s) => (s.recipient ? [s.recipient] : [])) + ) + ), + ]; + items.push({ + id: 'recipient', + label: 'Recipient', + status: recipients.length > 0 ? 'info' : 'warning', + detail: + recipients.length > 0 + ? recipients.join(', ') + : 'SubjectConfirmationData に Recipient がありません', + }); + + // 6. NameID + const hasNameId = res.assertions.some((a) => a.nameId); + items.push({ + id: 'nameid', + label: 'NameID', + status: hasNameId ? 'success' : 'warning', + detail: hasNameId + ? 'NameID が含まれています' + : 'NameID が含まれていません(SP 側でユーザを特定できない可能性があります)', + }); + + return items; +} + +/** LogoutRequest の定番チェックリストを実行する(spEntityId は無関係なため now のみ受ける) */ +export function runLogoutRequestChecks( + req: SamlLogoutRequestData, + opts: Pick = {} +): CheckItem[] { + const now = opts.now ?? new Date(); + const items: CheckItem[] = []; + + // 1. NotOnOrAfter(LogoutRequest では任意属性のため、なしは info) + if (!req.notOnOrAfter) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'info', + detail: '期限指定はありません(SAML 仕様上は任意)', + }); + } else { + const limit = new Date(req.notOnOrAfter); + if (isNaN(limit.getTime())) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'warning', + detail: `日時を解釈できません(NotOnOrAfter: ${req.notOnOrAfter})`, + }); + } else { + const { note, missingTimezone } = timezoneNote(req.notOnOrAfter); + if (now >= limit) { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: 'error', + detail: `期限切れです(NotOnOrAfter: ${req.notOnOrAfter})${note}`, + }); + } else { + items.push({ + id: 'notOnOrAfter', + label: 'NotOnOrAfter', + status: missingTimezone ? 'warning' : 'success', + detail: `期限内です(NotOnOrAfter: ${req.notOnOrAfter})${note}`, + }); + } + } + } + + // 2. NameID(SAML 2.0 Core 仕様上 BaseID / NameID / EncryptedID のいずれかが必須) + if (req.nameId) { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'success', + detail: 'NameID が含まれています', + }); + } else if (req.encryptedNameId) { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'warning', + detail: '暗号化されており内容を確認できません(復号は非対応)', + }); + } else { + items.push({ + id: 'nameid', + label: 'NameID', + status: 'error', + detail: + 'NameID が含まれていません(LogoutRequest には NameID / EncryptedID のいずれかが必要です)', + }); + } + + return items; +} + +/** LogoutResponse の定番チェックリストを実行する(Status のみ) */ +export function runLogoutResponseChecks(res: SamlLogoutResponseData): CheckItem[] { + return [statusCheckItem(res)]; +} diff --git a/src/utils/saml/decode.ts b/src/utils/saml/decode.ts new file mode 100644 index 00000000..4299fcd0 --- /dev/null +++ b/src/utils/saml/decode.ts @@ -0,0 +1,188 @@ +import { Decompress } from 'fflate'; +import type { DecodedInput } from './types'; + +const utf8 = new TextDecoder('utf-8', { fatal: true }); + +/** 展開後サイズの上限(deflate 展開の zip bomb 対策) */ +const MAX_INFLATED_SIZE = 32 * 1024 * 1024; +/** + * 圧縮データを与える単位(ストリーミング展開で上限超過を早期検知するため小さく刻む)。 + * fflate の Decompress は 1 回の push から生成された展開結果を単一チャンクで ondata に渡すため、 + * 上限チェックはチャンク割当後にしか走らない。圧縮チャンクを小さくすることで、 + * 1 push あたりの最悪展開量(8KB × deflate 最大圧縮率 ~1032:1 ≒ ~8MB)を抑える。 + * 正常系の SAML メッセージは数 KB 程度のため、この縮小による性能影響はない。 + */ +const INFLATE_CHUNK_SIZE = 8 * 1024; + +/** 展開後サイズ上限超過を表す専用エラー(deflate 自体の失敗と区別するため) */ +class InflateLimitError extends Error {} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + +/** + * fflate のストリーミング Decompress で raw deflate / zlib / gzip を自動判定しつつ展開する。 + * decompressSync は展開後データを一括生成するため上限チェックが手遅れになる。 + * 圧縮データを小さいチャンクに分けて渡すことで、上限超過を検知した時点で残りの展開を打ち切れる。 + * + * 実効ピークメモリの目安: 上限チェックは push 単位でしか走らないため、実効ピークは + * MAX_INFLATED_SIZE(32MB)+ 1 push あたりの最悪展開量(INFLATE_CHUNK_SIZE 分、~8MB) + * 程度になりうる。 + */ +function inflateWithLimit(bytes: Uint8Array): Uint8Array { + const chunks: Uint8Array[] = []; + let total = 0; + const inflator = new Decompress((chunk) => { + total += chunk.length; + if (total > MAX_INFLATED_SIZE) { + throw new InflateLimitError('deflate 展開後のサイズが上限(32MB)を超えました'); + } + chunks.push(chunk); + }); + + let offset = 0; + do { + const end = Math.min(offset + INFLATE_CHUNK_SIZE, bytes.length); + inflator.push(bytes.subarray(offset, end), end === bytes.length); + offset = end; + } while (offset < bytes.length); + + const result = new Uint8Array(total); + let pos = 0; + for (const chunk of chunks) { + result.set(chunk, pos); + pos += chunk.length; + } + return result; +} + +/** + * クエリ文字列(`key=value&key=value...` 形式)から SAMLResponse / SAMLRequest の値を抽出する。 + * searchParams.get() は "+" を空白に変換してしまい base64 を破壊するため、 + * 生のクエリ文字列から自前でパラメータを取り出す(値は percent エンコードのまま保持)。 + * SAMLResponse を優先し、無ければ最初の SAMLRequest を採用する。 + */ +function extractSamlParam(rawQuery: string): string | undefined { + let param: string | undefined; + for (const pair of rawQuery.split('&')) { + const eq = pair.indexOf('='); + if (eq < 0) continue; + const rawKey = pair.slice(0, eq); + // キー名が percent エンコードされている場合(例: SAML%52esponse)に備え、 + // 比較前に一度デコードを試みる(失敗時は生キーのまま比較を続行) + let key = rawKey; + try { + key = decodeURIComponent(rawKey); + } catch { + /* 不正な %-シーケンスの場合は生キーのまま比較する */ + } + if (key === 'SAMLResponse' || (key === 'SAMLRequest' && param === undefined)) { + param = pair.slice(eq + 1); + if (key === 'SAMLResponse') break; + } + } + return param; +} + +/** + * SAML メッセージ入力の自動判定デコード。 + * URL 全体 / URL エンコード base64 / base64(POST)/ base64+deflate(Redirect)/ 生 XML に対応。 + */ +export function decodeSamlInput(raw: string): DecodedInput { + const steps: string[] = []; + let text = raw.trim(); + if (!text) throw new Error('入力が空です'); + + // 1. URL 全体 → SAMLResponse / SAMLRequest パラメータ抽出 + if (/^https?:\/\//i.test(text)) { + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error('URL として解釈できません'); + } + const param = extractSamlParam(url.search.slice(1)); + if (!param) throw new Error('URL に SAMLResponse / SAMLRequest パラメータが見つかりません'); + steps.push('URL からパラメータ抽出'); + text = param; + } else if (/^SAML(Response|Request)=/.test(text)) { + // 1.1 クエリ文字列断片の救済(URL 全体ではなく `SAMLResponse=...&RelayState=...` のような + // クエリ部分だけを貼り付けた場合)。実装を単純に保つため「SAMLResponse=」「SAMLRequest=」 + // で始まる場合のみを対象とし、それ以外の並び順(例: RelayState= が先頭)は対象外とする。 + const param = extractSamlParam(text); + if (!param) + throw new Error('クエリ文字列に SAMLResponse / SAMLRequest パラメータが見つかりません'); + steps.push('クエリ文字列からパラメータ抽出'); + text = param; + } + + // 2. 生 XML + if (text.startsWith('<')) { + return { xml: text, steps: [...steps, '生 XML と判定'], binding: 'xml' }; + } + + // 3. URL エンコード解除(%xx を含む場合のみ。復号失敗はそのまま続行) + if (/%[0-9a-fA-F]{2}/.test(text)) { + try { + text = decodeURIComponent(text); + steps.push('URL デコード'); + } catch { + /* 不正な %-シーケンスの場合はそのまま続行 */ + } + } + + // 3.1 URL エンコードされた生 XML の救済(URL デコード後に改めて判定) + if (text.startsWith('<')) { + return { xml: text, steps: [...steps, '生 XML と判定'], binding: 'xml' }; + } + + // 4. base64(base64url 表記の場合は標準 base64 へ変換してから decode する) + let normalized = text.replace(/\s+/g, ''); + if (/[-_]/.test(normalized)) { + normalized = normalized.replace(/-/g, '+').replace(/_/g, '/'); + normalized += '='.repeat((4 - (normalized.length % 4)) % 4); + steps.push('base64url を標準 base64 へ変換'); + } + let bytes: Uint8Array; + try { + bytes = base64ToBytes(normalized); + } catch { + throw new Error( + 'base64 として解釈できません(SAMLResponse / SAMLRequest の値か確認してください)' + ); + } + steps.push('base64 デコード'); + + // 5. そのまま XML → HTTP-POST binding + try { + const asText = utf8.decode(bytes); + if (asText.trimStart().startsWith('<')) { + return { xml: asText, steps, binding: 'post' }; + } + } catch { + /* UTF-8 でない → deflate 圧縮の可能性 */ + } + + // 6. deflate 展開 → HTTP-Redirect binding(raw deflate / zlib / gzip を自動判定) + let expanded: Uint8Array; + try { + expanded = inflateWithLimit(bytes); + } catch (e) { + if (e instanceof InflateLimitError) throw e; + throw new Error('デコード結果が XML ではありません(deflate 展開にも失敗しました)'); + } + let inflated: string; + try { + inflated = utf8.decode(expanded); + } catch { + throw new Error('デコード結果が XML ではありません(deflate 展開にも失敗しました)'); + } + if (!inflated.trimStart().startsWith('<')) { + throw new Error('デコード結果が XML ではありません(SAML メッセージか確認してください)'); + } + return { xml: inflated, steps: [...steps, 'deflate 展開'], binding: 'redirect' }; +} diff --git a/src/utils/saml/format.ts b/src/utils/saml/format.ts new file mode 100644 index 00000000..22fb2d6b --- /dev/null +++ b/src/utils/saml/format.ts @@ -0,0 +1,46 @@ +/** + * 表示用の簡易 XML 整形。 + * 要素・属性・テキストのみを再構成する(コメント・mixed content は SAML メッセージでは + * 実質使われないため対象外)。parse 不能な入力はそのまま返す。 + */ +export function formatXml(xml: string): string { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) return xml; + const lines: string[] = []; + const decl = xml.match(/^\s*<\?xml[^?]*\?>/); + if (decl) lines.push(decl[0].trim()); + serializeEl(doc.documentElement, 0, lines); + return lines.join('\n'); +} + +function escapeText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttr(s: string): string { + return escapeText(s).replace(/"/g, '"'); +} + +function openTag(el: Element): string { + const attrs = Array.from(el.attributes) + .map((a) => ` ${a.name}="${escapeAttr(a.value)}"`) + .join(''); + return `<${el.tagName}${attrs}`; +} + +function serializeEl(el: Element, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + const children = Array.from(el.children); + if (children.length === 0) { + const text = (el.textContent ?? '').trim(); + lines.push( + text + ? `${indent}${openTag(el)}>${escapeText(text)}` + : `${indent}${openTag(el)}/>` + ); + return; + } + lines.push(`${indent}${openTag(el)}>`); + for (const c of children) serializeEl(c, depth + 1, lines); + lines.push(`${indent}`); +} diff --git a/src/utils/saml/index.ts b/src/utils/saml/index.ts new file mode 100644 index 00000000..ae45f76f --- /dev/null +++ b/src/utils/saml/index.ts @@ -0,0 +1,11 @@ +export * from './types'; +export { decodeSamlInput } from './decode'; +export { parseSamlXml } from './parse'; +export { + runResponseChecks, + runLogoutRequestChecks, + runLogoutResponseChecks, + type CheckOptions, +} from './checks'; +export { formatXml } from './format'; +export { maskSamlXml, type SamlMaskResult } from './mask'; diff --git a/src/utils/saml/mask.ts b/src/utils/saml/mask.ts new file mode 100644 index 00000000..1cd8a1ac --- /dev/null +++ b/src/utils/saml/mask.ts @@ -0,0 +1,77 @@ +import { scrubText, type ScrubCategory } from '@/utils/secret-scrubber'; +import { NS_A } from './ns'; + +export interface SamlMaskResult { + /** マスク済み XML(シリアライズ後の文字列。表示側で formatXml して整形表示する) */ + xml: string; + /** 構造ベース(フェーズ1)でマスクした occurrence 数 */ + piiCount: number; + /** secret-scrubber(フェーズ2)でマスクした occurrence 数 */ + secretCount: number; +} + +/** + * フェーズ2 で有効にする secret-scrubber カテゴリ。 + * HIGH_ENTROPY を除外して X509Certificate / SignatureValue / DigestValue の + * base64(非 PII・公開情報)を over-mask しないようにする。 + */ +const SCRUB_ENABLED: Record = { + API_KEY: true, + PRIVATE_KEY: true, + CREDENTIAL: true, + JWT: true, + EMAIL: true, + IP: true, + PHONE_JP: true, + CREDIT_CARD: true, + HIGH_ENTROPY: false, +}; + +/** + * デコード済み SAML XML から PII / 機密文字列を除去した共有用 XML を生成する。 + * + * フェーズ1(構造ベース): saml:NameID / saml:AttributeValue のテキストを値ベース一貫 + * トークン [REDACTED:PII_n] に置換する(同一値 → 同一トークンで相関を保つ)。 + * フェーズ2(scrubber 併用): 再シリアライズ後の文字列に scrubText を HIGH_ENTROPY 除外で + * 適用し、URL 埋め込みメール等の構造で拾えない残余を救済する。 + * + * 純関数。パース不能な入力は件数 0 で元の文字列を返す。 + */ +export function maskSamlXml(xml: string): SamlMaskResult { + try { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + return { xml, piiCount: 0, secretCount: 0 }; + } + + // フェーズ1: 構造ベースマスク(値ベース一貫トークン化) + const tokenMap = new Map(); + let counter = 0; + let piiCount = 0; + const maskElement = (el: Element): void => { + const value = el.textContent ?? ''; + if (!value.trim()) return; + let token = tokenMap.get(value); + if (!token) { + counter += 1; + token = `[REDACTED:PII_${counter}]`; + tokenMap.set(value, token); + } + el.textContent = token; + piiCount += 1; + }; + const targets: Element[] = [ + ...Array.from(doc.getElementsByTagNameNS(NS_A, 'NameID')), + ...Array.from(doc.getElementsByTagNameNS(NS_A, 'AttributeValue')), + ]; + for (const el of targets) maskElement(el); + + const serialized = new XMLSerializer().serializeToString(doc); + + // フェーズ2: secret-scrubber 残余救済 + const scrubbed = scrubText(serialized, SCRUB_ENABLED); + return { xml: scrubbed.output, piiCount, secretCount: scrubbed.findings.length }; + } catch { + return { xml, piiCount: 0, secretCount: 0 }; + } +} diff --git a/src/utils/saml/ns.ts b/src/utils/saml/ns.ts new file mode 100644 index 00000000..83e2d0f5 --- /dev/null +++ b/src/utils/saml/ns.ts @@ -0,0 +1,4 @@ +/** SAML 2.0 の名前空間 URI(parse.ts / mask.ts の単一の真実源) */ +export const NS_P = 'urn:oasis:names:tc:SAML:2.0:protocol'; +export const NS_A = 'urn:oasis:names:tc:SAML:2.0:assertion'; +export const NS_DS = 'http://www.w3.org/2000/09/xmldsig#'; diff --git a/src/utils/saml/parse.ts b/src/utils/saml/parse.ts new file mode 100644 index 00000000..22374ceb --- /dev/null +++ b/src/utils/saml/parse.ts @@ -0,0 +1,192 @@ +import type { + SamlAssertion, + SamlAttribute, + SamlAuthnRequestData, + SamlLogoutRequestData, + SamlLogoutResponseData, + SamlMessage, + SamlResponseData, +} from './types'; + +import { NS_P, NS_A, NS_DS } from './ns'; + +/** 直下の子要素のみを名前空間 URI + localName で探す(prefix 非依存・ネスト混入防止) */ +function childNS(el: Element, ns: string, local: string): Element | undefined { + return Array.from(el.children).find((c) => c.namespaceURI === ns && c.localName === local); +} + +function childrenNS(el: Element, ns: string, local: string): Element[] { + return Array.from(el.children).filter((c) => c.namespaceURI === ns && c.localName === local); +} + +function textOf(el: Element | undefined): string | undefined { + const t = el?.textContent?.trim(); + return t || undefined; +} + +function attrOf(el: Element | undefined, name: string): string | undefined { + return el?.getAttribute(name) ?? undefined; +} + +function hasDirectSignature(el: Element): boolean { + return childNS(el, NS_DS, 'Signature') !== undefined; +} + +/** + * SAML XML を構造化モデルへパースする。 + * 対応: Response / AuthnRequest / LogoutRequest / LogoutResponse。それ以外の SAML メッセージ型はエラー。 + */ +export function parseSamlXml(xml: string): SamlMessage { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + if (doc.getElementsByTagName('parsererror').length > 0) { + throw new Error('XML の構文エラーがあります'); + } + const root = doc.documentElement; + if (root.namespaceURI === NS_P && root.localName === 'Response') return parseResponse(root); + if (root.namespaceURI === NS_P && root.localName === 'AuthnRequest') + return parseAuthnRequest(root); + if (root.namespaceURI === NS_P && root.localName === 'LogoutRequest') + return parseLogoutRequest(root); + if (root.namespaceURI === NS_P && root.localName === 'LogoutResponse') + return parseLogoutResponse(root); + throw new Error( + `対応していない SAML メッセージです(${root.namespaceURI ?? '名前空間なし'} の ${root.localName})。SAML 2.0 の Response / AuthnRequest / LogoutRequest / LogoutResponse のみ対応しています` + ); +} + +interface ParsedStatus { + statusCode?: string; + statusSubCode?: string; + statusMessage?: string; +} + +/** samlp:Status から外側/内側 StatusCode と StatusMessage を抽出する(Response / LogoutResponse 共通) */ +function parseStatus(root: Element): ParsedStatus { + const status = childNS(root, NS_P, 'Status'); + const outerStatusCode = status ? childNS(status, NS_P, 'StatusCode') : undefined; + // 二段階ステータス(外側 StatusCode の子にもう1つ StatusCode)の内側コード + const innerStatusCode = outerStatusCode + ? childNS(outerStatusCode, NS_P, 'StatusCode') + : undefined; + return { + statusCode: attrOf(outerStatusCode, 'Value'), + statusSubCode: attrOf(innerStatusCode, 'Value'), + statusMessage: status ? textOf(childNS(status, NS_P, 'StatusMessage')) : undefined, + }; +} + +function parseResponse(root: Element): SamlResponseData { + return { + type: 'response', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + ...parseStatus(root), + destination: attrOf(root, 'Destination'), + inResponseTo: attrOf(root, 'InResponseTo'), + issueInstant: attrOf(root, 'IssueInstant'), + signed: hasDirectSignature(root), + assertions: childrenNS(root, NS_A, 'Assertion').map(parseAssertion), + encryptedAssertionCount: childrenNS(root, NS_A, 'EncryptedAssertion').length, + }; +} + +function parseAssertion(el: Element): SamlAssertion { + const subject = childNS(el, NS_A, 'Subject'); + const nameId = subject && childNS(subject, NS_A, 'NameID'); + const conditions = childNS(el, NS_A, 'Conditions'); + const attrStatement = childNS(el, NS_A, 'AttributeStatement'); + return { + id: attrOf(el, 'ID'), + issuer: textOf(childNS(el, NS_A, 'Issuer')), + nameId: textOf(nameId), + nameIdFormat: attrOf(nameId, 'Format'), + attributes: attrStatement + ? childrenNS(attrStatement, NS_A, 'Attribute').map(parseAttribute) + : [], + conditions: conditions + ? { + notBefore: attrOf(conditions, 'NotBefore'), + notOnOrAfter: attrOf(conditions, 'NotOnOrAfter'), + audienceRestrictions: childrenNS(conditions, NS_A, 'AudienceRestriction').map((ar) => + childrenNS(ar, NS_A, 'Audience').flatMap((a) => textOf(a) ?? []) + ), + } + : undefined, + authnStatements: childrenNS(el, NS_A, 'AuthnStatement').map((s) => { + const ctx = childNS(s, NS_A, 'AuthnContext'); + return { + authnInstant: attrOf(s, 'AuthnInstant'), + sessionIndex: attrOf(s, 'SessionIndex'), + authnContextClassRef: ctx ? textOf(childNS(ctx, NS_A, 'AuthnContextClassRef')) : undefined, + }; + }), + subjectConfirmations: subject + ? childrenNS(subject, NS_A, 'SubjectConfirmation').map((sc) => { + const data = childNS(sc, NS_A, 'SubjectConfirmationData'); + return { + method: attrOf(sc, 'Method'), + recipient: attrOf(data, 'Recipient'), + notOnOrAfter: attrOf(data, 'NotOnOrAfter'), + inResponseTo: attrOf(data, 'InResponseTo'), + }; + }) + : [], + signed: hasDirectSignature(el), + }; +} + +function parseAttribute(el: Element): SamlAttribute { + return { + name: attrOf(el, 'Name') ?? '(名前なし)', + friendlyName: attrOf(el, 'FriendlyName'), + values: childrenNS(el, NS_A, 'AttributeValue').map((v) => v.textContent?.trim() ?? ''), + }; +} + +function parseAuthnRequest(root: Element): SamlAuthnRequestData { + const nameIdPolicy = childNS(root, NS_P, 'NameIDPolicy'); + const requestedCtx = childNS(root, NS_P, 'RequestedAuthnContext'); + return { + type: 'authnRequest', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + destination: attrOf(root, 'Destination'), + acsUrl: attrOf(root, 'AssertionConsumerServiceURL'), + protocolBinding: attrOf(root, 'ProtocolBinding'), + issueInstant: attrOf(root, 'IssueInstant'), + nameIdPolicyFormat: attrOf(nameIdPolicy, 'Format'), + allowCreate: attrOf(nameIdPolicy, 'AllowCreate'), + authnContextClassRefs: requestedCtx + ? childrenNS(requestedCtx, NS_A, 'AuthnContextClassRef').flatMap((e) => textOf(e) ?? []) + : [], + signed: hasDirectSignature(root), + }; +} + +function parseLogoutRequest(root: Element): SamlLogoutRequestData { + const nameId = childNS(root, NS_A, 'NameID'); + return { + type: 'logoutRequest', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + destination: attrOf(root, 'Destination'), + issueInstant: attrOf(root, 'IssueInstant'), + notOnOrAfter: attrOf(root, 'NotOnOrAfter'), + reason: attrOf(root, 'Reason'), + nameId: textOf(nameId), + nameIdFormat: attrOf(nameId, 'Format'), + encryptedNameId: childNS(root, NS_A, 'EncryptedID') !== undefined, + // SessionIndex は assertion 側ではなく protocol 名前空間の要素 + sessionIndexes: childrenNS(root, NS_P, 'SessionIndex').flatMap((e) => textOf(e) ?? []), + signed: hasDirectSignature(root), + }; +} + +function parseLogoutResponse(root: Element): SamlLogoutResponseData { + return { + type: 'logoutResponse', + issuer: textOf(childNS(root, NS_A, 'Issuer')), + ...parseStatus(root), + destination: attrOf(root, 'Destination'), + inResponseTo: attrOf(root, 'InResponseTo'), + issueInstant: attrOf(root, 'IssueInstant'), + signed: hasDirectSignature(root), + }; +} diff --git a/src/utils/saml/types.ts b/src/utils/saml/types.ts new file mode 100644 index 00000000..706e1cfe --- /dev/null +++ b/src/utils/saml/types.ts @@ -0,0 +1,123 @@ +/** 入力の由来バインディング */ +export type SamlBinding = 'redirect' | 'post' | 'xml'; + +export interface DecodedInput { + xml: string; + /** 適用した変換ステップ(UI 表示用、適用順) */ + steps: string[]; + binding: SamlBinding; +} + +export interface SamlAttribute { + name: string; + friendlyName?: string; + values: string[]; +} + +export interface SamlConditions { + notBefore?: string; + notOnOrAfter?: string; + /** AudienceRestriction ごとの Audience 列挙(外側 = AND、内側 = restriction 内の OR) */ + audienceRestrictions: string[][]; +} + +export interface SamlSubjectConfirmation { + method?: string; + recipient?: string; + notOnOrAfter?: string; + inResponseTo?: string; +} + +export interface SamlAuthnStatement { + authnInstant?: string; + sessionIndex?: string; + authnContextClassRef?: string; +} + +export interface SamlAssertion { + id?: string; + issuer?: string; + nameId?: string; + nameIdFormat?: string; + attributes: SamlAttribute[]; + conditions?: SamlConditions; + authnStatements: SamlAuthnStatement[]; + subjectConfirmations: SamlSubjectConfirmation[]; + /** Assertion 直下に ds:Signature を持つか(存在表示のみ、検証はしない) */ + signed: boolean; +} + +export interface SamlResponseData { + type: 'response'; + issuer?: string; + statusCode?: string; + /** 外側 StatusCode の直下にネストした内側 StatusCode の Value */ + statusSubCode?: string; + statusMessage?: string; + destination?: string; + inResponseTo?: string; + issueInstant?: string; + /** Response 直下に ds:Signature を持つか */ + signed: boolean; + assertions: SamlAssertion[]; + encryptedAssertionCount: number; +} + +export interface SamlAuthnRequestData { + type: 'authnRequest'; + issuer?: string; + destination?: string; + acsUrl?: string; + protocolBinding?: string; + issueInstant?: string; + nameIdPolicyFormat?: string; + allowCreate?: string; + authnContextClassRefs: string[]; + signed: boolean; +} + +export interface SamlLogoutRequestData { + type: 'logoutRequest'; + issuer?: string; + destination?: string; + issueInstant?: string; + /** ルート属性。リクエスト自体の有効期限(SAML 仕様上は任意) */ + notOnOrAfter?: string; + /** Reason 属性(URI) */ + reason?: string; + nameId?: string; + nameIdFormat?: string; + /** NameID が EncryptedID で暗号化されている場合 true(内容は表示不可・復号は非対応) */ + encryptedNameId: boolean; + /** samlp:SessionIndex(複数可) */ + sessionIndexes: string[]; + signed: boolean; +} + +export interface SamlLogoutResponseData { + type: 'logoutResponse'; + issuer?: string; + statusCode?: string; + /** 外側 StatusCode の直下にネストした内側 StatusCode の Value */ + statusSubCode?: string; + statusMessage?: string; + destination?: string; + inResponseTo?: string; + issueInstant?: string; + signed: boolean; +} + +export type SamlMessage = + | SamlResponseData + | SamlAuthnRequestData + | SamlLogoutRequestData + | SamlLogoutResponseData; + +export type CheckStatus = 'success' | 'warning' | 'error' | 'info'; + +export interface CheckItem { + id: string; + label: string; + status: CheckStatus; + detail: string; +} diff --git a/tests/e2e/dsn-builder.spec.ts b/tests/e2e/dsn-builder.spec.ts index 11728a72..abea53cf 100644 --- a/tests/e2e/dsn-builder.spec.ts +++ b/tests/e2e/dsn-builder.spec.ts @@ -1,10 +1,14 @@ import { test, expect } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; const PG_URI = 'postgresql://app:s3cret@db.example.com:5432/app_db?sslmode=require'; test.describe('DSN/接続文字列ビルダ', () => { test.beforeEach(async ({ page }) => { await page.goto('/tools/dsn-builder'); + // fill → React onChange パースが hydration 完了前に走ると DOM のみ更新され + // flaky になるため、island の hydration 完了を待つ (issue #750) + await waitForReactHydration(page); }); test('URI 貼り付けでフォームに分解される', async ({ page }) => { diff --git a/tests/e2e/dummy-personal-data.spec.ts b/tests/e2e/dummy-personal-data.spec.ts index c7904c49..ff201be1 100644 --- a/tests/e2e/dummy-personal-data.spec.ts +++ b/tests/e2e/dummy-personal-data.spec.ts @@ -1,8 +1,12 @@ import { test, expect } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; test.describe('日本語ダミー個人データ生成', () => { test.beforeEach(async ({ page }) => { await page.goto('/tools/dummy-personal-data'); + // click → React handler が hydration 完了前に走ると無反応になり + // flaky になるため、island の hydration 完了を待つ (issue #750) + await waitForReactHydration(page); }); test('生成するとプレビュー表が表示される', async ({ page }) => { diff --git a/tests/e2e/har-viewer.spec.ts b/tests/e2e/har-viewer.spec.ts index f5e7e085..d4885161 100644 --- a/tests/e2e/har-viewer.spec.ts +++ b/tests/e2e/har-viewer.spec.ts @@ -1,4 +1,5 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; /** * n 件のエントリを持つ最小 HAR を生成する。 @@ -86,7 +87,7 @@ function buildCookieHar(entryCount: number): string { * client:load コンポーネントは hydrate 前にはイベントハンドラが未登録のため、 * ネットワーク idle + ラベルボタンが visible になってから upload する。 */ -async function uploadHar(page: import('@playwright/test').Page, json: string): Promise { +async function uploadHar(page: Page, json: string): Promise { // ページリソース読み込み完了 + React hydrate 完了を待つ await page.waitForLoadState('networkidle'); const fileInput = page.getByLabel('ファイルを選択'); @@ -98,9 +99,20 @@ async function uploadHar(page: import('@playwright/test').Page, json: string): P }); } +/** + * HAR ビューアを開き React island の hydration 完了まで待つ。 + * setInputFiles → React onChange が hydration 前に走ると file が処理されず + * flaky になるため、各 test の goto はこのヘルパー経由に統一する (issue #750)。 + * ※ viewport を変える test は本ヘルパー呼び出し前に setViewportSize すること。 + */ +async function openHarViewer(page: Page): Promise { + await page.goto('/tools/har-viewer'); + await waitForReactHydration(page); +} + test.describe('HAR ビューア', () => { test('Web Worker 経由で読み込み、全エントリが描画される(ページャは無い)', async ({ page }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); const json = buildTestHar(120); await uploadHar(page, json); @@ -118,7 +130,7 @@ test.describe('HAR ビューア', () => { }); test('entry クリックで詳細パネルにそのリクエストが表示される', async ({ page }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); const json = buildTestHar(120); await uploadHar(page, json); @@ -137,7 +149,7 @@ test.describe('HAR ビューア', () => { }) => { // スマホ相当の狭幅にしてカラムが squeeze される状況を再現 await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/tools/har-viewer'); + await openHarViewer(page); const json = buildTestHar(3); await uploadHar(page, json); @@ -164,7 +176,7 @@ test.describe('HAR ビューア', () => { test('redact トグルで Web Worker の再 sanitize が走り redact 件数が変化する', async ({ page, }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); // 各エントリに Cookie ヘッダを持つ 5 件の HAR(COOKIE redact 件数 = 5) const json = buildCookieHar(5); @@ -185,7 +197,7 @@ test.describe('HAR ビューア', () => { test('壊れた entry(request/response 欠落)を含んでもクラッシュせず描画する', async ({ page, }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); // 1 件目は正常、2 件目は request/response を欠く壊れた entry(issue #681 再現データ) const json = JSON.stringify({ @@ -221,7 +233,7 @@ test.describe('HAR ビューア', () => { }); test('先頭 entry が null でも自動選択で詳細プレースホルダが表示される', async ({ page }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); // 先頭 entry が null(index=0 が自動選択される)+ 2 件目は正常 const json = JSON.stringify({ @@ -257,7 +269,7 @@ test.describe('HAR ビューア', () => { test('タイミング列が PC 幅で表示され詳細パネルにタイミング内訳が出る', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto('/tools/har-viewer'); + await openHarViewer(page); // timings を持つ 2 件の HAR(2 件目は起点をずらして相対配置を確認) const json = JSON.stringify({ @@ -315,7 +327,7 @@ test.describe('HAR ビューア', () => { test('正常 entry 選択後も壊れた行を再クリックして詳細プレースホルダを再表示できる', async ({ page, }) => { - await page.goto('/tools/har-viewer'); + await openHarViewer(page); // 先頭が壊れた entry({})+ 2 件目は正常(issue #701 再現データ) const json = JSON.stringify({ diff --git a/tests/e2e/saml-decoder.spec.ts b/tests/e2e/saml-decoder.spec.ts new file mode 100644 index 00000000..9bed6d65 --- /dev/null +++ b/tests/e2e/saml-decoder.spec.ts @@ -0,0 +1,187 @@ +import { test, expect } from '@playwright/test'; +import { waitForReactHydration } from './helpers'; + +/** 有効期間を現在時刻基準で生成する Response XML(E2E は実時刻でチェックが走るため動的に組む) */ +function responseXml(opts: { notOnOrAfterOffsetMs: number; statusCode?: string }): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + const status = opts.statusCode ?? 'urn:oasis:names:tc:SAML:2.0:status:Success'; + return ` + + https://idp.example.com/metadata + + + https://idp.example.com/metadata + taro@example.com + + https://sp.example.com/metadata + + + taro@example.com + + +`; +} + +const AUTHN_REQUEST_XML = ` + + https://sp.example.com/metadata +`; + +/** NotOnOrAfter を現在時刻基準で生成する LogoutRequest XML(実時刻でチェックが走るため動的に組む) */ +function logoutRequestXml(opts: { notOnOrAfterOffsetMs: number }): string { + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + return ` + + https://sp.example.com/metadata + taro@example.com + _s1 +`; +} + +/** 二段階ステータスで失敗する LogoutResponse(時刻非依存のため静的でよい) */ +const FAILED_LOGOUT_RESPONSE_XML = ` + + https://idp.example.com/metadata + + + + + Session not found + +`; + +/** UTF-8 → base64url(パディングなし、`-`/`_` 表記) */ +function toBase64Url(xml: string): string { + const bytes = new TextEncoder().encode(xml); + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +test.describe('SAMLデコーダ', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tools/saml-decoder'); + // client:load の Astro island hydration 完了前に fill() すると入力イベントが + // React にバインドされておらず state 更新が発生しない(サンドボックス環境で + // 特に顕在化する race)。hydration 完了を正典 helper で待ってから操作する。 + await waitForReactHydration(page); + }); + + test('有効な Response を貼ると内容とチェックリストが表示される', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: 300_000 })); + await expect(page.getByText('Response サマリ')).toBeVisible(); + await expect(page.getByText('https://idp.example.com/metadata').first()).toBeVisible(); + await expect(page.getByText('taro@example.com').first()).toBeVisible(); + await expect(page.getByText('有効期間内です', { exact: false })).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeVisible(); + }); + + test('サンプルボタンでデコード結果が表示される', async ({ page }) => { + await page.getByRole('button', { name: 'サンプル' }).click(); + await expect(page.getByText('Response サマリ')).toBeVisible(); + await expect(page.getByText('HTTP-POST binding', { exact: false }).first()).toBeVisible(); + }); + + test('陽性対照: 期限切れ Response はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: -300_000 })); + await expect(page.getByText('期限切れです', { exact: false })).toBeVisible(); + }); + + test('陽性対照: Status Responder はエラー表示になる', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill( + responseXml({ + notOnOrAfterOffsetMs: 300_000, + statusCode: 'urn:oasis:names:tc:SAML:2.0:status:Responder', + }) + ); + await expect(page.getByText('Success ではありません', { exact: false })).toBeVisible(); + }); + + test('陽性対照: SP entityID 不一致はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(responseXml({ notOnOrAfterOffsetMs: 300_000 })); + await page.getByLabel(/SP entityID/).fill('https://other.example.com/metadata'); + await expect(page.getByText('SP entityID と不一致です', { exact: false })).toBeVisible(); + }); + + test('base64url(- _ を含みパディングなし)でエンコードした Response も正常にデコードされる', async ({ + page, + }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(toBase64Url(responseXml({ notOnOrAfterOffsetMs: 300_000 }))); + await expect(page.getByText('Response サマリ')).toBeVisible(); + await expect(page.getByText('taro@example.com').first()).toBeVisible(); + }); + + test('AuthnRequest はサマリのみ表示されチェックリストは出ない', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill(AUTHN_REQUEST_XML); + await expect(page.getByText('AuthnRequest サマリ')).toBeVisible(); + await expect(page.getByText('https://sp.example.com/acs').first()).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeHidden(); + }); + + test('不正な入力はエラーメッセージが表示される', async ({ page }) => { + await page.getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/).fill('これはSAMLではない'); + await expect(page.getByText('base64 として解釈できません', { exact: false })).toBeVisible(); + }); + + test('LogoutRequest を貼るとサマリとチェックリストが表示される', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(logoutRequestXml({ notOnOrAfterOffsetMs: 300_000 })); + await expect(page.getByText('LogoutRequest サマリ')).toBeVisible(); + await expect(page.getByText('https://sp.example.com/metadata').first()).toBeVisible(); + await expect(page.getByText('_s1').first()).toBeVisible(); + await expect(page.getByText('期限内です', { exact: false })).toBeVisible(); + await expect(page.getByText('チェックリスト')).toBeVisible(); + }); + + test('陽性対照: 期限切れ LogoutRequest はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(logoutRequestXml({ notOnOrAfterOffsetMs: -300_000 })); + await expect(page.getByText('期限切れです', { exact: false })).toBeVisible(); + }); + + test('陽性対照: Status 失敗の LogoutResponse はエラー表示になる', async ({ page }) => { + await page + .getByLabel(/SAMLResponse \/ SAMLRequest を貼り付け/) + .fill(FAILED_LOGOUT_RESPONSE_XML); + await expect(page.getByText('LogoutResponse サマリ')).toBeVisible(); + await expect(page.getByText('Responder / RequestDenied', { exact: false })).toBeVisible(); + // 'Session not found' 単体だと raw XML 表示・整形済み XML 表示にも同一文字列が + // 含まれ strict mode violation になるため、チェックリストが生成する文言で一意に絞り込む + await expect( + page.getByText('StatusMessage: Session not found', { exact: false }) + ).toBeVisible(); + }); + + test('マスク XML トグルで PII がトークン化されコピー対象も切替わる', async ({ page }) => { + await page.getByRole('button', { name: 'サンプル' }).click(); + await expect(page.getByText('Response サマリ')).toBeVisible(); + + // 整形済み XML の details を開く + await page.getByText('整形済み XML(簡易整形)').click(); + + // 生 XML モードでは NameID のメールが表示される + const xmlBlock = page.locator('pre').last(); + await expect(xmlBlock).toContainText('taro.yamada@example.com'); + + // マスク XML に切替 + await page.getByRole('button', { name: 'マスク XML(共有用)' }).click(); + await expect(xmlBlock).not.toContainText('taro.yamada@example.com'); + await expect(xmlBlock).toContainText('[REDACTED:PII_'); + await expect(xmlBlock).not.toContainText('山田 太郎'); + + // 件数バッジが表示される + await expect(page.getByText(/PII \d+ 件・機密 \d+ 件をマスク/)).toBeVisible(); + }); +}); diff --git a/tests/e2e/visual-regression-pages.ts b/tests/e2e/visual-regression-pages.ts index 6aff3d13..b5156be0 100644 --- a/tests/e2e/visual-regression-pages.ts +++ b/tests/e2e/visual-regression-pages.ts @@ -48,6 +48,7 @@ export const PAGES = [ '/tools/contrast-matrix', '/tools/dummy-personal-data', '/tools/ddl-er-diagram', + '/tools/saml-decoder', ] as const; export const STATIC_PAGES = new Set(['/', '/about', '/privacy', '/404']); diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index a69d73b7..e4722c48 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-base64-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-base64-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index b58e5628..135e4e12 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-base64-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-base64-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cert-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cert-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index fc0f9a36..7da73de1 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cert-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cert-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-char-count-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-char-count-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index ec9efcc5..cb946937 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-char-count-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-char-count-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cidr-calculator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cidr-calculator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index ba7c0998..c7689e28 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cidr-calculator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-cidr-calculator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-clipboard-inspector-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-clipboard-inspector-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index b33d8f61..1933a74f 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-clipboard-inspector-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-clipboard-inspector-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-config-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-config-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index a4f52527..e857e62b 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-config-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-config-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-contrast-matrix-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-contrast-matrix-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 6bff7ea2..d7d24cf2 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-contrast-matrix-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-contrast-matrix-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-csr-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-csr-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 517a82cc..55f9915e 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-csr-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-csr-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ddl-er-diagram-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ddl-er-diagram-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 6b8c8ff0..d1280760 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ddl-er-diagram-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ddl-er-diagram-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dsn-builder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dsn-builder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index e15479b5..d7637aaa 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dsn-builder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dsn-builder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-personal-data-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-personal-data-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 791c8251..b441e935 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-personal-data-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-personal-data-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-text-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-text-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 8272ca5d..58d5bfc1 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-text-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-dummy-text-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-encoding-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-encoding-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 8555ddab..274a06e7 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-encoding-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-encoding-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-gs1-databar-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-gs1-databar-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index f827f6ab..25d28f1e 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-gs1-databar-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-gs1-databar-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-har-viewer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-har-viewer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 8f66849b..934e5d1f 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-har-viewer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-har-viewer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jan-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jan-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 90fadd03..7f805dc0 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jan-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jan-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-csv-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-csv-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 27cd93ed..1e82b5e3 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-csv-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-csv-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 1dbc1da6..60104e75 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-xml-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-xml-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index c98be294..fe726a97 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-xml-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-json-xml-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jwt-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jwt-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 3d0e4d9a..387bca72 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jwt-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-jwt-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-key-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-key-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 3cda9abf..f6ebcb2e 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-key-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-key-converter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-markdown-editor-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-markdown-editor-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index b90362cc..1767329c 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-markdown-editor-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-markdown-editor-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 371a5d6a..0d82b800 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-code-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-reader-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-reader-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 6212b39b..21a618bb 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-reader-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-reader-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-ticket-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-ticket-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index b5af13a0..f0c64870 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-ticket-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-qr-ticket-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-regex-visualizer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-regex-visualizer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index ad6687df..51437ad1 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-regex-visualizer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-regex-visualizer-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" new file mode 100644 index 00000000..652afb48 Binary files /dev/null and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-secret-scrubber-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-secret-scrubber-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 8fb37bda..7910ec1c 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-secret-scrubber-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-secret-scrubber-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-sql-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-sql-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index d1094532..7dae18b2 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-sql-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-sql-formatter-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-totp-hotp-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-totp-hotp-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 2da907a7..e4be2186 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-totp-hotp-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-totp-hotp-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ulid-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ulid-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index e129b9a2..9410de49 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ulid-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-ulid-generator-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-url-encode-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-url-encode-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 0421d3a9..4c09bd2d 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-url-encode-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-url-encode-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-uuid-v7-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-uuid-v7-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 4ee569d7..d85c1503 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-uuid-v7-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-tools-uuid-v7-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index f9860e63..56133980 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---desktop-1280x800-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index d740e492..a4e4a4d9 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-about-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" new file mode 100644 index 00000000..fad0d141 Binary files /dev/null and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-tools-saml-decoder-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" index 098becbb..947172ae 100644 Binary files "a/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" and "b/tests/e2e/visual-regression.spec.ts-snapshots/visual-regression---mobile-390x844-\343\201\256-screenshot-\343\201\214-baseline-\343\201\250\344\270\200\350\207\264-1-visual-regression-linux.png" differ diff --git a/tests/meta/e2e-hydration-wait-coverage.test.ts b/tests/meta/e2e-hydration-wait-coverage.test.ts new file mode 100644 index 00000000..2b995bbc --- /dev/null +++ b/tests/meta/e2e-hydration-wait-coverage.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * meta test: E2E spec の hydration 待ち漏れ検出 (issue #750 再発防止策) + * + * Playwright の fill / click / setInputFiles が React island の hydration + * 完了前に実行されると、DOM だけ書き換わり React の onChange が発火しない + * hydration race で flaky になる(CI は workers:1 で顕在化せずローカル並列 + * 実行でのみ落ちるため発見が遅れる)。 + * + * 検知ルール: `tests/e2e/*.spec.ts` のうちソースに `goto('/tools/...')` を + * 含むファイルは `waitForReactHydration` または `withProductionCsp`(内部で + * hydration 待ちを実施)への参照を必須とする。 + * + * 除外基準: React のイベントハンドラ発火に依存しない spec(computed style + * 読取のみ等)は ALLOWLIST に理由付きで登録する。`/test-fixtures/*` や + * 静的ページのみへ goto する spec は検知対象外(gate spec 等は自然に除外)。 + * + * 注意: 参照検出はソース文字列ベースの heuristic(コメント内の言及でも + * 通過しうる)。厳密性より「新規 spec 作成時の完全な失念」の検知を目的とする。 + * 既知の限界(false negative): + * - 変数経由の goto(`const P = '/tools/x'; goto(P)`)は検知できない + * - `withProductionCsp` に `{ skipHydration: true }` を渡す spec は参照ありと + * みなされるが、ラッパ内部の hydration 待ちは skip される(static page 用 + * オプションのため `/tools/` ページでの使用時は spec 側で待機を担保すること) + */ + +const E2E_DIR = join(__dirname, '../e2e'); + +/** hydration 待ち不要と判断した spec の allowlist(除外理由を必ず併記) */ +const ALLOWLIST: ReadonlyMap = new Map([ + [ + 'prefers-reduced-motion.spec.ts', + 'computed style の読取のみで React イベントハンドラの発火に依存しない', + ], +]); + +const TOOLS_GOTO_RE = /goto\(\s*['"`]\/tools\//; +const HYDRATION_HELPER_RE = /waitForReactHydration|withProductionCsp/; + +interface SpecSource { + name: string; + content: string; +} + +/** hydration 待ちが漏れている spec 名を返す純粋関数(陰性/陽性両対照で共有) */ +function findSpecsMissingHydrationWait( + specs: readonly SpecSource[], + allowlist: ReadonlyMap +): string[] { + return specs + .filter((s) => !allowlist.has(s.name)) + .filter((s) => TOOLS_GOTO_RE.test(s.content)) + .filter((s) => !HYDRATION_HELPER_RE.test(s.content)) + .map((s) => s.name) + .sort(); +} + +/** + * allowlist の腐敗(orphan)を返す純粋関数。 + * 実在しない・既にヘルパー使用済み・/tools/ へ goto しない spec が + * allowlist に残っている場合に検出する(vrt-pages-coverage の orphan 検出踏襲)。 + */ +function findOrphanAllowlistEntries( + specs: readonly SpecSource[], + allowlist: ReadonlyMap +): string[] { + const byName = new Map(specs.map((s) => [s.name, s])); + return [...allowlist.keys()] + .filter((name) => { + const spec = byName.get(name); + if (!spec) return true; + return HYDRATION_HELPER_RE.test(spec.content) || !TOOLS_GOTO_RE.test(spec.content); + }) + .sort(); +} + +function loadSpecs(): SpecSource[] { + return readdirSync(E2E_DIR) + .filter((f) => f.endsWith('.spec.ts')) + .map((name) => ({ name, content: readFileSync(join(E2E_DIR, name), 'utf8') })); +} + +describe('E2E hydration 待ちカバレッジ', () => { + it('goto(/tools/*) する全 spec が waitForReactHydration か withProductionCsp を使用している', () => { + const missing = findSpecsMissingHydrationWait(loadSpecs(), ALLOWLIST); + expect(missing).toEqual([]); + }); + + it('ALLOWLIST に orphan エントリがない', () => { + const orphans = findOrphanAllowlistEntries(loadSpecs(), ALLOWLIST); + expect(orphans).toEqual([]); + }); +}); + +// 陽性対照: 検知機構が空回りしていないことを保証 (test-gates skill 準拠)。 +// fixture を注入し、検知ロジックが実際に違反を列挙することを確認する。 +describe('[陽性対照] E2E hydration 待ちカバレッジ検知機構', () => { + const emptyAllowlist: ReadonlyMap = new Map(); + + it('hydration 待ちなしで /tools/ へ goto する fixture を検出する', () => { + const fixture: SpecSource = { + name: 'fake-missing.spec.ts', + content: `await page.goto('/tools/fake-tool');\nawait page.fill('#x', 'y');`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([ + 'fake-missing.spec.ts', + ]); + }); + + it('waitForReactHydration 使用済み fixture は検出しない(過検知なし)', () => { + const fixture: SpecSource = { + name: 'fake-ok.spec.ts', + content: `await page.goto('/tools/fake-tool');\nawait waitForReactHydration(page);`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('withProductionCsp 使用済み fixture は検出しない(過検知なし)', () => { + const fixture: SpecSource = { + name: 'fake-csp.spec.ts', + content: `await withProductionCsp(browser, '/tools/fake-tool', async (page) => {});`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('/tools/ 以外へ goto する fixture は検出しない(gate spec 等の除外)', () => { + const fixture: SpecSource = { + name: 'fake-fixture-page.spec.ts', + content: `await page.goto('/test-fixtures/hydration-broken');`, + }; + expect(findSpecsMissingHydrationWait([fixture], emptyAllowlist)).toEqual([]); + }); + + it('allowlist 登録済み fixture は検出しない', () => { + const fixture: SpecSource = { + name: 'fake-allowed.spec.ts', + content: `await page.goto('/tools/fake-tool');`, + }; + const allowlist = new Map([['fake-allowed.spec.ts', 'テスト用の除外理由']]); + expect(findSpecsMissingHydrationWait([fixture], allowlist)).toEqual([]); + }); + + it('orphan 検出: 実在しない allowlist エントリを検出する', () => { + const allowlist = new Map([['no-such-file.spec.ts', '理由']]); + expect(findOrphanAllowlistEntries([], allowlist)).toEqual(['no-such-file.spec.ts']); + }); + + it('orphan 検出: ヘルパー使用済みなのに allowlist に残るエントリを検出する', () => { + const fixture: SpecSource = { + name: 'fake-migrated.spec.ts', + content: `await page.goto('/tools/x');\nawait waitForReactHydration(page);`, + }; + const allowlist = new Map([['fake-migrated.spec.ts', '理由']]); + expect(findOrphanAllowlistEntries([fixture], allowlist)).toEqual(['fake-migrated.spec.ts']); + }); +});