// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Secret registry and redaction. * * Every token created during a test is registered (automatically for tokens printed by * `token new` / `token list` through the harness CLI runners). Registered tokens are: * - replaced by `[REDACTED:]` in every artifact the harness attaches; * - persisted to a per-test file read by the redacting reporter, which scrubs error * messages, stdout/stderr and text attachments of the final report; * - searched for in captured logs (`assertNoSecretsIn`), automatically at teardown. */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { expect } from '@playwright/test'; /** Token format produced by `dpsapi token new`: 32 random bytes, base64url, no padding. */ export const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; /** A token-shaped run of characters not adjacent to other token characters. */ export const TOKEN_CANDIDATE_RE = /(?, { patterns = true }: { patterns?: boolean } = {}): string { let out = text; const list = [...new Set(secrets)].filter((s) => s.length > 0).sort((a, b) => b.length - a.length); for (const s of list) { if (out.includes(s)) out = out.split(s).join(redactionFor(s)); } if (patterns) out = out.replace(TOKEN_CANDIDATE_RE, (m) => redactionFor(m)); return out; } /** Recursively redacts every string inside a JSON-like value (BigInts become decimal strings, so the result always serializes). */ export function redactValue(value: T, secrets: Iterable): T { const list = [...secrets]; const walk = (v: unknown): unknown => { if (typeof v === 'string') return redactText(v, list); if (typeof v === 'bigint') return v.toString(); // JSON has no BigInt: keep the value, as text if (Array.isArray(v)) return v.map(walk); if (Buffer.isBuffer(v)) return redactText(v.toString('utf8'), list); if (v && typeof v === 'object') { const o: Record = {}; for (const [k, x] of Object.entries(v)) o[k] = walk(x); return o; } return v; }; return walk(value) as T; } export class SecretRegistry { private readonly secrets = new Map(); // secret -> label constructor(private readonly persistFile?: string) {} /** Registers a secret (idempotent). Returns it for chaining. */ register(secret: string, label = 'token'): string { if (!secret || this.secrets.has(secret)) return secret; this.secrets.set(secret, label); if (this.persistFile) { fs.mkdirSync(path.dirname(this.persistFile), { recursive: true, mode: 0o700 }); fs.appendFileSync(this.persistFile, JSON.stringify(secret) + '\n', { mode: 0o600 }); } return secret; } /** Registers every token-shaped string found in text (e.g. `token list` output). */ registerAllIn(text: string, label = 'token'): string[] { const found = text.match(TOKEN_CANDIDATE_RE) ?? []; for (const t of found) this.register(t, label); return found; } has(secret: string): boolean { return this.secrets.has(secret); } all(): string[] { return [...this.secrets.keys()]; } get size(): number { return this.secrets.size; } redact(text: string): string { return redactText(text, this.secrets.keys()); } redactValue(value: T): T { return redactValue(value, this.secrets.keys()); } /** Secrets (redacted labels) found verbatim in text. */ findIn(text: string | Buffer): string[] { const s = typeof text === 'string' ? text : text.toString('latin1'); const b = typeof text === 'string' ? undefined : text; return this.all() .filter((secret) => (b ? b.includes(Buffer.from(secret, 'utf8')) : s.includes(secret))) .map((secret) => redactionFor(secret)); } /** Fails the test if any registered secret occurs in text. `where` names the source for the message. */ assertNoSecretsIn(text: string | Buffer, where = 'text'): void { const leaked = this.findIn(text); expect(leaked, `registered token secret(s) found in ${where}`).toEqual([]); } } /** Reads every secret persisted by workers for this run (used by the reporter). */ export function loadPersistedSecrets(dir: string): string[] { const out = new Set(); let files: string[] = []; try { files = fs.readdirSync(dir); } catch { return []; } for (const f of files) { try { for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) { if (line.trim()) out.add(JSON.parse(line) as string); } } catch { /* partially written line: ignore */ } } return [...out]; }