From 957f8a6dc09c1357e0f5ad209381a49be4ebc8f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 03:05:02 +0000 Subject: [PATCH] fix(reporter): escape pipes and newlines in Markdown table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Markdown reporter interpolated component names, versions, and CVE fields straight into table rows. SBOM-derived strings are attacker-influenced, so a package name containing `|` injected a phantom column and a newline started a new row — corrupting (or letting crafted input forge/hide entries in) the report. This matters because the README pitches the Markdown output for posting into PR comments. Add an escapeCell() helper that escapes `|` and flattens line breaks, and route every Markdown cell value through it. Output is unchanged for ordinary inputs; the empty/undefined fallback (—) is preserved. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FgX1cVFKBmvvypM4Bj8pdd --- src/__tests__/reporter.test.ts | 20 ++++++++++++++++++++ src/reporter.ts | 25 ++++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/__tests__/reporter.test.ts b/src/__tests__/reporter.test.ts index 7cb1553..cfc5a9d 100644 --- a/src/__tests__/reporter.test.ts +++ b/src/__tests__/reporter.test.ts @@ -37,4 +37,24 @@ describe('renderReport', () => { it('throws on unsupported format', () => { expect(() => renderReport(sampleReport, 'xml' as never)).toThrow(); }); + + it('escapes pipes and newlines in markdown cells so the table stays well-formed', () => { + const report: ChangeReport = { + added: [{ name: 'evil | pkg', version: '1.0', ecosystem: 'npm' }], + removed: [], + upgraded: [], + newCVEs: [{ id: 'CVE-2024-0001', affects: 'pkg:npm/a | b', severity: 'high', description: 'line1\nline2' }], + fixedCVEs: [], + summary: { totalAdded: 1, totalRemoved: 0, totalUpgraded: 0, totalNewCVEs: 1, totalFixedCVEs: 0 }, + }; + const out = renderReport(report, 'markdown'); + + // The pipe in the package name must be escaped, not interpreted as a column break. + expect(out).toContain('| evil \\| pkg | 1.0 | npm |'); + expect(out).toContain('| CVE-2024-0001 | high | pkg:npm/a \\| b |'); + + // Every body row under the Added table must keep its column count (4 leading bars: 3 cells). + const addedRow = out.split('\n').find(l => l.includes('evil'))!; + expect((addedRow.match(/(? 0) { lines.push('## \u2796 Removed Components', ''); lines.push('| Name | Version |'); lines.push('|------|---------|'); - for (const c of r.removed) lines.push(`| ${c.name} | ${c.version ?? '\u2014'} |`); + for (const c of r.removed) lines.push(`| ${escapeCell(c.name)} | ${escapeCell(c.version)} |`); lines.push(''); } if (r.upgraded.length > 0) { @@ -95,7 +110,7 @@ function renderMarkdown(r: ChangeReport): string { lines.push('| Name | From | To | Major? |'); lines.push('|------|------|----|--------|'); for (const u of r.upgraded) { - lines.push(`| ${u.component.name} | ${u.from} | ${u.to} | ${u.isMajorBump ? '\u26a0\ufe0f Yes' : 'No'} |`); + lines.push(`| ${escapeCell(u.component.name)} | ${escapeCell(u.from)} | ${escapeCell(u.to)} | ${u.isMajorBump ? '\u26a0\ufe0f Yes' : 'No'} |`); } lines.push(''); } @@ -103,14 +118,14 @@ function renderMarkdown(r: ChangeReport): string { lines.push('## \ud83d\udea8 New CVEs', ''); lines.push('| CVE ID | Severity | Affects |'); lines.push('|--------|----------|---------|'); - for (const v of r.newCVEs) lines.push(`| ${v.id} | ${v.severity ?? '\u2014'} | ${v.affects} |`); + for (const v of r.newCVEs) lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.severity)} | ${escapeCell(v.affects)} |`); lines.push(''); } if (r.fixedCVEs.length > 0) { lines.push('## \u2705 Fixed CVEs', ''); lines.push('| CVE ID | Affects |'); lines.push('|--------|---------|'); - for (const v of r.fixedCVEs) lines.push(`| ${v.id} | ${v.affects} |`); + for (const v of r.fixedCVEs) lines.push(`| ${escapeCell(v.id)} | ${escapeCell(v.affects)} |`); } return lines.join('\n');