diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 27c1d41..24b1adb 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -11,14 +11,15 @@ Phase 0 complete. Phase 1 in progress. ## Current Phase -Phase 0 - Repository Setup +Phase 1 - Core Utilities ## Completed Tasks - Task 0.1 — Create repository skeleton ✅ - Task 0.2 — package.json with dependencies ✅ - Task 1.1 — Configuration loader ✅ +- Task 1.2 — Secret redaction utility (`src/utils/redact.js`) ✅ ## Next Task -Task 1.2 — Secret redaction utility (`src/utils/redact.js`) +Task 1.3 — Context budget utility (`src/utils/context-budget.js`) diff --git a/TASKS.md b/TASKS.md index 4d40cb6..068ea3c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -60,7 +60,7 @@ Requirements: - Replace detected secrets with `[REDACTED]`. - Return the redacted string. -Status: ⏳ Pending +Status: ✅ Complete ### Task 1.3 - Context budget utility diff --git a/src/utils/redact.js b/src/utils/redact.js index 3d459a5..c1a2c86 100644 --- a/src/utils/redact.js +++ b/src/utils/redact.js @@ -1 +1,29 @@ // Secret redaction utility. +// Detects common secret patterns and replaces values with [REDACTED]. + +export function redact(input) { + const str = typeof input === 'string' ? input : String(input); + let out = str; + + // PEM blocks (multi-line): replace content between BEGIN/END markers + out = out.replace( + /(-----BEGIN[\s\S]*?PRIVATE KEY-----)[\s\S]*?(-----END[\s\S]*?PRIVATE KEY-----)/, + '$1\n[REDACTED]\n$2' + ); + + // Bearer tokens: preserve "Bearer " prefix + out = out.replace(/(Bearer\s+)(["']?\w[\w.-]+["']?)/g, '$1[REDACTED]'); + + // Git credentials: https://user:password@host → https://user:[REDACTED]@host + out = out.replace(/(https:\/\/\w+:)\S+(?=@)/, '$1[REDACTED]'); + + // Key=value secrets — quoted (double or single): capture up to matching close quote + const Q_PATTERN = /((?:OPENAI_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD)[_\w]*)[ ]*=[ ]*(["'])(.*?)\2/gi; + out = out.replace(Q_PATTERN, '$1=$2[REDACTED]$2'); + + // Key=value secrets — unquoted: capture non-whitespace value + const U_PATTERN = /((?:OPENAI_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD)[_\w]*)[ ]*=(?![\0`"'])([\S]+)/gi; + out = out.replace(U_PATTERN, '$1=[REDACTED]'); + + return out; +} diff --git a/test/utils/redact.test.js b/test/utils/redact.test.js new file mode 100644 index 0000000..329d424 --- /dev/null +++ b/test/utils/redact.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { redact } from '../../src/utils/redact.js'; + +describe('redact', () => { + it('redacts OPENAI_API_KEY (quoted)', () => { + const input = 'OPENAI_API_KEY="abc123"'; + const output = redact(input); + expect(output).toBe('OPENAI_API_KEY="[REDACTED]"'); + }); + + it('redacts API_KEY (unquoted)', () => { + const input = 'API_KEY=secretvalue'; + const output = redact(input); + expect(output).toBe('API_KEY=[REDACTED]'); + }); + + it('redacts TOKEN with AUTH_ prefix', () => { + const input = 'AUTH_TOKEN="my-token"'; + const output = redact(input); + expect(output).toBe('AUTH_TOKEN="[REDACTED]"'); + }); + + it('redacts SECRET value (single quotes preserved)', () => { + const input = "SECRET='s3cret'"; + const output = redact(input); + expect(output).toBe("SECRET='[REDACTED]'"); + }); + + it('redacts PASSWORD (unquoted)', () => { + const input = 'PASSWORD=plaintext'; + const output = redact(input); + expect(output).toBe('PASSWORD=[REDACTED]'); + }); + + it('redacts Bearer token', () => { + const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9'; + const output = redact(input); + expect(output).toBe('Authorization: Bearer [REDACTED]'); + }); + + it('redacts PEM private key block (multi-line)', () => { + const input = '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----'; + const output = redact(input); + expect(output).toBe('-----BEGIN RSA PRIVATE KEY-----\n[REDACTED]\n-----END RSA PRIVATE KEY-----'); + }); + + it('redacts Git URL credentials (user:password format)', () => { + const input = 'https://deploy:user:p4ss@github.com/org/repo.git'; + const output = redact(input); + expect(output).toBe('https://deploy:[REDACTED]@github.com/org/repo.git'); + }); + + it('handles multiple secrets in one string', () => { + const input = 'OPENAI_API_KEY="abc" TOKEN=\'def\' PASSWORD=ghi'; + const output = redact(input); + expect(output).toBe('OPENAI_API_KEY="[REDACTED]" TOKEN=\'[REDACTED]\' PASSWORD=[REDACTED]'); + }); + + it('returns unchanged input when no secrets found', () => { + const input = 'const greeting = "Hello, world!";\nexport default App;'; + expect(redact(input)).toBe(input); + }); + + it('handles empty string input', () => { + expect(redact('')).toBe(''); + }); +});