Skip to content

Commit fb31b90

Browse files
committed
refator vulnerability grouping, add unit tests
1 parent dd10604 commit fb31b90

File tree

2 files changed

+195
-12
lines changed

2 files changed

+195
-12
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { groupVulnerabilitiesByMajor } from '#site/next-data/generators/vulnerabilities.mjs';
5+
6+
const MOCK_VULNERABILITIES = {
7+
1: {
8+
cve: ['CVE-2017-1000381'],
9+
vulnerable: '8.x || 7.x || 4.x || 6.x || 5.x',
10+
},
11+
8: {
12+
cve: ['CVE-2016-5180'],
13+
vulnerable: '0.10.x || 0.12.x || 4.x',
14+
},
15+
11: {
16+
cve: [],
17+
vulnerable: '6.x',
18+
},
19+
24: {
20+
cve: ['CVE-2016-1669'],
21+
vulnerable: '>=6.0.0 <6.2.0 || 5.x || 4.x',
22+
},
23+
54: {
24+
cve: ['CVE-2018-12115'],
25+
vulnerable: '<= 10',
26+
},
27+
};
28+
29+
const VULNERABILITIES_VALUES = Object.values(MOCK_VULNERABILITIES);
30+
31+
describe('groupVulnerabilitiesByMajor', () => {
32+
it('returns an empty object when given an empty array', () => {
33+
const grouped = groupVulnerabilitiesByMajor([]);
34+
assert.deepEqual(grouped, {});
35+
});
36+
37+
it('ignores non-numeric values in the "vulnerable" string', () => {
38+
const vulnerabilities = [
39+
{ cve: ['CVE-2021-1234'], vulnerable: 'foo || bar || 12.x' },
40+
{ cve: ['CVE-2021-5678'], vulnerable: 'baz || 13.x' },
41+
];
42+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
43+
assert.deepEqual(Object.keys(grouped).sort(Number), ['12', '13']);
44+
});
45+
46+
it('handles vulnerabilities with no "vulnerable" field gracefully', () => {
47+
const vulnerabilities = [
48+
{ cve: ['CVE-2021-1234'], vulnerable: '12.x' },
49+
{ cve: ['CVE-2021-5678'] }, // no vulnerable field
50+
];
51+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
52+
assert.deepEqual(Object.keys(grouped).sort(Number), ['12']);
53+
});
54+
55+
it('can group a single version', () => {
56+
const vulnerabilities = [{ cve: ['CVE-2021-1234'], vulnerable: '12.x' }];
57+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
58+
assert.deepEqual(Object.keys(grouped).sort(Number), ['12']);
59+
});
60+
61+
it('can group a 0.x version', () => {
62+
const vulnerabilities = [{ cve: ['CVE-2021-1234'], vulnerable: '0.10.x' }];
63+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
64+
assert.deepEqual(Object.keys(grouped).sort(Number), ['0']);
65+
});
66+
67+
it('can group two versions', () => {
68+
const vulnerabilities = [
69+
{ cve: ['CVE-2021-1234'], vulnerable: '12.x || 13.x' },
70+
];
71+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
72+
assert.deepEqual(Object.keys(grouped).sort(Number), ['12', '13']);
73+
});
74+
75+
it('can group an integer version and a 0.X version', () => {
76+
const vulnerabilities = [
77+
{ cve: ['CVE-2021-1234'], vulnerable: '0.10.x || 12.x' },
78+
];
79+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
80+
assert.deepEqual(Object.keys(grouped).sort(Number), ['0', '12']);
81+
});
82+
83+
it('returns a the major when given a greater-than range', () => {
84+
const vulnerabilities = [
85+
{ cve: ['CVE-2021-5678'], vulnerable: '>=6.0.0 <6.2.0' },
86+
];
87+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
88+
assert.deepEqual(Object.keys(grouped).sort(Number), ['6']);
89+
});
90+
91+
it('returns a descending list of major versions when given a less-than range', () => {
92+
const vulnerabilities = [{ cve: ['CVE-2021-5678'], vulnerable: '< 5' }];
93+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
94+
assert.deepEqual(Object.keys(grouped).sort(Number), [
95+
'0',
96+
'1',
97+
'2',
98+
'3',
99+
'4',
100+
]);
101+
});
102+
103+
it('returns a descending list of major versions when given a less-than or equal range, inclusive', () => {
104+
const vulnerabilities = [{ cve: ['CVE-2021-5678'], vulnerable: '<= 5' }];
105+
const grouped = groupVulnerabilitiesByMajor(vulnerabilities);
106+
assert.deepEqual(Object.keys(grouped).sort(Number), [
107+
'0',
108+
'1',
109+
'2',
110+
'3',
111+
'4',
112+
'5',
113+
]);
114+
});
115+
116+
it('groups vulnerabilities by major version extracted from "vulnerable" string', () => {
117+
const grouped = groupVulnerabilitiesByMajor(VULNERABILITIES_VALUES);
118+
119+
assert.deepEqual(Object.keys(grouped).sort(Number), [
120+
'0',
121+
'1', // note, comes from the <= 10
122+
'2', // note, comes from the <= 10
123+
'3', // note, comes from the <= 10
124+
'4',
125+
'5',
126+
'6',
127+
'7',
128+
'8',
129+
'9', // note, comes from the <= 10
130+
'10', // note, comes from the <= 10
131+
]);
132+
133+
assert.strictEqual(grouped['0'].length, 3);
134+
assert.strictEqual(grouped['1'].length, 1);
135+
assert.strictEqual(grouped['2'].length, 1);
136+
assert.strictEqual(grouped['3'].length, 1);
137+
assert.strictEqual(grouped['4'].length, 4);
138+
assert.strictEqual(grouped['5'].length, 3);
139+
assert.strictEqual(grouped['6'].length, 4);
140+
assert.strictEqual(grouped['7'].length, 2);
141+
assert.strictEqual(grouped['8'].length, 2);
142+
assert.strictEqual(grouped['9'].length, 1);
143+
assert.strictEqual(grouped['10'].length, 1);
144+
});
145+
});

apps/site/next-data/generators/vulnerabilities.mjs

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,59 @@ import { VULNERABILITIES_URL } from '#site/next.constants.mjs';
55
*
66
* @param {Array<import('#site/types/vulnerabilities').Vulnerability>} vulnerabilities Array of Vulnerability objects
77
*/
8-
function groupVulnerabilitiesByMajor(vulnerabilities) {
8+
export function groupVulnerabilitiesByMajor(vulnerabilities) {
99
const grouped = {};
1010

1111
for (const vulnerability of vulnerabilities) {
12-
const majorVersions =
13-
vulnerability.vulnerable
14-
.match(/\b\d+\b/g)
15-
?.map(Number)
16-
.filter(major => !isNaN(major)) ?? [];
17-
18-
for (const majorVersion of majorVersions) {
19-
const key = majorVersion.toString();
20-
if (!grouped[key]) grouped[key] = [];
21-
grouped[key].push(vulnerability);
22-
}
12+
// split on '||' to handle multiple versions and trim whitespace
13+
const potentialVersions =
14+
vulnerability.vulnerable?.split('||').map(v => v.trim()) || [];
15+
16+
potentialVersions.forEach(version => {
17+
// handle 0.X versions, which did not follow semver
18+
// we don't even capture the minor here.
19+
if (/^0\.\d+(\.x)?$/.test(version)) {
20+
const majorVersion = '0';
21+
if (!grouped[majorVersion]) grouped[majorVersion] = [];
22+
grouped[majorVersion].push(vulnerability);
23+
return;
24+
}
25+
26+
// handle simple cases, where there is no range
27+
// this is something like 12.x
28+
if (/^\d+.x/.test(version)) {
29+
const majorVersion = version.split('.')[0];
30+
if (!grouped[majorVersion]) grouped[majorVersion] = [];
31+
grouped[majorVersion].push(vulnerability);
32+
return;
33+
}
34+
35+
// detect if there is a range in the values,
36+
// which would include a > or < or <= or >=, with spaces
37+
const rangeMatch = version.match(/([<>]=?)\s*(\d+)?\.?(\d+)?/);
38+
if (rangeMatch) {
39+
const operator = rangeMatch[1];
40+
41+
// if we have equality or greater than, we simply add the current
42+
// and assume that other piped sections handle any higher bounds
43+
if (operator === '>=' || operator === '>' || operator === '<=') {
44+
const majorVersion = rangeMatch[2];
45+
if (!grouped[majorVersion]) grouped[majorVersion] = [];
46+
grouped[majorVersion].push(vulnerability);
47+
}
48+
49+
// if we only specify (< pr <=) vulnerability,
50+
// we need to count down from this to all majors!
51+
if (operator === '<' || operator === '<=') {
52+
const majorVersion = rangeMatch[2];
53+
for (let i = majorVersion - 1; i >= 0; i--) {
54+
if (!grouped[i]) grouped[i] = [];
55+
grouped[i].push(vulnerability);
56+
}
57+
return;
58+
}
59+
}
60+
});
2361
}
2462

2563
return grouped;

0 commit comments

Comments
 (0)