Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/core/candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<address>)` — 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
Expand Down
43 changes: 28 additions & 15 deletions src/markup/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <address>". 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);
Expand Down
124 changes: 124 additions & 0 deletions test/redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading