diff --git a/src/core/candidates.ts b/src/core/candidates.ts index 74e5131..656f934 100644 --- a/src/core/candidates.ts +++ b/src/core/candidates.ts @@ -122,11 +122,33 @@ export function resumeForViewer( return { markdown, parsed: parseResume(markdown), redacted: true }; } +/** + * The headline, re-checked at render time rather than trusted. + * + * `parsed` is a cache written on save, so fixing the parser does not fix the + * rows already in the table. Cleaning the headline at parse time left the live + * directory still captioned `Operated by:** DevilX (
)` — correct code + * serving a stale value, which is the shape of every derived-cache bug. + * + * A backfill would repair those rows, but it would not stop the next one: any + * resume saved by an older build, restored from a backup, or written straight + * into the column arrives here the same way. This is a public directory page + * and the field is one line of text, so it is checked on the way out. Cheap, + * and it cannot go stale. + */ +function headlineOf(resume: Resume): string | null { + const headline = resume.parsed?.headline; + if (headline === null || headline === undefined) return null; + const cleaned = headline.replace(/\*\*|__/g, '').trim(); + if (cleaned === '') return null; + return /[^\s@]+@[^\s@]+\.[^\s@]+/.test(cleaned) ? null : cleaned; +} + export function toCandidateSummary(resume: Resume): CandidateSummary { return { slug: resume.publicSlug ?? '', name: nameOf(resume), - headline: resume.parsed?.headline ?? null, + headline: headlineOf(resume), location: locationOf(resume), skills: skillsOf(resume), // Capacity is a summary field for the same reason location is: it is what diff --git a/src/markup/resume.ts b/src/markup/resume.ts index 95c62e0..2994508 100644 --- a/src/markup/resume.ts +++ b/src/markup/resume.ts @@ -425,21 +425,34 @@ export function redactContactChannels(source: string): { markdown: string; redac continue; } - // A prose line, not a bullet — and this is where an address actually - // escaped. Only bullets that *parsed* as contact fields were withheld, - // so a resume opening `**Operated by:** X (someone@example.com)` served - // that address to every signed-out reader, and to the four download - // formats with it. That is the exact failure the redaction exists to - // prevent, arriving through the one line in the block nobody checked. - // - // The address is replaced in place rather than the line dropped: the - // sentence around it is the candidate's own description of who runs - // them, and it is still worth reading without the address in it. - if (EMAIL_IN_TEXT.test(line)) { - out.push(line.replace(EMAIL_IN_TEXT, CONTACT_WITHHELD)); - redacted = true; - continue; - } + } + + // An address anywhere in the document, not only in the contact block. + // + // Withholding only the preamble was a fix that fitted the example instead + // of the problem. The live resume that prompted it carried the address + // twice: once under the name, and once in a section body reading + // "Full-time autonomous. Contact: ". The first was withheld and + // the second went out to every signed-out reader, which is the same leak + // through a different line. + // + // A section body is not a special case to be enumerated. The rule this + // function exists to enforce is that a signed-out reader does not get a + // contact channel, and an address is a contact channel wherever it is + // written — so every line is checked. + // + // Replaced in place rather than dropped: the sentence around it is the + // candidate's own prose and still reads without the address in it. + // + // `replace` unconditionally, never `test` then `replace`: EMAIL_IN_TEXT is + // global, and a global regex's `test` advances `lastIndex` between calls, + // so it returns false on matches it has already walked past. That is how a + // redaction skips lines at random and still passes a one-line unit test. + const scrubbed = line.replace(EMAIL_IN_TEXT, CONTACT_WITHHELD); + if (scrubbed !== line) { + out.push(scrubbed); + redacted = true; + continue; } out.push(line); diff --git a/test/redaction.test.ts b/test/redaction.test.ts new file mode 100644 index 0000000..0bed8a1 --- /dev/null +++ b/test/redaction.test.ts @@ -0,0 +1,124 @@ +/** + * Withholding contact channels from signed-out readers. + * + * Both cases here were live on agenticjobs.work at the same time, in one real + * candidate's profile, and each defeated the redaction a different way: an + * address in a section body was never looked at, and the candidate directory + * served a cached headline that predated the parser fix. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { CONTACT_WITHHELD, redactContactChannels } from '../dist/markup/resume.js'; +import { toCandidateSummary } from '../dist/core/candidates.js'; + +const ADDRESS = 'bb8654838@example.com'; + +test('an address in a section body is withheld, not only one in the preamble', () => { + const source = [ + '# Athena', + '', + '**Operated by:** DevilX (' + ADDRESS + ')', + '', + '## Availability', + 'Full-time autonomous. Contact: ' + ADDRESS, + '', + ].join('\n'); + + const { markdown, redacted } = redactContactChannels(source); + + assert.equal(redacted, true); + assert.ok(!markdown.includes(ADDRESS), 'no copy of the address may survive'); + // Both occurrences, not just the first one anybody happened to look at. + assert.equal(markdown.split(CONTACT_WITHHELD).length - 1, 2); + assert.match(markdown, /Full-time autonomous/); +}); + +test('every address on a line is withheld, not just the first', () => { + const source = `# X\n\n## Contact\nReach a@example.com or b@example.com today\n`; + const { markdown } = redactContactChannels(source); + assert.ok(!markdown.includes('a@example.com')); + assert.ok(!markdown.includes('b@example.com')); +}); + +/** + * The global-regex trap. + * + * `EMAIL_IN_TEXT` is global, and a global regex's `test` advances `lastIndex` + * between calls — so `test` then `replace` skips matches it has already walked + * past. With many addresses on consecutive lines that drops roughly every + * other one, while a single-line unit test passes happily. + */ +test('consecutive lines each get redacted, with no lastIndex carry-over', () => { + const lines = ['# X', '', '## Contact']; + for (let i = 0; i < 10; i++) lines.push(`person${i}@example.com`); + const { markdown } = redactContactChannels(lines.join('\n')); + + for (let i = 0; i < 10; i++) { + assert.ok(!markdown.includes(`person${i}@example.com`), `person${i} survived`); + } +}); + +test('a resume with no address is returned untouched', () => { + const source = '# X\n\n- **Location**: Remote\n\n## Summary\nNothing to hide.\n'; + const { markdown, redacted } = redactContactChannels(source); + assert.equal(redacted, false); + assert.equal(markdown, source); +}); + +/** + * The stale cache. + * + * `parsed` is written on save, so a parser fix does not repair rows already in + * the table. The directory kept serving the old headline — with the markup and + * the address in it — from correct code reading a stale value. + */ +test('a cached headline holding an address is dropped at render time', () => { + const summary = toCandidateSummary({ + id: 'r1', + userId: 'u1', + slug: 'athena', + title: 'Athena', + markdown: '# Athena\n', + parsed: { + name: 'Athena', + headline: `Operated by:** DevilX (${ADDRESS})`, + contact: [], + sections: [], + markdown: '', + warnings: [], + }, + visibility: 'public', + publicSlug: 'athena', + sourceName: null, + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z', + } as never); + + assert.equal(summary.headline, null); +}); + +test('a cached headline with stray markup is cleaned, not dropped', () => { + const summary = toCandidateSummary({ + id: 'r1', + userId: 'u1', + slug: 'x', + title: 'X', + markdown: '# X\n', + parsed: { + name: 'X', + headline: 'Security agent** for hire', + contact: [], + sections: [], + markdown: '', + warnings: [], + }, + visibility: 'public', + publicSlug: 'x', + sourceName: null, + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z', + } as never); + + assert.equal(summary.headline, 'Security agent for hire'); +});