Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<!\\)\|/g) ?? []).length).toBe(4);
});
});
25 changes: 20 additions & 5 deletions src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ function renderText(r: ChangeReport): string {
return lines.join('\n');
}

/**
* Escape a value for safe interpolation into a Markdown table cell.
*
* Component and CVE strings originate from the SBOM under inspection, whose
* package names, versions, and CVE descriptions are attacker-influenced. A raw
* `|` adds a phantom column (corrupting the table), and a newline starts a new
* row (letting crafted input forge or hide entries) — both matter because the
* README pitches this Markdown output for posting into PR comments. Escape `|`
* and flatten line breaks so a value always renders as exactly one cell.
*/
function escapeCell(value: string | undefined, fallback = '—'): string {
if (value === undefined || value === '') return fallback;
return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
}

function renderMarkdown(r: ChangeReport): string {
const lines: string[] = [
'# SBOM Diff Report',
Expand All @@ -80,37 +95,37 @@ function renderMarkdown(r: ChangeReport): string {
lines.push('## \u2795 Added Components', '');
lines.push('| Name | Version | Ecosystem |');
lines.push('|------|---------|-----------|');
for (const c of r.added) lines.push(`| ${c.name} | ${c.version ?? '\u2014'} | ${c.ecosystem ?? '\u2014'} |`);
for (const c of r.added) lines.push(`| ${escapeCell(c.name)} | ${escapeCell(c.version)} | ${escapeCell(c.ecosystem)} |`);
lines.push('');
}
if (r.removed.length > 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) {
lines.push('## \u2b06\ufe0f Upgraded Components', '');
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('');
}
if (r.newCVEs.length > 0) {
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');
Expand Down
Loading