-
-
+
+
+ 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');
}}
/>
diff --git a/src/utils/__tests__/saml-mask.test.ts b/src/utils/__tests__/saml-mask.test.ts
new file mode 100644
index 00000000..5967cfad
--- /dev/null
+++ b/src/utils/__tests__/saml-mask.test.ts
@@ -0,0 +1,111 @@
+// @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(/>dev);
+ expect(xml).not.toMatch(/>admin);
+ expect(xml).toContain('Name="groups"');
+ });
+
+ it('同一値(NameID メール = mail 属性値)は同一トークンになる(相関)', () => {
+ 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(' = {
+ 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
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 {
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();
+ });
});