Files
dpsapi/e2e/harness/secrets.ts
T
bryanpedini fe0cd4bf6d test(e2e): add playwright e2e harness
Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
2026-10-06 10:52:25 +02:00

143 lines
5.2 KiB
TypeScript

// 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:<token_id>]` 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 = /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g;
/** The identifier the application logs instead of a token: first 8 hex chars of SHA-256. */
export function tokenId(token: string): string {
return crypto.createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 8);
}
export function redactionFor(secret: string): string {
return `[REDACTED:${tokenId(secret)}]`;
}
/**
* Replaces each secret (longest first) and, unless disabled, every token-shaped
* 43-character run, with a redaction marker.
*/
export function redactText(text: string, secrets: Iterable<string>, { 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<T>(value: T, secrets: Iterable<string>): 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<string, unknown> = {};
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<string, string>(); // 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<T>(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<string>();
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];
}