From f1539cc16b0129dc42ba49c5f0eeb2670e05382f Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Mon, 20 Jul 2026 18:39:13 -0300 Subject: [PATCH] feat: support affectedVersions as object Due to a recent update on the vulnerabilities.json schema, this needs to be adjusted. --- components/git/security.js | 5 ++- lib/security-release/security-release.js | 54 +++++++++++++++++++++++- lib/security_blog.js | 8 ++-- lib/update_security_release.js | 7 ++- test/unit/security_release.test.js | 41 ++++++++++++++++++ 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/components/git/security.js b/components/git/security.js index 452ddc9f..393ebea9 100644 --- a/components/git/security.js +++ b/components/git/security.js @@ -11,6 +11,7 @@ import UpdateSecurityRelease from '../../lib/update_security_release.js'; import SecurityBlog from '../../lib/security_blog.js'; import SecurityAnnouncement from '../../lib/security-announcement.js'; import { forceRunAsync } from '../../lib/run.js'; +import { getAffectedVersionLines } from '../../lib/security-release/security-release.js'; export const command = 'security [options]'; export const describe = 'Manage an in-progress security release or start a new one.'; @@ -289,7 +290,7 @@ async function applySecurityPatches(cli) { let patchedVersion; for (const { affectedVersions, prURL, title } of Object.values(dependencies).flat()) { - if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue; + if (!getAffectedVersionLines(affectedVersions).includes(`${nodeMajorVersion}.x`)) continue; cli.separator(`Taking care of ${title}...`); if (await skipIfExisting(cli, prURL)) continue; @@ -330,7 +331,7 @@ async function applySecurityPatches(cli) { cli.stopSpinner(`Fetched all PRs labeled for v${nodeMajorVersion}.x`); for (const { affectedVersions, prURL, cveIds, patchedVersions } of reports) { - if (!affectedVersions.includes(`${nodeMajorVersion}.x`)) continue; + if (!getAffectedVersionLines(affectedVersions).includes(`${nodeMajorVersion}.x`)) continue; patchedVersion ??= patchedVersions?.find(v => v.startsWith(`${nodeMajorVersion}.`)); cli.separator(`Taking care of ${cveIds.join(', ')}...`); diff --git a/lib/security-release/security-release.js b/lib/security-release/security-release.js index fbe29d1f..80890a05 100644 --- a/lib/security-release/security-release.js +++ b/lib/security-release/security-release.js @@ -12,6 +12,8 @@ export const NEXT_SECURITY_RELEASE_REPOSITORY = { }; const SEVERITY_RANKS = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; +const RELEASE_LINE_RE = /^v?(\d+)(?:\.x)?$/; +const SEMVER_RE = /^v?(\d+)\.\d+\.\d+/; export const PLACEHOLDERS = { releaseDate: '%RELEASE_DATE%', @@ -193,6 +195,56 @@ export function getHighestSeverityAnnouncement(reports, releaseLine = 'this rele }.`; } +function normalizeReleaseLine(version) { + if (typeof version !== 'string') return; + + const trimmed = version.trim(); + let match = RELEASE_LINE_RE.exec(trimmed); + if (match) return `${match[1]}.x`; + + match = SEMVER_RE.exec(trimmed); + if (match) return `${match[1]}.x`; +} + +function uniqueReleaseLines(releaseLines) { + return [...new Set(releaseLines.filter(Boolean))]; +} + +export function getAffectedVersionLines(affectedVersions) { + if (!affectedVersions) return []; + + if (typeof affectedVersions === 'string') { + return uniqueReleaseLines(affectedVersions.split(',').map(normalizeReleaseLine)); + } + + if (Array.isArray(affectedVersions)) { + return uniqueReleaseLines(affectedVersions.flatMap((version) => { + if (typeof version === 'string') { + return normalizeReleaseLine(version); + } + + if (version && typeof version === 'object') { + return normalizeReleaseLine(version.version); + } + + return []; + })); + } + + if (typeof affectedVersions === 'object') { + const releaseLines = Object.keys(affectedVersions).map(normalizeReleaseLine); + if (releaseLines.some(Boolean)) { + return uniqueReleaseLines(releaseLines); + } + + return uniqueReleaseLines( + Object.values(affectedVersions).flatMap(getAffectedVersionLines) + ); + } + + return []; +} + export function promptDependencies(cli) { return cli.prompt('Enter the link to the dependency update PR (leave empty to exit): ', { defaultAnswer: '', @@ -344,7 +396,7 @@ export class SecurityRelease { getAffectedVersions(content) { const affectedVersions = new Set(); for (const report of Object.values(content.reports)) { - for (const affectedVersion of report.affectedVersions) { + for (const affectedVersion of getAffectedVersionLines(report.affectedVersions)) { affectedVersions.add(affectedVersion); } } diff --git a/lib/security_blog.js b/lib/security_blog.js index d52abad2..57a79843 100644 --- a/lib/security_blog.js +++ b/lib/security_blog.js @@ -7,6 +7,7 @@ import { validateDate, SecurityRelease, commitAndPushVulnerabilitiesJSON, + getAffectedVersionLines, getHighestSeverityAnnouncement, writeSecurityFile, } from './security-release/security-release.js'; @@ -274,7 +275,7 @@ export default class SecurityBlog extends SecurityRelease { } template += `${report.summary}\n\n`; - const releaseLines = report.affectedVersions.join(', '); + const releaseLines = getAffectedVersionLines(report.affectedVersions).join(', '); template += `Impact:\n\n- This vulnerability affects all users\ in active release lines: ${releaseLines}\n\n`; if (!report.patchAuthors) { @@ -292,7 +293,8 @@ export default class SecurityBlog extends SecurityRelease { let template = '\nThis security release includes the following dependency' + ' updates to address public vulnerabilities:\n'; for (const [dependency, { versions, affectedVersions }] of Object.entries(dependencyUpdates)) { - template += `- ${dependency} (${versions.join(', ')}) on ${affectedVersions.join(', ')}\n`; + const releaseLines = getAffectedVersionLines(affectedVersions); + template += `- ${dependency} (${versions.join(', ')}) on ${releaseLines.join(', ')}\n`; } return template; } @@ -342,7 +344,7 @@ export default class SecurityBlog extends SecurityRelease { throw new Error(`severity.rating not found for report ${report.id}.`); } - for (const version of report.affectedVersions) { + for (const version of getAffectedVersionLines(report.affectedVersions)) { if (!impact.has(version)) impact.set(version, []); impact.get(version).push(report); } diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 7cdb6a64..69fc6871 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -4,6 +4,7 @@ import { commitAndPushVulnerabilitiesJSON, validateDate, pickReport, + getAffectedVersionLines, getReportSeverity, getSummary, confirmSecurityStep, @@ -218,6 +219,7 @@ export default class UpdateSecurityRelease extends SecurityRelease { const eolVersions = (await nv('eol')); for (const report of reports) { const { id, summary, title, affectedVersions, cveIds, link } = report; + const affectedVersionLines = getAffectedVersionLines(affectedVersions); // skip if already has a CVE // risky because the CVE associated might be // mentioned in the report and not requested by Node @@ -249,7 +251,7 @@ export default class UpdateSecurityRelease extends SecurityRelease { `Request a CVE for: \n Title: ${title}\n Link: ${link}\n -Affected versions: ${affectedVersions.join(', ')}\n +Affected versions: ${affectedVersionLines.join(', ')}\n Vector: ${cvss_vector_string}\n Summary: ${summary}\n`, { defaultAnswer: true }); @@ -257,7 +259,7 @@ Summary: ${summary}\n`, if (!create) continue; const { h1AffectedVersions, patchedVersions } = - await this.calculateVersions(affectedVersions, supportedVersions, eolVersions); + await this.calculateVersions(affectedVersionLines, supportedVersions, eolVersions); const body = { data: { type: 'cve-request', @@ -309,6 +311,7 @@ Summary: ${summary}\n`, } async calculateVersions(affectedVersions, supportedVersions, eolVersions) { + affectedVersions = getAffectedVersionLines(affectedVersions); const h1AffectedVersions = []; const patchedVersions = []; let isPatchRelease = true; diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index 67ea65b6..ae13364a 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -7,6 +7,7 @@ import PrepareSecurityRelease, { getNextTuesdayReleaseDates } from '../../lib/prepare_security.js'; import { + getAffectedVersionLines, getHighestSeverityAnnouncement } from '../../lib/security-release/security-release.js'; @@ -95,6 +96,26 @@ describe('security_release: severity announcement', () => { }); }); +describe('security_release: affected versions', () => { + it('normalizes legacy arrays, strings, and keyed version objects', () => { + assert.deepStrictEqual( + getAffectedVersionLines(['v24.x', '22.x']), + ['24.x', '22.x'] + ); + assert.deepStrictEqual( + getAffectedVersionLines('24.x, v22.x'), + ['24.x', '22.x'] + ); + assert.deepStrictEqual( + getAffectedVersionLines({ + '24.x': { affected: '<=24.4.0', patched: '24.4.1' }, + '22.x': { affected: '<=22.17.0', patched: '22.17.1' } + }), + ['24.x', '22.x'] + ); + }); +}); + describe('security_release: release date choices', () => { it('starts with the next Tuesday when today is Tuesday', () => { assert.deepStrictEqual( @@ -187,6 +208,26 @@ describe('security_blog: pre-release severity wording', () => { ); }); + it('supports keyed affected version objects with legacy arrays', () => { + const blog = new SecurityBlog(); + const content = { + reports: [ + report(1, 'low', { + '24.x': { affected: '<=24.4.0', patched: '24.4.1' }, + '22.x': { affected: '<=22.17.0', patched: '22.17.1' } + }), + report(2, 'high', ['22.x']) + ] + }; + + assert.strictEqual(blog.getAffectedVersions(content), '24.x, 22.x'); + assert.strictEqual( + blog.getImpact(content), + 'The highest severity issue fixed in the 24.x release line is LOW.\n' + + 'The highest severity issue fixed in the 22.x release line is HIGH.' + ); + }); + it('replaces the pre-release template placeholder with the highest severity sentence', () => { const blog = new SecurityBlog(); const template = blog.getSecurityPreReleaseTemplate();