- scripts/redact-log-pii.js: atomic in-place email redaction reusing the canonical DC-095 masker (no second regex), dry-run/keep-raw modes, dir walk with skip-set, post-verify (exit 2 if raw addresses remain). - src/utils/logging.js: export EMAIL_RE/maskEmailAddress/maskEmailsInString (additive; no logger behavior change). - __tests__/redact-log-pii.test.js: 11 tests (shape, idempotence, clean-untouched, dry-run, keep-raw, skip-set, passthroughs, exit codes). - Judge: GLM-5.3 cold read, round-1 A/ship (deleg_4e684a18), URN urn:ump:7y2q5upoht7xq2mhlum764y2h36qpsgufyqsgx4cbijfpmfpmrcq. - Suite: 120/120 suites, 2751 tests green.
180 lines
6.5 KiB
JavaScript
180 lines
6.5 KiB
JavaScript
/**
|
|
* 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 <file>.raw-<epoch> 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 <file>.raw-<epoch> 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:');
|
|
});
|
|
});
|