DC-095 masks emails at every live log sink; the rotated archive was the remaining belt-and-braces gap — any future sink that forgets masking would persist raw PII in error.log.1 for a full rotation cycle. appendErrorLog now scrubs the freshly rotated archive with the SAME canonical mask (sa****@example.com) via atomic rewrite (sibling .redact-<pid> temp, wx open preserving mode else 0600, fsync, rename). Scrub failure is caught and logged; the new error line is still appended. Stale crash-leftover .redact-<pid> temps are swept best-effort on every rotation. Also documents scripts/redact-log-pii.js usage in README (queue item e). Judge: GLM-5.3 cold read (deleg_e7cd7f8c), round-1 grade B ship — both nits addressed in-commit: stale-temp sweep (new 5th test pins it), quoted-local-part mask edge deferred as pre-existing DC-095 primitive. Tests: 122 suites / 2790 green (+5 DC-108 pins; was 2785 post-DC-107).
151 lines
6.2 KiB
JavaScript
151 lines
6.2 KiB
JavaScript
/**
|
|
* DC-108 — redact-on-rotate tests
|
|
*
|
|
* When error.log crosses MAX_ERROR_LOG_SIZE, the rotation renames it to
|
|
* error.log.1 and (new in DC-108) scrubs the archive with the canonical
|
|
* email mask. DC-095 masks at every live sink; this is the belt-and-braces
|
|
* backstop for any future sink that forgets.
|
|
*
|
|
* Covers:
|
|
* - rotation scrubs raw emails out of the archive (canonical sa****@ form)
|
|
* - already-clean archive is never rewritten (inode + mtime preserved)
|
|
* - scrub failure does NOT lose the new error line (append still runs)
|
|
* - archive mode is preserved across the atomic rewrite
|
|
* - no .redact-<pid> temp file is left behind on success
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const os = require('os');
|
|
|
|
// Isolated temp dir + env BEFORE the module capture (logging.test.js pattern)
|
|
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc108-rotate-test-'));
|
|
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
|
process.env.NODE_ENV = 'production'; // JSON output mode (stable, parseable)
|
|
|
|
const { log, ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE } = require('../src/utils/logging');
|
|
|
|
const ROTATED = ERROR_LOG_FILE + '.1';
|
|
const RAW_EMAIL = 'someone.example@example.com';
|
|
|
|
// Seed error.log past the rotation threshold. `extra` is appended raw to
|
|
// simulate pre-DC-095-style unmasked content (the backstop's threat model).
|
|
async function seedOversized(extra) {
|
|
const padding = 'x'.repeat(MAX_ERROR_LOG_SIZE + 64);
|
|
await fsp.writeFile(ERROR_LOG_FILE, padding + (extra || ''), 'utf8');
|
|
}
|
|
|
|
// log.error flushes to the file awaited; one call is one append+rotate.
|
|
async function triggerAppend() {
|
|
await log.error('dc108-test', 'rotation trigger', { seq: Math.random() });
|
|
}
|
|
|
|
afterAll(async () => {
|
|
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await fsp.writeFile(ERROR_LOG_FILE, '', 'utf8');
|
|
try { await fsp.rm(ROTATED, { force: true }); } catch (_) {}
|
|
// Sweep any stale temp files from failed assertions
|
|
for (const f of fs.readdirSync(TMP_DIR)) {
|
|
if (f.includes('.redact-')) await fsp.rm(path.join(TMP_DIR, f), { force: true });
|
|
}
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe('DC-108 redact-on-rotate', () => {
|
|
test('rotation scrubs raw emails from the archive', async () => {
|
|
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
await triggerAppend();
|
|
|
|
const arch = await fsp.readFile(ROTATED, 'utf8');
|
|
// Raw PII is gone; canonical masked form is present
|
|
expect(arch).not.toContain(RAW_EMAIL);
|
|
expect(arch).toContain('so****@example.com');
|
|
// New line landed in the fresh error.log
|
|
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
expect(fresh).toContain('rotation trigger');
|
|
// No temp residue
|
|
const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-'));
|
|
expect(leftovers).toEqual([]);
|
|
});
|
|
|
|
test('clean archive keeps the rename inode; PII archive is atomically rewritten', async () => {
|
|
// Clean case: rotation renames error.log → archive; scrub finds nothing
|
|
// to do → archive KEEPS the original error.log inode (rename, not rewrite).
|
|
await seedOversized('no PII here, fully clean\n');
|
|
const cleanInode = fs.statSync(ERROR_LOG_FILE).ino;
|
|
await triggerAppend();
|
|
expect(fs.statSync(ROTATED).ino).toBe(cleanInode);
|
|
const arch1 = await fsp.readFile(ROTATED, 'utf8');
|
|
expect(arch1).toContain('fully clean');
|
|
expect(arch1).not.toContain('****');
|
|
|
|
// PII case: scrub rewrites via temp+rename → archive inode DIFFERS from
|
|
// the pre-rotation error.log inode.
|
|
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
const piiInode = fs.statSync(ERROR_LOG_FILE).ino;
|
|
await triggerAppend();
|
|
expect(fs.statSync(ROTATED).ino).not.toBe(piiInode);
|
|
const arch2 = await fsp.readFile(ROTATED, 'utf8');
|
|
expect(arch2).toContain('so****@example.com');
|
|
expect(arch2).not.toContain(RAW_EMAIL);
|
|
});
|
|
|
|
test('scrub failure does not lose the new error line', async () => {
|
|
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Make ONLY the archive read fail — rotation itself must still succeed.
|
|
const realReadFile = fsp.readFile.bind(fsp);
|
|
const spy = jest.spyOn(fsp, 'readFile').mockImplementation(async (p, ...rest) => {
|
|
if (typeof p === 'string' && p === ROTATED) {
|
|
throw new Error('EACCES: permission denied, scrub boom');
|
|
}
|
|
return realReadFile(p, ...rest);
|
|
});
|
|
|
|
await triggerAppend();
|
|
|
|
// Scrub failure was contained + reported
|
|
expect(errSpy).toHaveBeenCalledWith(
|
|
'[logger] Failed to redact rotated error.log archive:',
|
|
expect.stringContaining('scrub boom')
|
|
);
|
|
// Rotation still committed and the new line was still appended
|
|
expect(fs.existsSync(ROTATED)).toBe(true);
|
|
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
expect(fresh).toContain('rotation trigger');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
test('archive file mode is preserved across the atomic rewrite', async () => {
|
|
await seedOversized(`user contact: ${RAW_EMAIL}\n`);
|
|
await fs.promises.chmod(ERROR_LOG_FILE, 0o640);
|
|
await triggerAppend();
|
|
|
|
const mode = fs.statSync(ROTATED).mode & 0o777;
|
|
expect(mode).toBe(0o640);
|
|
// And the rewrite actually happened (PII scrubbed)
|
|
const arch = await fsp.readFile(ROTATED, 'utf8');
|
|
expect(arch).not.toContain(RAW_EMAIL);
|
|
});
|
|
|
|
test('stale crash-leftover .redact-<pid> temps are swept on rotation', async () => {
|
|
// Simulate a prior hard crash: abandoned temp sibling still on disk
|
|
const stale = path.join(TMP_DIR, 'error.log.1.redact-999999');
|
|
await fsp.writeFile(stale, 'half-scrubbed partial write', 'utf8');
|
|
await seedOversized('clean rotation content\n');
|
|
await triggerAppend();
|
|
|
|
const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-'));
|
|
expect(leftovers).toEqual([]); // swept, archive + fresh log intact
|
|
expect(fs.existsSync(ROTATED)).toBe(true);
|
|
const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
expect(fresh).toContain('rotation trigger');
|
|
});
|
|
});
|