Skip to content

Commit fd33e1c

Browse files
committed
feat(lib): add structured session logger and secret redaction utility
Add two new library modules: 1. src/lib/redact.ts — Secret redaction utility - Built-in patterns for API keys (sk-*), bearer tokens, URL passwords, AWS keys, GitHub tokens, and hex secrets - Customizable via createRedactor() for project-specific patterns - Idempotent — safe to run multiple times on the same input - URL password redaction preserves URL structure 2. src/lib/session-logger.ts — Structured session logger - Correlation IDs (auto-minted UUID or explicit) for tracing - ISO 8601 timestamps on every entry - Four log levels: debug, info, warn, error - Two output formats: human-readable text and machine-readable JSONL - Automatic secret redaction on all messages and data values - Deep redaction of nested objects and arrays in structured data - Child loggers for scoping sub-operations within a session - Injectable writer, clock, and redaction patterns for testing - Runtime level adjustment via setLevel() Test coverage: 45 tests (23 redact + 22 logger) Zero new production dependencies
1 parent 08d732b commit fd33e1c

4 files changed

Lines changed: 895 additions & 0 deletions

File tree

src/lib/redact.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createRedactor, redactSecrets } from './redact.js';
3+
4+
describe('redactSecrets', () => {
5+
describe('API keys (sk- prefix)', () => {
6+
it('redacts TestSprite API keys', () => {
7+
const input = 'Using key sk-abcdef1234567890abcdef';
8+
expect(redactSecrets(input)).toBe('Using key [REDACTED:api-key]');
9+
});
10+
11+
it('redacts API keys embedded in config lines', () => {
12+
const input = 'api_key = sk-abcdef1234567890abcdef1234';
13+
expect(redactSecrets(input)).toBe('api_key = [REDACTED:api-key]');
14+
});
15+
16+
it('does not redact short sk- prefixes (< 20 chars)', () => {
17+
const input = 'sk-short';
18+
expect(redactSecrets(input)).toBe('sk-short');
19+
});
20+
21+
it('redacts multiple keys in one string', () => {
22+
const input = 'key1=sk-aaaabbbbccccddddeeeeffffgggg key2=sk-11112222333344445555666677778888';
23+
const result = redactSecrets(input);
24+
expect(result).not.toContain('sk-aaaa');
25+
expect(result).not.toContain('sk-1111');
26+
expect(result.match(/\[REDACTED:api-key\]/g)).toHaveLength(2);
27+
});
28+
});
29+
30+
describe('bearer tokens', () => {
31+
it('redacts Bearer authorization headers', () => {
32+
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature';
33+
expect(redactSecrets(input)).toBe('Authorization: [REDACTED:bearer-token]');
34+
});
35+
36+
it('is case-insensitive for bearer keyword', () => {
37+
const input = 'bearer abcdefghijklmnopqrstuvwxyz1234';
38+
expect(redactSecrets(input)).toBe('[REDACTED:bearer-token]');
39+
});
40+
});
41+
42+
describe('token patterns', () => {
43+
it('redacts token= assignments', () => {
44+
const input = 'token=abc123def456ghi789jkl012mno345';
45+
expect(redactSecrets(input)).toBe('[REDACTED:token]');
46+
});
47+
48+
it('redacts access_token values', () => {
49+
const input = 'access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"';
50+
expect(redactSecrets(input)).toBe('[REDACTED:token]');
51+
});
52+
53+
it('redacts refresh_token values', () => {
54+
const input = "refresh_token='abc123def456ghi789jkl012mno345pqr'";
55+
expect(redactSecrets(input)).toBe('[REDACTED:token]');
56+
});
57+
});
58+
59+
describe('URL passwords', () => {
60+
it('redacts password in URL while preserving structure', () => {
61+
const input = 'https://admin:supersecretpassword@db.example.com:5432/mydb';
62+
const result = redactSecrets(input);
63+
expect(result).toContain('admin');
64+
expect(result).toContain('[REDACTED:url-password]');
65+
expect(result).toContain('db.example.com');
66+
expect(result).not.toContain('supersecretpassword');
67+
});
68+
69+
it('redacts password in multiple URLs', () => {
70+
const input = 'db=postgres://user:pass123@host1 cache=redis://app:secret456@host2';
71+
const result = redactSecrets(input);
72+
expect(result).not.toContain('pass123');
73+
expect(result).not.toContain('secret456');
74+
});
75+
});
76+
77+
describe('AWS keys', () => {
78+
it('redacts AWS access key IDs', () => {
79+
const input = 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE';
80+
expect(redactSecrets(input)).toBe('AWS_ACCESS_KEY_ID=[REDACTED:aws-key]');
81+
});
82+
});
83+
84+
describe('GitHub tokens', () => {
85+
it('redacts ghp_ personal access tokens', () => {
86+
const input = 'GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn';
87+
expect(redactSecrets(input)).toBe('GITHUB_TOKEN=[REDACTED:github-token]');
88+
});
89+
90+
it('redacts gho_ OAuth tokens', () => {
91+
const input = 'token=gho_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn';
92+
// token= pattern may match first, but the key point is no token leaks
93+
const result = redactSecrets(input);
94+
expect(result).not.toContain('gho_ABCDEF');
95+
});
96+
});
97+
98+
describe('hex secrets', () => {
99+
it('redacts 32+ char hex strings', () => {
100+
const input = 'hash=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
101+
expect(redactSecrets(input)).toContain('[REDACTED:hex-secret]');
102+
});
103+
104+
it('does not redact short hex strings', () => {
105+
const input = 'short=abcdef123456';
106+
expect(redactSecrets(input)).toBe('short=abcdef123456');
107+
});
108+
});
109+
110+
describe('edge cases', () => {
111+
it('returns empty string unchanged', () => {
112+
expect(redactSecrets('')).toBe('');
113+
});
114+
115+
it('returns non-secret text unchanged', () => {
116+
const input = 'Running test suite for project checkout-flow...';
117+
expect(redactSecrets(input)).toBe(input);
118+
});
119+
120+
it('is idempotent — redacting twice gives the same result', () => {
121+
const input = 'key=sk-abcdef1234567890abcdef';
122+
const once = redactSecrets(input);
123+
const twice = redactSecrets(once);
124+
expect(twice).toBe(once);
125+
});
126+
127+
it('handles multiline input', () => {
128+
const input = [
129+
'config loaded',
130+
'api_key = sk-abcdef1234567890abcdef',
131+
'endpoint = https://api.testsprite.com',
132+
].join('\n');
133+
const result = redactSecrets(input);
134+
expect(result).toContain('[REDACTED:api-key]');
135+
expect(result).toContain('https://api.testsprite.com');
136+
});
137+
});
138+
});
139+
140+
describe('createRedactor', () => {
141+
it('applies custom patterns before defaults', () => {
142+
const redact = createRedactor([{ name: 'internal-key', regex: /\bINT-[A-Za-z0-9]{24,}\b/g }]);
143+
const input = 'key=INT-abcdefghijklmnopqrstuvwx';
144+
expect(redact(input)).toBe('key=[REDACTED:internal-key]');
145+
});
146+
147+
it('still applies default patterns', () => {
148+
const redact = createRedactor([]);
149+
const input = 'key=sk-abcdef1234567890abcdef';
150+
expect(redact(input)).toBe('key=[REDACTED:api-key]');
151+
});
152+
153+
it('custom patterns take priority over defaults', () => {
154+
const redact = createRedactor([{ name: 'custom-sk', regex: /\bsk-[A-Za-z0-9]+\b/g }]);
155+
// Custom runs first and catches the key
156+
const input = 'key=sk-abcdef1234567890abcdef';
157+
expect(redact(input)).toBe('key=[REDACTED:custom-sk]');
158+
});
159+
});

src/lib/redact.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Secret-redaction utility for CLI output, logs, and future TTS content.
3+
*
4+
* Scans strings for patterns that look like secrets — API keys, bearer
5+
* tokens, passwords in URLs, and generic high-entropy hex/base64 strings
6+
* — and replaces them with a fixed placeholder so they never appear in
7+
* terminal output, persisted logs, checkpoints, or spoken announcements.
8+
*
9+
* Design notes:
10+
* - Patterns are intentionally broad rather than precise. A false
11+
* positive (redacting a harmless string) is always preferable to a
12+
* false negative (leaking a real secret).
13+
* - The replacement placeholder includes the pattern name so an
14+
* operator can tell *which* kind of value was removed without
15+
* seeing the value itself.
16+
* - `redactSecrets` is idempotent — running it twice on already-
17+
* redacted text produces the same output.
18+
* - Custom patterns can be added via `createRedactor` for project-
19+
* specific secret formats.
20+
* - This module has zero external dependencies.
21+
*/
22+
23+
/** Default placeholder template. `{name}` is replaced by the pattern label. */
24+
const DEFAULT_PLACEHOLDER = '[REDACTED:{name}]';
25+
26+
/**
27+
* A named pattern that matches secret-shaped text.
28+
*
29+
* @param name Human-readable label included in the placeholder
30+
* (e.g. `"api-key"`, `"bearer-token"`).
31+
* @param regex Pattern to match. Must use the global flag (`g`) so
32+
* `String.replace` replaces every occurrence.
33+
*/
34+
export interface RedactionPattern {
35+
name: string;
36+
regex: RegExp;
37+
}
38+
39+
/**
40+
* Built-in patterns covering the most common secret shapes seen in
41+
* CLI / API environments. Ordered from most-specific to most-generic
42+
* so specific labels win when patterns overlap.
43+
*/
44+
export const DEFAULT_PATTERNS: readonly RedactionPattern[] = [
45+
// TestSprite API keys: `sk-` followed by 20+ alphanumeric chars
46+
{ name: 'api-key', regex: /\bsk-[A-Za-z0-9]{20,}\b/g },
47+
48+
// Bearer / token authorization header values
49+
{ name: 'bearer-token', regex: /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}\b/g },
50+
51+
// Generic `token=<value>` or `token: <value>` in logs/config
52+
{
53+
name: 'token',
54+
regex: /\b(?:token|access_token|refresh_token)[=:]\s*['"]?[A-Za-z0-9._~+/=-]{16,}['"]?/gi,
55+
},
56+
57+
// Password in URLs: `://user:password@host`
58+
{ name: 'url-password', regex: /:\/\/[^@/\s]+:([^@/\s]+)@/g },
59+
60+
// AWS-style keys: AKIA followed by 16 uppercase alphanumeric
61+
{ name: 'aws-key', regex: /\bAKIA[A-Z0-9]{16}\b/g },
62+
63+
// GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ prefix
64+
{ name: 'github-token', regex: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
65+
66+
// Generic hex secrets: 32+ consecutive hex chars (SHA-256, API keys, etc.)
67+
// Only matches lowercase or mixed-case to reduce false positives on UUIDs.
68+
{ name: 'hex-secret', regex: /\b[0-9a-f]{32,}\b/g },
69+
];
70+
71+
/**
72+
* Redact secrets from a string using the default built-in patterns.
73+
*
74+
* @param input The string to scan and redact.
75+
* @returns A new string with every matched secret replaced by a
76+
* `[REDACTED:<pattern-name>]` placeholder.
77+
*
78+
* @example
79+
* redactSecrets('key=sk-abc123def456ghi789jkl012')
80+
* // → 'key=[REDACTED:api-key]'
81+
*
82+
* redactSecrets('Authorization: Bearer eyJhbGciOiJIUzI...')
83+
* // → 'Authorization: [REDACTED:bearer-token]'
84+
*/
85+
export function redactSecrets(input: string): string {
86+
return redactWithPatterns(input, DEFAULT_PATTERNS);
87+
}
88+
89+
/**
90+
* Create a custom redactor with additional project-specific patterns
91+
* prepended to the built-in defaults. Custom patterns take precedence
92+
* because they run first.
93+
*
94+
* @param extraPatterns Additional patterns to match before the defaults.
95+
* @returns A redaction function with the same signature as `redactSecrets`.
96+
*
97+
* @example
98+
* const redact = createRedactor([
99+
* { name: 'internal-key', regex: /\bINT-[A-Za-z0-9]{24}\b/g },
100+
* ]);
101+
* redact('token REDACTED key=INT-abc123...')
102+
*/
103+
export function createRedactor(
104+
extraPatterns: readonly RedactionPattern[],
105+
): (input: string) => string {
106+
const allPatterns = [...extraPatterns, ...DEFAULT_PATTERNS];
107+
return (input: string) => redactWithPatterns(input, allPatterns);
108+
}
109+
110+
/**
111+
* Internal: apply an ordered list of patterns to a string, replacing
112+
* every match with a labelled placeholder.
113+
*
114+
* Each pattern's regex is cloned via `new RegExp` so the shared global
115+
* regex's `lastIndex` is reset on every call — without this, consecutive
116+
* calls to `redactSecrets` on different strings would skip matches
117+
* because `RegExp.prototype[Symbol.replace]` with a `g` flag mutates
118+
* `lastIndex`.
119+
*/
120+
function redactWithPatterns(input: string, patterns: readonly RedactionPattern[]): string {
121+
let result = input;
122+
for (const pattern of patterns) {
123+
const placeholder = DEFAULT_PLACEHOLDER.replace('{name}', pattern.name);
124+
// Clone to reset lastIndex — global regexes are stateful.
125+
const freshRegex = new RegExp(pattern.regex.source, pattern.regex.flags);
126+
127+
if (pattern.name === 'url-password') {
128+
// Special handling: only redact the password capture group, not the
129+
// entire URL structure. Replace `://user:PASSWORD@` with `://user:[REDACTED]@`.
130+
result = result.replace(freshRegex, (match, password: string) =>
131+
match.replace(password, placeholder),
132+
);
133+
} else {
134+
result = result.replace(freshRegex, placeholder);
135+
}
136+
}
137+
return result;
138+
}

0 commit comments

Comments
 (0)