Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions components/git/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(', ')}...`);

Expand Down
54 changes: 53 additions & 1 deletion lib/security-release/security-release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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);
}
}
Expand Down
8 changes: 5 additions & 3 deletions lib/security_blog.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
validateDate,
SecurityRelease,
commitAndPushVulnerabilitiesJSON,
getAffectedVersionLines,
getHighestSeverityAnnouncement,
writeSecurityFile,
} from './security-release/security-release.js';
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 5 additions & 2 deletions lib/update_security_release.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
commitAndPushVulnerabilitiesJSON,
validateDate,
pickReport,
getAffectedVersionLines,
getReportSeverity,
getSummary,
confirmSecurityStep,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -249,15 +251,15 @@ 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 });

if (!create) continue;

const { h1AffectedVersions, patchedVersions } =
await this.calculateVersions(affectedVersions, supportedVersions, eolVersions);
await this.calculateVersions(affectedVersionLines, supportedVersions, eolVersions);
const body = {
data: {
type: 'cve-request',
Expand Down Expand Up @@ -309,6 +311,7 @@ Summary: ${summary}\n`,
}

async calculateVersions(affectedVersions, supportedVersions, eolVersions) {
affectedVersions = getAffectedVersionLines(affectedVersions);
const h1AffectedVersions = [];
const patchedVersions = [];
let isPatchRelease = true;
Expand Down
41 changes: 41 additions & 0 deletions test/unit/security_release.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import PrepareSecurityRelease, {
getNextTuesdayReleaseDates
} from '../../lib/prepare_security.js';
import {
getAffectedVersionLines,
getHighestSeverityAnnouncement
} from '../../lib/security-release/security-release.js';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
Loading