From 454c53ca55a638f5c5db4d6fcb9d02661bde1733 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 16:25:22 +0000 Subject: [PATCH] Withhold an address wherever it is written, and never from cache The previous fix withheld an address in the preamble and shipped. 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, the second went out to every signed-out reader, and production still served it after the deploy. That was a fix shaped like the example rather than like the problem. A section body is not a special case to enumerate: 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 appears. Every line is checked now. The check is an unconditional `replace` rather than `test` then `replace`. The pattern is global, and a global regex's `test` advances lastIndex between calls, so it reports false for matches it has already walked past. That drops roughly every other address in a run of them while a single-line unit test passes, which is worse than not redacting at all because it looks fixed. The candidate directory leaked the same address a second way. `parsed` is a cache written on save, so correcting the parser does nothing for rows already in the table, and the live listing kept its old headline -- markup, address and all -- out of correct code reading a stale value. A backfill would repair those rows without stopping the next one: any resume saved by an older build, restored from a backup, or written straight into the column arrives the same way. The headline is one line of text on a public page, so it is re-checked on the way out, where it cannot go stale. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016DEUXec5um4FY3EbqfwtYm --- src/core/candidates.ts | 24 +++++++- src/markup/resume.ts | 43 +++++++++----- test/redaction.test.ts | 124 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 test/redaction.test.ts 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'); +});