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.
143 lines
5.2 KiB
TypeScript
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];
|
|
}
|