diff --git a/azure-pipeline.pr.yml b/azure-pipeline.pr.yml index df40e8d2b9..4eab95d713 100644 --- a/azure-pipeline.pr.yml +++ b/azure-pipeline.pr.yml @@ -4,6 +4,9 @@ jobs: pool: vmImage: 'macos-15' steps: + - checkout: self + fetchDepth: 2 + - template: scripts/ci/common-setup.yml - script: npm run compile diff --git a/package.json b/package.json index acd59913be..f1e4209745 100644 --- a/package.json +++ b/package.json @@ -4278,7 +4278,8 @@ "compile:web": "webpack --mode development --config-name extension:webworker --config-name webviews", "lint": "eslint --fix --cache . --ext .ts,.tsx", "package": "npx vsce package", - "test": "npm run test:preprocess && node ./out/src/test/runTests.js", + "test": "npm run test:preprocess && npm run test:scripts && node ./out/src/test/runTests.js", + "test:scripts": "mocha \"out/src/test/scripts/**/*.test.js\"", "test:preprocess": "npm run compile:test && npm run test:preprocess-gql && npm run test:preprocess-svg && npm run test:preprocess-fixtures", "browsertest:preprocess": "tsc ./src/test/browser/runTests.ts --outDir ./dist/browser/test --rootDir ./src/test/browser --target es6 --module commonjs", "browsertest": "npm run browsertest:preprocess && node ./dist/browser/test/runTests.js", @@ -4290,6 +4291,7 @@ "watch:web": "webpack --watch --mode development --config-name extension:webworker --config-name webviews", "hygiene": "node ./build/hygiene.js", "check:commands": "node scripts/check-commands.js", + "check:package-quarantine": "node scripts/check-package-quarantine.js", "prepare": "husky install", "update:codicons": "npx ts-node --project tsconfig.scripts.json build/update-codicons.ts" }, diff --git a/scripts/check-package-quarantine.js b/scripts/check-package-quarantine.js new file mode 100644 index 0000000000..7d1b4e7659 --- /dev/null +++ b/scripts/check-package-quarantine.js @@ -0,0 +1,462 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const childProcess = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_BASE_REF = 'HEAD^1'; +const DEFAULT_QUARANTINE_DAYS = 7; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +const REGISTRY_URL = 'https://registry.npmjs.org'; +const REQUEST_ATTEMPTS = 3; +const REQUEST_CONCURRENCY = 6; +const REQUEST_TIMEOUT_MS = 30_000; + +function parseArguments(args) { + const options = { + baseRef: DEFAULT_BASE_REF, + quarantineDays: DEFAULT_QUARANTINE_DAYS, + }; + + for (let index = 0; index < args.length; index++) { + switch (args[index]) { + case '--base-ref': + options.baseRef = args[++index]; + if (!options.baseRef) { + throw new Error('Missing value for --base-ref.'); + } + break; + case '--days': { + const value = args[++index]; + options.quarantineDays = Number(value); + if (!Number.isInteger(options.quarantineDays) || options.quarantineDays < 0) { + throw new Error(`Invalid value for --days: ${value}. Expected a non-negative integer.`); + } + break; + } + default: + throw new Error(`Unknown argument: ${args[index]}`); + } + } + + return options; +} + +function parseLockfile(contents, source) { + let lockfile; + try { + lockfile = JSON.parse(contents); + } catch (error) { + throw new Error(`Failed to parse ${source}: ${error.message}`); + } + + if (!lockfile || ![2, 3].includes(lockfile.lockfileVersion) + || !lockfile.packages || typeof lockfile.packages !== 'object' || Array.isArray(lockfile.packages)) { + throw new Error(`${source} must be a version 2 or 3 lockfile with a packages object.`); + } + + return lockfile; +} + +function getPackageName(packagePath) { + const segments = packagePath.split('/'); + let name; + let index = 0; + while (index < segments.length) { + if (segments[index++] !== 'node_modules') { + return undefined; + } + name = segments[index++]; + if (name?.startsWith('@')) { + if (index >= segments.length) { + return undefined; + } + name += `/${segments[index++]}`; + } + if (!isPackageName(name)) { + return undefined; + } + } + return name; +} + +function isPackageName(name) { + return typeof name === 'string' && /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i.test(name); +} + +function extractPackageVersions(lockfile) { + const packageVersions = new Map(); + + for (const [packagePath, metadata] of Object.entries(lockfile.packages)) { + if (packagePath === '') { + continue; + } + + const installedName = getPackageName(packagePath); + if (!installedName || !metadata || typeof metadata !== 'object' || Array.isArray(metadata) + || metadata.link || metadata.inBundle) { + throw new Error(`Cannot verify ${packagePath}: only registry packages are supported (no links or bundled packages).`); + } + + // For npm aliases, the installation path is not the published package name. + const name = metadata.name ?? installedName; + if (!isPackageName(name) + || typeof metadata.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?(?:\+[a-z0-9.-]+)?$/i.test(metadata.version)) { + throw new Error(`Cannot verify ${packagePath}: invalid registry package name or version.`); + } + + for (const field of ['resolved', 'integrity']) { + if (Object.hasOwn(metadata, `_${field}`)) { + throw new Error(`Cannot verify ${packagePath}: npm's alternate _${field} field is not supported.`); + } + if (metadata[field] !== undefined && (typeof metadata[field] !== 'string' || !metadata[field])) { + throw new Error(`Cannot verify ${packagePath}: invalid ${field}.`); + } + } + + const packageVersion = { + name, + version: metadata.version, + resolved: metadata.resolved, + integrity: metadata.integrity, + }; + packageVersions.set(artifactKey(packageVersion), packageVersion); + } + + return [...packageVersions.values()].sort(comparePackageVersions); +} + +function comparePackageVersions(left, right) { + return left.name.localeCompare(right.name) || left.version.localeCompare(right.version); +} + +function artifactKey({ name, version, resolved, integrity }) { + return JSON.stringify([name, version, resolved, integrity]); +} + +function findChangedPackageVersions(baseLockfile, currentLockfile) { + const baseVersions = new Set( + extractPackageVersions(baseLockfile).map(artifactKey) + ); + + // Changing or removing the source or integrity requires revalidation even at the same version. + return extractPackageVersions(currentLockfile) + .filter(packageVersion => !baseVersions.has(artifactKey(packageVersion))); +} + +function registryDependencyName(name, spec, source) { + if (!isPackageName(name) || typeof spec !== 'string') { + throw new Error(`Cannot verify ${source}: invalid dependency ${name}.`); + } + + let selector = spec.trim(); + let target = name; + if (selector.startsWith('npm:')) { + const alias = selector.slice(4); + const separator = alias.lastIndexOf('@'); + target = separator > 0 ? alias.slice(0, separator) : alias; + selector = separator > 0 ? alias.slice(separator + 1).trim() : '*'; + } + + // Exclude URLs, git shortcuts, local paths, and bare tarball filenames. + if (!isPackageName(target) || selector.startsWith('.') || !/^[a-z0-9*~^<>=|+.\s-]*$/i.test(selector) + || /\.(?:tgz|tar(?:\.gz)?)$/i.test(selector)) { + throw new Error(`Cannot verify ${source}: ${name} must use a registry selector or npm alias, not ${spec}.`); + } + return target; +} + +function findDependency(lockfile, packagePath, name) { + let parent = packagePath; + while (true) { + const candidate = `${parent ? `${parent}/` : ''}node_modules/${name}`; + if (Object.hasOwn(lockfile.packages, candidate)) { + return { path: candidate, metadata: lockfile.packages[candidate] }; + } + if (!parent) { + return undefined; + } + const segments = parent.split('/'); + parent = segments.slice(0, segments.lastIndexOf('node_modules')).join('/'); + } +} + +function validateInstallationSources(manifest, lockfile) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || manifest.workspaces) { + throw new Error('The quarantine check requires a root package manifest without workspaces.'); + } + + const fields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + for (const [packagePath, metadata] of [['', manifest], ...Object.entries(lockfile.packages)]) { + const source = packagePath || 'root package manifest'; + for (const field of fields) { + const dependencies = metadata?.[field]; + if (dependencies === undefined) { + continue; + } + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + throw new Error(`Cannot verify ${source}: invalid ${field}.`); + } + for (const [name, spec] of Object.entries(dependencies)) { + const target = registryDependencyName(name, spec, source); + const installed = findDependency(lockfile, packagePath, name); + if (installed && (installed.metadata.name ?? getPackageName(installed.path)) !== target) { + throw new Error(`Cannot verify ${source}: ${name}'s dependency source does not match its locked package identity.`); + } + } + } + } + + function validateOverrides(overrides) { + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) { + throw new Error('Cannot verify package.json: invalid overrides.'); + } + for (const [key, value] of Object.entries(overrides)) { + const separator = key.indexOf('@', 1); + const name = key === '.' ? 'self' : separator > 0 ? key.slice(0, separator) : key; + if (!isPackageName(name)) { + throw new Error(`Cannot verify package.json: invalid override ${key}.`); + } + if (separator > 0) { + const selector = key.slice(separator + 1); + // npm also uses the key selector as a replacement when an object omits ".". + if (selector.trim().startsWith('npm:')) { + throw new Error(`Cannot verify package.json: override key ${key} must not select an alias.`); + } + registryDependencyName(name, selector, `override key ${key}`); + } + if (typeof value === 'string') { + const spec = value.startsWith('$') + ? manifest.optionalDependencies?.[value.slice(1)] ?? manifest.dependencies?.[value.slice(1)] ?? manifest.devDependencies?.[value.slice(1)] + : value; + if (typeof spec !== 'string' || spec.trim().startsWith('npm:')) { + throw new Error(`Cannot verify package.json: override ${key} must use a registry selector, not an alias or unresolved reference.`); + } + registryDependencyName(name, spec, `override ${key}`); + } else { + validateOverrides(value); + } + } + } + + if (manifest.overrides !== undefined) { + validateOverrides(manifest.overrides); + } +} + +function delay(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +async function fetchPackageMetadata(packageName) { + const url = `${REGISTRY_URL}/${encodeURIComponent(packageName)}`; + let lastError; + + for (let attempt = 1; attempt <= REQUEST_ATTEMPTS; attempt++) { + let response; + try { + response = await fetch(url, { + redirect: 'error', + headers: { + Accept: 'application/json', + 'User-Agent': 'vscode-pull-request-github-package-quarantine', + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + lastError = error; + } + + if (response?.ok) { + try { + return await response.json(); + } catch (error) { + lastError = new Error(`npm registry returned invalid JSON for ${packageName}: ${error.message}`); + } + } else if (response) { + lastError = new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`); + if (response.status !== 429 && response.status < 500) { + throw lastError; + } + } + + if (attempt < REQUEST_ATTEMPTS) { + console.warn(`Registry lookup for ${packageName} failed (attempt ${attempt}); retrying.`); + await delay(attempt * 1000); + } + } + + throw new Error(`Failed to query publication data for ${packageName}: ${lastError.message}`); +} + +async function getPublicationDates(packageVersions) { + for (const { name, version, resolved, integrity } of packageVersions) { + if (!resolved || !integrity) { + throw new Error(`Cannot verify ${name}@${version}: new or changed packages must have both resolved and integrity in package-lock.json.`); + } + const url = new URL(resolved); + if (url.origin !== REGISTRY_URL || url.username || url.password || url.search || url.hash) { + throw new Error(`Cannot verify ${name}@${version}: only tarballs from ${REGISTRY_URL} are supported.`); + } + } + + const packageNames = [...new Set(packageVersions.map(packageVersion => packageVersion.name))]; + const metadataByPackage = new Map(); + let nextIndex = 0; + + async function worker() { + while (nextIndex < packageNames.length) { + const packageName = packageNames[nextIndex++]; + metadataByPackage.set(packageName, await fetchPackageMetadata(packageName)); + } + } + + await Promise.all( + Array.from({ length: Math.min(REQUEST_CONCURRENCY, packageNames.length) }, () => worker()) + ); + + const publicationDates = new Map(); + for (const { name, version, resolved, integrity } of packageVersions) { + const metadata = metadataByPackage.get(name); + const registryVersion = metadata?.versions?.[version]; + const dist = registryVersion?.dist; + if (registryVersion?.name !== name || registryVersion?.version !== version || dist?.tarball !== resolved) { + throw new Error(`Cannot verify ${name}@${version}: lockfile identity or tarball does not match the npm registry.`); + } + + const sha1Integrity = typeof dist.shasum === 'string' && /^[a-f0-9]{40}$/i.test(dist.shasum) + ? `sha1-${Buffer.from(dist.shasum, 'hex').toString('base64')}` : undefined; + if (integrity !== dist.integrity && integrity !== sha1Integrity) { + throw new Error(`Cannot verify ${name}@${version}: lockfile integrity does not match the npm registry.`); + } + + const publishedAt = metadata?.time?.[version]; + if (typeof publishedAt !== 'string' || Number.isNaN(Date.parse(publishedAt))) { + throw new Error(`The npm registry did not provide a valid publication time for ${name}@${version}.`); + } + publicationDates.set(JSON.stringify([name, version]), new Date(publishedAt)); + } + + return publicationDates; +} + +function findQuarantineViolations(packageVersions, publicationDates, now, quarantineDays) { + const quarantineMilliseconds = quarantineDays * MILLISECONDS_PER_DAY; + const violations = []; + + for (const packageVersion of packageVersions) { + const key = JSON.stringify([packageVersion.name, packageVersion.version]); + const publishedAt = publicationDates.get(key); + if (!(publishedAt instanceof Date) || Number.isNaN(publishedAt.getTime())) { + throw new Error(`Missing publication time for ${packageVersion.name}@${packageVersion.version}.`); + } + + const eligibleAt = new Date(publishedAt.getTime() + quarantineMilliseconds); + if (now < eligibleAt) { + violations.push({ ...packageVersion, publishedAt, eligibleAt }); + } + } + + return violations.sort((left, right) => left.eligibleAt.getTime() - right.eligibleAt.getTime()); +} + +function hasShrinkwrap(fileNames) { + return fileNames.some(name => name.toLowerCase() === 'npm-shrinkwrap.json'); +} + +function readBaseLockfile(workspaceRoot, baseRef) { + const rootEntries = childProcess.execFileSync( + 'git', + ['ls-tree', '--name-only', '-z', baseRef], + { cwd: workspaceRoot, encoding: 'utf8' } + ); + if (hasShrinkwrap(rootEntries.split('\0'))) { + throw new Error(`Cannot verify the base: npm-shrinkwrap.json at ${baseRef} takes precedence over package-lock.json and is not supported.`); + } + + let contents; + try { + contents = childProcess.execFileSync( + 'git', + ['show', `${baseRef}:package-lock.json`], + { + cwd: workspaceRoot, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + } + ); + } catch (error) { + throw new Error(`Failed to read package-lock.json from ${baseRef}: ${error.message}`); + } + + return parseLockfile(contents, `package-lock.json at ${baseRef}`); +} + +function readCurrentLockfile(workspaceRoot) { + if (hasShrinkwrap(fs.readdirSync(workspaceRoot))) { + throw new Error('npm-shrinkwrap.json takes precedence over package-lock.json and is not supported by the quarantine check.'); + } + + const currentLockfilePath = path.join(workspaceRoot, 'package-lock.json'); + return parseLockfile(fs.readFileSync(currentLockfilePath, 'utf8'), currentLockfilePath); +} + +async function main(args = process.argv.slice(2)) { + const options = parseArguments(args); + const workspaceRoot = path.resolve(__dirname, '..'); + const currentLockfile = readCurrentLockfile(workspaceRoot); + const manifest = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')); + validateInstallationSources(manifest, currentLockfile); + const baseLockfile = readBaseLockfile(workspaceRoot, options.baseRef); + const changedPackageVersions = findChangedPackageVersions(baseLockfile, currentLockfile); + + if (!changedPackageVersions.length) { + console.log('Package quarantine check passed: no new or changed npm package artifacts were introduced.'); + return; + } + + console.log(`Checking ${changedPackageVersions.length} new or changed npm package artifact(s) against the ${options.quarantineDays}-day quarantine.`); + const publicationDates = await getPublicationDates(changedPackageVersions); + const violations = findQuarantineViolations( + changedPackageVersions, + publicationDates, + new Date(), + options.quarantineDays + ); + + if (violations.length) { + console.error(`Package quarantine check failed: ${violations.length} package version(s) are less than ${options.quarantineDays} days old.`); + for (const violation of violations) { + console.error( + ` - ${violation.name}@${violation.version} was published ${violation.publishedAt.toISOString()} and is quarantined until ${violation.eligibleAt.toISOString()}.` + ); + } + process.exitCode = 1; + return; + } + + console.log(`Package quarantine check passed: all ${changedPackageVersions.length} new or changed package artifact(s) are verified and at least ${options.quarantineDays} days old.`); +} + +module.exports = { + extractPackageVersions, + findChangedPackageVersions, + findQuarantineViolations, + getPublicationDates, + getPackageName, + parseArguments, + readBaseLockfile, + readCurrentLockfile, + validateInstallationSources, + main, +}; + +if (require.main === module) { + main().catch(error => { + console.error(`Package quarantine check failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/ci/common-setup.yml b/scripts/ci/common-setup.yml index 5d835ccc52..911d0a3734 100644 --- a/scripts/ci/common-setup.yml +++ b/scripts/ci/common-setup.yml @@ -4,6 +4,9 @@ steps: inputs: versionSpec: "20.x" + - script: npm run check:package-quarantine + displayName: Enforce package quarantine + - script: npm ci displayName: Install dependencies retryCountOnTaskFailure: 3 diff --git a/src/test/browser/index.ts b/src/test/browser/index.ts index ead80a2baf..dd0e4cb322 100644 --- a/src/test/browser/index.ts +++ b/src/test/browser/index.ts @@ -23,7 +23,7 @@ async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null try { const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r); - importAll(require.context('../', true, /\.test$/)); + importAll(require.context('../', true, /^\.\/(?!scripts\/).*\.test$/)); } catch (e) { console.log(e); } diff --git a/src/test/index.ts b/src/test/index.ts index 6ded19c0c2..1aab41a00a 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -33,16 +33,16 @@ async function runAllExtensionTests(testsRoot: string, clb: (error: Error | null // Import all test files using webpack's require.context try { // Load tests from src/test directory only - // Webview tests are compiled separately with the webview configuration + // Webview tests are compiled separately; CLI script tests run with test:scripts. const importAll = (r: __WebpackModuleApi.RequireContext) => r.keys().forEach(r); - importAll(require.context('./', true, /\.test$/)); + importAll(require.context('./', true, /^\.\/(?!scripts\/).*\.test$/)); } catch (e) { // Fallback if 'require.context' is not available (e.g., in non-webpack environments) const files = glob.sync('**/*.test.js', { cwd: testsRoot, absolute: true, - // Browser/webview tests are loaded via the separate browser runner - ignore: ['browser/**'] + // Browser and CLI script tests have separate runners. + ignore: ['browser/**', 'scripts/**'] }); if (!files.length) { console.log('Fallback test discovery found no test files. Original error:', e); diff --git a/src/test/scripts/checkPackageQuarantine.test.ts b/src/test/scripts/checkPackageQuarantine.test.ts new file mode 100644 index 0000000000..07e46e78a2 --- /dev/null +++ b/src/test/scripts/checkPackageQuarantine.test.ts @@ -0,0 +1,565 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import childProcess from 'child_process'; +import fs from 'fs'; +import * as path from 'path'; +import { createSandbox, SinonSandbox } from 'sinon'; + +interface PackageVersion { + name: string; + version: string; + resolved?: string; + integrity?: string; +} + +interface QuarantineViolation extends PackageVersion { + publishedAt: Date; + eligibleAt: Date; +} + +interface PackageQuarantine { + extractPackageVersions(lockfile: object): PackageVersion[]; + findChangedPackageVersions(baseLockfile: object, currentLockfile: object): PackageVersion[]; + findQuarantineViolations( + packageVersions: PackageVersion[], + publicationDates: Map, + now: Date, + quarantineDays: number + ): QuarantineViolation[]; + getPublicationDates(packageVersions: PackageVersion[]): Promise>; + getPackageName(packagePath: string): string | undefined; + parseArguments(args: string[]): { baseRef: string; quarantineDays: number }; + readBaseLockfile(workspaceRoot: string, baseRef: string): object; + readCurrentLockfile(workspaceRoot: string): object; + validateInstallationSources(manifest: object, lockfile: object): void; + main(args: string[]): Promise; +} + +const quarantine: PackageQuarantine = require( + path.resolve(__dirname, '../../../../scripts/check-package-quarantine.js') +); +const directoryReader: { readdirSync(directory: string): string[] } = fs; + +function artifact(name: string, version: string, overrides: Partial = {}): PackageVersion { + return { + name, + version, + resolved: `https://registry.npmjs.org/${name}/-/${name.split('/').pop()}-${version}.tgz`, + integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`, + ...overrides, + }; +} + +function registryMetadata(packageVersion: PackageVersion, publishedAt = '2026-09-01T00:00:00Z') { + return { + versions: { + [packageVersion.version]: { + name: packageVersion.name, + version: packageVersion.version, + dist: { tarball: packageVersion.resolved, integrity: packageVersion.integrity }, + }, + }, + time: { [packageVersion.version]: publishedAt }, + }; +} + +describe('Package quarantine check', () => { + let sandbox: SinonSandbox; + + beforeEach(() => { + sandbox = createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function mockRegistry(metadata: object) { + return sandbox.stub(globalThis, 'fetch').resolves(new Response(JSON.stringify(metadata))); + } + + it('extracts package names from top-level, scoped, and nested paths', () => { + const lockfile = { + packages: { + '': { version: '1.0.0' }, + 'node_modules/alpha': artifact('alpha', '1.0.0'), + 'node_modules/@scope/bravo': artifact('@scope/bravo', '2.0.0'), + 'node_modules/alpha/node_modules/charlie': artifact('charlie', '3.0.0'), + }, + }; + + assert.deepStrictEqual(quarantine.extractPackageVersions(lockfile), [ + artifact('@scope/bravo', '2.0.0'), + artifact('alpha', '1.0.0'), + artifact('charlie', '3.0.0'), + ]); + }); + + it('finds new artifacts but exempts identical artifacts moved within the tree', () => { + const baseLockfile = { + packages: { + 'node_modules/alpha': artifact('alpha', '1.0.0'), + 'node_modules/bravo': artifact('bravo', '1.0.0'), + }, + }; + const currentLockfile = { + packages: { + 'node_modules/alpha': artifact('alpha', '2.0.0'), + 'node_modules/charlie/node_modules/bravo': artifact('bravo', '1.0.0'), + 'node_modules/delta': artifact('delta', '1.0.0'), + }, + }; + + assert.deepStrictEqual(quarantine.findChangedPackageVersions(baseLockfile, currentLockfile), [ + artifact('alpha', '2.0.0'), + artifact('delta', '1.0.0'), + ]); + }); + + it('keeps unchanged legacy entries without resolution or integrity exempt', () => { + const lockfile = { packages: { 'node_modules/alpha': { version: '1.0.0' } } }; + assert.deepStrictEqual(quarantine.findChangedPackageVersions(lockfile, lockfile), []); + }); + + for (const field of ['_resolved', '_integrity']) { + it(`rejects npm's alternate ${field} field on an otherwise unchanged legacy entry`, () => { + const original = { version: '1.0.0' }; + const replacement = { + ...original, + [field]: field === '_resolved' ? 'https://example.invalid/replacement.tgz' : artifact('alpha', '1.0.0').integrity, + }; + + assert.throws(() => quarantine.findChangedPackageVersions( + { packages: { 'node_modules/alpha': original } }, + { packages: { 'node_modules/alpha': replacement } } + ), /alternate _(?:resolved|integrity) field is not supported/); + }); + } + + it('does not mistake node_modules inside a scope name for a directory boundary', async () => { + const name = '@review-proof-node_modules/semver'; + const scoped = { version: '7.7.2' }; + const original = { packages: { 'node_modules/semver': scoped } }; + const changed = quarantine.findChangedPackageVersions(original, { + packages: { ...original.packages, [`node_modules/${name}`]: scoped }, + }); + + assert.strictEqual(quarantine.getPackageName(`node_modules/${name}`), name); + assert.strictEqual(quarantine.getPackageName(`node_modules/parent/node_modules/${name}`), name); + assert.strictEqual(changed.length, 1); + assert.strictEqual(changed[0].name, name); + await assert.rejects(quarantine.getPublicationDates(changed), /must have both resolved and integrity/); + }); + + for (const packagePath of [ + 'node_modules/@scope', + 'node_modules/@scope/', + 'node_modules/@scope/package/extra', + 'node_modules/alpha//node_modules/bravo', + 'node_modules/../alpha', + 'something_node_modules/alpha', + ]) { + it(`rejects noncanonical installation path ${packagePath}`, () => { + assert.strictEqual(quarantine.getPackageName(packagePath), undefined); + assert.throws(() => quarantine.extractPackageVersions({ packages: { [packagePath]: { version: '1.0.0' } } }), /Cannot verify/); + }); + } + + it('does not let an alias borrow its installation name and version from the base', async () => { + const existing = artifact('alpha', '1.0.0'); + const alias = artifact('@scope/new-package', '1.0.0'); + const baseLockfile = { packages: { 'node_modules/alpha': existing } }; + const currentLockfile = { packages: { 'node_modules/alpha': alias } }; + const fetch = mockRegistry(registryMetadata(alias, '2026-09-09T00:00:00Z')); + + const changed = quarantine.findChangedPackageVersions(baseLockfile, currentLockfile); + assert.deepStrictEqual(changed, [alias]); + const dates = await quarantine.getPublicationDates(changed); + const violations = quarantine.findQuarantineViolations(changed, dates, new Date('2026-09-10T00:00:00Z'), 7); + + assert.strictEqual(violations.length, 1); + assert.strictEqual(violations[0].name, '@scope/new-package'); + assert.strictEqual(fetch.callCount, 1); + assert.strictEqual(fetch.firstCall.args[0], 'https://registry.npmjs.org/%40scope%2Fnew-package'); + }); + + it('uses canonical identities for aliases in the base as well', () => { + const alias = artifact('alpha', '1.0.0'); + const baseLockfile = { packages: { 'node_modules/alias': alias } }; + const currentLockfile = { packages: { 'node_modules/alpha': alias } }; + + assert.deepStrictEqual(quarantine.findChangedPackageVersions(baseLockfile, currentLockfile), []); + }); + + it('validates a new alias even when its installation name is an old published package', async () => { + const alias = artifact('new-package', '1.0.0'); + const changed = quarantine.findChangedPackageVersions( + { packages: {} }, + { packages: { 'node_modules/alpha/node_modules/old-package': alias } } + ); + const fetch = mockRegistry(registryMetadata(alias, '2026-09-09T00:00:00Z')); + const dates = await quarantine.getPublicationDates(changed); + + assert.strictEqual(fetch.firstCall.args[0], 'https://registry.npmjs.org/new-package'); + assert.strictEqual(quarantine.findQuarantineViolations(changed, dates, new Date('2026-09-10T00:00:00Z'), 7).length, 1); + }); + + it('rejects a substituted tarball even if the package name and version are unchanged', async () => { + const original = artifact('alpha', '1.0.0'); + const replacement = artifact('alpha', '1.0.0', { + resolved: 'https://registry.npmjs.org/attacker/-/attacker-1.0.0.tgz', + integrity: `sha512-${Buffer.alloc(64, 2).toString('base64')}`, + }); + const changed = quarantine.findChangedPackageVersions( + { packages: { 'node_modules/alpha': original } }, + { packages: { 'node_modules/alpha': replacement } } + ); + assert.deepStrictEqual(changed, [replacement]); + mockRegistry(registryMetadata(original)); + + await assert.rejects(quarantine.getPublicationDates(changed), /tarball does not match/); + }); + + it('rejects an integrity-only substitution at an unchanged name and version', async () => { + const original = artifact('alpha', '1.0.0'); + const replacement = { ...original, integrity: `sha512-${Buffer.alloc(64, 2).toString('base64')}` }; + const changed = quarantine.findChangedPackageVersions( + { packages: { 'node_modules/alpha': original } }, + { packages: { 'node_modules/alpha': replacement } } + ); + assert.deepStrictEqual(changed, [replacement]); + mockRegistry(registryMetadata(original)); + + await assert.rejects(quarantine.getPublicationDates(changed), /integrity does not match/); + }); + + for (const field of ['resolved', 'integrity'] as const) { + it(`rejects removal of ${field} instead of exempting the unchanged version`, async () => { + const original = artifact('alpha', '1.0.0'); + const replacement = { ...original, [field]: undefined }; + const changed = quarantine.findChangedPackageVersions( + { packages: { 'node_modules/alpha': original } }, + { packages: { 'node_modules/alpha': replacement } } + ); + const fetch = sandbox.stub(globalThis, 'fetch'); + assert.deepStrictEqual(changed, [replacement]); + + await assert.rejects(quarantine.getPublicationDates(changed), /must have both resolved and integrity/); + assert.strictEqual(fetch.callCount, 0); + }); + } + + it('does not deduplicate a substituted artifact against a genuine copy elsewhere', async () => { + const original = artifact('alpha', '1.0.0'); + const replacement = { ...original, resolved: 'https://registry.npmjs.org/attacker/-/attacker-1.0.0.tgz' }; + const changed = quarantine.findChangedPackageVersions( + { packages: {} }, + { + packages: { + 'node_modules/alpha': original, + 'node_modules/bravo/node_modules/alpha': replacement, + }, + } + ); + assert.strictEqual(changed.length, 2); + mockRegistry(registryMetadata(original)); + + await assert.rejects(quarantine.getPublicationDates(changed), /tarball does not match/); + }); + + for (const resolved of [ + 'https://example.invalid/alpha.tgz', + 'http://registry.npmjs.org/alpha/-/alpha-1.0.0.tgz', + 'file:../alpha.tgz', + 'git+https://example.invalid/alpha.git', + ]) { + it(`rejects unsupported artifact source ${resolved} without a registry lookup`, async () => { + const fetch = sandbox.stub(globalThis, 'fetch'); + + await assert.rejects(quarantine.getPublicationDates([artifact('alpha', '1.0.0', { resolved })]), /only tarballs from/); + assert.strictEqual(fetch.callCount, 0); + }); + } + + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + it(`rejects manifest-only tarball substitutions in ${field} with an unchanged pinned lockfile`, () => { + const lockfile = { packages: { 'node_modules/alpha': artifact('alpha', '1.0.0') } }; + assert.deepStrictEqual(quarantine.findChangedPackageVersions(lockfile, lockfile), []); + + assert.throws(() => quarantine.validateInstallationSources( + { [field]: { alpha: 'https://example.invalid/replacement.tgz' } }, + lockfile + ), /must use a registry selector or npm alias/); + }); + } + + it('validates manifest sources before the CLI can take the unchanged-lockfile shortcut', async () => { + const lockfile = { lockfileVersion: 3, packages: { 'node_modules/alpha': artifact('alpha', '1.0.0') } }; + const manifest = { dependencies: { alpha: 'https://example.invalid/replacement.tgz' } }; + sandbox.stub(directoryReader, 'readdirSync').returns(['package.json', 'package-lock.json']); + const readFile = sandbox.stub(fs, 'readFileSync'); + readFile.onFirstCall().returns(JSON.stringify(lockfile)); + readFile.onSecondCall().returns(JSON.stringify(manifest)); + const git = sandbox.stub(childProcess, 'execFileSync'); + const fetch = sandbox.stub(globalThis, 'fetch'); + + await assert.rejects(quarantine.main([]), /must use a registry selector or npm alias/); + assert.strictEqual(git.callCount, 0); + assert.strictEqual(fetch.callCount, 0); + }); + + for (const spec of [ + 'https://registry.npmjs.org/attacker/-/attacker-1.0.0.tgz', + 'git+https://example.invalid/alpha.git', + 'owner/repository', + 'github:owner/repository', + 'file:../alpha', + '../alpha', + 'replacement.tgz', + 'replacement.tar.gz', + 'npm:alpha@https://example.invalid/replacement.tgz', + ]) { + it(`rejects dependency source ${spec} even inside lockfile dependency edges`, () => { + assert.throws(() => quarantine.validateInstallationSources({}, { + packages: { 'node_modules/parent': { ...artifact('parent', '1.0.0'), dependencies: { alpha: spec } } }, + }), /must use a registry selector or npm alias/); + }); + } + + it('rejects a manifest alias whose locked package has a different canonical name', () => { + assert.throws(() => quarantine.validateInstallationSources( + { dependencies: { alpha: 'npm:new-package@1.0.0' } }, + { packages: { 'node_modules/alpha': artifact('alpha', '1.0.0') } } + ), /dependency source does not match its locked package identity/); + }); + + it('accepts registry selectors and aliases matching the installed canonical identities', () => { + const manifest = { + dependencies: { alpha: '^1.0.0', alias: 'npm:@scope/bravo@~2.0.0' }, + overrides: { alpha: { '.': '$alpha', charlie: '>=1 <2 || ^3' } }, + }; + const lockfile = { + packages: { + 'node_modules/alpha': { ...artifact('alpha', '1.0.0'), dependencies: { charlie: '*' } }, + 'node_modules/alias': artifact('@scope/bravo', '2.0.0'), + 'node_modules/charlie': artifact('charlie', '1.0.0'), + }, + }; + + assert.doesNotThrow(() => quarantine.validateInstallationSources(manifest, lockfile)); + }); + + for (const overrides of [ + { alpha: 'https://example.invalid/replacement.tgz' }, + { parent: { alpha: { '.': 'https://example.invalid/replacement.tgz' } } }, + { alpha: 'npm:new-package@1.0.0' }, + { alpha: '$alias' }, + { 'alpha@https://example.invalid/replacement.tgz': {} }, + { parent: { 'alpha@file:../alpha': {} } }, + { 'alpha@git+https://example.invalid/alpha.git': {} }, + { 'alpha@npm:new-package@1.0.0': {} }, + { '@scope/alpha@https://example.invalid/replacement.tgz': { '.': '1.0.0' } }, + ]) { + it(`rejects source-changing overrides ${JSON.stringify(overrides)}`, () => { + assert.throws(() => quarantine.validateInstallationSources( + { dependencies: { alias: 'npm:new-package@1.0.0' }, overrides }, + { packages: {} } + ), /Cannot verify/); + }); + } + + it('rejects an implicit override-key replacement combined with a tag in a transitive edge', () => { + const base = { + packages: { + 'node_modules/markdown-it': { version: '14.1.0', dependencies: { mdurl: '^2.0.0' } }, + 'node_modules/mdurl': { version: '2.0.0' }, + }, + }; + const current = { + packages: { + ...base.packages, + 'node_modules/markdown-it': { version: '14.1.0', dependencies: { mdurl: 'latest' } }, + }, + }; + const manifest = { overrides: { 'mdurl@https://example.invalid/replacement.tgz': {} } }; + assert.deepStrictEqual(quarantine.findChangedPackageVersions(base, current), []); + + assert.throws(() => quarantine.validateInstallationSources(manifest, current), /override key.*must use a registry selector/); + }); + + it('accepts scoped and nested overrides with registry-only key selectors', () => { + assert.doesNotThrow(() => quarantine.validateInstallationSources( + { overrides: { '@scope/alpha@^1.0.0': { '.': '1.0.1', 'bravo@>=1 <3': {} } } }, + { packages: {} } + )); + }); + + it('rejects workspaces whose manifests are outside the root lockfile check', () => { + assert.throws(() => quarantine.validateInstallationSources( + { workspaces: ['packages/*'] }, + { packages: {} } + ), /without workspaces/); + }); + + it('verifies the registry record identity as well as the URL and integrity', async () => { + const packageVersion = artifact('alpha', '1.0.0'); + const metadata = registryMetadata(packageVersion); + metadata.versions['1.0.0'].name = 'different-package'; + mockRegistry(metadata); + + await assert.rejects(quarantine.getPublicationDates([packageVersion]), /identity or tarball does not match/); + }); + + it('accepts matching registry artifacts after seven days', async () => { + const packageVersion = artifact('alpha', '1.0.0'); + const fetch = mockRegistry(registryMetadata(packageVersion)); + const dates = await quarantine.getPublicationDates([packageVersion]); + + assert.deepStrictEqual(quarantine.findQuarantineViolations([packageVersion], dates, new Date('2026-09-08T00:00:00Z'), 7), []); + assert.strictEqual(fetch.firstCall.args[1]?.redirect, 'error'); + }); + + it('supports legacy SHA-1 integrity but not mixed integrity containing an attacker hash', async () => { + const packageVersion = artifact('alpha', '1.0.0'); + const shasum = '01'.repeat(20); + const sha1 = `sha1-${Buffer.from(shasum, 'hex').toString('base64')}`; + const metadata = registryMetadata(packageVersion); + const response = { + ...metadata, + versions: { '1.0.0': { ...metadata.versions['1.0.0'], dist: { ...metadata.versions['1.0.0'].dist, shasum } } }, + }; + const fetch = mockRegistry(response); + const dates = await quarantine.getPublicationDates([{ ...packageVersion, integrity: sha1 }]); + assert.strictEqual(dates.size, 1); + fetch.resolves(new Response(JSON.stringify(response))); + + await assert.rejects( + quarantine.getPublicationDates([{ ...packageVersion, integrity: `${sha1} sha512-${Buffer.alloc(64, 2).toString('base64')}` }]), + /integrity does not match/ + ); + }); + + it('fails closed if publication data is missing', async () => { + const packageVersion = artifact('alpha', '1.0.0'); + mockRegistry({ ...registryMetadata(packageVersion), time: {} }); + + await assert.rejects(quarantine.getPublicationDates([packageVersion]), /valid publication time/); + }); + + it('fails closed when the registry cannot find the package', async () => { + sandbox.stub(globalThis, 'fetch').resolves(new Response('', { status: 404 })); + + await assert.rejects(quarantine.getPublicationDates([artifact('alpha', '1.0.0')]), /HTTP 404/); + }); + + for (const metadata of [ + { version: '1.0.0', link: true, resolved: '../local-package' }, + { version: '1.0.0', inBundle: true }, + { version: 'git+https://example.invalid/alpha.git' }, + { name: '../alpha', version: '1.0.0' }, + {}, + ]) { + it(`rejects uncheckable package metadata ${JSON.stringify(metadata)}`, () => { + assert.throws(() => quarantine.extractPackageVersions({ packages: { 'node_modules/alpha': metadata } }), /Cannot verify/); + }); + } + + for (const fileName of ['npm-shrinkwrap.json', 'NPM-SHRINKWRAP.JSON']) { + it(`rejects ${fileName} before reading the otherwise unchanged package lock`, () => { + sandbox.stub(directoryReader, 'readdirSync').returns([fileName, 'package-lock.json']); + const readFile = sandbox.stub(fs, 'readFileSync'); + + assert.throws(() => quarantine.readCurrentLockfile('workspace'), /npm-shrinkwrap.json takes precedence/); + assert.strictEqual(readFile.callCount, 0); + }); + } + + for (const fileName of ['npm-shrinkwrap.json', 'NPM-SHRINKWRAP.JSON', 'NpM-ShrinkWrap.JsOn']) { + it(`rejects ${fileName} in the base rather than trusting an unused package lock`, () => { + const git = sandbox.stub(childProcess, 'execFileSync').returns(`README.md\0${fileName}\0package-lock.json\0`); + + assert.throws(() => quarantine.readBaseLockfile('workspace', 'HEAD^1'), /npm-shrinkwrap.json at HEAD\^1 takes precedence/); + assert.strictEqual(git.callCount, 1); + assert.deepStrictEqual(git.firstCall.args, [ + 'git', + ['ls-tree', '--name-only', '-z', 'HEAD^1'], + { cwd: 'workspace', encoding: 'utf8' }, + ]); + }); + } + + it('rejects an uppercase base shrinkwrap even when it was removed from the current tree', async () => { + const lockfile = { lockfileVersion: 3, packages: {} }; + sandbox.stub(directoryReader, 'readdirSync').returns(['package.json', 'package-lock.json']); + const readFile = sandbox.stub(fs, 'readFileSync'); + readFile.onFirstCall().returns(JSON.stringify(lockfile)); + readFile.onSecondCall().returns('{}'); + const git = sandbox.stub(childProcess, 'execFileSync').returns('NPM-SHRINKWRAP.JSON\0package-lock.json\0'); + const fetch = sandbox.stub(globalThis, 'fetch'); + + await assert.rejects(quarantine.main([]), /npm-shrinkwrap.json at HEAD\^1 takes precedence/); + assert.strictEqual(git.callCount, 1); + assert.strictEqual(fetch.callCount, 0); + }); + + it('reads the base lockfile without confusing embedded newlines for root entries', () => { + const lockfile = { lockfileVersion: 3, packages: {} }; + const git = sandbox.stub(childProcess, 'execFileSync'); + git.onFirstCall().returns('README.md\0notes\nNPM-SHRINKWRAP.JSON\0package-lock.json\0'); + git.onSecondCall().returns(JSON.stringify(lockfile)); + + assert.deepStrictEqual(quarantine.readBaseLockfile('workspace', 'HEAD^1'), lockfile); + assert.strictEqual(git.callCount, 2); + assert.deepStrictEqual(git.secondCall.args[1], ['show', 'HEAD^1:package-lock.json']); + }); + + it('reads the package lock when there is no overriding shrinkwrap', () => { + const lockfile = { lockfileVersion: 3, packages: { 'node_modules/alpha': artifact('alpha', '1.0.0') } }; + sandbox.stub(directoryReader, 'readdirSync').returns(['package-lock.json']); + sandbox.stub(fs, 'readFileSync').returns(JSON.stringify(lockfile)); + + assert.deepStrictEqual(quarantine.readCurrentLockfile('workspace'), lockfile); + }); + + for (const lockfile of [null, { packages: [] }, { lockfileVersion: 1, dependencies: {} }]) { + it(`rejects unsupported lockfile structure ${JSON.stringify(lockfile)}`, () => { + sandbox.stub(directoryReader, 'readdirSync').returns(['package-lock.json']); + sandbox.stub(fs, 'readFileSync').returns(JSON.stringify(lockfile)); + + assert.throws(() => quarantine.readCurrentLockfile('workspace'), /must be a version 2 or 3 lockfile/); + }); + } + + it('rejects versions until the full quarantine period has elapsed', () => { + const packageVersions = [ + { name: 'alpha', version: '1.0.0' }, + { name: 'bravo', version: '2.0.0' }, + ]; + const publicationDates = new Map([ + [JSON.stringify(['alpha', '1.0.0']), new Date('2026-09-02T12:00:00Z')], + [JSON.stringify(['bravo', '2.0.0']), new Date('2026-09-02T11:59:59Z')], + ]); + + const violations = quarantine.findQuarantineViolations( + packageVersions, + publicationDates, + new Date('2026-09-09T11:59:59Z'), + 7 + ); + + assert.deepStrictEqual(violations.map(({ name, version }) => ({ name, version })), [ + { name: 'alpha', version: '1.0.0' }, + ]); + }); + + it('uses a seven-day quarantine and the first parent by default', () => { + assert.deepStrictEqual(quarantine.parseArguments([]), { + baseRef: 'HEAD^1', + quarantineDays: 7, + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 26ea565bfc..e2e5c31838 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -236,8 +236,8 @@ async function getExtensionConfig(target, mode, env) { // Add main test runner entry['test/index'] = './src/test/index.ts'; - // Add individual test files as separate entry points - const testFiles = glob.sync('src/test/**/*.test.ts', { cwd: __dirname }); + // CLI script tests run unbundled with test:scripts, not in the extension host. + const testFiles = glob.sync('src/test/**/*.test.ts', { cwd: __dirname, ignore: ['src/test/scripts/**'] }); testFiles.forEach(testFile => { // Convert src/test/github/utils.test.ts -> test/github/utils.test const entryName = testFile.replace('src/', '').replace('.ts', '');