Skip to content

Commit 03ab477

Browse files
committed
feat: add process docs and scripts for yarn.lock only backports
Signed-off-by: Jessica He <jhe@redhat.com>
1 parent c5d9f95 commit 03ab477

10 files changed

Lines changed: 1584 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# yarnlock-backport
2+
3+
Generate `0-cve-yarn-lock.patch` and `cve-backports.yaml` for overlay workspaces without bumping `source.json:repo-ref`.
4+
5+
Full workflow: [user-guide/06-patch-management.md](../../user-guide/06-patch-management.md).
6+
7+
```bash
8+
cd scripts/yarnlock-backport && npm install
9+
10+
export OVERLAY_WORKSPACE=<absolute-path>/workspaces/orchestrator
11+
export PLUGINS_REPO=<absolute-path>
12+
13+
yarnlock-backport prepare --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO"
14+
# … yarn up in plugins workspace …
15+
yarnlock-backport generate --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO" --cve 'CVE-…,CVE-…'
16+
```
17+
18+
Requires: Node.js, `git`, `yarn`, `patch`, `diff`, `npm`. `--overlay-workspace` and `--plugins-repo` must be **absolute** paths.
19+
20+
Fork clones need an `upstream` remote pointing at `https://github.com/redhat-developer/rhdh-plugin-export-overlays.git` (step 0 syncs the release branch from there).
21+
22+
```bash
23+
npm test
24+
```
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import {
5+
buildManifestRows,
6+
collectInstallationPaths,
7+
collectPackageVersionsFromNpmLs,
8+
formatNpmLsSpine,
9+
formatPatchVersions,
10+
mergeManifestNotes,
11+
normalizeCveId,
12+
parseCveArg,
13+
parseCveCliToken,
14+
parseCveDetails,
15+
parsePatchVersions,
16+
releaseBranch,
17+
requireLockfileChange,
18+
resolveOverlayGitRemote,
19+
resolvePluginsGitRemote,
20+
resolveVersions,
21+
semverInAnyAffected,
22+
stripAutoNotes,
23+
vulnerablePatchVersions,
24+
vulnerabilityNoteForRow,
25+
} from './backport.ts';
26+
27+
describe('requireLockfileChange', () => {
28+
it('accepts when baseline and patched differ', () => {
29+
assert.doesNotThrow(() => requireLockfileChange('a', 'b'));
30+
});
31+
32+
it('rejects when lockfile is unchanged from repo-ref', () => {
33+
assert.throws(() => requireLockfileChange('same', 'same'), /yarn.lock unchanged from repo-ref baseline/);
34+
});
35+
});
36+
37+
describe('resolveOverlayGitRemote', () => {
38+
const forkRemotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch)
39+
origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (push)
40+
upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch)
41+
upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (push)`;
42+
43+
it('prefers upstream on fork clones', () => {
44+
assert.equal(
45+
resolveOverlayGitRemote(forkRemotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'),
46+
'upstream',
47+
);
48+
});
49+
50+
it('uses origin for direct upstream clones', () => {
51+
const remotes = `origin\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch)`;
52+
assert.equal(
53+
resolveOverlayGitRemote(remotes, 'https://github.com/redhat-developer/rhdh-plugin-export-overlays.git'),
54+
'origin',
55+
);
56+
});
57+
58+
it('errors when fork has no upstream remote', () => {
59+
const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch)`;
60+
assert.throws(
61+
() => resolveOverlayGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'),
62+
/no upstream remote/,
63+
);
64+
});
65+
});
66+
67+
describe('resolvePluginsGitRemote', () => {
68+
it('prefers upstream on fork clones', () => {
69+
const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugins.git (fetch)
70+
upstream\thttps://github.com/redhat-developer/rhdh-plugins.git (fetch)`;
71+
assert.equal(resolvePluginsGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugins.git'), 'upstream');
72+
});
73+
});
74+
75+
describe('releaseBranch', () => {
76+
it('maps release version to branch name', () => {
77+
assert.equal(releaseBranch('1.10'), 'release-1.10');
78+
});
79+
80+
it('accepts branch name as-is', () => {
81+
assert.equal(releaseBranch('release-1.10'), 'release-1.10');
82+
});
83+
84+
it('rejects empty release', () => {
85+
assert.throws(() => releaseBranch(' '), /release is empty/);
86+
});
87+
});
88+
89+
describe('normalizeCveId', () => {
90+
it('accepts valid CVE ids', () => {
91+
assert.equal(normalizeCveId('cve-2026-1234'), 'CVE-2026-1234');
92+
});
93+
94+
it('rejects invalid CVE ids', () => {
95+
assert.throws(() => normalizeCveId('not-a-cve'), /not a CVE id/);
96+
});
97+
});
98+
99+
describe('parseCveCliToken', () => {
100+
it('parses CVE id without package override', () => {
101+
assert.deepEqual(parseCveCliToken('CVE-2026-1234'), ['CVE-2026-1234', []]);
102+
});
103+
104+
it('parses package override after slash', () => {
105+
assert.deepEqual(parseCveCliToken('CVE-2026-1234/axios'), ['CVE-2026-1234', ['axios']]);
106+
});
107+
});
108+
109+
describe('parseCveArg', () => {
110+
it('rejects duplicate CVE ids', () => {
111+
assert.throws(() => parseCveArg('CVE-2026-1234,CVE-2026-1234'), /duplicate CVE/);
112+
});
113+
});
114+
115+
describe('parseCveDetails', () => {
116+
it('returns empty details for rejected CVE records', () => {
117+
const result = parseCveDetails({ cveMetadata: { state: 'REJECTED' }, containers: { cna: {} } });
118+
assert.equal(result.name, '');
119+
assert.deepEqual(result.patch_versions, []);
120+
assert.deepEqual(result.affected_ranges, []);
121+
});
122+
123+
it('extracts lessThan fix version and affected range', () => {
124+
const result = parseCveDetails({
125+
cveMetadata: { state: 'PUBLISHED' },
126+
containers: {
127+
cna: {
128+
affected: [{ packageName: 'axios', versions: [{ status: 'affected', version: '0', lessThan: '1.18.1' }] }],
129+
},
130+
},
131+
});
132+
assert.equal(result.name, 'axios');
133+
assert.deepEqual(result.patch_versions, ['1.18.1']);
134+
assert.deepEqual(result.affected_ranges, [{ from: '0', to: '1.18.1', upper_inclusive: false }]);
135+
});
136+
});
137+
138+
describe('semverInAnyAffected', () => {
139+
const wsRanges = [
140+
{ from: '8.0.0', to: '8.21.0', upper_inclusive: false },
141+
{ from: '7.0.0', to: '7.5.11', upper_inclusive: false },
142+
];
143+
144+
it('flags versions below the fix bound as vulnerable', () => {
145+
assert.equal(semverInAnyAffected('8.18.0', wsRanges), true);
146+
assert.equal(semverInAnyAffected('8.21.0', wsRanges), false);
147+
});
148+
});
149+
150+
describe('vulnerabilityNoteForRow', () => {
151+
const npmLs = {
152+
name: '@internal/lightspeed',
153+
version: '1.0.0',
154+
dependencies: {
155+
'@backstage/cli-defaults': {
156+
version: '0.1.0',
157+
dependencies: {
158+
'@backstage/cli-module-build': {
159+
version: '0.1.2',
160+
dependencies: {
161+
'@module-federation/enhanced': {
162+
version: '0.21.6',
163+
dependencies: {
164+
'@module-federation/dts-plugin': {
165+
version: '0.21.6',
166+
dependencies: {
167+
ws: { version: '8.18.0' },
168+
},
169+
},
170+
},
171+
},
172+
},
173+
},
174+
},
175+
},
176+
},
177+
};
178+
179+
it('notes still-vulnerable patch versions with an npm ls spine', () => {
180+
const row = {
181+
cve_ids: ['CVE-2026-45736'],
182+
package: 'ws',
183+
patch_version: '8.18.0, 8.21.0',
184+
};
185+
const cveDict = {
186+
'CVE-2026-45736': {
187+
name: 'ws',
188+
patch_versions: ['8.21.0'],
189+
affected_ranges: [{ from: '8.0.0', to: '8.21.0', upper_inclusive: false }],
190+
},
191+
};
192+
assert.deepEqual(vulnerablePatchVersions(parsePatchVersions(row.patch_version), row.cve_ids, cveDict), ['8.18.0']);
193+
const note = vulnerabilityNoteForRow(row, cveDict, npmLs);
194+
assert.match(note!, /ws@8\.18\.0 is still in CVE affected range/);
195+
assert.match(note!, /@backstage\/cli-defaults@0\.1\.0/);
196+
assert.match(note!, /ws@8\.18\.0/);
197+
});
198+
199+
it('collects installation paths for a specific version', () => {
200+
const paths = collectInstallationPaths(npmLs, 'ws', '8.18.0');
201+
assert.equal(paths.length, 1);
202+
assert.equal(formatNpmLsSpine(paths[0]).includes('ws@8.18.0'), true);
203+
});
204+
});
205+
206+
describe('mergeManifestNotes', () => {
207+
it('preserves manual notes and replaces auto-generated block', () => {
208+
const merged = mergeManifestNotes(
209+
'Upstream PR 123\n\n[auto] old auto note',
210+
'[auto] ws@8.18.0 is still in CVE affected range; verify dev-only',
211+
);
212+
assert.match(merged!, /Upstream PR 123/);
213+
assert.match(merged!, /ws@8\.18\.0 is still in CVE affected range/);
214+
assert.doesNotMatch(merged!, /old auto note/);
215+
});
216+
217+
it('stripAutoNotes removes only the auto block', () => {
218+
assert.equal(stripAutoNotes('manual only'), 'manual only');
219+
assert.equal(stripAutoNotes('[auto] generated'), undefined);
220+
});
221+
});
222+
223+
describe('buildManifestRows', () => {
224+
it('merges CVEs with the same package and patch_version', () => {
225+
const rows = buildManifestRows([
226+
{ cveId: 'CVE-2026-44486', package: 'axios', patch_versions: ['1.18.1'] },
227+
{ cveId: 'CVE-2026-44487', package: 'axios', patch_versions: ['1.18.1'], notes: 'MITRE fix >= 1.16.0' },
228+
]);
229+
assert.equal(rows.length, 1);
230+
assert.deepEqual(rows[0].cve_ids, ['CVE-2026-44486', 'CVE-2026-44487']);
231+
assert.equal(rows[0].notes, 'MITRE fix >= 1.16.0');
232+
});
233+
234+
it('omits notes when none are set', () => {
235+
const rows = buildManifestRows([{ cveId: 'CVE-2026-12143', package: 'form-data', patch_versions: ['2.5.6', '4.0.6'] }]);
236+
assert.equal(rows[0].patch_version, '2.5.6, 4.0.6');
237+
assert.equal(rows[0].notes, undefined);
238+
});
239+
});
240+
241+
describe('formatPatchVersions', () => {
242+
it('joins sorted versions with comma and space', () => {
243+
assert.equal(formatPatchVersions(['4.0.6', '2.5.6']), '2.5.6, 4.0.6');
244+
});
245+
});
246+
247+
describe('parsePatchVersions', () => {
248+
it('parses comma-separated versions', () => {
249+
assert.deepEqual(parsePatchVersions('4.0.6, 2.5.6'), ['2.5.6', '4.0.6']);
250+
});
251+
252+
it('accepts a single version', () => {
253+
assert.deepEqual(parsePatchVersions('1.18.1'), ['1.18.1']);
254+
});
255+
});
256+
257+
describe('collectPackageVersionsFromNpmLs', () => {
258+
it('collects every distinct version from the dependency tree', () => {
259+
const tree = {
260+
dependencies: {
261+
'form-data': { version: '4.0.6' },
262+
'@backstage/backend-defaults': {
263+
dependencies: {
264+
'@types/request': {
265+
dependencies: {
266+
'form-data': { version: '2.5.6' },
267+
},
268+
},
269+
},
270+
},
271+
},
272+
};
273+
assert.deepEqual(collectPackageVersionsFromNpmLs(tree, 'form-data'), ['2.5.6', '4.0.6']);
274+
});
275+
});
276+
277+
describe('resolveVersions', () => {
278+
it('returns all distinct versions present in yarn.lock', () => {
279+
const lockText = `
280+
"form-data@npm:2.5.6":
281+
resolution: "form-data@npm:2.5.6"
282+
"form-data@npm:4.0.6":
283+
resolution: "form-data@npm:4.0.6"
284+
`;
285+
assert.deepEqual(resolveVersions('form-data', '/tmp/ws', lockText, undefined, ''), ['2.5.6', '4.0.6']);
286+
});
287+
});
288+
289+
describe('maxVersion', () => {
290+
it('orders semver-like versions numerically', () => {
291+
const sorted = ['1.10.0', '1.9.0', '1.18.1'].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
292+
assert.deepEqual(sorted, ['1.9.0', '1.10.0', '1.18.1']);
293+
});
294+
});

0 commit comments

Comments
 (0)