fix(security): quoted local-part email mask — strip delimiter quotes, split on last @ (DC-109) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-23 07:28:10 -07:00
parent c429b8fdd7
commit 9322831f1b
3 changed files with 50 additions and 3 deletions
@@ -242,7 +242,9 @@ describe('DC-095 round 2: adversarial judge findings', () => {
const out = consoleOut();
expect(out).not.toContain('john doe');
expect(out).not.toContain('"john doe"@example.com');
expect(out).toContain('****@example.com');
// DC-109: delimiter quotes are syntax, not PII — strip, never re-emit.
expect(out).toContain('jo****@example.com'); // 2 REAL local chars, canonical shape
expect(out).not.toMatch(/["']j\*{4}/); // old bug: stray quote among the 2 chars
});
test('class instance enumerable email prop masked, prototype preserved', () => {
@@ -177,3 +177,37 @@ describe('DC-098 redact-log-pii.js end-to-end', () => {
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
});
});
+13 -2
View File
@@ -85,9 +85,20 @@ function formatTime() {
const EMAIL_RE = /(?:[A-Za-z0-9._%+-]{1,64}|"[^"\n\\]{1,64}")@[A-Za-z0-9.-]{0,253}\.[A-Za-z]{2,24}/g;
function maskEmailAddress(addr) {
const at = addr.indexOf('@');
const local = addr.slice(0, at);
// addr is always a full EMAIL_RE match. Quoted local-parts (RFC 5322) match
// WITH their delimiter quotes and may contain '@' inside the quotes, so
// split on the LAST '@' (the real domain boundary), never the first.
// The quotes are syntax, not PII: strip them before masking and never
// re-emit them — slice(0, 2) of '"john doe"@…' used to leave a stray
// unbalanced quote in the output that could glue onto later text and
// re-match EMAIL_RE on a second pass (DC-109).
const at = addr.lastIndexOf('@');
let local = addr.slice(0, at);
const domain = addr.slice(at);
if (local.length >= 2 && local.startsWith('"') && local.endsWith('"')) {
local = local.slice(1, -1);
}
if (local.length === 0) return '****' + domain;
if (local.length <= 2) return local[0] + '****' + domain;
return local.slice(0, 2) + '****' + domain;
}