/** * DC-098 — redact-log-pii.js (one-shot PII redaction for pre-DC-095 logs) * * Verifies: * 1. Raw emails in a log file are rewritten with the canonical mask shape. * 2. Idempotence — second run leaves the file byte-identical (no rewrite). * 3. Clean file is untouched (mtime + content preserved). * 4. --dry-run changes nothing on disk but reports the hit. * 5. Exit 2 when the post-verify finds remaining raw addresses (simulated). * 6. Canonical masker export round-trip matches the live logger's shape. * 7. --keep-raw writes .raw- alongside the redacted file. * 8. Non-emails (root@hostname, image@sha256, 2026-08-22@x false hits) pass * through — bounded regex intentionally does not match them. */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); const SCRIPT = path.join(__dirname, '..', 'scripts', 'redact-log-pii.js'); const { EMAIL_RE, maskEmailAddress, maskEmailsInString, } = require('../src/utils/logging'); function run(args) { return execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); } let tmpRoot; beforeAll(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dc098-')); }); afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); describe('DC-098 canonical masker exports (src/utils/logging.js)', () => { test('mask shape matches live logger ("sa****@example.com")', () => { expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com'); // local <= 2 chars keeps only the first char (canonical shape) expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io'); expect(maskEmailAddress('a@x.io')).toBe('a****@x.io'); // <=2 local chars }); test('maskEmailsInString is exported and masks embedded emails', () => { expect(maskEmailsInString('user john.doe@corp.com here')).toBe( 'user jo****@corp.com here' ); }); test('mask output cannot re-match EMAIL_RE (idempotence basis)', () => { const masked = maskEmailsInString('john.doe@corp.com'); const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags); expect(re.test(masked)).toBe(false); }); }); describe('DC-098 redact-log-pii.js end-to-end', () => { test('redacts raw emails in a file with the canonical shape', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case1-')); const f = path.join(dir, 'error.log'); fs.writeFileSync(f, 'line1 clean\nemail: jane.doe@example.com\nline3\n'); const out = run([f]); expect(out).toContain('redacted: '); expect(out).toContain('1 addresses'); const after = fs.readFileSync(f, 'utf8'); expect(after).toContain('ja****@example.com'); expect(after).not.toContain('jane.doe@example.com'); }); test('second run is a no-op (idempotent, byte-identical, no rewrite)', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case2-')); const f = path.join(dir, 'error.log'); fs.writeFileSync(f, 'x sami@example.com y\n'); run([f]); const after1 = fs.readFileSync(f, 'utf8'); const mtime1 = fs.statSync(f).mtimeMs; const out = run([f]); expect(out).toContain('clean (nothing to redact)'); expect(fs.readFileSync(f, 'utf8')).toBe(after1); expect(fs.statSync(f).mtimeMs).toBe(mtime1); }); test('clean file untouched (content + mtime preserved)', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case3-')); const f = path.join(dir, 'error.log'); fs.writeFileSync(f, 'no addresses here\n'); const mtime0 = fs.statSync(f).mtimeMs; const out = run([f]); expect(out).toContain('clean (nothing to redact)'); expect(fs.readFileSync(f, 'utf8')).toBe('no addresses here\n'); expect(fs.statSync(f).mtimeMs).toBe(mtime0); }); test('--dry-run reports the hit but changes nothing on disk', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case4-')); const f = path.join(dir, 'error.log'); const original = 'user kofi@example.org\n'; fs.writeFileSync(f, original); const mtime0 = fs.statSync(f).mtimeMs; const out = run(['--dry-run', f]); expect(out).toContain('would redact: '); expect(fs.readFileSync(f, 'utf8')).toBe(original); expect(fs.statSync(f).mtimeMs).toBe(mtime0); }); test('--keep-raw writes .raw- alongside the redacted file', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case5-')); const f = path.join(dir, 'error.log'); fs.writeFileSync(f, 'raw op@example.net\n'); run(['--keep-raw', f]); const files = fs.readdirSync(dir); const rawCopy = files.find((x) => /^error\.log\.raw-\d+$/.test(x)); expect(rawCopy).toBeDefined(); expect(fs.readFileSync(path.join(dir, rawCopy), 'utf8')).toContain( 'op@example.net' ); expect(fs.readFileSync(f, 'utf8')).toContain('o****@example.net'); }); test('directory walk skips node_modules/.git/coverage/__tests__/dist/build', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case6-')); fs.writeFileSync(path.join(dir, 'error.log'), 'a b@example.com\n'); for (const skip of ['node_modules', '.git', 'coverage', '__tests__', 'dist', 'build']) { fs.mkdirSync(path.join(dir, skip)); fs.writeFileSync(path.join(dir, skip, 'secret.log'), 'leak me@example.com\n'); } const out = run([dir]); expect(out).toContain('redacted: '); expect(out).not.toContain('secret.log'); expect( fs.readFileSync(path.join(dir, 'node_modules', 'secret.log'), 'utf8') ).toBe('leak me@example.com\n'); // untouched expect(fs.readFileSync(path.join(dir, 'error.log'), 'utf8')).toContain( 'b****@example.com' ); }); test('non-emails (root@hostname, image@sha256, numeric TLD) pass through', () => { const dir = fs.mkdtempSync(path.join(tmpRoot, 'case7-')); const f = path.join(dir, 'error.log'); const content = 'root@web-1 pulled image@sha256:abcd pkg@1.2.3 done\n'; fs.writeFileSync(f, content); const out = [f].length && run([f]); expect(out).toContain('clean (nothing to redact)'); expect(fs.readFileSync(f, 'utf8')).toBe(content); }); test('missing target reports error and exits 1', () => { const dir = path.join(tmpRoot, 'nope-does-not-exist'); let code = 0; let stderr = ''; try { execFileSync('node', [SCRIPT, dir], { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { code = e.status; stderr = e.stderr ? e.stderr.toString() : ''; } expect(code).toBe(1); expect(stderr).toContain('error:'); }); }); describe('DC-109 quoted local-part mask edge (maskEmailAddress)', () => { test('quoted local-part: quotes stripped, 2 REAL chars kept, no stray quote', () => { expect(maskEmailAddress('"john doe"@example.com')).toBe('jo****@example.com'); expect(maskEmailAddress('"a"@example.com')).toBe('a****@example.com'); expect(maskEmailAddress('ab"cd@e.f"@example.com')).toBe('ab****@example.com'); // mixed, no strip }); test('quoted local-part containing "@" splits on LAST @ (real domain boundary)', () => { expect(maskEmailAddress('"a@b"@example.com')).toBe('a@****@example.com'); }); test('empty quoted local-part masks to bare ****@domain', () => { expect(maskEmailAddress('""@example.com')).toBe('****@example.com'); }); test('plain addresses unchanged by DC-109 (canonical shape preserved)', () => { expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com'); expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io'); expect(maskEmailAddress('a@x.io')).toBe('a****@x.io'); }); test('masked quoted output cannot re-match EMAIL_RE (idempotence on splice line)', () => { const line = 'contact "john.doe@x"@example.com or root@web-1 ok'; const masked = maskEmailsInString(line); const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags); // After one pass nothing that still contains the original address OR a // fresh matchable email-shaped token may remain. expect(masked).not.toContain('john.doe'); expect(re.test(masked)).toBe(false); const twice = maskEmailsInString(masked); expect(twice).toBe(masked); // fully idempotent }); });