fix(security): quoted local-part email mask — strip delimiter quotes, split on last @ (DC-109) [glm-grade=A]
This commit is contained in:
@@ -242,7 +242,9 @@ describe('DC-095 round 2: adversarial judge findings', () => {
|
|||||||
const out = consoleOut();
|
const out = consoleOut();
|
||||||
expect(out).not.toContain('john doe');
|
expect(out).not.toContain('john doe');
|
||||||
expect(out).not.toContain('"john doe"@example.com');
|
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', () => {
|
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:');
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
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) {
|
function maskEmailAddress(addr) {
|
||||||
const at = addr.indexOf('@');
|
// addr is always a full EMAIL_RE match. Quoted local-parts (RFC 5322) match
|
||||||
const local = addr.slice(0, at);
|
// 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);
|
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;
|
if (local.length <= 2) return local[0] + '****' + domain;
|
||||||
return local.slice(0, 2) + '****' + domain;
|
return local.slice(0, 2) + '****' + domain;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user