From 1d19b607ade4fa0b568fdd3d34b675862164ad7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:40:45 +0000 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20SAML=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E5=85=B1=E6=9C=89=E7=94=A8=E3=83=9E=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=81=AE=E8=A8=AD=E8=A8=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #745 の機能候補「共有用マスク出力(secret-scrubber との連携)」の 設計をブレインストーミングで確定。構造ベースマスク+scrubber 併用方針。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- .../2026-07-20-saml-mask-output-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-20-saml-mask-output-design.md 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 に目視確認を促す注記を置く From fa85e8598ef8ed3dde907e5c790d6031467dbf56 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:44:53 +0000 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20SAML=20=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E5=85=B1=E6=9C=89=E7=94=A8=E3=83=9E=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=81=AE=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- .../plans/2026-07-20-saml-mask-output.md | 542 ++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-saml-mask-output.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 利用で一致。 From 987322bc5b45952476802714fc5d37ead6179198 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:46:44 +0000 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20SAML=20=E5=90=8D=E5=89=8D?= =?UTF-8?q?=E7=A9=BA=E9=96=93=E5=AE=9A=E6=95=B0=E3=82=92=20ns.ts=20?= =?UTF-8?q?=E3=81=B8=E5=88=87=E3=82=8A=E5=87=BA=E3=81=97=20parse/mask=20?= =?UTF-8?q?=E3=81=A7=E5=85=B1=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- src/utils/saml/ns.ts | 4 ++++ src/utils/saml/parse.ts | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 src/utils/saml/ns.ts 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 index 0d8220d2..22374ceb 100644 --- a/src/utils/saml/parse.ts +++ b/src/utils/saml/parse.ts @@ -8,9 +8,7 @@ import type { 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#'; +import { NS_P, NS_A, NS_DS } from './ns'; /** 直下の子要素のみを名前空間 URI + localName で探す(prefix 非依存・ネスト混入防止) */ function childNS(el: Element, ns: string, local: string): Element | undefined { From 10ead544ea19b69a17aa950f275fa9e3efc7a814 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:48:27 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20SAML=20=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=81=AB=E5=85=B1=E6=9C=89=E7=94=A8=E3=83=9E=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E5=87=BA=E5=8A=9B=E3=83=AD=E3=82=B8=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 構造ベース(NameID/AttributeValue)+secret-scrubber 併用(HIGH_ENTROPY 除外)で PII・機密を除去。値ベース一貫トークンで相関を保つ。陽性/陰性対照テスト付き。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- src/utils/__tests__/saml-mask.test.ts | 109 ++++++++++++++++++++++++++ src/utils/saml/index.ts | 1 + src/utils/saml/mask.ts | 77 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/utils/__tests__/saml-mask.test.ts create mode 100644 src/utils/saml/mask.ts diff --git a/src/utils/__tests__/saml-mask.test.ts b/src/utils/__tests__/saml-mask.test.ts new file mode 100644 index 00000000..d733d77d --- /dev/null +++ b/src/utils/__tests__/saml-mask.test.ts @@ -0,0 +1,109 @@ +// @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 }; + } +} From fa38772d3fc0b5c8cca383f2168476ca7e46f492 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:50:05 +0000 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20SAML=20=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=81=AB=20=E7=94=9F=20XML=20/=20=E3=83=9E=E3=82=B9?= =?UTF-8?q?=E3=82=AF=20XML=20=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=83=88=E3=82=B0?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- src/components/tools/SamlDecoder.tsx | 41 +++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/components/tools/SamlDecoder.tsx b/src/components/tools/SamlDecoder.tsx index c2aa1929..161b04aa 100644 --- a/src/components/tools/SamlDecoder.tsx +++ b/src/components/tools/SamlDecoder.tsx @@ -5,6 +5,7 @@ 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, @@ -12,6 +13,7 @@ import { runLogoutRequestChecks, runLogoutResponseChecks, formatXml, + maskSamlXml, type CheckItem, type DecodedInput, type SamlAssertion, @@ -26,6 +28,8 @@ const BINDING_LABEL: Record = { 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'); @@ -222,6 +226,7 @@ interface ParsedNg { 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; @@ -247,6 +252,9 @@ export function SamlDecoderTool() { }, [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 (
@@ -389,16 +397,40 @@ export function SamlDecoderTool() { ))} - {/* 生 XML */} + {/* 生 XML / マスク XML */}
整形済み XML(簡易整形)
-
- +
+ + options={[ + { value: 'raw', label: '生 XML' }, + { value: 'masked', label: 'マスク XML(共有用)' }, + ]} + value={xmlView} + onChange={setXmlView} + ariaLabel="XML 表示モード" + size="sm" + layout="wrap" + /> +
-
{prettyXml}
+ {xmlView === 'masked' && masked && ( +
+ + {masked.piiCount + masked.secretCount > 0 + ? `PII ${masked.piiCount} 件・機密 ${masked.secretCount} 件をマスク` + : 'マスク対象なし'} + +

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

+
+ )} +
{displayedXml}

簡易整形のため、タグ間に混在するテキスト(mixed content)は表示されない場合があります。 @@ -420,6 +452,7 @@ export function SamlDecoderTool() { onClick={() => { setInput(''); setSpEntityId(''); + setXmlView('raw'); }} />

From 36cc64c0f7db7622af48ea294429619f370ff785 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:52:36 +0000 Subject: [PATCH 6/8] =?UTF-8?q?test:=20SAML=20=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=81=AE=E3=83=9E=E3=82=B9=E3=82=AF=20XML=20=E3=83=88?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=81=AE=20E2E=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- tests/e2e/saml-decoder.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/e2e/saml-decoder.spec.ts b/tests/e2e/saml-decoder.spec.ts index b0e4fedd..9bed6d65 100644 --- a/tests/e2e/saml-decoder.spec.ts +++ b/tests/e2e/saml-decoder.spec.ts @@ -163,4 +163,25 @@ test.describe('SAMLデコーダ', () => { 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(); + }); }); From 465c86fb9b61d58ea1c79858864fd47602afd291 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:54:57 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20SAML=20=E3=83=87=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E5=85=B1=E6=9C=89=E7=94=A8=E3=83=9E=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=81=AE=E4=BB=95=E7=B5=84=E3=81=BF=E3=83=BB?= =?UTF-8?q?=E6=B1=BA=E5=AE=9A=E3=82=92=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- SPEC.md | 7 ++++--- docs/decisions.md | 33 +++++++++++++++++++++++++++++++++ docs/tools.md | 2 ++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index fd8f6040..f5841112 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1397,9 +1397,9 @@ SQL のプレースホルダにJSON形式のパラメータを埋め込み、人 - 適用した変換ステップの表示 - Response/AuthnRequest のサマリと定番チェックリスト(Response のみ) - Assertion ごとの構造表示(NameID・属性テーブル・Conditions・AuthnStatement・SubjectConfirmationData) -- 整形済み生 XML の折りたたみ表示 + `CopyButton` +- 整形済み生 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 整形 / `index.ts`)/ `src/components/tools/SamlDecoder.tsx` / `src/pages/tools/saml-decoder.astro` +**モジュール構成:** `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 展開)。 @@ -1408,8 +1408,9 @@ SQL のプレースホルダにJSON形式のパラメータを埋め込み、人 - XMLDSig 署名検証・`EncryptedAssertion` の復号・LogoutRequest/LogoutResponse 等の他メッセージ型は非対応(署名・暗号化は存在の有無のみ表示) - ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない - 全処理はブラウザ内で完結し、入力(Assertion に含まれる PII を含む)を外部サーバーに送信しない +- 「共有用マスク XML」は構造上の PII フィールドと `secret-scrubber` の既知パターンの除去であり、完全な匿名化を保証しない(共有前の目視確認が必要) -**スコープ外(v1):** XMLDSig 署名検証(C14N 実装)・EncryptedAssertion 復号・LogoutRequest/LogoutResponse 等の他メッセージ型・共有用マスク出力(secret-scrubber 連携) +**スコープ外(v1):** XMLDSig 署名検証(C14N 実装)・EncryptedAssertion 復号・LogoutRequest/LogoutResponse 等の他メッセージ型 --- diff --git a/docs/decisions.md b/docs/decisions.md index 2bad03fb..3dac8698 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -4663,3 +4663,36 @@ XMLDSig 署名検証は C14N(正規化)の実装コストが大きく、初 - ✅ 追加依存は `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/tools.md b/docs/tools.md index a9a1b820..baf5e259 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -378,6 +378,7 @@ JWT を `.` で 3 分割し、Header・Payload を base64url デコードして - 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 @@ -390,6 +391,7 @@ JWT を `.` で 3 分割し、Header・Payload を base64url デコードして - ブラウザの `DOMParser` は外部エンティティを解決しないため XXE は発生しない - 全処理はブラウザ内で完結し、入力(Assertion に含まれる氏名・メール等の PII を含む)は外部に送信しない - deflate 展開後のサイズが 32MB を超える入力はエラーにする(zip bomb 対策) +- 「共有用マスク XML」は構造上の PII フィールド(NameID・AttributeValue)と `secret-scrubber` の既知パターンの除去であり、完全な匿名化を保証するものではない。共有前に必ず目視で確認すること ## 変換・解析 From 7e618d63bcd432fbf95ec760cf66ef655328f0de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 08:35:48 +0000 Subject: [PATCH 8/8] =?UTF-8?q?test:=20LogoutRequest=20=E3=83=9E=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=AE=E9=99=BD=E6=80=A7=E5=AF=BE=E7=85=A7=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E5=9C=A8=E5=80=A4=E3=81=A7=E6=A4=9C=E8=A8=BC=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フィクスチャに存在しない taro@example.com での not.toContain は空振り していたため、実在値 taro.yamada@example.com とトークン出現で検証する 真の陽性対照に修正(レビュー指摘・test-gates 準拠)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzXQvK6mSGKEtAHRki75Ds --- src/utils/__tests__/saml-mask.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/saml-mask.test.ts b/src/utils/__tests__/saml-mask.test.ts index d733d77d..5967cfad 100644 --- a/src/utils/__tests__/saml-mask.test.ts +++ b/src/utils/__tests__/saml-mask.test.ts @@ -59,7 +59,9 @@ describe('maskSamlXml: フェーズ1 構造ベースマスク(陽性対照)' it('LogoutRequest の NameID もマスクされる', () => { const { xml, piiCount } = maskSamlXml(LOGOUT_REQUEST_XML); - expect(xml).not.toContain('taro@example.com'); + // フィクスチャの NameID 実在値で検証する(空振り防止・陽性対照) + expect(xml).not.toContain('taro.yamada@example.com'); + expect(xml).toContain('[REDACTED:PII_'); expect(piiCount).toBeGreaterThanOrEqual(1); }); });