diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec3eb494a1..d10fe11c2b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,10 @@ ', index + 4); + if (commentEnd === -1) return false; + index = commentEnd + 3; + } else if (/\S/.test(value[index])) { + return true; + } else { + index += 1; + } + } + return false; +} + +export function selectRelease({ currentTag, manifestVersion = firstReleaseVersion, commits, mode = 'auto' }) { + const classified = commits.map((commit) => ({ ...commit, classification: classifyConventionalCommit(commit.subject, commit.body) })); + const invalid = classified.filter(({ classification }) => classification.level === 'invalid'); + if (invalid.length > 0) { + return { status: 'blocked', reason: `Invalid Conventional Commit title: ${invalid[0].subject}`, commits: classified }; + } + + const releasable = classified.filter(({ classification }) => classification.level !== 'none'); + if (mode === 'promote-stable') { + const current = parseVersion(currentTag ?? manifestVersion); + if (current.beta === null) return { status: 'blocked', reason: 'Stable promotion is available only from a beta.', commits: classified }; + return releaseResult(`${current.major}.${current.minor}.${current.patch}`, 'latest', 'explicit stable promotion', classified); + } + if (mode !== 'auto') return { status: 'blocked', reason: `Unknown release mode: ${mode}`, commits: classified }; + if (releasable.length === 0) return { status: 'none', reason: 'No release-relevant commits accumulated.', commits: classified }; + + if (!currentTag) { + return releaseResult(manifestVersion, 'next', 'first RxJS 9 beta', classified); + } + + const current = parseVersion(currentTag); + if (current.major !== 9) + return { status: 'blocked', reason: `The latest release tag is not an RxJS 9 version: ${currentTag}`, commits: classified }; + if (current.beta !== null) { + return releaseResult( + `${current.major}.${current.minor}.${current.patch}-beta.${current.beta + 1}`, + 'next', + 'beta counter increment', + classified + ); + } + + const levels = new Set(releasable.map(({ classification }) => classification.level)); + if (levels.has('breaking')) { + return { + status: 'blocked', + reason: 'A breaking change after stable RxJS 9 requires 10.0.0; the 9.x release train is blocked.', + commits: classified, + }; + } + if (levels.has('feature')) { + return releaseResult(`${current.major}.${current.minor + 1}.0`, 'latest', 'highest accumulated change is a feature', classified); + } + return releaseResult( + `${current.major}.${current.minor}.${current.patch + 1}`, + 'latest', + 'highest accumulated change is a fix', + classified + ); +} + +function releaseResult(version, channel, reason, commits) { + return { channel, commits, reason, status: 'planned', version }; +} + +export function validatePullRequestTitle(title) { + const classification = classifyConventionalCommit(title); + if (classification.level === 'invalid') throw new Error(classification.reason); + return classification; +} diff --git a/scripts/release/release-policy.test.mjs b/scripts/release/release-policy.test.mjs new file mode 100644 index 0000000000..6f648c5a2b --- /dev/null +++ b/scripts/release/release-policy.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import test from 'node:test'; +import { classifyConventionalCommit, selectRelease, validatePullRequestTitle } from './release-policy.mjs'; + +const commit = (subject, body = '') => ({ body, sha: subject.padEnd(40, '0').slice(0, 40), subject }); + +test('increments only the beta counter for fixes, features, and breaking changes during beta', () => { + for (const subject of ['fix(core): repair teardown', 'feat(map): add projection option', 'feat(api)!: remove legacy form']) { + const result = selectRelease({ currentTag: '9.0.0-beta.7', commits: [commit(subject)] }); + assert.equal(result.channel, 'next'); + assert.equal(result.reason, 'beta counter increment'); + assert.equal(result.status, 'planned'); + assert.equal(result.version, '9.0.0-beta.8'); + } +}); + +test('selects patch and minor releases by the highest accumulated stable change', () => { + assert.equal(selectRelease({ currentTag: '9.2.3', commits: [commit('fix(core): correct error')] }).version, '9.2.4'); + const result = selectRelease({ + currentTag: '9.2.3', + commits: [commit('fix(core): correct error'), commit('feat(test): add helper')], + }); + assert.equal(result.version, '9.3.0'); + assert.equal(result.channel, 'latest'); +}); + +test('blocks breaking stable changes and permits explicit stable promotion', () => { + assert.equal(selectRelease({ currentTag: '9.2.3', commits: [commit('feat(api)!: break shape')] }).status, 'blocked'); + assert.match(selectRelease({ currentTag: '9.2.3', commits: [commit('feat(api)!: break shape')] }).reason, /10\.0\.0/); + const promotion = selectRelease({ currentTag: '9.0.0-beta.9', commits: [commit('docs: clarify example')], mode: 'promote-stable' }); + assert.equal(promotion.channel, 'latest'); + assert.equal(promotion.reason, 'explicit stable promotion'); + assert.equal(promotion.status, 'planned'); + assert.equal(promotion.version, '9.0.0'); +}); + +test('does not release documentation or internal chores', () => { + assert.equal( + selectRelease({ currentTag: '9.1.0', commits: [commit('docs: explain release'), commit('chore: format files')] }).status, + 'none' + ); +}); + +test('uses beta.0 for the first release and validates Conventional Commit titles', () => { + assert.equal(selectRelease({ currentTag: null, commits: [commit('feat(core): initial beta')] }).version, '9.0.0-beta.0'); + assert.equal(classifyConventionalCommit('fix(core): correct teardown').level, 'fix'); + assert.throws(() => validatePullRequestTitle('Correct teardown'), /supported Conventional Commit/); +}); + +test('ignores the empty pull-request template breaking-change placeholder', () => { + assert.equal( + classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** ').level, + 'fix' + ); + assert.equal( + classifyConventionalCommit('fix(core): correct teardown', 'BREAKING CHANGE: changes cancellation ownership').level, + 'breaking' + ); + assert.equal( + classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** changes cancellation ownership').level, + 'breaking' + ); + assert.equal( + classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** ').level, + 'fix' + ); + assert.equal( + classifyConventionalCommit('fix(core): correct teardown', '**BREAKING CHANGE:** changes cancellation ownership') + .level, + 'breaking' + ); +}); + +test('selects beta versions monotonically for arbitrary counters and releasable titles', () => { + fc.assert( + fc.property( + fc.nat({ max: 1_000_000 }), + fc.constantFrom('fix(core): repair lifecycle', 'feat(core): add operator', 'feat(core)!: change contract'), + (beta, subject) => { + const result = selectRelease({ currentTag: `9.0.0-beta.${beta}`, commits: [commit(subject)] }); + assert.equal(result.version, `9.0.0-beta.${beta + 1}`); + assert.equal(result.channel, 'next'); + } + ), + { numRuns: 200 } + ); +}); diff --git a/scripts/release/stage-release.mjs b/scripts/release/stage-release.mjs new file mode 100644 index 0000000000..d40c84a7a0 --- /dev/null +++ b/scripts/release/stage-release.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { assertNpmWebUrl, releasePackages, stagedPackagesVariable } from './release-config.mjs'; +import { verifyCandidate } from './release-candidate.mjs'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const [command, directory = '.release/candidate', outputFile = '.release/staged-release.json'] = process.argv.slice(2); + const candidateRoot = path.resolve(root, directory); + const outputPath = path.resolve(root, outputFile); + if (command === 'publish') await stageCandidate(candidateRoot, outputPath); + else if (command === 'comment') process.stdout.write(await renderStagingComment(outputPath, process.env[stagedPackagesVariable] ?? '')); + else throw new Error('Usage: stage-release.mjs [candidate-directory] [staged-result-file]'); +} + +async function stageCandidate(candidateRoot, outputPath) { + const manifest = await verifyCandidate(candidateRoot, { + expectedSourceCommit: process.env.RELEASE_EXPECTED_SOURCE_COMMIT, + }); + const state = { + schemaVersion: 1, + version: manifest.version, + channel: manifest.channel, + sourceCommit: manifest.sourceCommit, + status: 'staging', + packages: [], + }; + await persist(); + try { + for (const expected of releasePackages) { + const entry = manifest.packages.find(({ name }) => name === expected.name); + const result = spawnSync('npm', ['stage', 'publish', path.join(candidateRoot, entry.filename), '--tag', manifest.channel, '--json'], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, NPM_CONFIG_PROVENANCE: 'true' }, + }); + if (result.status !== 0) throw new Error(`Staging ${entry.name} failed (${result.status}).\n${result.stdout}${result.stderr}`); + const parsed = parseStageOutput(result.stdout); + const stagedEntry = { + name: entry.name, + version: entry.version, + distTag: manifest.channel, + stageId: parsed.stageId, + ...(parsed.url ? { url: assertNpmWebUrl(parsed.url, `${entry.name} staged-package URL`) } : {}), + sha512: entry.sha512, + integrity: entry.integrity, + stagedDigestVerified: false, + }; + state.packages.push(stagedEntry); + await persist(); + stagedEntry.stagedSha512 = await downloadAndVerifyStage(parsed.stageId, entry); + stagedEntry.stagedDigestVerified = true; + await persist(); + } + state.status = 'staged'; + await persist(); + } catch (error) { + state.status = 'partial'; + state.error = error.message; + await persist(); + throw error; + } + + async function persist() { + await writeFile(outputPath, `${JSON.stringify(state, null, 2)}\n`); + } +} + +async function downloadAndVerifyStage(stageId, entry) { + const downloadRoot = await mkdtemp(path.join(tmpdir(), 'rxjs-npm-stage-download-')); + try { + const result = spawnSync('npm', ['stage', 'download', stageId], { + cwd: downloadRoot, + encoding: 'utf8', + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`Downloading npm stage ${stageId} failed (${result.status}).\n${result.stdout}${result.stderr}`); + } + return await verifyDownloadedStage(downloadRoot, entry); + } finally { + await rm(downloadRoot, { recursive: true, force: true }); + } +} + +export async function verifyDownloadedStage(downloadRoot, entry) { + const files = (await readdir(downloadRoot)).filter((file) => file.endsWith('.tgz')); + if (files.length !== 1) throw new Error(`npm stage download produced ${files.length} tarballs; expected exactly one.`); + const bytes = await readFile(path.join(downloadRoot, files[0])); + const stagedSha512 = createHash('sha512').update(bytes).digest('hex'); + if (bytes.byteLength !== entry.size || stagedSha512 !== entry.sha512) { + throw new Error(`${entry.name} npm-staged bytes do not match the qualified tarball. Reject every stage in this candidate.`); + } + return stagedSha512; +} + +export function parseStageOutput(stdout) { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + parsed = null; + } + const values = parsed ? flatten(parsed) : []; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + const stageId = + values.find(({ key, value }) => /^stage-?id$/i.test(key) && typeof value === 'string' && uuid.test(value))?.value ?? + values.find(({ key, value }) => /^id$/i.test(key) && typeof value === 'string' && uuid.test(value))?.value ?? + stdout.match(/\b(?:stage(?:\s+|[-_])?id)\s*[:=]\s*["']?([0-9a-f-]{36})/i)?.[1]; + if (!stageId || !uuid.test(stageId)) { + throw new Error(`npm did not return a supported stage ID. Preserve this output and inspect npm staging:\n${stdout}`); + } + const returnedUrl = values.find( + ({ key, value }) => /url|href|link/i.test(key) && typeof value === 'string' && value.startsWith('http') + )?.value; + let url; + if (returnedUrl) { + try { + url = assertNpmWebUrl(returnedUrl, 'npm returned stage URL'); + } catch { + // Registry/API links are not rendered. Stage IDs remain the supported fallback. + } + } + return { stageId, ...(url ? { url } : {}) }; +} + +function flatten(value, key = '') { + if (Array.isArray(value)) return value.flatMap((item) => flatten(item, key)); + if (value && typeof value === 'object') return Object.entries(value).flatMap(([childKey, child]) => flatten(child, childKey)); + return [{ key, value }]; +} + +export async function renderStagingComment(stagedResultPath, configuredUrl) { + const state = JSON.parse(await readFile(stagedResultPath, 'utf8')); + const stagedPackagesUrl = assertNpmWebUrl(configuredUrl, stagedPackagesVariable); + const complete = + state.status === 'staged' && + state.packages.length === releasePackages.length && + state.packages.every((entry, index) => entry.name === releasePackages[index].name && entry.stagedDigestVerified === true); + const rows = state.packages + .map( + (entry, index) => + `| ${index + 1} | \`${entry.name}\` | \`${entry.version}\` | \`${entry.distTag}\` | \`${entry.stageId}\` | \`${entry.sha512}\` | ${ + entry.stagedDigestVerified ? 'verified' : '**not verified**' + } |${entry.url ? ` [Open stage](${assertNpmWebUrl(entry.url, `${entry.name} stage URL`)}) |` : ' — |'}` + ) + .join('\n'); + const command = complete ? 'approve' : 'reject'; + const commands = state.packages.map(({ name, stageId }) => `# ${name}\nnpm stage ${command} ${stageId}`).join('\n\n'); + return ( + `# ${complete ? 'npm approval required' : 'DO NOT APPROVE — reject partial staging'} for ${state.version}\n\n` + + (complete + ? `> [!CAUTION]\n> RxJS cannot be unpublished. Verify the package, version, channel, stage ID, and SHA-512 below before approving. Approve \`rxjs\` last.\n\n` + : `> [!WARNING]\n> Staging or staged-digest verification did not complete. Approve nothing. Open npm and reject every stage for this candidate with TFA, including any stage missing from this receipt, then create a fresh candidate and version.\n\n`) + + `[**Open npm Staged Packages**](${stagedPackagesUrl})\n\n` + + `| Order | Package | Version | Dist-tag | Stage ID | Qualified and staged SHA-512 | Staged download | Direct stage |\n| ---: | --- | --- | --- | --- | --- | --- | --- |\n${rows}\n\n` + + `## CLI fallback\n\n\`\`\`sh\n${commands}\n\`\`\`\n\n` + + `Every command requires npm TFA. If any value differs from this comment, reject the stages and create a fresh candidate.\n` + ); +} diff --git a/scripts/release/stage-release.test.mjs b/scripts/release/stage-release.test.mjs new file mode 100644 index 0000000000..21446c61df --- /dev/null +++ b/scripts/release/stage-release.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fc from 'fast-check'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { parseStageOutput, renderStagingComment, verifyDownloadedStage } from './stage-release.mjs'; +import { releasePackages } from './release-config.mjs'; +import { assertNpmWebUrl } from './release-config.mjs'; + +test('extracts supported stage IDs and validates returned npm links', () => { + const stageId = '123e4567-e89b-42d3-a456-426614174000'; + assert.deepEqual(parseStageOutput(`{"id":"rxjs@9.0.0","stageId":"${stageId}","url":"https://www.npmjs.com/example"}`), { + stageId, + url: 'https://www.npmjs.com/example', + }); + assert.deepEqual(parseStageOutput(`{"stageId":"${stageId}","url":"https://registry.npmjs.org/internal"}`), { + stageId, + }); + assert.throws(() => parseStageOutput('{"ok":true}'), /supported stage ID/); +}); + +test('parses arbitrary supported UUID stage output and never renders a foreign npm origin', () => { + fc.assert( + fc.property(fc.uuid(), fc.string({ maxLength: 100 }), (stageId, suffix) => { + const parsed = parseStageOutput(JSON.stringify({ stageId, url: `https://evil.example/${encodeURIComponent(suffix)}` })); + assert.equal(parsed.stageId, stageId); + assert.equal(parsed.url, undefined); + assert.throws(() => assertNpmWebUrl(`https://npmjs.com/${encodeURIComponent(suffix)}`), /exact https:\/\/www\.npmjs\.com/); + }), + { numRuns: 200 } + ); +}); + +test('requires the downloaded npm stage to match the qualified bytes', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-download-')); + try { + const bytes = Buffer.from('exact staged tarball'); + const sha512 = createHash('sha512').update(bytes).digest('hex'); + await writeFile(path.join(root, 'download.tgz'), bytes); + assert.equal(await verifyDownloadedStage(root, { name: 'rxjs', size: bytes.byteLength, sha512 }), sha512); + await writeFile(path.join(root, 'download.tgz'), 'changed'); + await assert.rejects(() => verifyDownloadedStage(root, { name: 'rxjs', size: bytes.byteLength, sha512 }), /do not match/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('renders the exact approval order, hashes, links, and CLI fallbacks', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-comment-')); + try { + const file = path.join(root, 'state.json'); + await writeFile( + file, + JSON.stringify({ + status: 'staged', + version: '9.0.0-beta.3', + packages: releasePackages.map(({ name }, index) => ({ + name, + version: '9.0.0-beta.3', + distTag: 'next', + stageId: `stage_${index}`, + sha512: `digest_${index}`, + stagedDigestVerified: true, + })), + }) + ); + const comment = await renderStagingComment(file, 'https://www.npmjs.com/settings/example/packages'); + assert.ok(comment.indexOf('`@rxjs/observable-polyfill`') < comment.indexOf('| `rxjs` |')); + assert.match(comment, /npm stage approve stage_3/); + assert.match(comment, /Approve `rxjs` last/); + assert.match(comment, /Qualified and staged SHA-512/); + await assert.rejects(() => renderStagingComment(file, 'https://npmjs.com/not-exact'), /exact https:\/\/www\.npmjs\.com/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('turns a partial staging receipt into rejection instructions', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'rxjs-stage-reject-')); + try { + const file = path.join(root, 'state.json'); + await writeFile( + file, + JSON.stringify({ + status: 'partial', + version: '9.0.0-beta.3', + packages: [ + { + name: '@rxjs/observable-polyfill', + version: '9.0.0-beta.3', + distTag: 'next', + stageId: 'stage_polyfill', + sha512: 'abc', + }, + ], + }) + ); + const comment = await renderStagingComment(file, 'https://www.npmjs.com/settings/example/packages'); + assert.match(comment, /DO NOT APPROVE/); + assert.match(comment, /npm stage reject stage_polyfill/); + assert.doesNotMatch(comment, /npm stage approve/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/release/validate-commit-message.mjs b/scripts/release/validate-commit-message.mjs new file mode 100644 index 0000000000..e04980fed6 --- /dev/null +++ b/scripts/release/validate-commit-message.mjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import { validatePullRequestTitle } from './release-policy.mjs'; + +const messagePath = process.argv[2]; +if (!messagePath) throw new Error('Usage: validate-commit-message.mjs '); +const [title] = (await readFile(messagePath, 'utf8')).split(/\r?\n/); +const result = validatePullRequestTitle(title.trim()); +process.stdout.write(`Validated ${result.type} Conventional Commit message (${result.level}).\n`); diff --git a/scripts/release/validate-pr-title.mjs b/scripts/release/validate-pr-title.mjs new file mode 100644 index 0000000000..1d723df43b --- /dev/null +++ b/scripts/release/validate-pr-title.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { validatePullRequestTitle } from './release-policy.mjs'; + +const title = process.argv.slice(2).join(' ').trim(); +if (!title) throw new Error('Usage: validate-pr-title.mjs '); +const result = validatePullRequestTitle(title); +process.stdout.write(`Validated ${result.type} Conventional Commit title (${result.level}).\n`); diff --git a/scripts/release/wait-for-required-checks.mjs b/scripts/release/wait-for-required-checks.mjs new file mode 100644 index 0000000000..7e80104b9f --- /dev/null +++ b/scripts/release/wait-for-required-checks.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +const [repository, commit] = process.argv.slice(2); +const token = process.env.GH_TOKEN; +const required = (process.env.RELEASE_REQUIRED_CHECKS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +if (!repository || !commit || !token || required.length === 0) { + throw new Error('Repository, commit, GH_TOKEN, and comma-separated RELEASE_REQUIRED_CHECKS are required.'); +} + +const deadline = Date.now() + 30 * 60_000; +while (true) { + const response = await fetch(`https://api.github.com/repos/${repository}/commits/${commit}/check-runs?per_page=100`, { + headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'x-github-api-version': '2022-11-28' }, + }); + if (!response.ok) throw new Error(`GitHub check-runs request failed: ${response.status} ${await response.text()}`); + const payload = await response.json(); + const byName = new Map(payload.check_runs.map((check) => [check.name, check])); + const failures = required.filter( + (name) => byName.has(name) && byName.get(name).status === 'completed' && byName.get(name).conclusion !== 'success' + ); + if (failures.length > 0) throw new Error(`Required master checks failed: ${failures.join(', ')}`); + const pending = required.filter((name) => byName.get(name)?.conclusion !== 'success'); + if (pending.length === 0) { + process.stdout.write(`All required checks passed for ${commit}: ${required.join(', ')}\n`); + break; + } + if (Date.now() >= deadline) throw new Error(`Timed out waiting for required master checks: ${pending.join(', ')}`); + process.stdout.write(`Waiting for required master checks: ${pending.join(', ')}\n`); + await new Promise((resolve) => setTimeout(resolve, 15_000)); +} diff --git a/scripts/security/check-osv-exceptions.mjs b/scripts/security/check-osv-exceptions.mjs new file mode 100644 index 0000000000..43dbf181c8 --- /dev/null +++ b/scripts/security/check-osv-exceptions.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const requiredWorkspace = 'apps/rxjs.dev'; +const requiredTracker = 'docs/security/LEGACY_DOCS_VULNERABILITIES.md'; + +export function parseOsvExceptions(source) { + return source + .split('[[IgnoredVulns]]') + .slice(1) + .map((block) => + Object.fromEntries( + [...block.matchAll(/^([A-Za-z]+)\s*=\s*(?:"([^"]*)"|(\d{4}-\d{2}-\d{2}))/gm)].map((match) => [match[1], match[2] ?? match[3]]) + ) + ); +} + +export function validateOsvExceptions(source, now = new Date()) { + const errors = []; + const seen = new Set(); + for (const [index, entry] of parseOsvExceptions(source).entries()) { + const label = entry.id ?? `entry ${index + 1}`; + if (!/^GHSA-[a-z0-9-]+$/i.test(entry.id ?? '')) errors.push(`${label} has no valid advisory ID.`); + if (seen.has(entry.id)) errors.push(`${label} is duplicated.`); + seen.add(entry.id); + const expiry = Date.parse(`${entry.ignoreUntil ?? ''}T00:00:00Z`); + const reviewed = /(?:^|;) reviewed=(\d{4}-\d{2}-\d{2})(?:;|$)/.exec(entry.reason ?? '')?.[1]; + const reviewedAt = Date.parse(`${reviewed ?? ''}T00:00:00Z`); + if (!Number.isFinite(expiry) || expiry < now.getTime()) errors.push(`${label} is expired or has no expiry.`); + if (!Number.isFinite(reviewedAt) || expiry - reviewedAt > 90 * 24 * 60 * 60_000) + errors.push(`${label} exceeds the 90-day exception limit.`); + if (!(entry.reason ?? '').includes(`workspace=${requiredWorkspace}`)) errors.push(`${label} has no affected workspace.`); + if (!(entry.reason ?? '').includes(`owner=benlesh`)) errors.push(`${label} has no owner.`); + if (!(entry.reason ?? '').includes(`tracking=${requiredTracker}`)) errors.push(`${label} has no tracking record.`); + if (!(entry.reason ?? '').includes('unreachable=')) errors.push(`${label} has no reachability justification.`); + } + if (seen.size === 0) errors.push('No reviewed OSV exceptions are recorded.'); + return errors; +} + +export function classifyAuditPaths(audit) { + const unreviewed = []; + const legacyDocs = []; + for (const advisory of Object.values(audit.advisories ?? {})) { + for (const finding of advisory.findings ?? []) { + for (const dependencyPath of finding.paths ?? []) { + const record = { id: advisory.github_advisory_id, package: advisory.module_name, version: finding.version, path: dependencyPath }; + (dependencyPath.startsWith('apps__rxjs.dev>') ? legacyDocs : unreviewed).push(record); + } + } + } + return { unreviewed, legacyDocs }; +} + +export function canonicalGithubAdvisoryId(vulnerability) { + const githubIds = [vulnerability.id, ...(vulnerability.aliases ?? [])].filter((id) => /^GHSA-[a-z0-9-]+$/i.test(id)); + return githubIds.sort()[0] ?? vulnerability.id; +} + +export function isCompleteAudit(audit) { + return Boolean( + audit && + typeof audit.advisories === 'object' && + audit.metadata && + Number.isSafeInteger(audit.metadata.totalDependencies) && + audit.metadata.totalDependencies > 0 + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const root = fileURLToPath(new URL('../..', import.meta.url)); + const source = await readFile(`${root}/osv-scanner.toml`, 'utf8'); + const errors = validateOsvExceptions(source); + const auditPath = process.argv[2]; + if (auditPath) { + const audit = JSON.parse(await readFile(auditPath, 'utf8')); + if (!isCompleteAudit(audit)) { + errors.push('The npm audit result is incomplete; vulnerability classification must fail closed.'); + } + const { unreviewed, legacyDocs } = classifyAuditPaths(audit); + if (unreviewed.length > 0) { + errors.push( + `Advisory paths reach release, build, or test tooling:\n${unreviewed + .map(({ id, package: name, version, path }) => ` ${id} ${name}@${version} via ${path}`) + .join('\n')}` + ); + } + process.stdout.write(`Classified ${legacyDocs.length} advisory paths as isolated legacy documentation tooling.\n`); + } + assert.deepEqual(errors, [], `OSV exception policy failed:\n- ${errors.join('\n- ')}`); + process.stdout.write(`Validated ${parseOsvExceptions(source).length} time-bounded OSV exceptions.\n`); +} diff --git a/scripts/security/check-osv-exceptions.test.mjs b/scripts/security/check-osv-exceptions.test.mjs new file mode 100644 index 0000000000..226fa26850 --- /dev/null +++ b/scripts/security/check-osv-exceptions.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { canonicalGithubAdvisoryId, classifyAuditPaths, isCompleteAudit, validateOsvExceptions } from './check-osv-exceptions.mjs'; + +const valid = `[[IgnoredVulns]] +id = "GHSA-aaaa-bbbb-cccc" +ignoreUntil = 2026-10-31 +reason = "workspace=apps/rxjs.dev; owner=benlesh; tracking=docs/security/LEGACY_DOCS_VULNERABILITIES.md; reviewed=2026-08-02; unreachable=excluded from build, qualification, and publication" +`; + +test('requires owned, justified, tracked exceptions of at most 90 days', () => { + assert.deepEqual(validateOsvExceptions(valid, new Date('2026-08-02T00:00:00Z')), []); + assert.match(validateOsvExceptions(valid, new Date('2026-11-01T00:00:00Z')).join('\n'), /expired/); + assert.match(validateOsvExceptions(valid.replace('2026-10-31', '2027-10-31'), new Date('2026-08-02T00:00:00Z')).join('\n'), /90-day/); + assert.match(validateOsvExceptions(valid.replace('owner=benlesh; ', ''), new Date('2026-08-02T00:00:00Z')).join('\n'), /owner/); +}); + +test('separates the excluded docs app from every release-reachable path', () => { + const audit = { + advisories: { + one: { github_advisory_id: 'GHSA-one', module_name: 'a', findings: [{ version: '1', paths: ['apps__rxjs.dev>a'] }] }, + two: { github_advisory_id: 'GHSA-two', module_name: 'b', findings: [{ version: '2', paths: ['.>b'] }] }, + }, + }; + const result = classifyAuditPaths(audit); + assert.equal(result.legacyDocs.length, 1); + assert.equal(result.unreviewed.length, 1); +}); + +test('uses one stable GitHub advisory ID for aliased OSV records', () => { + assert.equal( + canonicalGithubAdvisoryId({ id: 'GHSA-r5fr-rjxr-66jc', aliases: ['CVE-2026-4800', 'GHSA-35jh-r3h4-6jhm'] }), + 'GHSA-35jh-r3h4-6jhm' + ); + assert.equal(canonicalGithubAdvisoryId({ id: 'GHSA-jhpw-976m-542j', aliases: ['CVE-2026-0001'] }), 'GHSA-jhpw-976m-542j'); +}); + +test('fails closed when npm audit did not return a complete dependency result', () => { + assert.equal(isCompleteAudit({ advisories: {}, metadata: { totalDependencies: 2237 } }), true); + assert.equal(isCompleteAudit({ error: 'registry unavailable' }), false); + assert.equal(isCompleteAudit({ advisories: {}, metadata: { totalDependencies: 0 } }), false); +}); diff --git a/scripts/security/generate-osv-baseline.mjs b/scripts/security/generate-osv-baseline.mjs new file mode 100644 index 0000000000..68a67e1e92 --- /dev/null +++ b/scripts/security/generate-osv-baseline.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { canonicalGithubAdvisoryId, classifyAuditPaths } from './check-osv-exceptions.mjs'; + +const [osvPath, auditPath, outputPath = 'osv-scanner.toml'] = process.argv.slice(2); +if (!osvPath || !auditPath) throw new Error('Usage: generate-osv-baseline.mjs [output]'); +const osv = JSON.parse(await readFile(osvPath, 'utf8')); +const audit = JSON.parse(await readFile(auditPath, 'utf8')); +const { unreviewed, legacyDocs } = classifyAuditPaths(audit); +if (unreviewed.length > 0) throw new Error(`Refusing to baseline ${unreviewed.length} release-reachable advisory paths.`); +const docsPackages = new Set(legacyDocs.map(({ package: name, version }) => `${name}@${version}`)); +const ids = new Set(); +for (const result of osv.results ?? []) { + for (const entry of result.packages ?? []) { + const key = `${entry.package?.name}@${entry.package?.version}`; + for (const vulnerability of entry.vulnerabilities ?? []) { + // The OSV input may predate a remediation. Only the post-remediation, + // path-classified docs inventory is eligible for the generated baseline. + if (!docsPackages.has(key)) continue; + ids.add(canonicalGithubAdvisoryId(vulnerability)); + } + } +} +const reason = + 'workspace=apps/rxjs.dev; owner=benlesh; tracking=docs/security/LEGACY_DOCS_VULNERABILITIES.md; reviewed=2026-08-03; unreachable=apps/rxjs.dev is excluded from RxJS 9 build, test, qualification, and publication'; +const source = + '# Generated from the reviewed 2026-08-03 OSV/npm path audit. Do not hand-edit.\n' + + '# Regenerate only after scripts/security/check-osv-exceptions.mjs proves every path remains isolated.\n\n' + + [...ids] + .sort() + .map((id) => `[[IgnoredVulns]]\nid = "${id}"\nignoreUntil = 2026-10-31\nreason = "${reason}"\n`) + .join('\n'); +await writeFile(outputPath, source); +process.stdout.write(`Recorded ${ids.size} reviewed legacy-docs advisory IDs in ${outputPath}.\n`);