Files
dockhand-upstream/tests/vuln-export-helpers.test.ts
T

253 lines
10 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { filterFindings, flattenScansToFindings, sortFindings } from '../src/lib/utils/vulnerability';
import type { Finding } from '../src/lib/utils/vulnerability';
import { escapeCSV, rowsToCSV } from '../src/lib/server/csv';
const base = {
cve: 'CVE-1',
package: 'openssl',
severity: 'high',
imageName: 'nginx:latest',
containers: [{ name: 'web-1' }],
stacks: ['frontend']
};
describe('filterFindings', () => {
const findings = [
{ ...base, cve: 'CVE-1', severity: 'critical', imageName: 'nginx', containers: [{ name: 'web-1' }], stacks: ['frontend'] },
{ ...base, cve: 'CVE-2', severity: 'low', imageName: 'redis', containers: [{ name: 'cache-1' }], stacks: ['backend'] },
{ ...base, cve: 'CVE-3', severity: 'high', imageName: 'redis', containers: [], stacks: [] }
];
test('no filter returns all', () => {
expect(filterFindings(findings, {})).toHaveLength(3);
});
test('severity filter', () => {
expect(filterFindings(findings, { severities: ['critical'] }).map((f) => f.cve)).toEqual(['CVE-1']);
});
test('image filter', () => {
expect(filterFindings(findings, { images: ['redis'] }).map((f) => f.cve)).toEqual(['CVE-2', 'CVE-3']);
});
test('container filter', () => {
expect(filterFindings(findings, { containers: ['cache-1'] }).map((f) => f.cve)).toEqual(['CVE-2']);
});
test('stack filter', () => {
expect(filterFindings(findings, { stacks: ['frontend'] }).map((f) => f.cve)).toEqual(['CVE-1']);
});
test('search matches cve/package/image/container/stack', () => {
expect(filterFindings(findings, { q: 'cache-1' }).map((f) => f.cve)).toEqual(['CVE-2']);
expect(filterFindings(findings, { q: 'frontend' }).map((f) => f.cve)).toEqual(['CVE-1']);
expect(filterFindings(findings, { q: 'redis' }).map((f) => f.cve)).toEqual(['CVE-2', 'CVE-3']);
});
test('filters combine (AND)', () => {
expect(filterFindings(findings, { images: ['redis'], severities: ['high'] }).map((f) => f.cve)).toEqual(['CVE-3']);
});
test('empty arrays are treated as no filter', () => {
expect(filterFindings(findings, { severities: [], images: [] })).toHaveLength(3);
});
});
describe('flattenScansToFindings', () => {
test('flattens and dedupes by image|cve|package|version', () => {
const scans = [
{
imageId: 'sha256:a', imageName: 'nginx', scannedAt: 't1',
vulnerabilities: [
{ id: 'CVE-1', severity: 'high', package: 'p', version: '1', fixedVersion: '2', link: 'l' },
{ id: 'CVE-1', severity: 'high', package: 'p', version: '1' } // duplicate
]
}
];
const findings = flattenScansToFindings(scans);
expect(findings).toHaveLength(1);
expect(findings[0].cve).toBe('CVE-1');
expect(findings[0].fixedVersion).toBe('2');
expect(findings[0].imageId).toBe('sha256:a');
});
test('parses legacy double-encoded vulnerabilities string', () => {
const scans = [
{ imageId: 'sha256:b', imageName: 'redis', scannedAt: 't', vulnerabilities: JSON.stringify([{ id: 'CVE-9', severity: 'low', package: 'q', version: '3' }]) }
];
const findings = flattenScansToFindings(scans);
expect(findings).toHaveLength(1);
expect(findings[0].cve).toBe('CVE-9');
});
test('tolerates missing/invalid vulnerabilities', () => {
expect(flattenScansToFindings([{ imageId: 'x', imageName: 'y', scannedAt: 't', vulnerabilities: null }])).toHaveLength(0);
expect(flattenScansToFindings([{ imageId: 'x', imageName: 'y', scannedAt: 't', vulnerabilities: 'not-json' }])).toHaveLength(0);
});
});
describe('sortFindings', () => {
function f(over: Partial<Finding>): Finding {
return {
key: 'k', cve: 'CVE-0', package: 'pkg', severity: 'low', installedVersion: '1.0',
fixedVersion: '', imageId: 'sha256:x', imageName: 'img', description: '', link: '',
scannedAt: '', containers: [], stacks: [], ...over
} as Finding;
}
test('severity: rank is critical=0..low=3, so asc lists critical first', () => {
const rows = [f({ severity: 'low' }), f({ severity: 'critical' }), f({ severity: 'medium' }), f({ severity: 'high' })];
expect(sortFindings(rows, 'severity', 'asc').map((r) => r.severity)).toEqual(['critical', 'high', 'medium', 'low']);
expect(sortFindings(rows, 'severity', 'desc').map((r) => r.severity)).toEqual(['low', 'medium', 'high', 'critical']);
});
test('cve: plain string order', () => {
const rows = [f({ cve: 'CVE-3' }), f({ cve: 'CVE-1' }), f({ cve: 'CVE-2' })];
expect(sortFindings(rows, 'cve', 'asc').map((r) => r.cve)).toEqual(['CVE-1', 'CVE-2', 'CVE-3']);
});
test('installed: numeric-aware version compare (1.2.10 > 1.2.9)', () => {
const rows = [f({ installedVersion: '1.2.9' }), f({ installedVersion: '1.2.10' }), f({ installedVersion: '1.2.1' })];
expect(sortFindings(rows, 'installed', 'asc').map((r) => r.installedVersion)).toEqual(['1.2.1', '1.2.9', '1.2.10']);
});
test('container: sorts by the alphabetically-first container name (min of the list)', () => {
const rows = [
f({ cve: 'A', containers: [{ name: 'zeta' }, { name: 'beta' }] }), // min = beta
f({ cve: 'B', containers: [{ name: 'alpha' }] }), // min = alpha
f({ cve: 'C', containers: [] }) // min = '' (first)
];
expect(sortFindings(rows, 'container', 'asc').map((r) => r.cve)).toEqual(['C', 'B', 'A']);
});
test('stack: sorts by the alphabetically-first stack; empty sorts first', () => {
const rows = [
f({ cve: 'A', stacks: ['web', 'api'] }), // min = api
f({ cve: 'B', stacks: ['db'] }), // min = db
f({ cve: 'C', stacks: [] }) // min = ''
];
expect(sortFindings(rows, 'stack', 'asc').map((r) => r.cve)).toEqual(['C', 'A', 'B']);
});
test('returns a new array and does not mutate the input', () => {
const rows = [f({ cve: 'CVE-2' }), f({ cve: 'CVE-1' })];
const sorted = sortFindings(rows, 'cve', 'asc');
expect(sorted).not.toBe(rows);
expect(rows.map((r) => r.cve)).toEqual(['CVE-2', 'CVE-1']); // original order intact
});
});
describe('escapeCSV — plain values', () => {
test('leaves a plain string untouched', () => {
expect(escapeCSV('plain')).toBe('plain');
expect(escapeCSV('CVE-2024-1234')).toBe('CVE-2024-1234');
expect(escapeCSV('nginx:1.25.3')).toBe('nginx:1.25.3');
});
test('renders positive numbers as their string form, unquoted', () => {
expect(escapeCSV(42)).toBe('42');
expect(escapeCSV(0)).toBe('0');
expect(escapeCSV(3.14)).toBe('3.14');
});
test('a NEGATIVE number stringifies to a leading "-", so it is prefixed', () => {
// String(-5) === "-5" starts with '-', a formula trigger. Prefixing is the
// safe, intended outcome (the cell stays human-readable as '-5).
expect(escapeCSV(-5)).toBe("'-5");
});
test('maps null/undefined to an empty cell', () => {
expect(escapeCSV(null)).toBe('');
expect(escapeCSV(undefined)).toBe('');
});
test('leaves an empty string empty', () => {
expect(escapeCSV('')).toBe('');
});
});
describe('escapeCSV — RFC-4180 quoting', () => {
test('quotes values containing a comma', () => {
expect(escapeCSV('a,b')).toBe('"a,b"');
});
test('quotes and doubles embedded quotes', () => {
expect(escapeCSV('say "hi"')).toBe('"say ""hi"""');
expect(escapeCSV('"')).toBe('""""'); // one quote -> quoted + doubled
});
test('quotes values containing a newline (LF)', () => {
expect(escapeCSV('line1\nline2')).toBe('"line1\nline2"');
});
test('quotes values containing a carriage return (CR)', () => {
// CR is also a row-break in many CSV parsers; must be quoted. It is also a
// leading-CR formula-injection trigger, so it additionally gets the ' prefix.
expect(escapeCSV('a\rb')).toBe('"a\rb"');
});
test('does not quote a value with no special characters', () => {
expect(escapeCSV('no-specials_here.123')).toBe('no-specials_here.123');
});
});
describe('escapeCSV — formula-injection neutralization', () => {
test('prefixes a leading = with a single quote', () => {
expect(escapeCSV('=SUM(A1:A9)')).toBe("'=SUM(A1:A9)");
expect(escapeCSV('=1+1')).toBe("'=1+1");
});
test('prefixes leading +, -, @ (spreadsheet formula starters)', () => {
expect(escapeCSV('+1')).toBe("'+1");
expect(escapeCSV('-1+2')).toBe("'-1+2");
expect(escapeCSV('@SUM')).toBe("'@SUM");
});
test('prefixes a leading tab (payload smuggling via whitespace)', () => {
// Tab is a formula trigger (gets the ' prefix) but NOT an RFC-4180 quote
// trigger, so no surrounding quotes are added.
expect(escapeCSV('\t=cmd')).toBe("'\t=cmd");
expect(escapeCSV('\thello')).toBe("'\thello");
});
test('a leading CR is prefixed AND quoted (CR is both triggers)', () => {
expect(escapeCSV('\rhello')).toBe('"\'\rhello"');
});
test('only the FIRST character matters — interior symbols are safe', () => {
expect(escapeCSV('a=b')).toBe('a=b');
expect(escapeCSV('nginx-1.25')).toBe('nginx-1.25');
expect(escapeCSV('user@host')).toBe('user@host');
});
test('injection + RFC-4180: a leading = with a comma is BOTH prefixed and quoted', () => {
// e.g. an attacker-crafted image name like "=HYPERLINK(x),evil"
expect(escapeCSV('=cmd,arg')).toBe('"\'=cmd,arg"');
});
test('injection char that also needs quoting for an embedded quote', () => {
expect(escapeCSV('=say"hi"')).toBe('"\'=say""hi"""');
});
});
describe('rowsToCSV', () => {
test('builds a header row plus escaped data rows', () => {
const csv = rowsToCSV(['A', 'B'], [['x', 'y,z'], [1, null]]);
expect(csv).toBe('A,B\nx,"y,z"\n1,');
});
test('emits just the header when there are no rows', () => {
expect(rowsToCSV(['A', 'B'], [])).toBe('A,B');
});
test('escapes injection payloads inside data rows', () => {
const csv = rowsToCSV(['CVE', 'Image'], [['CVE-1', '=IMPORTXML(1)']]);
expect(csv).toBe("CVE,Image\nCVE-1,'=IMPORTXML(1)");
});
test('handles mixed null / number / string cells per row', () => {
const csv = rowsToCSV(['A', 'B', 'C'], [[null, 7, 'hi'], ['', undefined, 'a,b']]);
expect(csv).toBe('A,B,C\n,7,hi\n,,"a,b"');
});
test('does not escape header cells (headers are fixed, trusted labels)', () => {
// Sanity: headers are joined verbatim; callers pass safe constant labels.
const csv = rowsToCSV(['Fixed Version', 'Link'], [['1.2.3', 'http://x']]);
expect(csv.split('\n')[0]).toBe('Fixed Version,Link');
});
});