diff --git a/scripts/yarnlock-backport/.gitignore b/scripts/yarnlock-backport/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/scripts/yarnlock-backport/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/scripts/yarnlock-backport/README.md b/scripts/yarnlock-backport/README.md new file mode 100644 index 000000000..5d1858df8 --- /dev/null +++ b/scripts/yarnlock-backport/README.md @@ -0,0 +1,24 @@ +# yarnlock-backport + +Generate `0-cve-yarn-lock.patch` and `cve-backports.yaml` for overlay workspaces without bumping `source.json:repo-ref`. + +Full workflow: [user-guide/06-patch-management.md](../../user-guide/06-patch-management.md). + +```bash +cd scripts/yarnlock-backport && npm install + +export OVERLAY_WORKSPACE=/workspaces/orchestrator +export PLUGINS_REPO= + +yarnlock-backport prepare --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO" +# … yarn up in plugins workspace … +yarnlock-backport generate --release 1.10 --overlay-workspace "$OVERLAY_WORKSPACE" --plugins-repo "$PLUGINS_REPO" --cve 'CVE-…,CVE-…' +``` + +Requires: Node.js, `git`, `yarn`, `patch`, `diff`, `npm`. `--overlay-workspace` and `--plugins-repo` must be **absolute** paths. + +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). + +```bash +npm test +``` diff --git a/scripts/yarnlock-backport/backport.test.ts b/scripts/yarnlock-backport/backport.test.ts new file mode 100644 index 000000000..52e3b5027 --- /dev/null +++ b/scripts/yarnlock-backport/backport.test.ts @@ -0,0 +1,294 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildManifestRows, + collectInstallationPaths, + collectPackageVersionsFromNpmLs, + formatNpmLsSpine, + formatPatchVersions, + mergeManifestNotes, + normalizeCveId, + parseCveArg, + parseCveCliToken, + parseCveDetails, + parsePatchVersions, + releaseBranch, + requireLockfileChange, + resolveOverlayGitRemote, + resolvePluginsGitRemote, + resolveVersions, + semverInAnyAffected, + stripAutoNotes, + vulnerablePatchVersions, + vulnerabilityNoteForRow, +} from './backport.ts'; + +describe('requireLockfileChange', () => { + it('accepts when baseline and patched differ', () => { + assert.doesNotThrow(() => requireLockfileChange('a', 'b')); + }); + + it('rejects when lockfile is unchanged from repo-ref', () => { + assert.throws(() => requireLockfileChange('same', 'same'), /yarn.lock unchanged from repo-ref baseline/); + }); +}); + +describe('resolveOverlayGitRemote', () => { + const forkRemotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch) +origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (push) +upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch) +upstream\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (push)`; + + it('prefers upstream on fork clones', () => { + assert.equal( + resolveOverlayGitRemote(forkRemotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'), + 'upstream', + ); + }); + + it('uses origin for direct upstream clones', () => { + const remotes = `origin\thttps://github.com/redhat-developer/rhdh-plugin-export-overlays.git (fetch)`; + assert.equal( + resolveOverlayGitRemote(remotes, 'https://github.com/redhat-developer/rhdh-plugin-export-overlays.git'), + 'origin', + ); + }); + + it('errors when fork has no upstream remote', () => { + const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugin-export-overlays.git (fetch)`; + assert.throws( + () => resolveOverlayGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugin-export-overlays.git'), + /no upstream remote/, + ); + }); +}); + +describe('resolvePluginsGitRemote', () => { + it('prefers upstream on fork clones', () => { + const remotes = `origin\tgit@github.com:JessicaJHee/rhdh-plugins.git (fetch) +upstream\thttps://github.com/redhat-developer/rhdh-plugins.git (fetch)`; + assert.equal(resolvePluginsGitRemote(remotes, 'git@github.com:JessicaJHee/rhdh-plugins.git'), 'upstream'); + }); +}); + +describe('releaseBranch', () => { + it('maps release version to branch name', () => { + assert.equal(releaseBranch('1.10'), 'release-1.10'); + }); + + it('accepts branch name as-is', () => { + assert.equal(releaseBranch('release-1.10'), 'release-1.10'); + }); + + it('rejects empty release', () => { + assert.throws(() => releaseBranch(' '), /release is empty/); + }); +}); + +describe('normalizeCveId', () => { + it('accepts valid CVE ids', () => { + assert.equal(normalizeCveId('cve-2026-1234'), 'CVE-2026-1234'); + }); + + it('rejects invalid CVE ids', () => { + assert.throws(() => normalizeCveId('not-a-cve'), /not a CVE id/); + }); +}); + +describe('parseCveCliToken', () => { + it('parses CVE id without package override', () => { + assert.deepEqual(parseCveCliToken('CVE-2026-1234'), ['CVE-2026-1234', []]); + }); + + it('parses package override after slash', () => { + assert.deepEqual(parseCveCliToken('CVE-2026-1234/axios'), ['CVE-2026-1234', ['axios']]); + }); +}); + +describe('parseCveArg', () => { + it('rejects duplicate CVE ids', () => { + assert.throws(() => parseCveArg('CVE-2026-1234,CVE-2026-1234'), /duplicate CVE/); + }); +}); + +describe('parseCveDetails', () => { + it('returns empty details for rejected CVE records', () => { + const result = parseCveDetails({ cveMetadata: { state: 'REJECTED' }, containers: { cna: {} } }); + assert.equal(result.name, ''); + assert.deepEqual(result.patch_versions, []); + assert.deepEqual(result.affected_ranges, []); + }); + + it('extracts lessThan fix version and affected range', () => { + const result = parseCveDetails({ + cveMetadata: { state: 'PUBLISHED' }, + containers: { + cna: { + affected: [{ packageName: 'axios', versions: [{ status: 'affected', version: '0', lessThan: '1.18.1' }] }], + }, + }, + }); + assert.equal(result.name, 'axios'); + assert.deepEqual(result.patch_versions, ['1.18.1']); + assert.deepEqual(result.affected_ranges, [{ from: '0', to: '1.18.1', upper_inclusive: false }]); + }); +}); + +describe('semverInAnyAffected', () => { + const wsRanges = [ + { from: '8.0.0', to: '8.21.0', upper_inclusive: false }, + { from: '7.0.0', to: '7.5.11', upper_inclusive: false }, + ]; + + it('flags versions below the fix bound as vulnerable', () => { + assert.equal(semverInAnyAffected('8.18.0', wsRanges), true); + assert.equal(semverInAnyAffected('8.21.0', wsRanges), false); + }); +}); + +describe('vulnerabilityNoteForRow', () => { + const npmLs = { + name: '@internal/lightspeed', + version: '1.0.0', + dependencies: { + '@backstage/cli-defaults': { + version: '0.1.0', + dependencies: { + '@backstage/cli-module-build': { + version: '0.1.2', + dependencies: { + '@module-federation/enhanced': { + version: '0.21.6', + dependencies: { + '@module-federation/dts-plugin': { + version: '0.21.6', + dependencies: { + ws: { version: '8.18.0' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + it('notes still-vulnerable patch versions with an npm ls spine', () => { + const row = { + cve_ids: ['CVE-2026-45736'], + package: 'ws', + patch_version: '8.18.0, 8.21.0', + }; + const cveDict = { + 'CVE-2026-45736': { + name: 'ws', + patch_versions: ['8.21.0'], + affected_ranges: [{ from: '8.0.0', to: '8.21.0', upper_inclusive: false }], + }, + }; + assert.deepEqual(vulnerablePatchVersions(parsePatchVersions(row.patch_version), row.cve_ids, cveDict), ['8.18.0']); + const note = vulnerabilityNoteForRow(row, cveDict, npmLs); + assert.match(note!, /ws@8\.18\.0 is still in CVE affected range/); + assert.match(note!, /@backstage\/cli-defaults@0\.1\.0/); + assert.match(note!, /ws@8\.18\.0/); + }); + + it('collects installation paths for a specific version', () => { + const paths = collectInstallationPaths(npmLs, 'ws', '8.18.0'); + assert.equal(paths.length, 1); + assert.equal(formatNpmLsSpine(paths[0]).includes('ws@8.18.0'), true); + }); +}); + +describe('mergeManifestNotes', () => { + it('preserves manual notes and replaces auto-generated block', () => { + const merged = mergeManifestNotes( + 'Upstream PR 123\n\n[auto] old auto note', + '[auto] ws@8.18.0 is still in CVE affected range; verify dev-only', + ); + assert.match(merged!, /Upstream PR 123/); + assert.match(merged!, /ws@8\.18\.0 is still in CVE affected range/); + assert.doesNotMatch(merged!, /old auto note/); + }); + + it('stripAutoNotes removes only the auto block', () => { + assert.equal(stripAutoNotes('manual only'), 'manual only'); + assert.equal(stripAutoNotes('[auto] generated'), undefined); + }); +}); + +describe('buildManifestRows', () => { + it('merges CVEs with the same package and patch_version', () => { + const rows = buildManifestRows([ + { cveId: 'CVE-2026-44486', package: 'axios', patch_versions: ['1.18.1'] }, + { cveId: 'CVE-2026-44487', package: 'axios', patch_versions: ['1.18.1'], notes: 'MITRE fix >= 1.16.0' }, + ]); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0].cve_ids, ['CVE-2026-44486', 'CVE-2026-44487']); + assert.equal(rows[0].notes, 'MITRE fix >= 1.16.0'); + }); + + it('omits notes when none are set', () => { + const rows = buildManifestRows([{ cveId: 'CVE-2026-12143', package: 'form-data', patch_versions: ['2.5.6', '4.0.6'] }]); + assert.equal(rows[0].patch_version, '2.5.6, 4.0.6'); + assert.equal(rows[0].notes, undefined); + }); +}); + +describe('formatPatchVersions', () => { + it('joins sorted versions with comma and space', () => { + assert.equal(formatPatchVersions(['4.0.6', '2.5.6']), '2.5.6, 4.0.6'); + }); +}); + +describe('parsePatchVersions', () => { + it('parses comma-separated versions', () => { + assert.deepEqual(parsePatchVersions('4.0.6, 2.5.6'), ['2.5.6', '4.0.6']); + }); + + it('accepts a single version', () => { + assert.deepEqual(parsePatchVersions('1.18.1'), ['1.18.1']); + }); +}); + +describe('collectPackageVersionsFromNpmLs', () => { + it('collects every distinct version from the dependency tree', () => { + const tree = { + dependencies: { + 'form-data': { version: '4.0.6' }, + '@backstage/backend-defaults': { + dependencies: { + '@types/request': { + dependencies: { + 'form-data': { version: '2.5.6' }, + }, + }, + }, + }, + }, + }; + assert.deepEqual(collectPackageVersionsFromNpmLs(tree, 'form-data'), ['2.5.6', '4.0.6']); + }); +}); + +describe('resolveVersions', () => { + it('returns all distinct versions present in yarn.lock', () => { + const lockText = ` +"form-data@npm:2.5.6": + resolution: "form-data@npm:2.5.6" +"form-data@npm:4.0.6": + resolution: "form-data@npm:4.0.6" +`; + assert.deepEqual(resolveVersions('form-data', '/tmp/ws', lockText, undefined, ''), ['2.5.6', '4.0.6']); + }); +}); + +describe('maxVersion', () => { + it('orders semver-like versions numerically', () => { + const sorted = ['1.10.0', '1.9.0', '1.18.1'].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + assert.deepEqual(sorted, ['1.9.0', '1.10.0', '1.18.1']); + }); +}); diff --git a/scripts/yarnlock-backport/backport.ts b/scripts/yarnlock-backport/backport.ts new file mode 100644 index 000000000..8628211c9 --- /dev/null +++ b/scripts/yarnlock-backport/backport.ts @@ -0,0 +1,877 @@ +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +const OVERLAY_UPSTREAM_REPO = 'redhat-developer/rhdh-plugin-export-overlays'; +const PLUGINS_UPSTREAM_REPO = 'redhat-developer/rhdh-plugins'; +/** orchestrator yarn.lock is ~1.5 MiB; Node spawnSync default maxBuffer is 1 MiB */ +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; +const PATCH_FILE = '0-cve-yarn-lock.patch'; +const MANIFEST = 'cve-backports.yaml'; +const LOCKFILE = 'yarn.lock'; +const CVE_API = 'https://cveawg.mitre.org/api/cve/'; +const CVE_ID_RE = /^CVE-\d{4}-\d+$/i; +const MANIFEST_HEADER = `# CVE backports applied via workspace patches (not repo-ref bumps). +# Auto-generated by scripts/yarnlock-backport. +# Optional: add notes on any backport row (preserved when you re-run generate). + +`; + +type Json = Record; +type VersionRange = { from: string; to: string; upper_inclusive: boolean }; +type ManifestRow = { cve_ids: string[]; package: string; patch_version: string; notes?: string }; +type CveDetails = { name: string; patch_versions: string[]; affected_ranges: VersionRange[] }; + +const INCOMPARABLE_VERSIONS = new Set(['0', '*', 'N/A', 'NA']); +const AUTO_NOTE_PREFIX = '[auto] '; + +// --- CVE (MITRE) --- + +export function normalizeCveId(cveId: string): string { + const id = cveId.trim().toUpperCase().replace(/ /g, ''); + if (!CVE_ID_RE.test(id)) throw new Error('not a CVE id'); + return id; +} + +export function parseCveCliToken(raw: string): [string, string[]] { + const text = raw.trim().replace(/ /g, ''); + if (!text) throw new Error('empty CVE token'); + const slash = text.indexOf('/'); + const cveId = normalizeCveId(slash >= 0 ? text.slice(0, slash) : text); + const overrides = (slash >= 0 ? text.slice(slash + 1) : '').split(',').map(s => s.trim()).filter(Boolean); + return [cveId, overrides]; +} + +export function parseCveArg(raw: string): string[] { + const tokens = raw.split(',').map(t => t.trim()).filter(Boolean); + if (!tokens.length) throw new Error('CVE list is empty'); + const seen = new Set(); + return tokens.map(token => { + const [cveId] = parseCveCliToken(token); + if (seen.has(cveId)) throw new Error('duplicate CVE in --cve list'); + seen.add(cveId); + return token; + }); +} + +function versionEntryToRanges(v: Json): { fixes: string[]; ranges: VersionRange[] } { + const fixes: string[] = []; + const ranges: VersionRange[] = []; + if (String(v.status ?? '').toLowerCase() !== 'affected') return { fixes, ranges }; + + const lowRaw = String(v.version ?? '').trim(); + const loDefault = lowRaw && !INCOMPARABLE_VERSIONS.has(lowRaw.toUpperCase()) ? lowRaw : '0'; + + if (v.lessThan != null && String(v.lessThan).trim()) { + const hi = String(v.lessThan).trim(); + fixes.push(hi); + ranges.push({ from: loDefault, to: hi, upper_inclusive: false }); + } + if (v.lessThanOrEqual != null && String(v.lessThanOrEqual).trim()) { + const hi = String(v.lessThanOrEqual).trim(); + fixes.push(hi); + ranges.push({ from: loDefault, to: hi, upper_inclusive: true }); + } + + const compound = lowRaw.match(/>=\s*([\w.-]+)\s*,\s*<\s*([\w.-]+)/); + if (compound) { + ranges.push({ from: compound[1], to: compound[2], upper_inclusive: false }); + fixes.push(compound[2]); + return { fixes: [...new Set(fixes.filter(Boolean))], ranges }; + } + + if (!ranges.length && lowRaw) { + const lte = lowRaw.match(/^\s*<=\s*([\w.-]+)\s*$/); + if (lte) { + const hi = lte[1].trim(); + ranges.push({ from: '0', to: hi, upper_inclusive: true }); + fixes.push(hi); + return { fixes: [...new Set(fixes.filter(Boolean))], ranges }; + } + const lt = lowRaw.match(/^\s*<\s*([\w.-]+)\s*$/); + if (lt) { + const hi = lt[1].trim(); + ranges.push({ from: '0', to: hi, upper_inclusive: false }); + fixes.push(hi); + return { fixes: [...new Set(fixes.filter(Boolean))], ranges }; + } + const gte = lowRaw.match(/^\s*>=\s*([\w.-]+)\s*$/); + if (gte) { + ranges.push({ from: gte[1].trim(), to: '99999.0.0', upper_inclusive: true }); + return { fixes, ranges }; + } + } + + return { fixes: [...new Set(fixes.filter(Boolean))], ranges }; +} + +function parseAffectedRanges(ranges: VersionRange[]): VersionRange[] { + return ranges.filter(r => r.to); +} + +function collectPackageCveData(affected: Json[]): { fixes: string[]; ranges: VersionRange[] } { + const fixes: string[] = []; + const ranges: VersionRange[] = []; + for (const entry of affected) { + for (const versionEntry of (entry.versions as Json[]) ?? []) { + const parsed = versionEntryToRanges(versionEntry); + fixes.push(...parsed.fixes); + ranges.push(...parsed.ranges); + } + } + return { fixes, ranges }; +} + +function fixesFromVersion(v: Json): string[] { + return versionEntryToRanges(v).fixes; +} + +export function parseCveDetails(record: Json, overridePackage?: string): CveDetails { + const empty = { name: '', patch_versions: [], affected_ranges: [] }; + const meta = (record.cveMetadata as Json) ?? {}; + if (String(meta.state ?? '').toUpperCase() === 'REJECTED') return empty; + + const affected = (((record.containers as Json)?.cna as Json)?.affected as Json[]) ?? []; + + if (overridePackage) { + const { fixes, ranges } = collectPackageCveData(affected); + return { + name: overridePackage, + patch_versions: [...new Set(fixes)], + affected_ranges: parseAffectedRanges(ranges), + }; + } + + const byPackage = new Map(); + for (const entry of affected) { + for (const key of ['packageName', 'product', 'vendor']) { + const name = String(entry[key] ?? '').trim(); + if (!name) continue; + const parsed = collectPackageCveData([entry]); + const slot = byPackage.get(name) ?? { fixes: [], ranges: [] }; + slot.fixes.push(...parsed.fixes); + slot.ranges.push(...parsed.ranges); + byPackage.set(name, slot); + break; + } + } + if (!byPackage.size) return empty; + + const [name, slot] = [...byPackage.entries()].sort( + (a, b) => b[1].fixes.length + b[1].ranges.length - (a[1].fixes.length + a[1].ranges.length) || a[0].localeCompare(b[0]), + )[0]; + return { + name, + patch_versions: [...new Set(slot.fixes)], + affected_ranges: parseAffectedRanges(slot.ranges), + }; +} + +/** Semver-ish sort key (from rhdh-security/triage/branch-check.py). */ +export function semverSortKey(version: string): (number | string)[] { + const s = version.trim().replace(/^[vV]/, ''); + const key: (number | string)[] = []; + for (const part of s.replace(/_/g, '-').split('.')) { + let chunk = ''; + for (const ch of part) { + if (ch >= '0' && ch <= '9') chunk += ch; + else break; + } + key.push(chunk ? Number(chunk) : 0); + const tail = chunk ? part.slice(chunk.length) : part; + if (tail) key.push(tail); + } + return key; +} + +function compareSemver(a: string, b: string): number { + const ka = semverSortKey(a); + const kb = semverSortKey(b); + for (let i = 0; i < Math.max(ka.length, kb.length); i++) { + const va = ka[i] ?? 0; + const vb = kb[i] ?? 0; + if (va === vb) continue; + if (typeof va === 'number' && typeof vb === 'number') return va < vb ? -1 : 1; + return String(va).localeCompare(String(vb)); + } + return 0; +} + +export function semverInAffectedSegment(version: string, segment: VersionRange): boolean { + const hi = segment.to.trim(); + if (!hi) return false; + let lo = (segment.from || '0').trim(); + if (INCOMPARABLE_VERSIONS.has(lo.toUpperCase())) lo = '0'; + const cmpLo = compareSemver(version, lo); + const cmpHi = compareSemver(version, hi); + if (segment.upper_inclusive) return cmpLo >= 0 && cmpHi <= 0; + return cmpLo >= 0 && cmpHi < 0; +} + +export function semverInAnyAffected(version: string, affectedRanges: VersionRange[]): boolean { + if (!affectedRanges.length) return true; + return affectedRanges.some(seg => semverInAffectedSegment(version, seg)); +} + +function depLabel(depName: string, depNode: Json): string { + const version = String(depNode.version ?? '').trim(); + return version ? `${depName}@${version}` : depName; +} + +function rootLabel(npmLsJson: Json): string | undefined { + const name = String(npmLsJson.name ?? '').trim(); + const version = String(npmLsJson.version ?? '').trim(); + if (name && version) return `${name}@${version}`; + return name || undefined; +} + +/** Return ancestor paths from root to each installation of package@version. */ +export function collectInstallationPaths(npmLsJson: Json, packageName: string, version: string): string[][] { + const paths: string[][] = []; + const seen = new Set(); + const root = rootLabel(npmLsJson); + const initialAncestors = root ? [root] : []; + + const walk = (node: Json, ancestors: string[]): void => { + for (const [depName, dep] of Object.entries(node.dependencies ?? {})) { + if (!dep || typeof dep !== 'object') continue; + const depNode = dep as Json; + const label = depLabel(depName, depNode); + const path = [...ancestors, label]; + const depVersion = String(depNode.version ?? '').trim(); + if (depName === packageName && depVersion === version) { + const key = path.join('\0'); + if (!seen.has(key)) { + seen.add(key); + paths.push(path); + } + } + walk(depNode, path); + } + }; + + walk(npmLsJson, initialAncestors); + return paths; +} + +export function formatNpmLsSpine(path: string[]): string { + if (!path.length) return ''; + if (path.length === 1) return `└── ${path[0]}`; + const lines: string[] = []; + for (let i = 0; i < path.length; i++) { + const indent = ' '.repeat(i); + if (i === 0) lines.push(`└─┬ ${path[i]}`); + else if (i === path.length - 1) lines.push(`${indent}└── ${path[i]}`); + else lines.push(`${indent}└─┬ ${path[i]}`); + } + return lines.join('\n'); +} + +export function vulnerablePatchVersions( + versions: string[], + cveIds: string[], + cveDict: Record, +): string[] { + return sortVersions( + versions.filter(ver => + cveIds.some(cveId => semverInAnyAffected(ver, cveDict[normalizeCveId(cveId)]?.affected_ranges ?? [])), + ), + ); +} + +export function vulnerabilityNoteForRow( + row: ManifestRow, + cveDict: Record, + npmLsJson: Json | undefined, +): string | undefined { + const versions = parsePatchVersions(row.patch_version); + const vulnerable = vulnerablePatchVersions(versions, row.cve_ids, cveDict); + if (!vulnerable.length) return undefined; + + const blocks: string[] = []; + for (const ver of vulnerable) { + blocks.push(`${row.package}@${ver} is still in CVE affected range; verify dev-only`); + if (npmLsJson) { + const paths = collectInstallationPaths(npmLsJson, row.package, ver); + if (paths.length) blocks.push(formatNpmLsSpine(paths[0])); + } + } + return AUTO_NOTE_PREFIX + blocks.join('\n'); +} + +export function stripAutoNotes(notes: string | undefined): string | undefined { + if (!notes) return undefined; + const idx = notes.indexOf(AUTO_NOTE_PREFIX); + const manual = (idx >= 0 ? notes.slice(0, idx) : notes).trim(); + return manual || undefined; +} + +export function mergeManifestNotes(manual: string | undefined, auto: string | undefined): string | undefined { + const parts = [stripAutoNotes(manual), auto?.trim()].filter(Boolean); + return parts.length ? parts.join('\n\n') : undefined; +} + +function npmLsJsonForPackage(pluginsWorkspace: string, packageName: string, cache: Map): Json | undefined { + if (cache.has(packageName)) return cache.get(packageName); + let parsed: Json | undefined; + try { + const raw = exec(['npm', 'ls', packageName, '--json'], { cwd: pluginsWorkspace, quiet: true, ok: [0, 1] }).trim(); + parsed = raw ? (JSON.parse(raw) as Json) : undefined; + } catch { + console.error(`warning: npm ls failed for ${packageName}`); + parsed = undefined; + } + cache.set(packageName, parsed); + return parsed; +} + +export function enrichManifestRowsWithVulnerabilityNotes( + rows: ManifestRow[], + cveDict: Record, + pluginsWorkspace: string, +): ManifestRow[] { + const npmLsCache = new Map(); + return rows.map(row => { + const npmLs = npmLsJsonForPackage(pluginsWorkspace, row.package, npmLsCache); + const auto = vulnerabilityNoteForRow(row, cveDict, npmLs); + const notes = mergeManifestNotes(row.notes, auto); + return notes ? { ...row, notes } : { cve_ids: row.cve_ids, package: row.package, patch_version: row.patch_version }; + }); +} + +async function ensureCveDict(cveIds: string[], existing: Record): Promise> { + const out = { ...existing }; + for (const cveId of cveIds) { + const key = normalizeCveId(cveId); + if (out[key]) continue; + const url = CVE_API + encodeURIComponent(key); + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(45_000) }); + if (!res.ok) throw new Error(`CVE lookup failed: HTTP ${res.status}`); + const record = (await res.json()) as Json; + if (record.dataType !== 'CVE_RECORD') throw new Error('unexpected CVE Record response'); + out[key] = parseCveDetails(record); + } + return out; +} + +async function fetchAllCveDetails(tokens: string[]): Promise> { + const out: Record = {}; + for (const token of tokens) { + const [cveId, overrides] = parseCveCliToken(token); + const url = CVE_API + encodeURIComponent(cveId); + const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(45_000) }); + if (!res.ok) throw new Error(`CVE lookup failed: HTTP ${res.status}`); + const record = (await res.json()) as Json; + if (record.dataType !== 'CVE_RECORD') throw new Error('unexpected CVE Record response'); + out[cveId] = parseCveDetails(record, overrides[0]); + } + return out; +} + +// --- shell / paths --- + +function spawnText(cmd: string, args: string[], opts: { cwd?: string; input?: string } = {}) { + return spawnSync(cmd, args, { + cwd: opts.cwd, + input: opts.input, + encoding: 'utf-8', + maxBuffer: SPAWN_MAX_BUFFER, + }); +} + +function exec(cmd: string[], opts: { cwd?: string; input?: string; ok?: number[]; quiet?: boolean; dryRun?: boolean } = {}): string { + if (opts.dryRun) { + console.log(`> ${cmd[0]}`); + return ''; + } + if (!opts.quiet) console.log(`> ${cmd[0]}`); + const result = spawnSync(cmd[0], cmd.slice(1), { + cwd: opts.cwd, + input: opts.input, + encoding: 'utf-8', + maxBuffer: SPAWN_MAX_BUFFER, + stdio: opts.quiet ? ['inherit', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error || !(opts.ok ?? [0]).includes(result.status ?? -1)) { + throw new Error(`command failed: ${cmd[0]}`); + } + return result.stdout ?? ''; +} + +function validateGitRef(ref: string): string { + const trimmed = ref.trim(); + if (/^[0-9a-f]{7,40}$/i.test(trimmed)) return trimmed; + if (/^[A-Za-z0-9._/-]+$/.test(trimmed) && !trimmed.includes('..')) return trimmed; + throw new Error('invalid git ref'); +} + +function paths(overlay: string, pluginsRepo: string) { + const name = basename(overlay); + const pluginsWorkspace = join(pluginsRepo, 'workspaces', name); + const patchesDir = join(overlay, 'patches'); + return { + overlayWorkspace: overlay, + pluginsRepo, + pluginsWorkspace, + patchesDir, + patchPath: join(patchesDir, PATCH_FILE), + manifestPath: join(patchesDir, MANIFEST), + sourceJsonPath: join(overlay, 'source.json'), + localLockPath: join(pluginsWorkspace, LOCKFILE), + name, + }; +} + +function readSource(sourceJsonPath: string) { + if (!existsSync(sourceJsonPath)) throw new Error('missing source.json in overlay workspace'); + const data = JSON.parse(readFileSync(sourceJsonPath, 'utf-8')) as Json; + const repoRef = validateGitRef(String(data['repo-ref'] ?? '')); + return { repoRef, repoUrl: String(data.repo ?? '').trim() }; +} + +function readManifest(manifestPath: string): ManifestRow[] { + if (!existsSync(manifestPath)) return []; + const data = parseYaml(readFileSync(manifestPath, 'utf-8')) as Json; + const rows = data.backports; + if (rows == null) return []; + if (!Array.isArray(rows)) throw new Error('cve-backports.yaml: backports must be a list'); + return rows as ManifestRow[]; +} + +export function releaseBranch(release: string): string { + const trimmed = release.trim(); + if (!trimmed) throw new Error('release is empty'); + return trimmed.startsWith('release-') ? trimmed : `release-${trimmed}`; +} + +export function validateReleaseBranch(release: string): string { + const branch = releaseBranch(release); + const suffix = branch.slice('release-'.length); + if (!/^[0-9]+(\.[0-9]+)*(-[A-Za-z0-9._-]+)?$/.test(suffix)) { + throw new Error('invalid release version'); + } + return branch; +} + +function gitRepoRoot(startPath: string): string { + const root = exec(['git', 'rev-parse', '--show-toplevel'], { cwd: startPath, quiet: true }).trim(); + if (!root) throw new Error('not a git repository'); + return root; +} + +export function resolveGitRemote(remotesListing: string, originUrl: string, canonicalRepo: string): string { + if (/^upstream\t/m.test(remotesListing)) return 'upstream'; + if (originUrl.includes(canonicalRepo)) return 'origin'; + throw new Error('git repo has no upstream remote (fork setup)'); +} + +export function resolveOverlayGitRemote(remotesListing: string, originUrl: string): string { + return resolveGitRemote(remotesListing, originUrl, OVERLAY_UPSTREAM_REPO); +} + +export function resolvePluginsGitRemote(remotesListing: string, originUrl: string): string { + return resolveGitRemote(remotesListing, originUrl, PLUGINS_UPSTREAM_REPO); +} + +function gitRemote(root: string, canonicalRepo: string): string { + const names = exec(['git', 'remote'], { cwd: root, quiet: true }) + .split('\n') + .map(name => name.trim()) + .filter(Boolean); + if (names.includes('upstream')) return 'upstream'; + if (names.includes('origin')) { + const originUrl = exec(['git', 'remote', 'get-url', 'origin'], { cwd: root, quiet: true }).trim(); + if (originUrl.includes(canonicalRepo)) return 'origin'; + } + throw new Error('git repo has no upstream remote (fork setup)'); +} + +function syncPluginsRepo(pluginsRepo: string, repoRef: string, dryRun?: boolean): void { + const remote = gitRemote(pluginsRepo, PLUGINS_UPSTREAM_REPO); + console.log(`Syncing plugins repo to ${repoRef} from ${remote}`); + exec(['git', 'fetch', remote], { cwd: pluginsRepo, dryRun }); + exec(['git', 'fetch', remote, repoRef], { cwd: pluginsRepo, dryRun }); + exec(['git', '-c', 'advice.detachedHead=false', 'checkout', '-q', repoRef], { cwd: pluginsRepo, dryRun }); +} + +function fetchPluginsRef(pluginsRepo: string, repoRef: string, dryRun?: boolean): void { + const remote = gitRemote(pluginsRepo, PLUGINS_UPSTREAM_REPO); + exec(['git', 'fetch', remote, repoRef], { cwd: pluginsRepo, dryRun }); +} + +function ensureOverlayRelease(overlayWorkspace: string, release: string, opts: { dryRun?: boolean; force?: boolean }): void { + const branch = validateReleaseBranch(release); + const root = gitRepoRoot(overlayWorkspace); + const remote = gitRemote(root, OVERLAY_UPSTREAM_REPO); + const remoteRef = `${remote}/${branch}`; + + if (!opts.force && !opts.dryRun) { + const dirty = exec(['git', 'status', '--porcelain'], { cwd: root, quiet: true }); + if (dirty) throw new Error('overlay repo has uncommitted changes; commit/stash or pass --force'); + } + + console.log(`Syncing overlay repo to ${branch} from ${remote}`); + exec(['git', 'fetch', remote, branch], { cwd: root, dryRun: opts.dryRun }); + + const current = opts.dryRun ? '' : exec(['git', 'branch', '--show-current'], { cwd: root, quiet: true }).trim(); + if (current !== branch) { + exec(['git', 'checkout', '-B', branch, remoteRef], { cwd: root, dryRun: opts.dryRun }); + } else { + exec(['git', 'merge', '--ff-only', remoteRef], { cwd: root, dryRun: opts.dryRun }); + } +} + +function logContext(release: string, workspaceName: string) { + console.log(`RHDH release: ${release} (${releaseBranch(release)})`); + console.log(`workspace: ${workspaceName}`); +} + +function escPkg(pkg: string): string { + return pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function sortVersions(versions: string[]): string[] { + return [...new Set(versions.filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +} + +export function formatPatchVersions(versions: string[]): string { + return sortVersions(versions).join(', '); +} + +export function parsePatchVersions(value: string): string[] { + return sortVersions(value.split(',').map(v => v.trim())); +} + +/** Collect every distinct version of pkg from `npm ls --json` output. */ +export function collectPackageVersionsFromNpmLs(tree: unknown, pkg: string): string[] { + const found: string[] = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + for (const [name, dep] of Object.entries((node as Json).dependencies ?? {})) { + if (name === pkg && dep && typeof dep === 'object') { + const ver = String((dep as Json).version ?? '').trim(); + if (ver) found.push(ver); + } + walk(dep); + } + }; + walk(tree); + return sortVersions(found); +} + +function versionsInLock(text: string, pkg: string): string[] { + return sortVersions([...text.matchAll(new RegExp(`resolution: "${escPkg(pkg)}@npm:([^"]+)"`, 'gm'))].map(m => m[1])); +} + +export function resolveVersions( + pkg: string, + pluginsWorkspace: string, + lockText: string, + cve?: CveDetails, + npmLsJson?: string, +): string[] { + if (!new RegExp(`"${escPkg(pkg)}@npm:`).test(lockText)) return []; + try { + const raw = + npmLsJson ?? + exec(['npm', 'ls', pkg, '--json'], { cwd: pluginsWorkspace, quiet: true, ok: [0, 1] }).trim(); + if (raw) { + const fromTree = collectPackageVersionsFromNpmLs(JSON.parse(raw), pkg); + if (fromTree.length) return fromTree; + } + } catch { + console.error('warning: npm ls failed'); + } + const fromLock = versionsInLock(lockText, pkg); + if (fromLock.length) return fromLock; + if (cve?.patch_versions.length) { + console.error(`warning: using MITRE patch versions for ${pkg}`); + return sortVersions(cve.patch_versions); + } + throw new Error('could not resolve package version'); +} + +export function requireLockfileChange(baseline: string, patched: string): void { + if (baseline === patched) { + throw new Error('yarn.lock unchanged from repo-ref baseline; bump vulnerable packages with yarn up before generate'); + } +} + +function writeLockPatch(baseline: string, patched: string, patchPath: string, dryRun: boolean): void { + requireLockfileChange(baseline, patched); + const tmp = mkdtempSync(join(tmpdir(), 'yarnlock-backport-')); + try { + const a = join(tmp, 'a.lock'); + const b = join(tmp, 'b.lock'); + writeFileSync(a, baseline); + writeFileSync(b, patched); + const diff = spawnText('diff', ['-u', '--label', LOCKFILE, '--label', LOCKFILE, a, b]); + if (diff.error) throw new Error('diff failed'); + if (diff.status !== 0 && diff.status !== 1) throw new Error('diff failed'); + let body = diff.stdout ?? ''; + if (!body.trim()) { + console.log('diff produced no output; no patch written'); + return; + } + if (!body.endsWith('\n')) body += '\n'; + const verify = join(tmp, 'verify'); + mkdirSync(verify); + writeFileSync(join(verify, LOCKFILE), baseline); + const check = spawnText('patch', ['-p0', '--dry-run', '-l', '--no-backup-if-mismatch', '-f'], { cwd: verify, input: body }); + if (check.status !== 0) { + throw new Error('generated patch failed dry-run verification'); + } + if (dryRun) { + console.log(`[dry-run] would write ${PATCH_FILE} (${body.length} bytes)`); + return; + } + mkdirSync(dirname(patchPath), { recursive: true }); + writeFileSync(patchPath, body); + console.log(`Wrote ${PATCH_FILE}`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +type ManifestEntry = { cveId: string; package: string; patch_versions: string[]; notes?: string }; + +function notesByCveId(manifestPath: string): Map { + const notes = new Map(); + for (const row of readManifest(manifestPath)) { + const text = stripAutoNotes(String(row.notes ?? '').trim()); + if (!text) continue; + for (const id of row.cve_ids ?? []) { + const cveId = String(id).trim(); + if (cveId) notes.set(normalizeCveId(cveId), text); + } + } + return notes; +} + +function manifestEntries(manifestPath: string): ManifestEntry[] { + const entries: ManifestEntry[] = []; + for (const row of readManifest(manifestPath)) { + const pkg = String(row.package ?? '').trim(); + const vers = parsePatchVersions(String(row.patch_version ?? '')); + const notes = String(row.notes ?? '').trim() || undefined; + if (!pkg || !vers.length) continue; + for (const id of row.cve_ids ?? []) { + const cveId = String(id).trim(); + if (cveId) entries.push({ cveId: normalizeCveId(cveId), package: pkg, patch_versions: vers, notes }); + } + } + return entries; +} + +export function buildManifestRows(entries: ManifestEntry[]): ManifestRow[] { + const groups = new Map(); + for (const { cveId, package: pkg, patch_versions, notes } of [...entries].sort((a, b) => + a.cveId.localeCompare(b.cveId) || a.package.localeCompare(b.package) || formatPatchVersions(a.patch_versions).localeCompare(formatPatchVersions(b.patch_versions)), + )) { + const patch_version = formatPatchVersions(patch_versions); + const key = `${pkg}\0${patch_version}`; + const row = groups.get(key) ?? { cve_ids: [], package: pkg, patch_version }; + if (!row.cve_ids.includes(cveId)) row.cve_ids.push(cveId); + if (notes && !row.notes) row.notes = notes; + groups.set(key, row); + } + return [...groups.values()] + .sort((a, b) => a.package.localeCompare(b.package) || a.patch_version.localeCompare(b.patch_version)) + .map(r => { + const row: ManifestRow = { cve_ids: [...r.cve_ids].sort((a, b) => a.localeCompare(b)), package: r.package, patch_version: r.patch_version }; + if (r.notes) row.notes = r.notes; + return row; + }); +} + +function manifestRows(entries: ManifestEntry[]): ManifestRow[] { + return buildManifestRows(entries); +} + +async function updateManifest(opts: { + manifestPath: string; + cveIds: string[]; + cveDict: Record; + pluginsWorkspace: string; + lockText: string; + dryRun: boolean; + reportSkips?: boolean; +}) { + const updateIds = new Set(opts.cveIds.map(id => normalizeCveId(id))); + const preservedNotes = notesByCveId(opts.manifestPath); + const entries = manifestEntries(opts.manifestPath).filter(e => !updateIds.has(e.cveId)); + + const touched = new Set(); + const skipped: string[] = []; + for (const cveId of opts.cveIds) { + const key = normalizeCveId(cveId); + const cve = opts.cveDict[key]; + if (!cve?.name) throw new Error('no npm package resolved for CVE'); + let versions: string[]; + try { + versions = resolveVersions(cve.name, opts.pluginsWorkspace, opts.lockText, cve); + } catch { + versions = []; + } + if (!versions.length) { + skipped.push(`${key} (${cve.name}): not in yarn.lock`); + if (opts.reportSkips !== false) console.error(`warning: skipping ${key} (${cve.name}): not in yarn.lock`); + continue; + } + entries.push({ + cveId: key, + package: cve.name, + patch_versions: versions, + notes: preservedNotes.get(key), + }); + touched.add(key); + } + + let rows = manifestRows(entries); + const fullCveDict = await ensureCveDict([...new Set(entries.map(e => e.cveId))], opts.cveDict); + rows = enrichManifestRowsWithVulnerabilityNotes(rows, fullCveDict, opts.pluginsWorkspace); + + if (!opts.dryRun) { + mkdirSync(dirname(opts.manifestPath), { recursive: true }); + writeFileSync( + opts.manifestPath, + MANIFEST_HEADER + stringifyYaml({ patch_file: PATCH_FILE, backports: rows }, { lineWidth: 0 }), + ); + console.log(`Wrote ${MANIFEST}`); + } + + return { touched, skipped, rows: rows.filter(r => r.cve_ids.some(id => touched.has(id))) }; +} + +function printReroll(workspaceName: string, bumps: string[]) { + console.error(`\nPatch ${PATCH_FILE} failed — yarn.lock at repo-ref no longer matches the patch baseline.`); + console.error('Re-roll: yarn up', bumps.length ? [...new Set(bumps)].join(' ') : ''); + console.error(` in plugins workspace: ${workspaceName}`); + console.error(' yarnlock-backport generate --release … --cve …'); +} + +// --- commands --- + +export async function prepareWorkspace(opts: { + release: string; + overlayWorkspace: string; + pluginsRepo: string; + skipPatch?: boolean; + force?: boolean; + dryRun?: boolean; +}): Promise { + const ctx = paths(opts.overlayWorkspace, opts.pluginsRepo); + ensureOverlayRelease(ctx.overlayWorkspace, opts.release, { dryRun: opts.dryRun, force: opts.force }); + const { repoRef } = readSource(ctx.sourceJsonPath); + logContext(opts.release, ctx.name); + + if (!opts.force && !opts.dryRun && exec(['git', 'status', '--porcelain'], { cwd: ctx.pluginsRepo, quiet: true })) { + throw new Error('plugins repo has uncommitted changes; commit/stash or pass --force'); + } + + syncPluginsRepo(ctx.pluginsRepo, repoRef, opts.dryRun); + if (!opts.dryRun && !existsSync(ctx.pluginsWorkspace)) throw new Error('missing plugins workspace'); + + console.log('Step 1a: yarn install'); + exec(['yarn', 'install'], { cwd: ctx.pluginsWorkspace, dryRun: opts.dryRun, quiet: true }); + + if (opts.skipPatch) { + console.log('Skipping patch (--skip-patch). Next: yarnlock-backport generate …'); + return; + } + + if (!existsSync(ctx.patchPath)) { + console.log(`No ${PATCH_FILE}; starting from repo-ref baseline`); + return; + } + + const patchData = readFileSync(ctx.patchPath, 'utf-8'); + const patchOpts = patchData.includes('diff --git') || patchData.includes('--- a/') ? ['-p1'] : ['-p0']; + console.log(`Step 1b: applying ${PATCH_FILE}`); + + if (opts.dryRun) { + exec(['patch', ...patchOpts, '--dry-run', '-l', '--no-backup-if-mismatch', '-f'], { cwd: ctx.pluginsWorkspace, input: patchData, quiet: true }); + return; + } + + const result = spawnText('patch', [...patchOpts, '-l', '--no-backup-if-mismatch', '-f'], { cwd: ctx.pluginsWorkspace, input: patchData }); + if (result.status !== 0) { + const bumps: string[] = []; + for (const row of readManifest(ctx.manifestPath)) { + const pkg = String(row.package ?? '').trim(); + for (const ver of parsePatchVersions(String(row.patch_version ?? ''))) { + if (pkg && ver) bumps.push(`${pkg}@${ver}`); + } + } + printReroll(ctx.name, bumps); + throw new Error(`failed to apply ${PATCH_FILE}`); + } + console.log('Patch applied. Next: yarnlock-backport generate …'); +} + +export async function generateBackport(opts: { + release: string; + overlayWorkspace: string; + pluginsRepo: string; + cve: string; + dryRun?: boolean; +}): Promise { + const ctx = paths(opts.overlayWorkspace, opts.pluginsRepo); + ensureOverlayRelease(ctx.overlayWorkspace, opts.release, { dryRun: opts.dryRun }); + const { repoRef } = readSource(ctx.sourceJsonPath); + logContext(opts.release, ctx.name); + + if (!existsSync(ctx.localLockPath)) throw new Error(`missing local ${LOCKFILE}`); + if (!opts.dryRun && !existsSync(ctx.pluginsWorkspace)) throw new Error('missing plugins workspace'); + + console.log('yarn install'); + exec(['yarn', 'install'], { cwd: ctx.pluginsWorkspace, dryRun: opts.dryRun, quiet: true }); + + fetchPluginsRef(ctx.pluginsRepo, repoRef, opts.dryRun); + const baseline = exec(['git', 'show', `${repoRef}:workspaces/${ctx.name}/${LOCKFILE}`], { cwd: ctx.pluginsRepo, quiet: true }); + const patched = readFileSync(ctx.localLockPath, 'utf-8'); + if (!opts.dryRun) requireLockfileChange(baseline, patched); + + const tokens = parseCveArg(opts.cve); + const cveDict = await fetchAllCveDetails(tokens); + const cveIds = tokens.map(t => parseCveCliToken(t)[0]); + + let { touched, skipped, rows } = await updateManifest({ + manifestPath: ctx.manifestPath, + cveIds, + cveDict, + pluginsWorkspace: ctx.pluginsWorkspace, + lockText: patched, + dryRun: true, + }); + if (!touched.size && skipped.length) throw new Error('no CVE packages from --cve are present in this workspace yarn.lock'); + + writeLockPatch(baseline, patched, ctx.patchPath, opts.dryRun ?? false); + if (!opts.dryRun) { + ({ rows, skipped } = await updateManifest({ + manifestPath: ctx.manifestPath, + cveIds, + cveDict, + pluginsWorkspace: ctx.pluginsWorkspace, + lockText: patched, + dryRun: false, + reportSkips: false, + })); + } + + if (rows.length) { + console.log(`\n${opts.dryRun ? '[dry-run] ' : ''}Recorded ${rows.length} manifest row(s)`); + } + if (skipped.length) { + console.log(`Skipped ${skipped.length} CVE entries not in yarn.lock`); + } +} diff --git a/scripts/yarnlock-backport/index.ts b/scripts/yarnlock-backport/index.ts new file mode 100755 index 000000000..b0771d634 --- /dev/null +++ b/scripts/yarnlock-backport/index.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** CLI entrypoint — see backport.ts for prepare/generate workflow. */ + +import { parseArgs } from 'node:util'; +import { generateBackport, prepareWorkspace } from './backport.ts'; + +const USAGE = `Usage: + yarnlock-backport prepare --release --overlay-workspace --plugins-repo [--skip-patch] [--force] [--dry-run] + yarnlock-backport generate --release --overlay-workspace --plugins-repo --cve [--dry-run]`; + +const common = { + release: { type: 'string' as const }, + 'overlay-workspace': { type: 'string' as const }, + 'plugins-repo': { type: 'string' as const }, + 'dry-run': { type: 'boolean' as const, default: false }, +}; + +function requirePaths(values: Record, extra: string[] = []): void { + const missing = ['release', 'overlay-workspace', 'plugins-repo', ...extra].filter(k => !values[k]); + if (missing.length) { + console.error(USAGE); + process.exit(1); + } +} + +const [command, ...rest] = process.argv.slice(2); + +try { + if (command === 'prepare') { + const { values } = parseArgs({ + args: rest, + options: { ...common, 'skip-patch': { type: 'boolean', default: false }, force: { type: 'boolean', default: false } }, + strict: true, + }); + requirePaths(values); + await prepareWorkspace({ + release: values.release!, + overlayWorkspace: values['overlay-workspace']!, + pluginsRepo: values['plugins-repo']!, + skipPatch: values['skip-patch'], + force: values.force, + dryRun: values['dry-run'], + }); + } else if (command === 'generate') { + const { values } = parseArgs({ args: rest, options: { ...common, cve: { type: 'string' as const } }, strict: true }); + requirePaths(values, ['cve']); + await generateBackport({ + release: values.release!, + overlayWorkspace: values['overlay-workspace']!, + pluginsRepo: values['plugins-repo']!, + cve: values.cve!, + dryRun: values['dry-run'], + }); + } else { + console.error(USAGE); + process.exit(command ? 1 : 0); + } +} catch (err) { + console.error(err instanceof Error ? err.message : 'command failed'); + process.exit(1); +} diff --git a/scripts/yarnlock-backport/package.json b/scripts/yarnlock-backport/package.json new file mode 100644 index 000000000..5f17f4448 --- /dev/null +++ b/scripts/yarnlock-backport/package.json @@ -0,0 +1,16 @@ +{ + "name": "yarnlock-backport", + "version": "0.1.0", + "private": true, + "description": "Generate yarn.lock CVE backport patches for RHDH plugin export overlays", + "type": "module", + "bin": { + "yarnlock-backport": "index.ts" + }, + "scripts": { + "test": "node --test" + }, + "dependencies": { + "yaml": "2.8.2" + } +} diff --git a/user-guide/06-patch-management.md b/user-guide/06-patch-management.md index f09f934f3..78dbf9eef 100644 --- a/user-guide/06-patch-management.md +++ b/user-guide/06-patch-management.md @@ -112,16 +112,16 @@ For simple changes, create manually: ### Naming Convention -``` -[number]-[description].patch +Export applies all `*.patch` files in **lexicographic (alphabetical) order** by filename +([override-sources.sh](https://github.com/redhat-developer/rhdh-plugin-export-utils/blob/main/override-sources/override-sources.sh)). -Examples: -1-fix-typescript-errors.patch -2-add-missing-export.patch -3-fix-private-root-package.patch -``` +| Class | Pattern | Examples | +|-------|---------|----------| +| **CVE yarn.lock** | `0-cve-yarn-lock.patch` | At most **one** per workspace — dependency CVE fixes in `yarn.lock` only | +| **Code / build** | `[1-9][0-9]*-[description].patch` | `1-fix-typescript-errors.patch`, `2-add-missing-export.patch` | -Patches are applied in **numerical order**. +The `0-` prefix ensures CVE lockfile patches run **before** numbered code patches (`1-`, `2-`, …). +Use numbered prefixes to control order among code patches. --- @@ -446,6 +446,170 @@ dos2unix my-patch.patch --- +## CVE yarn.lock Backports + +Use this workflow to backport CVE fixes when a transitive dependency bump in `yarn.lock` +is all that is required — without bumping `source.json:repo-ref` or changing plugin +source. Maintain at most one `yarn.lock` patch; `cve-backports.yaml` is auto-generated +to track the CVEs fixed by that patch. + +This is not for code fixes; use numbered patches (`1-*.patch`) or overlays for those. + +### Patch and manifest layout + +``` +workspaces/[ws]/patches/ +├── 0-cve-yarn-lock.patch # CVE dependency bumps (max one; applied first) +├── cve-backports.yaml # Auto-generated CVE tracking +└── 1-fix-something.patch # Optional code/build patches (applied after 0-) +``` + +### Workflow + +**Prerequisites:** Node.js (see `versions.json`), `git`, `yarn`, `patch`, `diff`, and `npm`. Install tool dependencies once with `npm install` in `scripts/yarnlock-backport/`. + +For fork clones of [rhdh-plugin-export-overlays](https://github.com/redhat-developer/rhdh-plugin-export-overlays), add the upstream remote once (if not already present): + +```bash +git remote add upstream https://github.com/redhat-developer/rhdh-plugin-export-overlays.git +git fetch upstream +``` + +Step 0 uses `upstream` when available (otherwise `origin` on a direct upstream clone) to fetch the latest release branch. + +Prepare needs network for overlay `git fetch`/`git merge` and plugins-repo `git fetch`; generate additionally fetches CVE records from MITRE. + +From `scripts/yarnlock-backport/` (after `npm install`), run `yarnlock-backport …` or `npx yarnlock-backport …`: + +```bash +cd scripts/yarnlock-backport && npm install # first time only + +export OVERLAY_WORKSPACE=/workspaces/orchestrator +export PLUGINS_REPO= +``` + +Both paths must be absolute. When testing tooling on a feature branch while the +overlay content lives on a release branch, point `--overlay-workspace` at a +[git worktree](https://git-scm.com/docs/git-worktree) checked out at +`release-{version}` (prepare step 0 syncs the overlay repo root to that branch). + +**Step 1 — Prepare plugins workspace** + +Reset the plugins workspace to the pinned `repo-ref` and leave `yarn.lock` in a +known-good state — either with the existing CVE patch applied, or ready for new +fixes in Step 2. + +```bash +yarnlock-backport prepare \ + --release 1.10 \ + --overlay-workspace "$OVERLAY_WORKSPACE" \ + --plugins-repo "$PLUGINS_REPO" +``` + +Sub-steps (all in prepare): + +| Step | Action | +|------|--------| +| **0** | Fetch and fast-forward overlay repo branch `release-{release}` from `upstream` (or `origin` on a direct clone) | +| **1** | Checkout plugins repo at `repo-ref` from `source.json` | +| **1a** | `yarn install` | +| **1b** | Apply `0-cve-yarn-lock.patch` if present | + +| Option | Required | Description | +|--------|----------|-------------| +| `--release` | yes | RHDH release line (e.g. `1.10` → branch `release-1.10` on rhdh-plugin-export-overlays) | +| `--overlay-workspace` | yes | Absolute path to the overlays workspace; reads `source.json` and `patches/` | +| `--plugins-repo` | yes | Absolute path to the local rhdh-plugins repository root | +| `--skip-patch` | no | Skip step 1b; do not apply `0-cve-yarn-lock.patch` | +| `--force` | no | Skip the clean-repo check on overlay and plugins repos and proceed when either has uncommitted changes (does not discard them) | +| `--dry-run` | no | Print planned git, yarn, and patch commands without executing them | + +**Re-roll when patch apply fails (step 1b)** + +If step 1b fails (`Hunk #N FAILED`), the `yarn.lock` at the current source ref no +longer matches the patch baseline. `yarn install` (step 1a) has already run — discard +the stale patch, then restore known CVE bumps with `yarn up` for each package in +`cve-backports.yaml` (prepare prints the exact command). Continue to **Step 2** for +any additional CVE fixes, then **Step 3**. + +Step 1 only establishes the baseline; Step 2 is where new or extra dependency +bumps are applied before generating a fresh patch in Step 3. + +**Step 2 — Apply CVE fixes locally** + +Bump vulnerable transitive dependencies in `yarn.lock` (e.g. `yarn up`). This +includes fixes beyond what was restored during a re-roll. Changes may be uncommitted. + +**Step 3 — Generate patch + manifest** + +Runs `yarn install` in the plugins workspace (syncs `node_modules` for `npm ls`), +then diffs **repo-ref baseline** `yarn.lock` vs the local file, writes/replaces +`0-cve-yarn-lock.patch`, and merges `cve-backports.yaml` for the CVE ids in `--cve`. +Existing manifest rows are preserved; pass every CVE id you want recorded when +regenerating after a re-roll (not only newly added ones). + +```bash +yarnlock-backport generate \ + --release 1.10 \ + --overlay-workspace "$OVERLAY_WORKSPACE" \ + --plugins-repo "$PLUGINS_REPO" \ + --cve 'CVE-2026-44487,CVE-2026-44494' +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--release` | yes | RHDH release line (e.g. `1.10` → branch `release-1.10` on rhdh-plugin-export-overlays) | +| `--overlay-workspace` | yes | Absolute path to the overlays workspace; reads `source.json` and `patches/` | +| `--plugins-repo` | yes | Absolute path to the local rhdh-plugins repository root | +| `--cve` | yes | Comma-separated CVE ids to fetch from MITRE and record in the manifest; optional `/package` override per id (e.g. `CVE-2026-1234/axios`) | +| `--dry-run` | no | Validate inputs and print actions without writing the patch or manifest | + +**Step 4 — Verify OCI artifacts (future)** + +Planned: `yarnlock-backport verify` to compare published OCI image +`node_modules` against the manifest after `/publish`. + +### Manifest format (`cve-backports.yaml`) + +Auto-generated by `yarnlock-backport generate`. CVEs that share the same +`package` and `patch_version` are **merged into one row** with a `cve_ids` list: + +```yaml +patch_file: 0-cve-yarn-lock.patch + +backports: + - cve_ids: + - CVE-2026-44486 + - CVE-2026-44487 + package: axios + patch_version: 1.18.1 + notes: MITRE fix >= 1.16.0; shipped in backend OCI node_modules + - cve_ids: + - CVE-2026-12143 + package: form-data + patch_version: 2.5.6, 4.0.6 +``` + +| Field | Set by | Required | Description | +|-------|--------|----------|-------------| +| `patch_file` | script | yes | Always `0-cve-yarn-lock.patch` | +| `cve_ids` | script | yes | CVE identifiers fixed by the same package version | +| `package` | script | yes | npm package from MITRE CVE record | +| `patch_version` | script | yes | Resolved version(s) in patched `yarn.lock` (comma-separated when a package appears on multiple major lines) | +| `notes` | manual + script | Optional manual context (preserved). Script adds `[auto]` block when a listed `patch_version` is still in the CVE affected range, with an `npm ls` spine — verify dev-only | + +### Lifecycle + +| Event | Action | +|-------|--------| +| First CVE backport | Run generate script; commit patch + manifest | +| Add CVEs | Re-run generate with expanded `--cve` list | +| Re-roll patch | Re-run generate after local `yarn.lock` changes | +| Patch fails at step 1b | `yarn up` from manifest → Step 2 → Step 3 (full `--cve` list) | +| Retire backport | Delete patch + manifest when fixes are upstream in `repo-ref` | + +--- + ## Related Documentation - [02 - Export Tools](./02-export-tools.md) – Overlays vs Patches comparison