#!/usr/bin/env node /** * DC-098 — One-shot PII redaction for pre-DC-095 log files. * * DC-095 masked email PII at every log sink, but files written BEFORE that * change still hold raw addresses on disk (e.g. error.log.1 line ~48136: * POST /auth/login context with "email":"user@domain"). This script rewrites * such files in place, applying the SAME canonical mask the live logger uses * (sa****@example.com), so historical and new lines show one consistent shape. * * Design constraints (judge-facing): * - Reuses the canonical EMAIL_RE + maskEmailAddress from src/utils/logging.js * — no second regex to drift. (EMAIL_RE is /g: never reuse a /g regex across * .test/.exec calls; here we only .replace() which resets lastIndex.) * - Atomic rewrite: write sibling temp file in the same directory, fsync, then * rename() over the original. A crash mid-redaction can never leave a * half-redacted file behind. * - No PII backup by default: the point of this pass is to REMOVE raw PII from * disk. Backups would silently reintroduce the leak we are fixing. * --keep-raw exists for operators who explicitly want a copy. * - Idempotent: the canonical mask output cannot re-match EMAIL_RE (stars and * quotes are outside the local-part class), so re-running is a no-op. * - Read-only when nothing matches (byte-identical content is never rewritten, * mtime preserved) — safe to point at a whole directory. * - Whole-file read + write. These logs are rotation-bounded (error.log.1 is * ~5 MB); buffering is the simplest correct approach and keeps the atomic * single-rename guarantee. Not for unbounded/streaming files. * * Usage: * node scripts/redact-log-pii.js [--dry-run] [--keep-raw] [...] * --dry-run report what would change, touch nothing * --keep-raw alongside the redacted file, keep .raw- * (WARNING: this preserves the PII you are trying to remove) * * Exit codes: 0 = success (incl. "nothing to do"), 1 = usage/IO error, * 2 = redaction ran but raw addresses remain (must not happen — * EMAIL_RE is total over its own match set). */ 'use strict'; const fs = require('fs'); const path = require('path'); const { EMAIL_RE, maskEmailAddress } = require('../src/utils/logging'); const argv = process.argv.slice(2); const DRY_RUN = argv.includes('--dry-run'); const KEEP_RAW = argv.includes('--keep-raw'); const targets = argv.filter((a) => !a.startsWith('--')); if (targets.length === 0) { console.error( 'Usage: node scripts/redact-log-pii.js [--dry-run] [--keep-raw] [...]' ); process.exit(1); } // Directories this script will never touch, even when handed a directory. const SKIP_NAMES = new Set([ 'node_modules', '.git', 'coverage', '__tests__', 'dist', 'build', ]); // Log-line size guard: readline-style splitting is unbounded per line; a // pathological single-line file is instead processed as one segment. This is // only a memory guard, not a correctness limit — segments are redacted with // the same total function. function redactString(s, stats) { if (typeof s !== 'string' || !s.includes('@')) return s; // .replace() with a /g regex always starts at index 0 (resets lastIndex), // so sharing EMAIL_RE here is safe. const out = s.replace(EMAIL_RE, (addr) => { stats.addresses += 1; return maskEmailAddress(addr); }); if (out !== s) stats.lines += 1; return out; } function redactFile(filePath, dryRun, keepRaw, report) { const stat = fs.lstatSync(filePath); if (!stat.isFile()) { report.skipped.push(`${filePath} (not a regular file)`); return; } const raw = fs.readFileSync(filePath, 'utf8'); const stats = { addresses: 0, lines: 0 }; const out = redactString(raw, stats); if (out === raw) { report.clean.push(filePath); return; // byte-identical → never rewrite (preserves mtime, inode) } if (dryRun) { report.wouldRedact.push({ file: filePath, ...stats }); return; } if (keepRaw) { fs.copyFileSync(filePath, `${filePath}.raw-${Math.floor(Date.now() / 1000)}`); } // Atomic rewrite: same-directory temp + fsync + rename. const tmp = path.join( path.dirname(filePath), `.${path.basename(filePath)}.redact-${process.pid}` ); const fd = fs.openSync(tmp, 'wx', stat.mode); try { fs.writeSync(fd, out, null, 'utf8'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(tmp, filePath); report.redacted.push({ file: filePath, ...stats }); } function walk(target, report) { let st; try { st = fs.lstatSync(target); } catch (e) { report.errors.push(`${target}: ${e.message}`); return; } if (st.isDirectory()) { for (const ent of fs.readdirSync(target, { withFileTypes: true })) { if (SKIP_NAMES.has(ent.name)) continue; walk(path.join(target, ent.name), report); } } else { try { redactFile(target, DRY_RUN, KEEP_RAW, report); } catch (e) { report.errors.push(`${target}: ${e.message}`); } } } const report = { clean: [], redacted: [], wouldRedact: [], skipped: [], errors: [] }; for (const t of targets) walk(t, report); if (report.errors.length > 0) { for (const e of report.errors) console.error(`error: ${e}`); process.exit(1); } for (const f of report.clean) console.log(`clean (nothing to redact): ${f}`); for (const r of report.wouldRedact) console.log(`would redact: ${r.file} (${r.addresses} addresses in ${r.lines} segments)`); for (const r of report.redacted) console.log(`redacted: ${r.file} (${r.addresses} addresses in ${r.lines} segments)`); // Post-verify: after an actual run, no raw address may remain in any file we // redacted. This is a belt-and-braces check — mask output cannot re-match. if (!DRY_RUN) { let leaked = 0; for (const r of report.redacted) { const content = fs.readFileSync(r.file, 'utf8'); const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags); if (re.test(content)) { console.error(`POST-VERIFY FAIL: raw addresses remain in ${r.file}`); leaked += 1; } } if (leaked > 0) process.exit(2); } console.log('done.');