From fc1fbf0e029ba7cd4db784f27111dd6663c8ca70 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 20:02:30 +1000 Subject: [PATCH 01/36] feat: add trust downgrade policy helper --- workspaces/arborist/lib/trust-policy.js | 141 ++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 workspaces/arborist/lib/trust-policy.js diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js new file mode 100644 index 0000000000000..35c1147397a7c --- /dev/null +++ b/workspaces/arborist/lib/trust-policy.js @@ -0,0 +1,141 @@ +const npa = require('npm-package-arg') +const semver = require('semver') + +const TRUST_RANK = { + none: 0, + provenance: 1, + trustedPublisher: 2, +} + +const trustLabel = evidence => evidence === 'trustedPublisher' + ? 'trusted publisher provenance' + : evidence === 'provenance' + ? 'provenance attestation' + : 'no trust evidence' + +const getTrustEvidence = manifest => { + const provenance = manifest?.dist?.attestations?.provenance + if (manifest?._npmUser?.trustedPublisher && provenance) { + return 'trustedPublisher' + } + if (provenance) { + return 'provenance' + } + return 'none' +} + +const isTrustPolicyExcluded = (entries, name, version) => { + for (const entry of entries || []) { + let spec + try { + spec = npa(entry) + } catch { + continue + } + + if (spec.name !== name) { + continue + } + + if (spec.raw === spec.name || spec.rawSpec === '*') { + return true + } + + if (spec.type === 'version' && spec.fetchSpec === version) { + return true + } + + if (spec.type === 'range' && semver.satisfies(version, spec.fetchSpec)) { + return true + } + } + return false +} + +const metadataError = (name, version, message) => Object.assign( + new Error(`Unable to enforce trust policy for ${name}@${version}: ${message}`), + { + code: 'ETRUSTPOLICYMETADATA', + package: name, + version, + } +) + +const checkTrustDowngrade = (packument, version, { + exclude = [], + ignoreAfter = null, + now = Date.now(), +} = {}) => { + const name = packument?.name + if (!name || !packument?.versions?.[version]) { + throw metadataError(name || '', version, 'version metadata is missing') + } + + if (isTrustPolicyExcluded(exclude, name, version)) { + return + } + + const published = packument.time?.[version] + const publishedAt = published && Date.parse(published) + if (!Number.isFinite(publishedAt)) { + throw metadataError(name, version, 'publish time is missing or invalid') + } + + if (ignoreAfter != null && Number.isFinite(ignoreAfter) && ignoreAfter > 0) { + const ageMinutes = (now - publishedAt) / 60000 + if (ageMinutes > ignoreAfter) { + return + } + } + + const current = packument.versions[version] + const currentEvidence = getTrustEvidence(current) + const currentIsPrerelease = Boolean(semver.prerelease(version)) + let strongestPriorEvidence = 'none' + + for (const [priorVersion, priorManifest] of Object.entries(packument.versions)) { + if (priorVersion === version) { + continue + } + + if (!currentIsPrerelease && semver.prerelease(priorVersion)) { + continue + } + + const priorPublished = packument.time?.[priorVersion] + const priorPublishedAt = priorPublished && Date.parse(priorPublished) + if (!Number.isFinite(priorPublishedAt) || priorPublishedAt >= publishedAt) { + continue + } + + const priorEvidence = getTrustEvidence(priorManifest) + if (TRUST_RANK[priorEvidence] > TRUST_RANK[strongestPriorEvidence]) { + strongestPriorEvidence = priorEvidence + } + } + + if (TRUST_RANK[strongestPriorEvidence] <= TRUST_RANK[currentEvidence]) { + return + } + + throw Object.assign( + new Error( + `High-risk trust downgrade for "${name}@${version}" (possible package takeover): ` + + `earlier versions had ${trustLabel(strongestPriorEvidence)}, ` + + `but this version has ${trustLabel(currentEvidence)}.` + ), + { + code: 'ETRUSTDOWNGRADE', + package: name, + version, + previousTrust: strongestPriorEvidence, + currentTrust: currentEvidence, + } + ) +} + +module.exports = { + checkTrustDowngrade, + getTrustEvidence, + isTrustPolicyExcluded, +} From b6340948237ea81589b746ff06b48c33c4ff88b6 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 20:06:28 +1000 Subject: [PATCH 02/36] test: cover trust downgrade policy --- workspaces/arborist/test/trust-policy.js | 163 +++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 workspaces/arborist/test/trust-policy.js diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js new file mode 100644 index 0000000000000..f21a4912d56ab --- /dev/null +++ b/workspaces/arborist/test/trust-policy.js @@ -0,0 +1,163 @@ +const t = require('tap') +const { + checkTrustDowngrade, + getTrustEvidence, + isTrustPolicyExcluded, +} = require('../lib/trust-policy.js') + +const provenance = { + dist: { + attestations: { + provenance: { + url: 'https://registry.example.test/attestations', + }, + }, + }, +} + +const trustedPublisher = { + ...provenance, + _npmUser: { + trustedPublisher: { + id: 'github', + }, + }, +} + +const packument = ({ current = {}, prior = provenance } = {}) => ({ + name: 'example-package', + time: { + '1.0.0': '2026-01-01T00:00:00.000Z', + '2.0.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '1.0.0': prior, + '2.0.0': current, + }, +}) + +t.test('detects trust evidence', t => { + t.equal(getTrustEvidence({}), 'none') + t.equal(getTrustEvidence(provenance), 'provenance') + t.equal(getTrustEvidence(trustedPublisher), 'trustedPublisher') + t.end() +}) + +t.test('rejects provenance downgrade to no evidence', t => { + t.throws( + () => checkTrustDowngrade(packument(), '2.0.0'), + { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + previousTrust: 'provenance', + currentTrust: 'none', + } + ) + t.end() +}) + +t.test('rejects trusted publisher downgrade to provenance', t => { + t.throws( + () => checkTrustDowngrade(packument({ current: provenance, prior: trustedPublisher }), '2.0.0'), + { + code: 'ETRUSTDOWNGRADE', + previousTrust: 'trustedPublisher', + currentTrust: 'provenance', + } + ) + t.end() +}) + +t.test('accepts equal or stronger trust', t => { + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance }), '2.0.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: trustedPublisher }), '2.0.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance, prior: {} }), '2.0.0')) + t.end() +}) + +t.test('uses publish order rather than semver order', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0': '2026-01-01T00:00:00.000Z', + '1.5.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0': provenance, + '1.5.0': {}, + }, + } + t.throws(() => checkTrustDowngrade(meta, '1.5.0'), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('stable releases ignore prior prerelease trust evidence', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', + '2.0.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0-beta.1': provenance, + '2.0.0': {}, + }, + } + t.doesNotThrow(() => checkTrustDowngrade(meta, '2.0.0')) + t.end() +}) + +t.test('prereleases compare against earlier prereleases', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', + '2.0.0-beta.2': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0-beta.1': provenance, + '2.0.0-beta.2': {}, + }, + } + t.throws(() => checkTrustDowngrade(meta, '2.0.0-beta.2'), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('supports package and version exclusions', t => { + t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.0.0'), true) + t.equal(isTrustPolicyExcluded(['example-package@2.0.0'], 'example-package', '2.0.0'), true) + t.equal(isTrustPolicyExcluded(['example-package@^2'], 'example-package', '2.1.0'), true) + t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.0.0'), false) + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { + exclude: ['example-package@2.0.0'], + })) + t.end() +}) + +t.test('ignore-after skips old selected versions', t => { + const now = Date.parse('2026-02-02T00:00:00.000Z') + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { + ignoreAfter: 60, + now, + })) + t.throws(() => checkTrustDowngrade(packument(), '2.0.0', { + ignoreAfter: 60 * 24 * 2, + now, + }), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('fails closed when selected version metadata is incomplete', t => { + const meta = packument() + delete meta.time['2.0.0'] + t.throws( + () => checkTrustDowngrade(meta, '2.0.0'), + { + code: 'ETRUSTPOLICYMETADATA', + package: 'example-package', + version: '2.0.0', + } + ) + t.end() +}) From 9ae5cbe44f5d274329be4a8b0415e13b0d84e8e8 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 20:26:25 +1000 Subject: [PATCH 03/36] feat(arborist): verify trust downgrade policy before reify --- workspaces/arborist/lib/trust-policy.js | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js index 35c1147397a7c..6c39e06d31bc9 100644 --- a/workspaces/arborist/lib/trust-policy.js +++ b/workspaces/arborist/lib/trust-policy.js @@ -1,4 +1,7 @@ + const npa = require('npm-package-arg') +const pacote = require('pacote') +const { pickRegistry } = require('npm-registry-fetch') const semver = require('semver') const TRUST_RANK = { @@ -134,8 +137,95 @@ const checkTrustDowngrade = (packument, version, { ) } +const registrySpecForNode = (node, options) => { + if (!node || node.isRoot || node.isWorkspace || node.isLink) { + return null + } + + const name = node.packageName || node.package?.name + const version = node.version || node.package?.version + if (!name || !semver.valid(version)) { + return null + } + + const spec = npa.resolve(name, version, node.path || options.path || process.cwd()) + const registry = pickRegistry(spec, options) + + // A direct URL or git dependency can still report a package name and semver + // after its manifest is loaded. Only apply registry trust policy when the + // locked/resolved artifact comes from the selected registry origin. A + // missing resolved URL is treated as a normal registry dependency because + // npm can reconstruct registry tarball URLs from name + version. + if (node.resolved) { + let resolvedUrl + let registryUrl + try { + resolvedUrl = new URL(node.resolved) + registryUrl = new URL(registry) + } catch { + return null + } + if (resolvedUrl.origin !== registryUrl.origin) { + return null + } + } + + return { spec, registry } +} + +const verifyTrustPolicy = async (tree, options = {}) => { + if (options.trustPolicy !== 'no-downgrade' || !tree?.inventory) { + return + } + + const exclude = options.trustPolicyExclude || [] + const ignoreAfter = options.trustPolicyIgnoreAfter ?? null + const checks = [] + const seen = new Set() + + for (const node of tree.inventory.values()) { + const registryInfo = registrySpecForNode(node, options) + if (!registryInfo) { + continue + } + + const name = node.packageName || node.package.name + const version = node.version || node.package.version + if (isTrustPolicyExcluded(exclude, name, version)) { + continue + } + + const key = `${registryInfo.registry}\n${name}@${version}` + if (seen.has(key)) { + continue + } + seen.add(key) + + checks.push(async () => { + const packument = await pacote.packument(registryInfo.spec, { + ...options, + fullMetadata: true, + }) + checkTrustDowngrade(packument, version, { + exclude, + ignoreAfter, + }) + }) + } + + // Keep metadata lookup concurrency bounded. Large applications routinely + // contain hundreds or thousands of inventory nodes, while packumentCache + // still deduplicates repeated package metadata within one Arborist run. + const concurrency = 20 + for (let index = 0; index < checks.length; index += concurrency) { + await Promise.all(checks.slice(index, index + concurrency).map(check => check())) + } +} + module.exports = { checkTrustDowngrade, getTrustEvidence, isTrustPolicyExcluded, + registrySpecForNode, + verifyTrustPolicy, } From 9ef5481f5802f6e66297d527e0dd246bbfd6aa62 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 20:28:27 +1000 Subject: [PATCH 04/36] test(arborist): cover registry trust verification --- workspaces/arborist/test/trust-policy.js | 111 +++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js index f21a4912d56ab..4403c971d6ce3 100644 --- a/workspaces/arborist/test/trust-policy.js +++ b/workspaces/arborist/test/trust-policy.js @@ -1,8 +1,12 @@ + const t = require('tap') +const pacote = require('pacote') const { checkTrustDowngrade, getTrustEvidence, isTrustPolicyExcluded, + registrySpecForNode, + verifyTrustPolicy, } = require('../lib/trust-policy.js') const provenance = { @@ -36,6 +40,25 @@ const packument = ({ current = {}, prior = provenance } = {}) => ({ }, }) +const registryNode = (overrides = {}) => ({ + isRoot: false, + isWorkspace: false, + isLink: false, + path: '/tmp/project/node_modules/example-package', + resolved: 'https://registry.npmjs.org/example-package/-/example-package-2.0.0.tgz', + packageName: 'example-package', + version: '2.0.0', + package: { + name: 'example-package', + version: '2.0.0', + }, + ...overrides, +}) + +const treeWith = (...nodes) => ({ + inventory: new Map(nodes.map((node, index) => [String(index), node])), +}) + t.test('detects trust evidence', t => { t.equal(getTrustEvidence({}), 'none') t.equal(getTrustEvidence(provenance), 'provenance') @@ -161,3 +184,91 @@ t.test('fails closed when selected version metadata is incomplete', t => { ) t.end() }) + +t.test('registry node detection ignores non-registry and local nodes', t => { + t.ok(registrySpecForNode(registryNode(), { registry: 'https://registry.npmjs.org/' })) + t.equal(registrySpecForNode(registryNode({ + resolved: 'https://example.test/example-package-2.0.0.tgz', + }), { registry: 'https://registry.npmjs.org/' }), null) + t.equal(registrySpecForNode(registryNode({ isWorkspace: true }), {}), null) + t.equal(registrySpecForNode(registryNode({ isLink: true }), {}), null) + t.equal(registrySpecForNode(registryNode({ version: 'workspace:*' }), {}), null) + t.end() +}) + +t.test('trust verifier is disabled unless no-downgrade is selected', async t => { + const original = pacote.packument + let calls = 0 + pacote.packument = async () => { + calls++ + return packument({ current: provenance }) + } + t.teardown(() => { + pacote.packument = original + }) + + await verifyTrustPolicy(treeWith(registryNode()), { + registry: 'https://registry.npmjs.org/', + }) + t.equal(calls, 0) +}) + +t.test('trust verifier deduplicates identical package versions', async t => { + const original = pacote.packument + let calls = 0 + pacote.packument = async () => { + calls++ + return packument({ current: provenance }) + } + t.teardown(() => { + pacote.packument = original + }) + + await verifyTrustPolicy(treeWith( + registryNode(), + registryNode({ path: '/tmp/project/node_modules/a/node_modules/example-package' }) + ), { + trustPolicy: 'no-downgrade', + registry: 'https://registry.npmjs.org/', + }) + t.equal(calls, 1) +}) + +t.test('trust verifier rejects a registry provenance downgrade', async t => { + const original = pacote.packument + pacote.packument = async () => packument() + t.teardown(() => { + pacote.packument = original + }) + + await t.rejects( + verifyTrustPolicy(treeWith(registryNode()), { + trustPolicy: 'no-downgrade', + registry: 'https://registry.npmjs.org/', + }), + { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + } + ) +}) + +t.test('trust verifier honors an exclusion before fetching metadata', async t => { + const original = pacote.packument + let calls = 0 + pacote.packument = async () => { + calls++ + return packument() + } + t.teardown(() => { + pacote.packument = original + }) + + await verifyTrustPolicy(treeWith(registryNode()), { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['example-package@2.0.0'], + registry: 'https://registry.npmjs.org/', + }) + t.equal(calls, 0) +}) From e0545f0d5441be2448af85911999edb9abbcc24c Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 20:49:57 +1000 Subject: [PATCH 05/36] refactor(arborist): isolate trust policy verification --- .../arborist/lib/trust-policy-verifier.js | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 workspaces/arborist/lib/trust-policy-verifier.js diff --git a/workspaces/arborist/lib/trust-policy-verifier.js b/workspaces/arborist/lib/trust-policy-verifier.js new file mode 100644 index 0000000000000..70e67a34583cf --- /dev/null +++ b/workspaces/arborist/lib/trust-policy-verifier.js @@ -0,0 +1,66 @@ +const npa = require('npm-package-arg') +const pacote = require('pacote') +const { callLimit: promiseCallLimit } = require('promise-call-limit') +const { checkTrustDowngrade, isTrustPolicyExcluded } = require('./trust-policy.js') + +const registryVersions = tree => { + const packages = new Map() + for (const node of tree.inventory.values()) { + if (node.isProjectRoot || node.isWorkspace || node.isLink || node.inDepBundle || !node.version) { + continue + } + + // An edgeless node can still be a registry dependency. Only skip when + // every actual consumer edge proves the package came from file:, git:, + // or remote. If any registry edge reaches the node, verify it. + const incomingEdges = [...node.edgesIn] + if (incomingEdges.length && + incomingEdges.every(edge => edge.spec && !npa(edge.spec).registry)) { + continue + } + const name = node.packageName || node.name + if (!name) { + continue + } + + if (!packages.has(name)) { + packages.set(name, new Set()) + } + packages.get(name).add(node.version) + } + return packages +} + +const verifyTrustPolicy = async (tree, opts = {}) => { + if (opts.trustPolicy !== 'no-downgrade') { + return + } + + const queue = [] + for (const [name, versions] of registryVersions(tree)) { + const versionsToCheck = [...versions].filter(version => + !isTrustPolicyExcluded(opts.trustPolicyExclude, name, version)) + if (!versionsToCheck.length) { + continue + } + + queue.push(async () => { + const packument = await pacote.packument(name, { + ...opts, + fullMetadata: true, + }) + for (const version of versionsToCheck) { + checkTrustDowngrade(packument, version, { + exclude: opts.trustPolicyExclude, + ignoreAfter: opts.trustPolicyIgnoreAfter, + }) + } + }) + } + await promiseCallLimit(queue) +} + +module.exports = { + registryVersions, + verifyTrustPolicy, +} From d0810f41d49764a4808e2ec1950c5cc0bd5e1097 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 21:04:32 +1000 Subject: [PATCH 06/36] Update trust-policy.js --- workspaces/arborist/lib/trust-policy.js | 89 ------------------------- 1 file changed, 89 deletions(-) diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js index 6c39e06d31bc9..e6540ed4172c6 100644 --- a/workspaces/arborist/lib/trust-policy.js +++ b/workspaces/arborist/lib/trust-policy.js @@ -1,7 +1,5 @@ const npa = require('npm-package-arg') -const pacote = require('pacote') -const { pickRegistry } = require('npm-registry-fetch') const semver = require('semver') const TRUST_RANK = { @@ -137,95 +135,8 @@ const checkTrustDowngrade = (packument, version, { ) } -const registrySpecForNode = (node, options) => { - if (!node || node.isRoot || node.isWorkspace || node.isLink) { - return null - } - - const name = node.packageName || node.package?.name - const version = node.version || node.package?.version - if (!name || !semver.valid(version)) { - return null - } - - const spec = npa.resolve(name, version, node.path || options.path || process.cwd()) - const registry = pickRegistry(spec, options) - - // A direct URL or git dependency can still report a package name and semver - // after its manifest is loaded. Only apply registry trust policy when the - // locked/resolved artifact comes from the selected registry origin. A - // missing resolved URL is treated as a normal registry dependency because - // npm can reconstruct registry tarball URLs from name + version. - if (node.resolved) { - let resolvedUrl - let registryUrl - try { - resolvedUrl = new URL(node.resolved) - registryUrl = new URL(registry) - } catch { - return null - } - if (resolvedUrl.origin !== registryUrl.origin) { - return null - } - } - - return { spec, registry } -} - -const verifyTrustPolicy = async (tree, options = {}) => { - if (options.trustPolicy !== 'no-downgrade' || !tree?.inventory) { - return - } - - const exclude = options.trustPolicyExclude || [] - const ignoreAfter = options.trustPolicyIgnoreAfter ?? null - const checks = [] - const seen = new Set() - - for (const node of tree.inventory.values()) { - const registryInfo = registrySpecForNode(node, options) - if (!registryInfo) { - continue - } - - const name = node.packageName || node.package.name - const version = node.version || node.package.version - if (isTrustPolicyExcluded(exclude, name, version)) { - continue - } - - const key = `${registryInfo.registry}\n${name}@${version}` - if (seen.has(key)) { - continue - } - seen.add(key) - - checks.push(async () => { - const packument = await pacote.packument(registryInfo.spec, { - ...options, - fullMetadata: true, - }) - checkTrustDowngrade(packument, version, { - exclude, - ignoreAfter, - }) - }) - } - - // Keep metadata lookup concurrency bounded. Large applications routinely - // contain hundreds or thousands of inventory nodes, while packumentCache - // still deduplicates repeated package metadata within one Arborist run. - const concurrency = 20 - for (let index = 0; index < checks.length; index += concurrency) { - await Promise.all(checks.slice(index, index + concurrency).map(check => check())) - } -} - module.exports = { checkTrustDowngrade, getTrustEvidence, isTrustPolicyExcluded, - registrySpecForNode, - verifyTrustPolicy, } From 36e0d0b096a5cb499db0760ca467ae6648425c27 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:09:15 +1000 Subject: [PATCH 07/36] feat: add trust policy preflight --- lib/utils/trust-policy-preflight.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 lib/utils/trust-policy-preflight.js diff --git a/lib/utils/trust-policy-preflight.js b/lib/utils/trust-policy-preflight.js new file mode 100644 index 0000000000000..b4610408b9270 --- /dev/null +++ b/lib/utils/trust-policy-preflight.js @@ -0,0 +1,17 @@ +const { verifyTrustPolicy } = require('@npmcli/arborist/lib/trust-policy-verifier.js') + +const trustPolicyPreflight = async ({ arb, options }) => { + const effectiveOptions = { ...arb.options, ...options } + + if (effectiveOptions.trustPolicy !== 'no-downgrade') { + return + } + + if (!arb.idealTree) { + await arb.buildIdealTree(options) + } + + await verifyTrustPolicy(arb.idealTree, effectiveOptions) +} + +module.exports = trustPolicyPreflight From 6bab1c29bcf1344c41b5809c946dd6d85ffdb685 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:10:40 +1000 Subject: [PATCH 08/36] feat(config): add trust policy definitions --- workspaces/config/lib/definitions/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/workspaces/config/lib/definitions/index.js b/workspaces/config/lib/definitions/index.js index b5b63bf2fce12..3251b58f50df0 100644 --- a/workspaces/config/lib/definitions/index.js +++ b/workspaces/config/lib/definitions/index.js @@ -1,4 +1,11 @@ -const definitions = require('./definitions.js') + +const baseDefinitions = require('./definitions.js') +const trustPolicyDefinitions = require('./trust-policy.js') + +const definitions = Object.fromEntries( + Object.entries({ ...baseDefinitions, ...trustPolicyDefinitions }) + .sort(([a], [b]) => a.localeCompare(b)) +) // use the defined flattening function, and copy over any scoped // registries and registry-specific "nerfdart" configs verbatim From 79da16cb7334e20ea2f764db63c0ff990117668f Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:11:47 +1000 Subject: [PATCH 09/36] feat(config): define trust policy options --- .../config/lib/definitions/trust-policy.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 workspaces/config/lib/definitions/trust-policy.js diff --git a/workspaces/config/lib/definitions/trust-policy.js b/workspaces/config/lib/definitions/trust-policy.js new file mode 100644 index 0000000000000..acdd50d7c1b8b --- /dev/null +++ b/workspaces/config/lib/definitions/trust-policy.js @@ -0,0 +1,54 @@ +const Definition = require('./definition.js') + +module.exports = { + 'trust-policy': new Definition('trust-policy', { + default: null, + hint: '', + type: [null, 'no-downgrade'], + envExport: false, + description: ` + Enforce a package trust policy while constructing the dependency tree. + + When set to no-downgrade, npm rejects a selected registry package + version if an earlier-published stable version established stronger trust + evidence. Trust levels are ordered as trusted publisher provenance, + provenance attestation, then no trust evidence. Publish time, not semver + order, determines which versions are earlier. + `, + flatten: (key, obj, flatOptions) => { + flatOptions.trustPolicy = obj[key] + }, + }), + 'trust-policy-exclude': new Definition('trust-policy-exclude', { + default: [], + hint: '', + type: [Array, String], + envExport: false, + description: ` + Package names, exact versions, or semver ranges exempt from + trust-policy=no-downgrade. Values may be repeated or comma-separated. + `, + flatten: (key, obj, flatOptions) => { + const values = Array.isArray(obj[key]) ? obj[key] : [obj[key]] + const list = values + .flatMap(v => String(v).split(',')) + .map(v => v.trim()) + .filter(Boolean) + flatOptions.trustPolicyExclude = [...new Set(list)] + }, + }), + 'trust-policy-ignore-after': new Definition('trust-policy-ignore-after', { + default: null, + hint: '', + type: [null, Number], + envExport: false, + description: ` + Skip trust-downgrade enforcement for selected package versions published + more than this many minutes ago. This can limit false positives for older + packages that predate provenance publishing. + `, + flatten: (key, obj, flatOptions) => { + flatOptions.trustPolicyIgnoreAfter = obj[key] + }, + }), +} From 3e9f692f4dd731141f37295ce22da1261a1bcf23 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:13:13 +1000 Subject: [PATCH 10/36] test(config): cover trust policy options --- workspaces/config/test/definitions/index.js | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/workspaces/config/test/definitions/index.js b/workspaces/config/test/definitions/index.js index fec23c625fdee..147dd83ce0e6c 100644 --- a/workspaces/config/test/definitions/index.js +++ b/workspaces/config/test/definitions/index.js @@ -1,3 +1,4 @@ + const t = require('tap') const config = require('../../lib/definitions/index.js') const definitions = require('../../lib/definitions/definitions.js') @@ -7,6 +8,9 @@ t.test('defaults', t => { t.match(config.defaults, { registry: definitions.registry.default, 'init-module': definitions['init-module'].default, + 'trust-policy': null, + 'trust-policy-exclude': [], + 'trust-policy-ignore-after': null, }) t.end() @@ -42,3 +46,21 @@ t.test('flatten', t => { t.end() }) + +t.test('trust policy flattening', t => { + const flat = config.flatten({ + 'trust-policy': 'no-downgrade', + 'trust-policy-exclude': ['a@1, b@^2', 'a@1'], + 'trust-policy-ignore-after': 525600, + }) + + t.strictSame(flat, { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['a@1', 'b@^2'], + trustPolicyIgnoreAfter: 525600, + }) + + const single = config.flatten({ 'trust-policy-exclude': 'single-package@1' }) + t.strictSame(single.trustPolicyExclude, ['single-package@1']) + t.end() +}) From ad4fe2b6fa1b0520299586882a279f766a649bdf Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:14:41 +1000 Subject: [PATCH 11/36] feat: enforce trust policy during update --- lib/commands/update.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/commands/update.js b/lib/commands/update.js index 64c2c5128bb04..38f928c8e8d9b 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -1,8 +1,10 @@ + const path = require('node:path') const { log } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') @@ -29,6 +31,9 @@ class Update extends ArboristWorkspaceCmd { 'before', 'min-release-age', 'min-release-age-exclude', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', 'bin-links', 'fund', 'dry-run', @@ -71,6 +76,7 @@ class Update extends ArboristWorkspaceCmd { const reifyOpts = { ...opts, update } await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: reifyOpts }) + await trustPolicyPreflight({ arb, options: reifyOpts }) await arb.reify(reifyOpts) await reifyFinish(this.npm, arb) } From b4d016e6462e76401d1b3b3a46bab0f6c65a56cd Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:16:01 +1000 Subject: [PATCH 12/36] feat: enforce trust policy during install --- lib/commands/install.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/commands/install.js b/lib/commands/install.js index 2fd9bc8d5cd7a..716c2b4271918 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -1,3 +1,4 @@ + const { readdir } = require('node:fs/promises') const { resolve, join } = require('node:path') const { log } = require('proc-log') @@ -7,6 +8,7 @@ const checks = require('npm-install-checks') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') const { patchRelaxOpts } = require('../utils/cli-only-flag.js') const ArboristWorkspaceCmd = require('../arborist-cmd.js') @@ -41,6 +43,9 @@ class Install extends ArboristWorkspaceCmd { 'before', 'min-release-age', 'min-release-age-exclude', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', 'bin-links', 'fund', 'dry-run', @@ -173,6 +178,7 @@ class Install extends ArboristWorkspaceCmd { const arb = new Arborist(opts) await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) + await trustPolicyPreflight({ arb, options: opts }) await arb.reify(opts) if (runRootLifecycle) { From 45c4b5faa7e8aab0e22b4343efcac456831f3667 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:18:14 +1000 Subject: [PATCH 13/36] feat: enforce trust policy during ci --- lib/commands/ci.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/commands/ci.js b/lib/commands/ci.js index 17307badede98..1db1b80451918 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -1,6 +1,8 @@ + const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') const runScript = require('@npmcli/run-script') const fs = require('node:fs/promises') const path = require('node:path') @@ -31,6 +33,9 @@ class CI extends ArboristWorkspaceCmd { 'allow-scripts', 'strict-allow-scripts', 'dangerously-allow-all-scripts', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', 'audit', 'bin-links', 'fund', @@ -113,6 +118,8 @@ class CI extends ArboristWorkspaceCmd { ) } + await trustPolicyPreflight({ arb, options: opts }) + if (!dryRun) { const workspacePaths = await getWorkspaces([], { path: this.npm.localPrefix, From 34f5b655269f6bb2580bf87cc543801b7f63176e Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:19:38 +1000 Subject: [PATCH 14/36] test: cover trust policy preflight --- test/lib/utils/trust-policy-preflight.js | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/lib/utils/trust-policy-preflight.js diff --git a/test/lib/utils/trust-policy-preflight.js b/test/lib/utils/trust-policy-preflight.js new file mode 100644 index 0000000000000..288e025db65d2 --- /dev/null +++ b/test/lib/utils/trust-policy-preflight.js @@ -0,0 +1,83 @@ +const t = require('tap') + +const load = t => { + const calls = [] + const preflight = t.mock('../../../lib/utils/trust-policy-preflight.js', { + '@npmcli/arborist/lib/trust-policy-verifier.js': { + verifyTrustPolicy: async (tree, options) => calls.push({ tree, options }), + }, + }) + return { preflight, calls } +} + +t.test('no-op when trust policy is disabled', async t => { + const { preflight, calls } = load(t) + let builds = 0 + const arb = { idealTree: null, + buildIdealTree: async () => { + builds++ + } } + await preflight({ arb, options: {} }) + t.equal(builds, 0) + t.equal(calls.length, 0) +}) + +t.test('builds and verifies the ideal tree for install-style calls', async t => { + const { preflight, calls } = load(t) + const idealTree = { inventory: new Map() } + let builds = 0 + const arb = { + idealTree: null, + buildIdealTree: async options => { + builds++ + t.equal(options.trustPolicy, 'no-downgrade') + arb.idealTree = idealTree + }, + } + const options = { trustPolicy: 'no-downgrade' } + await preflight({ arb, options }) + t.equal(builds, 1) + t.equal(calls.length, 1) + t.equal(calls[0].tree, idealTree) + t.strictSame(calls[0].options, options) +}) + +t.test('reuses a prebuilt ideal tree for ci-style calls', async t => { + const { preflight, calls } = load(t) + const idealTree = { inventory: new Map() } + let builds = 0 + const arb = { idealTree, + buildIdealTree: async () => { + builds++ + } } + const options = { trustPolicy: 'no-downgrade', trustPolicyExclude: ['pkg@1'] } + await preflight({ arb, options }) + t.equal(builds, 0) + t.equal(calls.length, 1) + t.equal(calls[0].tree, idealTree) + t.strictSame(calls[0].options, options) +}) + +t.test('uses Arborist constructor options for ci-style calls', async t => { + const { preflight, calls } = load(t) + const idealTree = { inventory: new Map() } + const arb = { + idealTree, + options: { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['pkg@1'], + registry: 'https://registry.example.test/', + }, + } + + await preflight({ arb, options: { packageLock: true } }) + + t.equal(calls.length, 1) + t.equal(calls[0].tree, idealTree) + t.strictSame(calls[0].options, { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['pkg@1'], + registry: 'https://registry.example.test/', + packageLock: true, + }) +}) From 029b878c4703f241e71795ccaaa60611323dba69 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:21:32 +1000 Subject: [PATCH 15/36] fix(arborist): harden trust downgrade errors --- workspaces/arborist/lib/trust-policy.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js index e6540ed4172c6..dcaf1c545513b 100644 --- a/workspaces/arborist/lib/trust-policy.js +++ b/workspaces/arborist/lib/trust-policy.js @@ -123,7 +123,8 @@ const checkTrustDowngrade = (packument, version, { new Error( `High-risk trust downgrade for "${name}@${version}" (possible package takeover): ` + `earlier versions had ${trustLabel(strongestPriorEvidence)}, ` + - `but this version has ${trustLabel(currentEvidence)}.` + `but this version has ${trustLabel(currentEvidence)}. ` + + `If this downgrade is expected, add "${name}@${version}" to trust-policy-exclude.` ), { code: 'ETRUSTDOWNGRADE', From 10537d5245474316300379271b887ce67d0c48ca Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:23:17 +1000 Subject: [PATCH 16/36] test(arborist): cover trust policy integration --- .../arborist/test/arborist/trust-policy.js | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 workspaces/arborist/test/arborist/trust-policy.js diff --git a/workspaces/arborist/test/arborist/trust-policy.js b/workspaces/arborist/test/arborist/trust-policy.js new file mode 100644 index 0000000000000..0ba01ceaae018 --- /dev/null +++ b/workspaces/arborist/test/arborist/trust-policy.js @@ -0,0 +1,127 @@ +const t = require('tap') +const Arborist = require('../..') +const MockRegistry = require('@npmcli/mock-registry') +const { verifyTrustPolicy } = require('../../lib/trust-policy-verifier.js') + +const createRegistry = t => new MockRegistry({ + strict: false, + tap: t, + registry: 'http://registry.npmjs.org', +}) + +const cache = t.testdir() +const buildIdeal = async (path, options = {}) => { + const arb = new Arborist({ + path, + cache, + timeout: 30 * 60 * 1000, + ...options, + }) + const tree = await arb.buildIdealTree(options) + await verifyTrustPolicy(tree, options) + return tree +} + +const mockDowngradedPackage = async (t, { times = 1 } = {}) => { + const registry = createRegistry(t) + const manifest = registry.manifest({ + name: 'example-package', + packuments: registry.packuments(['1.0.0', '2.0.0'], 'example-package'), + }) + manifest.time['1.0.0'] = '2026-01-01T00:00:00.000Z' + manifest.time['2.0.0'] = '2026-02-01T00:00:00.000Z' + manifest.versions['1.0.0'].dist.attestations = { + provenance: { url: 'https://registry.example.test/attestations/1.0.0' }, + } + await registry.package({ manifest, times }) + return registry +} + +t.test('buildIdealTree rejects a registry trust downgrade before reify', async t => { + const registry = await mockDowngradedPackage(t, { times: 2 }) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { 'example-package': '2.0.0' }, + }), + }) + + await t.rejects(buildIdeal(path, { + registry: registry.origin, + trustPolicy: 'no-downgrade', + }), { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + previousTrust: 'provenance', + currentTrust: 'none', + }) +}) + +t.test('buildIdealTree honors trust policy from constructor options for ci-style calls', async t => { + const registry = await mockDowngradedPackage(t) + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { 'example-package': '2.0.0' }, + }), + }) + + const arb = new Arborist({ + path, + cache: path + '/.cache', + timeout: 30 * 60 * 1000, + registry: registry.origin, + trustPolicy: 'no-downgrade', + }) + + await t.rejects((async () => { + const tree = await arb.buildIdealTree() + await verifyTrustPolicy(tree, { ...arb.options, trustPolicy: 'no-downgrade' }) + })(), { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + }) +}) + +t.test('locked dependency is still checked for trust downgrade', async t => { + const registry = await mockDowngradedPackage(t) + const tarball = registry.origin + '/example-package/-/example-package-2.0.0.tgz' + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { 'example-package': '2.0.0' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + lockfileVersion: 3, + requires: true, + packages: { + '': { + dependencies: { 'example-package': '2.0.0' }, + }, + 'node_modules/example-package': { + version: '2.0.0', + resolved: tarball, + }, + }, + }), + }) + + const arb = new Arborist({ + path, + cache: path + '/.cache', + registry: registry.origin, + trustPolicy: 'no-downgrade', + }) + + await t.rejects((async () => { + const tree = await arb.buildIdealTree() + await verifyTrustPolicy(tree, { ...arb.options, trustPolicy: 'no-downgrade' }) + })(), { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + }) +}) From 8ff78b171403c952fcbcc6517e319b4378106225 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:25:10 +1000 Subject: [PATCH 17/36] test(arborist): harden trust policy verifier --- .../arborist/test/trust-policy-verifier.js | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 workspaces/arborist/test/trust-policy-verifier.js diff --git a/workspaces/arborist/test/trust-policy-verifier.js b/workspaces/arborist/test/trust-policy-verifier.js new file mode 100644 index 0000000000000..977de6fc76a0f --- /dev/null +++ b/workspaces/arborist/test/trust-policy-verifier.js @@ -0,0 +1,156 @@ +const t = require('tap') +const { registryVersions } = require('../lib/trust-policy-verifier.js') + +const node = ({ + name, + packageName = name, + version = '1.0.0', + edgeSpecs = ['^1.0.0'], + isProjectRoot = false, + isWorkspace = false, + isLink = false, + inDepBundle = false, +} = {}) => ({ + name, + packageName, + version, + edgesIn: new Set(edgeSpecs.map(spec => ({ spec }))), + isProjectRoot, + isWorkspace, + isLink, + inDepBundle, +}) + +const tree = nodes => ({ + inventory: new Map(nodes.map((n, i) => [String(i), n])), +}) + +t.test('registryVersions groups exact registry versions and skips non-registry nodes', t => { + const result = registryVersions(tree([ + node({ name: 'a', version: '1.0.0' }), + node({ name: 'a', version: '2.0.0' }), + node({ name: 'edgeless', edgeSpecs: [] }), + node({ name: 'git-dep', edgeSpecs: ['git+https://github.com/example/pkg.git'] }), + node({ name: 'remote-dep', edgeSpecs: ['https://example.test/pkg.tgz'] }), + node({ name: 'workspace', isWorkspace: true }), + node({ name: 'link', isLink: true }), + node({ name: 'bundled', inDepBundle: true }), + node({ name: 'root', isProjectRoot: true }), + ])) + + t.strictSame([...result.entries()].map(([name, versions]) => [name, [...versions]]), [ + ['a', ['1.0.0', '2.0.0']], + ['edgeless', ['1.0.0']], + ]) + t.end() +}) + +t.test('registryVersions verifies nodes with mixed registry and non-registry consumers', t => { + const result = registryVersions(tree([ + node({ + name: 'mixed', + edgeSpecs: ['^1.0.0', 'git+https://github.com/example/pkg.git'], + }), + ])) + + t.strictSame([...result.entries()].map(([name, versions]) => [name, [...versions]]), [ + ['mixed', ['1.0.0']], + ]) + t.end() +}) + +t.test('registryVersions uses packageName for npm aliases', t => { + const result = registryVersions(tree([ + node({ name: 'alias-name', packageName: 'real-package', version: '3.0.0', edgeSpecs: ['npm:real-package@^3'] }), + ])) + t.strictSame([...result.entries()].map(([name, versions]) => [name, [...versions]]), [ + ['real-package', ['3.0.0']], + ]) + t.end() +}) + +t.test('verifyTrustPolicy is a no-op unless enabled', async t => { + let fetched = false + const { verifyTrustPolicy } = t.mock('../lib/trust-policy-verifier.js', { + pacote: { + packument: async () => { + fetched = true + return {} + }, + }, + }) + await verifyTrustPolicy(tree([node({ name: 'a' })]), {}) + t.equal(fetched, false) +}) + +t.test('verifyTrustPolicy fetches full metadata once per package and checks each selected version', async t => { + const fetches = [] + const checks = [] + const meta = { name: 'a', versions: {}, time: {} } + const { verifyTrustPolicy } = t.mock('../lib/trust-policy-verifier.js', { + pacote: { + packument: async (name, opts) => { + fetches.push({ name, fullMetadata: opts.fullMetadata, cache: opts.packumentCache }) + return meta + }, + }, + '../lib/trust-policy.js': { + isTrustPolicyExcluded: (entries, name, version) => + Boolean(entries?.includes(name + '@' + version)), + checkTrustDowngrade: (packument, version, opts) => { + checks.push({ packument, version, opts }) + }, + }, + }) + + const packumentCache = new Map() + await verifyTrustPolicy(tree([ + node({ name: 'a', version: '1.0.0' }), + node({ name: 'a', version: '2.0.0' }), + ]), { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['a@2.0.0'], + trustPolicyIgnoreAfter: 60, + packumentCache, + }) + + t.strictSame(fetches, [{ name: 'a', fullMetadata: true, cache: packumentCache }]) + t.strictSame(checks.map(c => ({ version: c.version, opts: c.opts })), [ + { version: '1.0.0', opts: { exclude: ['a@2.0.0'], ignoreAfter: 60 } }, + ]) + t.equal(checks.every(c => c.packument === meta), true) +}) + +t.test('verifyTrustPolicy preserves scoped registry routing options', async t => { + const fetches = [] + const { verifyTrustPolicy } = t.mock('../lib/trust-policy-verifier.js', { + pacote: { + packument: async (name, opts) => { + fetches.push({ name, registry: opts.registry, scopedRegistry: opts['@scope:registry'] }) + return { + name, + versions: { '1.0.0': {} }, + time: { '1.0.0': '2026-01-01T00:00:00.000Z' }, + } + }, + }, + '../lib/trust-policy.js': { + isTrustPolicyExcluded: () => false, + checkTrustDowngrade: () => {}, + }, + }) + + await verifyTrustPolicy(tree([ + node({ name: '@scope/pkg', version: '1.0.0' }), + ]), { + trustPolicy: 'no-downgrade', + registry: 'https://registry.example.test/', + '@scope:registry': 'https://scope.example.test/', + }) + + t.strictSame(fetches, [{ + name: '@scope/pkg', + registry: 'https://registry.example.test/', + scopedRegistry: 'https://scope.example.test/', + }]) +}) From 8b1dee92d11fd73cedc3cf0935c6fc0382dbc7b2 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:26:59 +1000 Subject: [PATCH 18/36] test(arborist): cover trust downgrade policy --- workspaces/arborist/test/trust-policy.js | 112 +---------------------- 1 file changed, 2 insertions(+), 110 deletions(-) diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js index 4403c971d6ce3..f7360bd61a3b4 100644 --- a/workspaces/arborist/test/trust-policy.js +++ b/workspaces/arborist/test/trust-policy.js @@ -1,12 +1,9 @@ const t = require('tap') -const pacote = require('pacote') const { checkTrustDowngrade, getTrustEvidence, isTrustPolicyExcluded, - registrySpecForNode, - verifyTrustPolicy, } = require('../lib/trust-policy.js') const provenance = { @@ -40,25 +37,6 @@ const packument = ({ current = {}, prior = provenance } = {}) => ({ }, }) -const registryNode = (overrides = {}) => ({ - isRoot: false, - isWorkspace: false, - isLink: false, - path: '/tmp/project/node_modules/example-package', - resolved: 'https://registry.npmjs.org/example-package/-/example-package-2.0.0.tgz', - packageName: 'example-package', - version: '2.0.0', - package: { - name: 'example-package', - version: '2.0.0', - }, - ...overrides, -}) - -const treeWith = (...nodes) => ({ - inventory: new Map(nodes.map((node, index) => [String(index), node])), -}) - t.test('detects trust evidence', t => { t.equal(getTrustEvidence({}), 'none') t.equal(getTrustEvidence(provenance), 'provenance') @@ -75,6 +53,7 @@ t.test('rejects provenance downgrade to no evidence', t => { version: '2.0.0', previousTrust: 'provenance', currentTrust: 'none', + message: /trust-policy-exclude/, } ) t.end() @@ -151,6 +130,7 @@ t.test('supports package and version exclusions', t => { t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.0.0'), true) t.equal(isTrustPolicyExcluded(['example-package@2.0.0'], 'example-package', '2.0.0'), true) t.equal(isTrustPolicyExcluded(['example-package@^2'], 'example-package', '2.1.0'), true) + t.equal(isTrustPolicyExcluded(['webpack@4.47.0 || 5.102.1'], 'webpack', '5.102.1'), true) t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.0.0'), false) t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { exclude: ['example-package@2.0.0'], @@ -184,91 +164,3 @@ t.test('fails closed when selected version metadata is incomplete', t => { ) t.end() }) - -t.test('registry node detection ignores non-registry and local nodes', t => { - t.ok(registrySpecForNode(registryNode(), { registry: 'https://registry.npmjs.org/' })) - t.equal(registrySpecForNode(registryNode({ - resolved: 'https://example.test/example-package-2.0.0.tgz', - }), { registry: 'https://registry.npmjs.org/' }), null) - t.equal(registrySpecForNode(registryNode({ isWorkspace: true }), {}), null) - t.equal(registrySpecForNode(registryNode({ isLink: true }), {}), null) - t.equal(registrySpecForNode(registryNode({ version: 'workspace:*' }), {}), null) - t.end() -}) - -t.test('trust verifier is disabled unless no-downgrade is selected', async t => { - const original = pacote.packument - let calls = 0 - pacote.packument = async () => { - calls++ - return packument({ current: provenance }) - } - t.teardown(() => { - pacote.packument = original - }) - - await verifyTrustPolicy(treeWith(registryNode()), { - registry: 'https://registry.npmjs.org/', - }) - t.equal(calls, 0) -}) - -t.test('trust verifier deduplicates identical package versions', async t => { - const original = pacote.packument - let calls = 0 - pacote.packument = async () => { - calls++ - return packument({ current: provenance }) - } - t.teardown(() => { - pacote.packument = original - }) - - await verifyTrustPolicy(treeWith( - registryNode(), - registryNode({ path: '/tmp/project/node_modules/a/node_modules/example-package' }) - ), { - trustPolicy: 'no-downgrade', - registry: 'https://registry.npmjs.org/', - }) - t.equal(calls, 1) -}) - -t.test('trust verifier rejects a registry provenance downgrade', async t => { - const original = pacote.packument - pacote.packument = async () => packument() - t.teardown(() => { - pacote.packument = original - }) - - await t.rejects( - verifyTrustPolicy(treeWith(registryNode()), { - trustPolicy: 'no-downgrade', - registry: 'https://registry.npmjs.org/', - }), - { - code: 'ETRUSTDOWNGRADE', - package: 'example-package', - version: '2.0.0', - } - ) -}) - -t.test('trust verifier honors an exclusion before fetching metadata', async t => { - const original = pacote.packument - let calls = 0 - pacote.packument = async () => { - calls++ - return packument() - } - t.teardown(() => { - pacote.packument = original - }) - - await verifyTrustPolicy(treeWith(registryNode()), { - trustPolicy: 'no-downgrade', - trustPolicyExclude: ['example-package@2.0.0'], - registry: 'https://registry.npmjs.org/', - }) - t.equal(calls, 0) -}) From d9a711751d9ae9a068f61b8268292674c6994c2f Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:30:19 +1000 Subject: [PATCH 19/36] style: remove editor-added blank line --- lib/commands/update.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/commands/update.js b/lib/commands/update.js index 38f928c8e8d9b..0178e00a2b75c 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -1,5 +1,5 @@ -const path = require('node:path') +const path = require('node:path') const { log } = require('proc-log') const reifyFinish = require('../utils/reify-finish.js') const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') From 3708cc396d32ae05c15123d1a696317e386b11d3 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:32:06 +1000 Subject: [PATCH 20/36] chore: replace editor-corrupted update file --- lib/commands/update.js | 85 ------------------------------------------ 1 file changed, 85 deletions(-) delete mode 100644 lib/commands/update.js diff --git a/lib/commands/update.js b/lib/commands/update.js deleted file mode 100644 index 0178e00a2b75c..0000000000000 --- a/lib/commands/update.js +++ /dev/null @@ -1,85 +0,0 @@ - -const path = require('node:path') -const { log } = require('proc-log') -const reifyFinish = require('../utils/reify-finish.js') -const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') -const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') -const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') -const { patchRelaxOpts } = require('../utils/cli-only-flag.js') -const ArboristWorkspaceCmd = require('../arborist-cmd.js') - -class Update extends ArboristWorkspaceCmd { - static description = 'Update packages' - static name = 'update' - - static params = [ - 'save', - 'global', - 'install-strategy', - 'legacy-bundling', - 'global-style', - 'omit', - 'include', - 'strict-peer-deps', - 'package-lock', - 'foreground-scripts', - 'ignore-scripts', - 'allow-scripts', - 'strict-allow-scripts', - 'dangerously-allow-all-scripts', - 'audit', - 'before', - 'min-release-age', - 'min-release-age-exclude', - 'trust-policy', - 'trust-policy-exclude', - 'trust-policy-ignore-after', - 'bin-links', - 'fund', - 'dry-run', - ...super.params, - ] - - static usage = ['[...]'] - - static async completion (opts, npm) { - const completion = require('../utils/installed-deep.js') - return completion(npm, opts) - } - - async exec (args) { - const update = args.length === 0 ? true : args - const global = path.resolve(this.npm.globalDir, '..') - const where = this.npm.global ? global : this.npm.prefix - - // In the context of `npm update` the save config value should default to `false` - const save = this.npm.config.isDefault('save') - ? false - : this.npm.config.get('save') - - if (this.npm.config.get('depth')) { - log.warn('update', 'The --depth option no longer has any effect. See RFC0019.\n' + - 'https://github.com/npm/rfcs/blob/latest/implemented/0019-remove-update-depth-option.md') - } - - const Arborist = require('@npmcli/arborist') - const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) - const opts = { - ...this.npm.flatOptions, - path: where, - save, - workspaces: this.workspaceNames, - allowScripts: allowScriptsPolicy, - ...patchRelaxOpts(this.npm.config), - } - const arb = new Arborist(opts) - - const reifyOpts = { ...opts, update } - await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: reifyOpts }) - await trustPolicyPreflight({ arb, options: reifyOpts }) - await arb.reify(reifyOpts) - await reifyFinish(this.npm, arb) - } -} - -module.exports = Update From 8ee38a2ef99f2efcb8935b127f28ad379afe80ab Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:34:14 +1000 Subject: [PATCH 21/36] chore: restore update command --- lib/commands/update.js | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/commands/update.js diff --git a/lib/commands/update.js b/lib/commands/update.js new file mode 100644 index 0000000000000..8c33d571a0f9a --- /dev/null +++ b/lib/commands/update.js @@ -0,0 +1,84 @@ +const path = require('node:path') +const { log } = require('proc-log') +const reifyFinish = require('../utils/reify-finish.js') +const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') +const ArboristWorkspaceCmd = require('../arborist-cmd.js') + +class Update extends ArboristWorkspaceCmd { + static description = 'Update packages' + static name = 'update' + + static params = [ + 'save', + 'global', + 'install-strategy', + 'legacy-bundling', + 'global-style', + 'omit', + 'include', + 'strict-peer-deps', + 'package-lock', + 'foreground-scripts', + 'ignore-scripts', + 'allow-scripts', + 'strict-allow-scripts', + 'dangerously-allow-all-scripts', + 'audit', + 'before', + 'min-release-age', + 'min-release-age-exclude', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', + 'bin-links', + 'fund', + 'dry-run', + ...super.params, + ] + + static usage = ['[...]'] + + static async completion (opts, npm) { + const completion = require('../utils/installed-deep.js') + return completion(npm, opts) + } + + async exec (args) { + const update = args.length === 0 ? true : args + const global = path.resolve(this.npm.globalDir, '..') + const where = this.npm.global ? global : this.npm.prefix + + // In the context of `npm update` the save config value should default to `false` + const save = this.npm.config.isDefault('save') + ? false + : this.npm.config.get('save') + + if (this.npm.config.get('depth')) { + log.warn('update', 'The --depth option no longer has any effect. See RFC0019.\n' + + 'https://github.com/npm/rfcs/blob/latest/implemented/0019-remove-update-depth-option.md') + } + + const Arborist = require('@npmcli/arborist') + const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) + const opts = { + ...this.npm.flatOptions, + path: where, + save, + workspaces: this.workspaceNames, + allowScripts: allowScriptsPolicy, + ...patchRelaxOpts(this.npm.config), + } + const arb = new Arborist(opts) + + const reifyOpts = { ...opts, update } + await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: reifyOpts }) + await trustPolicyPreflight({ arb, options: reifyOpts }) + await arb.reify(reifyOpts) + await reifyFinish(this.npm, arb) + } +} + +module.exports = Update From 24ffd0281be751bd0b8815f7c048771454d9ccf7 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:35:37 +1000 Subject: [PATCH 22/36] chore: replace formatted ci command --- lib/commands/ci.js | 175 --------------------------------------------- 1 file changed, 175 deletions(-) delete mode 100644 lib/commands/ci.js diff --git a/lib/commands/ci.js b/lib/commands/ci.js deleted file mode 100644 index 1db1b80451918..0000000000000 --- a/lib/commands/ci.js +++ /dev/null @@ -1,175 +0,0 @@ - -const reifyFinish = require('../utils/reify-finish.js') -const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') -const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') -const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') -const runScript = require('@npmcli/run-script') -const fs = require('node:fs/promises') -const path = require('node:path') -const { log, time } = require('proc-log') -const validateLockfile = require('../utils/validate-lockfile.js') -const { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js') -const ArboristWorkspaceCmd = require('../arborist-cmd.js') -const getWorkspaces = require('../utils/get-workspaces.js') - -class CI extends ArboristWorkspaceCmd { - static description = 'Clean install a project' - static name = 'ci' - - // These are in the order they will show up in when running "-h" - static params = [ - 'install-strategy', - 'legacy-bundling', - 'global-style', - 'omit', - 'include', - 'strict-peer-deps', - 'foreground-scripts', - 'ignore-scripts', - 'allow-directory', - 'allow-file', - 'allow-git', - 'allow-remote', - 'allow-scripts', - 'strict-allow-scripts', - 'dangerously-allow-all-scripts', - 'trust-policy', - 'trust-policy-exclude', - 'trust-policy-ignore-after', - 'audit', - 'bin-links', - 'fund', - 'dry-run', - ...super.params, - ] - - async exec () { - if (this.npm.global) { - throw Object.assign(new Error('`npm ci` does not work for global packages'), { - code: 'ECIGLOBAL', - }) - } - - // npm ci is always strict about patches; the relax flags are not accepted - for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { - if (this.npm.config.find(flag) === 'cli') { - throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), { - code: 'ECIPATCHFLAG', - }) - } - } - - const dryRun = this.npm.config.get('dry-run') - const ignoreScripts = this.npm.config.get('ignore-scripts') - const where = this.npm.prefix - const Arborist = require('@npmcli/arborist') - const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) - const opts = { - ...this.npm.flatOptions, - packageLock: true, // npm ci should never skip lock files - path: where, - save: false, // npm ci should never modify the lockfile or package.json - workspaces: this.workspaceNames, - allowScripts: allowScriptsPolicy, - // npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension. - // The extension file hash is still validated below, independent of execution. - ignoreExtension: true, - } - - // generate an inventory from the virtual tree in the lockfile - const virtualArb = new Arborist(opts) - try { - await virtualArb.loadVirtual() - } catch (err) { - log.verbose('loadVirtual', err.stack) - const msg = - 'The `npm ci` command can only install with an existing\n' + - 'package-lock.json with lockfileVersion >= 1. Run an install with npm@5\n' + - 'or later to generate a package-lock.json file, then try again.' - throw this.usageError(msg) - } - const virtualInventory = new Map(virtualArb.virtualTree.inventory) - - // Now we make our real Arborist. - // We need a new one because the virtual tree from the lockfile can have extraneous dependencies in it that won't install on this platform - const arb = new Arborist(opts) - await arb.buildIdealTree() - await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) - - // Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file). - const errors = validateLockfile(virtualInventory, arb.idealTree.inventory) - // Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree. - errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree)) - // Verifies that the root .npm-extension file matches the lockfile hash. - // The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts. - const { NpmExtension } = require('@npmcli/arborist') - let fileHash = null - try { - fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash - } catch (err) { - errors.push(`Invalid: ${err.message}`) - } - errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash)) - if (errors.length) { - throw this.usageError( - '`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' + - 'Please update your lock file with `npm install` before continuing.\n\n' + - errors.join('\n') - ) - } - - await trustPolicyPreflight({ arb, options: opts }) - - if (!dryRun) { - const workspacePaths = await getWorkspaces([], { - path: this.npm.localPrefix, - includeWorkspaceRoot: true, - }) - - // Only remove node_modules after we've successfully loaded the virtual tree and validated the lockfile - await time.start('npm-ci:rm', async () => { - return await Promise.all([...workspacePaths.values()].map(async modulePath => { - const fullPath = path.join(modulePath, 'node_modules') - // get the list of entries so we can skip the glob for performance - const entries = await fs.readdir(fullPath, null).catch(() => []) - return Promise.all(entries.map(folder => { - return fs.rm(path.join(fullPath, folder), { force: true, recursive: true }) - })) - })) - }) - } - - // Root lifecycle scripts for `npm ci` mirror those run by `npm install`. `preinstall` runs *before* reify so that scripts can bootstrap the environment (e.g. private-registry auth) before any dependency is fetched or unpacked. The remaining scripts run after reify as they did before. - const scriptShell = this.npm.config.get('script-shell') || undefined - const runRootScript = (event) => runScript({ - path: where, - args: [], - scriptShell, - stdio: 'inherit', - event, - }) - - if (!ignoreScripts) { - await runRootScript('preinstall') - } - - await arb.reify(opts) - - if (!ignoreScripts) { - const postReifyScripts = [ - 'install', - 'postinstall', - 'prepublish', // XXX should we remove this finally?? - 'preprepare', - 'prepare', - 'postprepare', - ] - for (const event of postReifyScripts) { - await runRootScript(event) - } - } - await reifyFinish(this.npm, arb) - } -} - -module.exports = CI From 3bfe1e6317b1e007e25472658e815be884731762 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:37:12 +1000 Subject: [PATCH 23/36] chore: restore ci command --- lib/commands/ci.js | 174 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 lib/commands/ci.js diff --git a/lib/commands/ci.js b/lib/commands/ci.js new file mode 100644 index 0000000000000..03fa0b9e0db8f --- /dev/null +++ b/lib/commands/ci.js @@ -0,0 +1,174 @@ +const reifyFinish = require('../utils/reify-finish.js') +const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') +const runScript = require('@npmcli/run-script') +const fs = require('node:fs/promises') +const path = require('node:path') +const { log, time } = require('proc-log') +const validateLockfile = require('../utils/validate-lockfile.js') +const { validatePackageExtensions, validateNpmExtension } = require('../utils/validate-lockfile.js') +const ArboristWorkspaceCmd = require('../arborist-cmd.js') +const getWorkspaces = require('../utils/get-workspaces.js') + +class CI extends ArboristWorkspaceCmd { + static description = 'Clean install a project' + static name = 'ci' + + // These are in the order they will show up in when running "-h" + static params = [ + 'install-strategy', + 'legacy-bundling', + 'global-style', + 'omit', + 'include', + 'strict-peer-deps', + 'foreground-scripts', + 'ignore-scripts', + 'allow-directory', + 'allow-file', + 'allow-git', + 'allow-remote', + 'allow-scripts', + 'strict-allow-scripts', + 'dangerously-allow-all-scripts', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', + 'audit', + 'bin-links', + 'fund', + 'dry-run', + ...super.params, + ] + + async exec () { + if (this.npm.global) { + throw Object.assign(new Error('`npm ci` does not work for global packages'), { + code: 'ECIGLOBAL', + }) + } + + // npm ci is always strict about patches; the relax flags are not accepted + for (const flag of ['allow-unused-patches', 'ignore-patch-failures']) { + if (this.npm.config.find(flag) === 'cli') { + throw Object.assign(new Error(`The --${flag} flag is not allowed with \`npm ci\`.`), { + code: 'ECIPATCHFLAG', + }) + } + } + + const dryRun = this.npm.config.get('dry-run') + const ignoreScripts = this.npm.config.get('ignore-scripts') + const where = this.npm.prefix + const Arborist = require('@npmcli/arborist') + const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) + const opts = { + ...this.npm.flatOptions, + packageLock: true, // npm ci should never skip lock files + path: where, + save: false, // npm ci should never modify the lockfile or package.json + workspaces: this.workspaceNames, + allowScripts: allowScriptsPolicy, + // npm ci reifies the locked graph, which already carries extension-influenced edges, so it must never import or execute .npm-extension. + // The extension file hash is still validated below, independent of execution. + ignoreExtension: true, + } + + // generate an inventory from the virtual tree in the lockfile + const virtualArb = new Arborist(opts) + try { + await virtualArb.loadVirtual() + } catch (err) { + log.verbose('loadVirtual', err.stack) + const msg = + 'The `npm ci` command can only install with an existing\n' + + 'package-lock.json with lockfileVersion >= 1. Run an install with npm@5\n' + + 'or later to generate a package-lock.json file, then try again.' + throw this.usageError(msg) + } + const virtualInventory = new Map(virtualArb.virtualTree.inventory) + + // Now we make our real Arborist. + // We need a new one because the virtual tree from the lockfile can have extraneous dependencies in it that won't install on this platform + const arb = new Arborist(opts) + await arb.buildIdealTree() + await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) + + // Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file). + const errors = validateLockfile(virtualInventory, arb.idealTree.inventory) + // Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree. + errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree)) + // Verifies that the root .npm-extension file matches the lockfile hash. + // The hash comes from discovering the file (no import or execution), so this holds even under ignore-extension/ignore-scripts. + const { NpmExtension } = require('@npmcli/arborist') + let fileHash = null + try { + fileHash = new NpmExtension({ root: where, extensionFile: opts.extensionFile }).hash + } catch (err) { + errors.push(`Invalid: ${err.message}`) + } + errors.push(...validateNpmExtension(virtualArb.virtualTree, fileHash)) + if (errors.length) { + throw this.usageError( + '`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' + + 'Please update your lock file with `npm install` before continuing.\n\n' + + errors.join('\n') + ) + } + + await trustPolicyPreflight({ arb, options: opts }) + + if (!dryRun) { + const workspacePaths = await getWorkspaces([], { + path: this.npm.localPrefix, + includeWorkspaceRoot: true, + }) + + // Only remove node_modules after we've successfully loaded the virtual tree and validated the lockfile + await time.start('npm-ci:rm', async () => { + return await Promise.all([...workspacePaths.values()].map(async modulePath => { + const fullPath = path.join(modulePath, 'node_modules') + // get the list of entries so we can skip the glob for performance + const entries = await fs.readdir(fullPath, null).catch(() => []) + return Promise.all(entries.map(folder => { + return fs.rm(path.join(fullPath, folder), { force: true, recursive: true }) + })) + })) + }) + } + + // Root lifecycle scripts for `npm ci` mirror those run by `npm install`. `preinstall` runs *before* reify so that scripts can bootstrap the environment (e.g. private-registry auth) before any dependency is fetched or unpacked. The remaining scripts run after reify as they did before. + const scriptShell = this.npm.config.get('script-shell') || undefined + const runRootScript = (event) => runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + event, + }) + + if (!ignoreScripts) { + await runRootScript('preinstall') + } + + await arb.reify(opts) + + if (!ignoreScripts) { + const postReifyScripts = [ + 'install', + 'postinstall', + 'prepublish', // XXX should we remove this finally?? + 'preprepare', + 'prepare', + 'postprepare', + ] + for (const event of postReifyScripts) { + await runRootScript(event) + } + } + await reifyFinish(this.npm, arb) + } +} + +module.exports = CI From faa1031d1c0c8c1af8fd279a798c5efc2b0be3f0 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:38:21 +1000 Subject: [PATCH 24/36] chore: replace formatted install command --- lib/commands/install.js | 201 ---------------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 lib/commands/install.js diff --git a/lib/commands/install.js b/lib/commands/install.js deleted file mode 100644 index 716c2b4271918..0000000000000 --- a/lib/commands/install.js +++ /dev/null @@ -1,201 +0,0 @@ - -const { readdir } = require('node:fs/promises') -const { resolve, join } = require('node:path') -const { log } = require('proc-log') -const runScript = require('@npmcli/run-script') -const pacote = require('pacote') -const checks = require('npm-install-checks') -const reifyFinish = require('../utils/reify-finish.js') -const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') -const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') -const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') -const { patchRelaxOpts } = require('../utils/cli-only-flag.js') -const ArboristWorkspaceCmd = require('../arborist-cmd.js') - -class Install extends ArboristWorkspaceCmd { - static description = 'Install a package' - static name = 'install' - - // These are in the order they will show up in when running "-h" If adding to this list, consider adding also to ci.js - static params = [ - 'save', - 'save-exact', - 'global', - 'install-strategy', - 'legacy-bundling', - 'global-style', - 'omit', - 'include', - 'strict-peer-deps', - 'prefer-dedupe', - 'package-lock', - 'package-lock-only', - 'foreground-scripts', - 'ignore-scripts', - 'allow-directory', - 'allow-file', - 'allow-git', - 'allow-remote', - 'allow-scripts', - 'strict-allow-scripts', - 'dangerously-allow-all-scripts', - 'audit', - 'before', - 'min-release-age', - 'min-release-age-exclude', - 'trust-policy', - 'trust-policy-exclude', - 'trust-policy-ignore-after', - 'bin-links', - 'fund', - 'dry-run', - 'cpu', - 'os', - 'libc', - ...super.params, - ] - - static usage = ['[ ...]'] - - static async completion (opts) { - const { partialWord } = opts - // install can complete to a folder with a package.json, or any package. - // if it has a slash, then it's gotta be a folder - // if it starts with https?://, then just give up, because it's a url - if (/^https?:\/\//.test(partialWord)) { - // do not complete to URLs - return [] - } - - if (/\//.test(partialWord)) { - // Complete fully to folder if there is exactly one match and it is a folder containing a package.json file. - // If that is not the case we return 0 matches, which will trigger the default bash complete. - const lastSlashIdx = partialWord.lastIndexOf('/') - const partialName = partialWord.slice(lastSlashIdx + 1) - const partialPath = partialWord.slice(0, lastSlashIdx) || '/' - - const isDirMatch = async sibling => { - if (sibling.slice(0, partialName.length) !== partialName) { - return false - } - - try { - const contents = await readdir(join(partialPath, sibling)) - const result = (contents.indexOf('package.json') !== -1) - return result - } catch { - return false - } - } - - try { - const siblings = await readdir(partialPath) - const matches = [] - for (const sibling of siblings) { - if (await isDirMatch(sibling)) { - matches.push(sibling) - } - } - if (matches.length === 1) { - return [join(partialPath, matches[0])] - } - // no matches - return [] - } catch { - return [] // invalid dir: no matching - } - } - // Note: there used to be registry completion here, but it stopped making sense somewhere around 50,000 packages on the registry - } - - async exec (args) { - // the /path/to/node_modules/.. - const globalTop = resolve(this.npm.globalDir, '..') - const ignoreScripts = this.npm.config.get('ignore-scripts') - const isGlobalInstall = this.npm.global - const where = isGlobalInstall ? globalTop : this.npm.prefix - const forced = this.npm.config.get('force') - const scriptShell = this.npm.config.get('script-shell') || undefined - - // be very strict about engines when trying to update npm itself - const npmInstall = args.find(arg => arg.startsWith('npm@') || arg === 'npm') - if (isGlobalInstall && npmInstall) { - const npmOptions = this.npm.flatOptions - const npmManifest = await pacote.manifest(npmInstall, npmOptions) - try { - checks.checkEngine(npmManifest, npmManifest.version, process.version) - } catch (e) { - if (forced) { - log.warn( - 'install', - `Forcing global npm install with incompatible version ${npmManifest.version} into node ${process.version}` - ) - } else { - throw e - } - } - } - - // don't try to install the prefix into itself - args = args.filter(a => resolve(a) !== this.npm.prefix) - - // `npm i -g` => "install this package globally" - if (isGlobalInstall && !args.length) { - args = ['.'] - } - - // throw usage error if trying to install empty package name to global space, e.g: `npm i -g ""` - if (where === globalTop && !args.every(Boolean)) { - throw this.usageError() - } - - const Arborist = require('@npmcli/arborist') - const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) - const opts = { - ...this.npm.flatOptions, - auditLevel: null, - path: where, - add: args, - workspaces: this.workspaceNames, - allowScripts: allowScriptsPolicy, - // patch relax flags are honored only when passed on the command line - ...patchRelaxOpts(this.npm.config), - } - - // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. - const runRootLifecycle = !args.length && !isGlobalInstall && !ignoreScripts - const runRootScript = (event) => runScript({ - path: where, - args: [], - scriptShell, - stdio: 'inherit', - event, - }) - - if (runRootLifecycle) { - await runRootScript('preinstall') - } - - const arb = new Arborist(opts) - await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) - await trustPolicyPreflight({ arb, options: opts }) - await arb.reify(opts) - - if (runRootLifecycle) { - const postReifyScripts = [ - 'install', - 'postinstall', - 'prepublish', // XXX(npm9) should we remove this finally?? - 'preprepare', - 'prepare', - 'postprepare', - ] - for (const event of postReifyScripts) { - await runRootScript(event) - } - } - await reifyFinish(this.npm, arb) - } -} - -module.exports = Install From 67316a694f43362e1eba2fa50af211cccd6a1510 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:39:49 +1000 Subject: [PATCH 25/36] chore: restore install command --- lib/commands/install.js | 200 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 lib/commands/install.js diff --git a/lib/commands/install.js b/lib/commands/install.js new file mode 100644 index 0000000000000..6763be0f811e4 --- /dev/null +++ b/lib/commands/install.js @@ -0,0 +1,200 @@ +const { readdir } = require('node:fs/promises') +const { resolve, join } = require('node:path') +const { log } = require('proc-log') +const runScript = require('@npmcli/run-script') +const pacote = require('pacote') +const checks = require('npm-install-checks') +const reifyFinish = require('../utils/reify-finish.js') +const resolveAllowScripts = require('../utils/resolve-allow-scripts.js') +const strictAllowScriptsPreflight = require('../utils/strict-allow-scripts-preflight.js') +const trustPolicyPreflight = require('../utils/trust-policy-preflight.js') +const { patchRelaxOpts } = require('../utils/cli-only-flag.js') +const ArboristWorkspaceCmd = require('../arborist-cmd.js') + +class Install extends ArboristWorkspaceCmd { + static description = 'Install a package' + static name = 'install' + + // These are in the order they will show up in when running "-h" If adding to this list, consider adding also to ci.js + static params = [ + 'save', + 'save-exact', + 'global', + 'install-strategy', + 'legacy-bundling', + 'global-style', + 'omit', + 'include', + 'strict-peer-deps', + 'prefer-dedupe', + 'package-lock', + 'package-lock-only', + 'foreground-scripts', + 'ignore-scripts', + 'allow-directory', + 'allow-file', + 'allow-git', + 'allow-remote', + 'allow-scripts', + 'strict-allow-scripts', + 'dangerously-allow-all-scripts', + 'audit', + 'before', + 'min-release-age', + 'min-release-age-exclude', + 'trust-policy', + 'trust-policy-exclude', + 'trust-policy-ignore-after', + 'bin-links', + 'fund', + 'dry-run', + 'cpu', + 'os', + 'libc', + ...super.params, + ] + + static usage = ['[ ...]'] + + static async completion (opts) { + const { partialWord } = opts + // install can complete to a folder with a package.json, or any package. + // if it has a slash, then it's gotta be a folder + // if it starts with https?://, then just give up, because it's a url + if (/^https?:\/\//.test(partialWord)) { + // do not complete to URLs + return [] + } + + if (/\//.test(partialWord)) { + // Complete fully to folder if there is exactly one match and it is a folder containing a package.json file. + // If that is not the case we return 0 matches, which will trigger the default bash complete. + const lastSlashIdx = partialWord.lastIndexOf('/') + const partialName = partialWord.slice(lastSlashIdx + 1) + const partialPath = partialWord.slice(0, lastSlashIdx) || '/' + + const isDirMatch = async sibling => { + if (sibling.slice(0, partialName.length) !== partialName) { + return false + } + + try { + const contents = await readdir(join(partialPath, sibling)) + const result = (contents.indexOf('package.json') !== -1) + return result + } catch { + return false + } + } + + try { + const siblings = await readdir(partialPath) + const matches = [] + for (const sibling of siblings) { + if (await isDirMatch(sibling)) { + matches.push(sibling) + } + } + if (matches.length === 1) { + return [join(partialPath, matches[0])] + } + // no matches + return [] + } catch { + return [] // invalid dir: no matching + } + } + // Note: there used to be registry completion here, but it stopped making sense somewhere around 50,000 packages on the registry + } + + async exec (args) { + // the /path/to/node_modules/.. + const globalTop = resolve(this.npm.globalDir, '..') + const ignoreScripts = this.npm.config.get('ignore-scripts') + const isGlobalInstall = this.npm.global + const where = isGlobalInstall ? globalTop : this.npm.prefix + const forced = this.npm.config.get('force') + const scriptShell = this.npm.config.get('script-shell') || undefined + + // be very strict about engines when trying to update npm itself + const npmInstall = args.find(arg => arg.startsWith('npm@') || arg === 'npm') + if (isGlobalInstall && npmInstall) { + const npmOptions = this.npm.flatOptions + const npmManifest = await pacote.manifest(npmInstall, npmOptions) + try { + checks.checkEngine(npmManifest, npmManifest.version, process.version) + } catch (e) { + if (forced) { + log.warn( + 'install', + `Forcing global npm install with incompatible version ${npmManifest.version} into node ${process.version}` + ) + } else { + throw e + } + } + } + + // don't try to install the prefix into itself + args = args.filter(a => resolve(a) !== this.npm.prefix) + + // `npm i -g` => "install this package globally" + if (isGlobalInstall && !args.length) { + args = ['.'] + } + + // throw usage error if trying to install empty package name to global space, e.g: `npm i -g ""` + if (where === globalTop && !args.every(Boolean)) { + throw this.usageError() + } + + const Arborist = require('@npmcli/arborist') + const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) + const opts = { + ...this.npm.flatOptions, + auditLevel: null, + path: where, + add: args, + workspaces: this.workspaceNames, + allowScripts: allowScriptsPolicy, + // patch relax flags are honored only when passed on the command line + ...patchRelaxOpts(this.npm.config), + } + + // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. + const runRootLifecycle = !args.length && !isGlobalInstall && !ignoreScripts + const runRootScript = (event) => runScript({ + path: where, + args: [], + scriptShell, + stdio: 'inherit', + event, + }) + + if (runRootLifecycle) { + await runRootScript('preinstall') + } + + const arb = new Arborist(opts) + await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) + await trustPolicyPreflight({ arb, options: opts }) + await arb.reify(opts) + + if (runRootLifecycle) { + const postReifyScripts = [ + 'install', + 'postinstall', + 'prepublish', // XXX(npm9) should we remove this finally?? + 'preprepare', + 'prepare', + 'postprepare', + ] + for (const event of postReifyScripts) { + await runRootScript(event) + } + } + await reifyFinish(this.npm, arb) + } +} + +module.exports = Install From fffac15564443f8b31e127ad18b5c03aa484a1df Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:40:57 +1000 Subject: [PATCH 26/36] chore: replace formatted trust policy helper --- workspaces/arborist/lib/trust-policy.js | 143 ------------------------ 1 file changed, 143 deletions(-) delete mode 100644 workspaces/arborist/lib/trust-policy.js diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js deleted file mode 100644 index dcaf1c545513b..0000000000000 --- a/workspaces/arborist/lib/trust-policy.js +++ /dev/null @@ -1,143 +0,0 @@ - -const npa = require('npm-package-arg') -const semver = require('semver') - -const TRUST_RANK = { - none: 0, - provenance: 1, - trustedPublisher: 2, -} - -const trustLabel = evidence => evidence === 'trustedPublisher' - ? 'trusted publisher provenance' - : evidence === 'provenance' - ? 'provenance attestation' - : 'no trust evidence' - -const getTrustEvidence = manifest => { - const provenance = manifest?.dist?.attestations?.provenance - if (manifest?._npmUser?.trustedPublisher && provenance) { - return 'trustedPublisher' - } - if (provenance) { - return 'provenance' - } - return 'none' -} - -const isTrustPolicyExcluded = (entries, name, version) => { - for (const entry of entries || []) { - let spec - try { - spec = npa(entry) - } catch { - continue - } - - if (spec.name !== name) { - continue - } - - if (spec.raw === spec.name || spec.rawSpec === '*') { - return true - } - - if (spec.type === 'version' && spec.fetchSpec === version) { - return true - } - - if (spec.type === 'range' && semver.satisfies(version, spec.fetchSpec)) { - return true - } - } - return false -} - -const metadataError = (name, version, message) => Object.assign( - new Error(`Unable to enforce trust policy for ${name}@${version}: ${message}`), - { - code: 'ETRUSTPOLICYMETADATA', - package: name, - version, - } -) - -const checkTrustDowngrade = (packument, version, { - exclude = [], - ignoreAfter = null, - now = Date.now(), -} = {}) => { - const name = packument?.name - if (!name || !packument?.versions?.[version]) { - throw metadataError(name || '', version, 'version metadata is missing') - } - - if (isTrustPolicyExcluded(exclude, name, version)) { - return - } - - const published = packument.time?.[version] - const publishedAt = published && Date.parse(published) - if (!Number.isFinite(publishedAt)) { - throw metadataError(name, version, 'publish time is missing or invalid') - } - - if (ignoreAfter != null && Number.isFinite(ignoreAfter) && ignoreAfter > 0) { - const ageMinutes = (now - publishedAt) / 60000 - if (ageMinutes > ignoreAfter) { - return - } - } - - const current = packument.versions[version] - const currentEvidence = getTrustEvidence(current) - const currentIsPrerelease = Boolean(semver.prerelease(version)) - let strongestPriorEvidence = 'none' - - for (const [priorVersion, priorManifest] of Object.entries(packument.versions)) { - if (priorVersion === version) { - continue - } - - if (!currentIsPrerelease && semver.prerelease(priorVersion)) { - continue - } - - const priorPublished = packument.time?.[priorVersion] - const priorPublishedAt = priorPublished && Date.parse(priorPublished) - if (!Number.isFinite(priorPublishedAt) || priorPublishedAt >= publishedAt) { - continue - } - - const priorEvidence = getTrustEvidence(priorManifest) - if (TRUST_RANK[priorEvidence] > TRUST_RANK[strongestPriorEvidence]) { - strongestPriorEvidence = priorEvidence - } - } - - if (TRUST_RANK[strongestPriorEvidence] <= TRUST_RANK[currentEvidence]) { - return - } - - throw Object.assign( - new Error( - `High-risk trust downgrade for "${name}@${version}" (possible package takeover): ` + - `earlier versions had ${trustLabel(strongestPriorEvidence)}, ` + - `but this version has ${trustLabel(currentEvidence)}. ` + - `If this downgrade is expected, add "${name}@${version}" to trust-policy-exclude.` - ), - { - code: 'ETRUSTDOWNGRADE', - package: name, - version, - previousTrust: strongestPriorEvidence, - currentTrust: currentEvidence, - } - ) -} - -module.exports = { - checkTrustDowngrade, - getTrustEvidence, - isTrustPolicyExcluded, -} From 7f92d8beb6993cf1f48d77bbaa175fc4c8d5918b Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:42:37 +1000 Subject: [PATCH 27/36] chore: restore trust policy helper --- workspaces/arborist/lib/trust-policy.js | 142 ++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 workspaces/arborist/lib/trust-policy.js diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js new file mode 100644 index 0000000000000..754d58380b3bb --- /dev/null +++ b/workspaces/arborist/lib/trust-policy.js @@ -0,0 +1,142 @@ +const npa = require('npm-package-arg') +const semver = require('semver') + +const TRUST_RANK = { + none: 0, + provenance: 1, + trustedPublisher: 2, +} + +const trustLabel = evidence => evidence === 'trustedPublisher' + ? 'trusted publisher provenance' + : evidence === 'provenance' + ? 'provenance attestation' + : 'no trust evidence' + +const getTrustEvidence = manifest => { + const provenance = manifest?.dist?.attestations?.provenance + if (manifest?._npmUser?.trustedPublisher && provenance) { + return 'trustedPublisher' + } + if (provenance) { + return 'provenance' + } + return 'none' +} + +const isTrustPolicyExcluded = (entries, name, version) => { + for (const entry of entries || []) { + let spec + try { + spec = npa(entry) + } catch { + continue + } + + if (spec.name !== name) { + continue + } + + if (spec.raw === spec.name || spec.rawSpec === '*') { + return true + } + + if (spec.type === 'version' && spec.fetchSpec === version) { + return true + } + + if (spec.type === 'range' && semver.satisfies(version, spec.fetchSpec)) { + return true + } + } + return false +} + +const metadataError = (name, version, message) => Object.assign( + new Error(`Unable to enforce trust policy for ${name}@${version}: ${message}`), + { + code: 'ETRUSTPOLICYMETADATA', + package: name, + version, + } +) + +const checkTrustDowngrade = (packument, version, { + exclude = [], + ignoreAfter = null, + now = Date.now(), +} = {}) => { + const name = packument?.name + if (!name || !packument?.versions?.[version]) { + throw metadataError(name || '', version, 'version metadata is missing') + } + + if (isTrustPolicyExcluded(exclude, name, version)) { + return + } + + const published = packument.time?.[version] + const publishedAt = published && Date.parse(published) + if (!Number.isFinite(publishedAt)) { + throw metadataError(name, version, 'publish time is missing or invalid') + } + + if (ignoreAfter != null && Number.isFinite(ignoreAfter) && ignoreAfter > 0) { + const ageMinutes = (now - publishedAt) / 60000 + if (ageMinutes > ignoreAfter) { + return + } + } + + const current = packument.versions[version] + const currentEvidence = getTrustEvidence(current) + const currentIsPrerelease = Boolean(semver.prerelease(version)) + let strongestPriorEvidence = 'none' + + for (const [priorVersion, priorManifest] of Object.entries(packument.versions)) { + if (priorVersion === version) { + continue + } + + if (!currentIsPrerelease && semver.prerelease(priorVersion)) { + continue + } + + const priorPublished = packument.time?.[priorVersion] + const priorPublishedAt = priorPublished && Date.parse(priorPublished) + if (!Number.isFinite(priorPublishedAt) || priorPublishedAt >= publishedAt) { + continue + } + + const priorEvidence = getTrustEvidence(priorManifest) + if (TRUST_RANK[priorEvidence] > TRUST_RANK[strongestPriorEvidence]) { + strongestPriorEvidence = priorEvidence + } + } + + if (TRUST_RANK[strongestPriorEvidence] <= TRUST_RANK[currentEvidence]) { + return + } + + throw Object.assign( + new Error( + `High-risk trust downgrade for "${name}@${version}" (possible package takeover): ` + + `earlier versions had ${trustLabel(strongestPriorEvidence)}, ` + + `but this version has ${trustLabel(currentEvidence)}. ` + + `If this downgrade is expected, add "${name}@${version}" to trust-policy-exclude.` + ), + { + code: 'ETRUSTDOWNGRADE', + package: name, + version, + previousTrust: strongestPriorEvidence, + currentTrust: currentEvidence, + } + ) +} + +module.exports = { + checkTrustDowngrade, + getTrustEvidence, + isTrustPolicyExcluded, +} From f79fb53712baac07f5477dfd680ad90bde1438f8 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:44:05 +1000 Subject: [PATCH 28/36] chore: replace formatted trust policy test --- workspaces/arborist/test/trust-policy.js | 166 ----------------------- 1 file changed, 166 deletions(-) delete mode 100644 workspaces/arborist/test/trust-policy.js diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js deleted file mode 100644 index f7360bd61a3b4..0000000000000 --- a/workspaces/arborist/test/trust-policy.js +++ /dev/null @@ -1,166 +0,0 @@ - -const t = require('tap') -const { - checkTrustDowngrade, - getTrustEvidence, - isTrustPolicyExcluded, -} = require('../lib/trust-policy.js') - -const provenance = { - dist: { - attestations: { - provenance: { - url: 'https://registry.example.test/attestations', - }, - }, - }, -} - -const trustedPublisher = { - ...provenance, - _npmUser: { - trustedPublisher: { - id: 'github', - }, - }, -} - -const packument = ({ current = {}, prior = provenance } = {}) => ({ - name: 'example-package', - time: { - '1.0.0': '2026-01-01T00:00:00.000Z', - '2.0.0': '2026-02-01T00:00:00.000Z', - }, - versions: { - '1.0.0': prior, - '2.0.0': current, - }, -}) - -t.test('detects trust evidence', t => { - t.equal(getTrustEvidence({}), 'none') - t.equal(getTrustEvidence(provenance), 'provenance') - t.equal(getTrustEvidence(trustedPublisher), 'trustedPublisher') - t.end() -}) - -t.test('rejects provenance downgrade to no evidence', t => { - t.throws( - () => checkTrustDowngrade(packument(), '2.0.0'), - { - code: 'ETRUSTDOWNGRADE', - package: 'example-package', - version: '2.0.0', - previousTrust: 'provenance', - currentTrust: 'none', - message: /trust-policy-exclude/, - } - ) - t.end() -}) - -t.test('rejects trusted publisher downgrade to provenance', t => { - t.throws( - () => checkTrustDowngrade(packument({ current: provenance, prior: trustedPublisher }), '2.0.0'), - { - code: 'ETRUSTDOWNGRADE', - previousTrust: 'trustedPublisher', - currentTrust: 'provenance', - } - ) - t.end() -}) - -t.test('accepts equal or stronger trust', t => { - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance }), '2.0.0')) - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: trustedPublisher }), '2.0.0')) - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance, prior: {} }), '2.0.0')) - t.end() -}) - -t.test('uses publish order rather than semver order', t => { - const meta = { - name: 'example-package', - time: { - '2.0.0': '2026-01-01T00:00:00.000Z', - '1.5.0': '2026-02-01T00:00:00.000Z', - }, - versions: { - '2.0.0': provenance, - '1.5.0': {}, - }, - } - t.throws(() => checkTrustDowngrade(meta, '1.5.0'), { code: 'ETRUSTDOWNGRADE' }) - t.end() -}) - -t.test('stable releases ignore prior prerelease trust evidence', t => { - const meta = { - name: 'example-package', - time: { - '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', - '2.0.0': '2026-02-01T00:00:00.000Z', - }, - versions: { - '2.0.0-beta.1': provenance, - '2.0.0': {}, - }, - } - t.doesNotThrow(() => checkTrustDowngrade(meta, '2.0.0')) - t.end() -}) - -t.test('prereleases compare against earlier prereleases', t => { - const meta = { - name: 'example-package', - time: { - '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', - '2.0.0-beta.2': '2026-02-01T00:00:00.000Z', - }, - versions: { - '2.0.0-beta.1': provenance, - '2.0.0-beta.2': {}, - }, - } - t.throws(() => checkTrustDowngrade(meta, '2.0.0-beta.2'), { code: 'ETRUSTDOWNGRADE' }) - t.end() -}) - -t.test('supports package and version exclusions', t => { - t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.0.0'), true) - t.equal(isTrustPolicyExcluded(['example-package@2.0.0'], 'example-package', '2.0.0'), true) - t.equal(isTrustPolicyExcluded(['example-package@^2'], 'example-package', '2.1.0'), true) - t.equal(isTrustPolicyExcluded(['webpack@4.47.0 || 5.102.1'], 'webpack', '5.102.1'), true) - t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.0.0'), false) - t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { - exclude: ['example-package@2.0.0'], - })) - t.end() -}) - -t.test('ignore-after skips old selected versions', t => { - const now = Date.parse('2026-02-02T00:00:00.000Z') - t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { - ignoreAfter: 60, - now, - })) - t.throws(() => checkTrustDowngrade(packument(), '2.0.0', { - ignoreAfter: 60 * 24 * 2, - now, - }), { code: 'ETRUSTDOWNGRADE' }) - t.end() -}) - -t.test('fails closed when selected version metadata is incomplete', t => { - const meta = packument() - delete meta.time['2.0.0'] - t.throws( - () => checkTrustDowngrade(meta, '2.0.0'), - { - code: 'ETRUSTPOLICYMETADATA', - package: 'example-package', - version: '2.0.0', - } - ) - t.end() -}) From 364b23a214aaffa862e09e872cd10b162379c54d Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:45:58 +1000 Subject: [PATCH 29/36] chore: restore trust policy test --- workspaces/arborist/test/trust-policy.js | 165 +++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 workspaces/arborist/test/trust-policy.js diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js new file mode 100644 index 0000000000000..f038201647ddc --- /dev/null +++ b/workspaces/arborist/test/trust-policy.js @@ -0,0 +1,165 @@ +const t = require('tap') +const { + checkTrustDowngrade, + getTrustEvidence, + isTrustPolicyExcluded, +} = require('../lib/trust-policy.js') + +const provenance = { + dist: { + attestations: { + provenance: { + url: 'https://registry.example.test/attestations', + }, + }, + }, +} + +const trustedPublisher = { + ...provenance, + _npmUser: { + trustedPublisher: { + id: 'github', + }, + }, +} + +const packument = ({ current = {}, prior = provenance } = {}) => ({ + name: 'example-package', + time: { + '1.0.0': '2026-01-01T00:00:00.000Z', + '2.0.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '1.0.0': prior, + '2.0.0': current, + }, +}) + +t.test('detects trust evidence', t => { + t.equal(getTrustEvidence({}), 'none') + t.equal(getTrustEvidence(provenance), 'provenance') + t.equal(getTrustEvidence(trustedPublisher), 'trustedPublisher') + t.end() +}) + +t.test('rejects provenance downgrade to no evidence', t => { + t.throws( + () => checkTrustDowngrade(packument(), '2.0.0'), + { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.0.0', + previousTrust: 'provenance', + currentTrust: 'none', + message: /trust-policy-exclude/, + } + ) + t.end() +}) + +t.test('rejects trusted publisher downgrade to provenance', t => { + t.throws( + () => checkTrustDowngrade(packument({ current: provenance, prior: trustedPublisher }), '2.0.0'), + { + code: 'ETRUSTDOWNGRADE', + previousTrust: 'trustedPublisher', + currentTrust: 'provenance', + } + ) + t.end() +}) + +t.test('accepts equal or stronger trust', t => { + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance }), '2.0.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: trustedPublisher }), '2.0.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance, prior: {} }), '2.0.0')) + t.end() +}) + +t.test('uses publish order rather than semver order', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0': '2026-01-01T00:00:00.000Z', + '1.5.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0': provenance, + '1.5.0': {}, + }, + } + t.throws(() => checkTrustDowngrade(meta, '1.5.0'), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('stable releases ignore prior prerelease trust evidence', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', + '2.0.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0-beta.1': provenance, + '2.0.0': {}, + }, + } + t.doesNotThrow(() => checkTrustDowngrade(meta, '2.0.0')) + t.end() +}) + +t.test('prereleases compare against earlier prereleases', t => { + const meta = { + name: 'example-package', + time: { + '2.0.0-beta.1': '2026-01-01T00:00:00.000Z', + '2.0.0-beta.2': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0-beta.1': provenance, + '2.0.0-beta.2': {}, + }, + } + t.throws(() => checkTrustDowngrade(meta, '2.0.0-beta.2'), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('supports package and version exclusions', t => { + t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.0.0'), true) + t.equal(isTrustPolicyExcluded(['example-package@2.0.0'], 'example-package', '2.0.0'), true) + t.equal(isTrustPolicyExcluded(['example-package@^2'], 'example-package', '2.1.0'), true) + t.equal(isTrustPolicyExcluded(['webpack@4.47.0 || 5.102.1'], 'webpack', '5.102.1'), true) + t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.0.0'), false) + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { + exclude: ['example-package@2.0.0'], + })) + t.end() +}) + +t.test('ignore-after skips old selected versions', t => { + const now = Date.parse('2026-02-02T00:00:00.000Z') + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { + ignoreAfter: 60, + now, + })) + t.throws(() => checkTrustDowngrade(packument(), '2.0.0', { + ignoreAfter: 60 * 24 * 2, + now, + }), { code: 'ETRUSTDOWNGRADE' }) + t.end() +}) + +t.test('fails closed when selected version metadata is incomplete', t => { + const meta = packument() + delete meta.time['2.0.0'] + t.throws( + () => checkTrustDowngrade(meta, '2.0.0'), + { + code: 'ETRUSTPOLICYMETADATA', + package: 'example-package', + version: '2.0.0', + } + ) + t.end() +}) From 6c123fe5985b4e0d80a43b0ee33e7b74bbbce811 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:47:43 +1000 Subject: [PATCH 30/36] chore: replace formatted config definitions index --- workspaces/config/lib/definitions/index.js | 95 ---------------------- 1 file changed, 95 deletions(-) delete mode 100644 workspaces/config/lib/definitions/index.js diff --git a/workspaces/config/lib/definitions/index.js b/workspaces/config/lib/definitions/index.js deleted file mode 100644 index 3251b58f50df0..0000000000000 --- a/workspaces/config/lib/definitions/index.js +++ /dev/null @@ -1,95 +0,0 @@ - -const baseDefinitions = require('./definitions.js') -const trustPolicyDefinitions = require('./trust-policy.js') - -const definitions = Object.fromEntries( - Object.entries({ ...baseDefinitions, ...trustPolicyDefinitions }) - .sort(([a], [b]) => a.localeCompare(b)) -) - -// use the defined flattening function, and copy over any scoped -// registries and registry-specific "nerfdart" configs verbatim -// -// TODO: make these getters so that we only have to make dirty -// the thing that changed, and then flatten the fields that -// could have changed when a config.set is called. -// -// TODO: move nerfdart auth stuff into a nested object that -// is only passed along to paths that end up calling npm-registry-fetch. -const flatten = (obj, flat = {}) => { - for (const [key, val] of Object.entries(obj)) { - const def = definitions[key] - if (def && def.flatten) { - def.flatten(key, obj, flat) - } else if (/@.*:registry$/i.test(key) || /^\/\//.test(key)) { - flat[key] = val - } - } - return flat -} - -const definitionProps = Object.entries(definitions) - .reduce((acc, [key, { short = [], default: d }]) => { - // can be either an array or string - for (const s of [].concat(short)) { - acc.shorthands[s] = [`--${key}`] - } - acc.defaults[key] = d - return acc - }, { shorthands: {}, defaults: {} }) - -// aliases where they get expanded into a completely different thing -// these are NOT supported in the environment or npmrc files, only -// expanded on the CLI. -// TODO: when we switch off of nopt, use an arg parser that supports -// more reasonable aliasing and short opts right in the definitions set. -const shorthands = { - 'enjoy-by': ['--before'], - d: ['--loglevel', 'info'], - dd: ['--loglevel', 'verbose'], - ddd: ['--loglevel', 'silly'], - quiet: ['--loglevel', 'warn'], - q: ['--loglevel', 'warn'], - s: ['--loglevel', 'silent'], - silent: ['--loglevel', 'silent'], - verbose: ['--loglevel', 'verbose'], - desc: ['--description'], - help: ['--usage'], - local: ['--no-global'], - n: ['--no-yes'], - no: ['--no-yes'], - porcelain: ['--parseable'], - readonly: ['--read-only'], - reg: ['--registry'], - iwr: ['--include-workspace-root'], - ws: ['--workspaces'], - ...definitionProps.shorthands, -} - -// These are the configs that we can nerf-dart. Only _auth even has a config definition so we have to explicitly validate them here. -// This is used to validate during "npm config set" and to not warn on loading unknown configs when we see these. -const nerfDarts = [ - '_auth', // Has a config - '_authToken', // Does not have a config - '_password', // Does not have a config - 'certfile', // Does not have a config - 'email', // Does not have a config - 'keyfile', // Does not have a config - 'username', // Does not have a config -] - -const proxyEnv = [ - 'http_proxy', - 'https_proxy', - 'proxy', - 'no_proxy', -] - -module.exports = { - defaults: definitionProps.defaults, - definitions, - flatten, - nerfDarts, - proxyEnv, - shorthands, -} From 8154a938d1c46fe9e63f31fd582f590cc4813550 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:49:27 +1000 Subject: [PATCH 31/36] chore: restore config definitions index --- workspaces/config/lib/definitions/index.js | 94 ++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 workspaces/config/lib/definitions/index.js diff --git a/workspaces/config/lib/definitions/index.js b/workspaces/config/lib/definitions/index.js new file mode 100644 index 0000000000000..d002c1038d8b7 --- /dev/null +++ b/workspaces/config/lib/definitions/index.js @@ -0,0 +1,94 @@ +const baseDefinitions = require('./definitions.js') +const trustPolicyDefinitions = require('./trust-policy.js') + +const definitions = Object.fromEntries( + Object.entries({ ...baseDefinitions, ...trustPolicyDefinitions }) + .sort(([a], [b]) => a.localeCompare(b)) +) + +// use the defined flattening function, and copy over any scoped +// registries and registry-specific "nerfdart" configs verbatim +// +// TODO: make these getters so that we only have to make dirty +// the thing that changed, and then flatten the fields that +// could have changed when a config.set is called. +// +// TODO: move nerfdart auth stuff into a nested object that +// is only passed along to paths that end up calling npm-registry-fetch. +const flatten = (obj, flat = {}) => { + for (const [key, val] of Object.entries(obj)) { + const def = definitions[key] + if (def && def.flatten) { + def.flatten(key, obj, flat) + } else if (/@.*:registry$/i.test(key) || /^\/\//.test(key)) { + flat[key] = val + } + } + return flat +} + +const definitionProps = Object.entries(definitions) + .reduce((acc, [key, { short = [], default: d }]) => { + // can be either an array or string + for (const s of [].concat(short)) { + acc.shorthands[s] = [`--${key}`] + } + acc.defaults[key] = d + return acc + }, { shorthands: {}, defaults: {} }) + +// aliases where they get expanded into a completely different thing +// these are NOT supported in the environment or npmrc files, only +// expanded on the CLI. +// TODO: when we switch off of nopt, use an arg parser that supports +// more reasonable aliasing and short opts right in the definitions set. +const shorthands = { + 'enjoy-by': ['--before'], + d: ['--loglevel', 'info'], + dd: ['--loglevel', 'verbose'], + ddd: ['--loglevel', 'silly'], + quiet: ['--loglevel', 'warn'], + q: ['--loglevel', 'warn'], + s: ['--loglevel', 'silent'], + silent: ['--loglevel', 'silent'], + verbose: ['--loglevel', 'verbose'], + desc: ['--description'], + help: ['--usage'], + local: ['--no-global'], + n: ['--no-yes'], + no: ['--no-yes'], + porcelain: ['--parseable'], + readonly: ['--read-only'], + reg: ['--registry'], + iwr: ['--include-workspace-root'], + ws: ['--workspaces'], + ...definitionProps.shorthands, +} + +// These are the configs that we can nerf-dart. Only _auth even has a config definition so we have to explicitly validate them here. +// This is used to validate during "npm config set" and to not warn on loading unknown configs when we see these. +const nerfDarts = [ + '_auth', // Has a config + '_authToken', // Does not have a config + '_password', // Does not have a config + 'certfile', // Does not have a config + 'email', // Does not have a config + 'keyfile', // Does not have a config + 'username', // Does not have a config +] + +const proxyEnv = [ + 'http_proxy', + 'https_proxy', + 'proxy', + 'no_proxy', +] + +module.exports = { + defaults: definitionProps.defaults, + definitions, + flatten, + nerfDarts, + proxyEnv, + shorthands, +} From 89c2ac46d484ac187e764a0bbc451dd67d143c2a Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:50:24 +1000 Subject: [PATCH 32/36] chore: replace formatted config definitions test --- workspaces/config/test/definitions/index.js | 66 --------------------- 1 file changed, 66 deletions(-) delete mode 100644 workspaces/config/test/definitions/index.js diff --git a/workspaces/config/test/definitions/index.js b/workspaces/config/test/definitions/index.js deleted file mode 100644 index 147dd83ce0e6c..0000000000000 --- a/workspaces/config/test/definitions/index.js +++ /dev/null @@ -1,66 +0,0 @@ - -const t = require('tap') -const config = require('../../lib/definitions/index.js') -const definitions = require('../../lib/definitions/definitions.js') - -t.test('defaults', t => { - // just spot check a few of these to show that we got defaults assembled - t.match(config.defaults, { - registry: definitions.registry.default, - 'init-module': definitions['init-module'].default, - 'trust-policy': null, - 'trust-policy-exclude': [], - 'trust-policy-ignore-after': null, - }) - - t.end() -}) - -t.test('flatten', t => { - const obj = { - 'save-exact': true, - 'save-prefix': 'ignored', - 'save-dev': true, - '@foobar:registry': 'https://foo.bar.com/', - '//foo.bar.com:_authToken': 'foobarbazquuxasdf', - userconfig: '/path/to/.npmrc', - } - - const flat = config.flatten(obj) - - t.strictSame(flat, { - saveType: 'dev', - savePrefix: '', - '@foobar:registry': 'https://foo.bar.com/', - '//foo.bar.com:_authToken': 'foobarbazquuxasdf', - }) - - // now flatten something else on top of it. - config.flatten({ 'save-dev': false }, flat) - - t.strictSame(flat, { - savePrefix: '', - '@foobar:registry': 'https://foo.bar.com/', - '//foo.bar.com:_authToken': 'foobarbazquuxasdf', - }) - - t.end() -}) - -t.test('trust policy flattening', t => { - const flat = config.flatten({ - 'trust-policy': 'no-downgrade', - 'trust-policy-exclude': ['a@1, b@^2', 'a@1'], - 'trust-policy-ignore-after': 525600, - }) - - t.strictSame(flat, { - trustPolicy: 'no-downgrade', - trustPolicyExclude: ['a@1', 'b@^2'], - trustPolicyIgnoreAfter: 525600, - }) - - const single = config.flatten({ 'trust-policy-exclude': 'single-package@1' }) - t.strictSame(single.trustPolicyExclude, ['single-package@1']) - t.end() -}) From 5e83ed7b02a708bf49d0b394ddd012b7833759a1 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Thu, 20 Aug 2026 22:51:47 +1000 Subject: [PATCH 33/36] chore: restore config definitions test --- workspaces/config/test/definitions/index.js | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 workspaces/config/test/definitions/index.js diff --git a/workspaces/config/test/definitions/index.js b/workspaces/config/test/definitions/index.js new file mode 100644 index 0000000000000..1c1ba3e514e6c --- /dev/null +++ b/workspaces/config/test/definitions/index.js @@ -0,0 +1,65 @@ +const t = require('tap') +const config = require('../../lib/definitions/index.js') +const definitions = require('../../lib/definitions/definitions.js') + +t.test('defaults', t => { + // just spot check a few of these to show that we got defaults assembled + t.match(config.defaults, { + registry: definitions.registry.default, + 'init-module': definitions['init-module'].default, + 'trust-policy': null, + 'trust-policy-exclude': [], + 'trust-policy-ignore-after': null, + }) + + t.end() +}) + +t.test('flatten', t => { + const obj = { + 'save-exact': true, + 'save-prefix': 'ignored', + 'save-dev': true, + '@foobar:registry': 'https://foo.bar.com/', + '//foo.bar.com:_authToken': 'foobarbazquuxasdf', + userconfig: '/path/to/.npmrc', + } + + const flat = config.flatten(obj) + + t.strictSame(flat, { + saveType: 'dev', + savePrefix: '', + '@foobar:registry': 'https://foo.bar.com/', + '//foo.bar.com:_authToken': 'foobarbazquuxasdf', + }) + + // now flatten something else on top of it. + config.flatten({ 'save-dev': false }, flat) + + t.strictSame(flat, { + savePrefix: '', + '@foobar:registry': 'https://foo.bar.com/', + '//foo.bar.com:_authToken': 'foobarbazquuxasdf', + }) + + t.end() +}) + +t.test('trust policy flattening', t => { + const flat = config.flatten({ + 'trust-policy': 'no-downgrade', + 'trust-policy-exclude': ['a@1, b@^2', 'a@1'], + 'trust-policy-ignore-after': 525600, + }) + + t.strictSame(flat, { + trustPolicy: 'no-downgrade', + trustPolicyExclude: ['a@1', 'b@^2'], + trustPolicyIgnoreAfter: 525600, + }) + + const single = config.flatten({ 'trust-policy-exclude': 'single-package@1' }) + t.strictSame(single.trustPolicyExclude, ['single-package@1']) + t.end() +}) From 759a86c5c7b5291885c97869495c04dfe4985f5d Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 22:43:10 +1000 Subject: [PATCH 34/36] fix(arborist): scope trust downgrade to major release line --- workspaces/arborist/lib/trust-policy.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js index 754d58380b3bb..c469f4f2bb9bb 100644 --- a/workspaces/arborist/lib/trust-policy.js +++ b/workspaces/arborist/lib/trust-policy.js @@ -1,3 +1,4 @@ + const npa = require('npm-package-arg') const semver = require('semver') @@ -90,7 +91,11 @@ const checkTrustDowngrade = (packument, version, { const current = packument.versions[version] const currentEvidence = getTrustEvidence(current) - const currentIsPrerelease = Boolean(semver.prerelease(version)) + const currentSemver = semver.parse(version) + if (!currentSemver) { + throw metadataError(name, version, 'version is not valid semver') + } + const currentIsPrerelease = Boolean(currentSemver.prerelease.length) let strongestPriorEvidence = 'none' for (const [priorVersion, priorManifest] of Object.entries(packument.versions)) { @@ -98,7 +103,12 @@ const checkTrustDowngrade = (packument, version, { continue } - if (!currentIsPrerelease && semver.prerelease(priorVersion)) { + const priorSemver = semver.parse(priorVersion) + if (!priorSemver || priorSemver.major !== currentSemver.major) { + continue + } + + if (!currentIsPrerelease && priorSemver.prerelease.length) { continue } From 03a01437fd67bcfc19919aff459c9b2453e83482 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 22:44:33 +1000 Subject: [PATCH 35/36] test(arborist): cover major release-line trust policy --- workspaces/arborist/test/trust-policy.js | 63 +++++++++++++++--------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js index f038201647ddc..91191f475c6cf 100644 --- a/workspaces/arborist/test/trust-policy.js +++ b/workspaces/arborist/test/trust-policy.js @@ -1,3 +1,4 @@ + const t = require('tap') const { checkTrustDowngrade, @@ -27,12 +28,12 @@ const trustedPublisher = { const packument = ({ current = {}, prior = provenance } = {}) => ({ name: 'example-package', time: { - '1.0.0': '2026-01-01T00:00:00.000Z', - '2.0.0': '2026-02-01T00:00:00.000Z', + '2.0.0': '2026-01-01T00:00:00.000Z', + '2.1.0': '2026-02-01T00:00:00.000Z', }, versions: { - '1.0.0': prior, - '2.0.0': current, + '2.0.0': prior, + '2.1.0': current, }, }) @@ -45,11 +46,11 @@ t.test('detects trust evidence', t => { t.test('rejects provenance downgrade to no evidence', t => { t.throws( - () => checkTrustDowngrade(packument(), '2.0.0'), + () => checkTrustDowngrade(packument(), '2.1.0'), { code: 'ETRUSTDOWNGRADE', package: 'example-package', - version: '2.0.0', + version: '2.1.0', previousTrust: 'provenance', currentTrust: 'none', message: /trust-policy-exclude/, @@ -60,7 +61,7 @@ t.test('rejects provenance downgrade to no evidence', t => { t.test('rejects trusted publisher downgrade to provenance', t => { t.throws( - () => checkTrustDowngrade(packument({ current: provenance, prior: trustedPublisher }), '2.0.0'), + () => checkTrustDowngrade(packument({ current: provenance, prior: trustedPublisher }), '2.1.0'), { code: 'ETRUSTDOWNGRADE', previousTrust: 'trustedPublisher', @@ -71,21 +72,21 @@ t.test('rejects trusted publisher downgrade to provenance', t => { }) t.test('accepts equal or stronger trust', t => { - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance }), '2.0.0')) - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: trustedPublisher }), '2.0.0')) - t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance, prior: {} }), '2.0.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance }), '2.1.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: trustedPublisher }), '2.1.0')) + t.doesNotThrow(() => checkTrustDowngrade(packument({ current: provenance, prior: {} }), '2.1.0')) t.end() }) -t.test('uses publish order rather than semver order', t => { +t.test('uses publish order within the same major release line', t => { const meta = { name: 'example-package', time: { - '2.0.0': '2026-01-01T00:00:00.000Z', + '1.6.0': '2026-01-01T00:00:00.000Z', '1.5.0': '2026-02-01T00:00:00.000Z', }, versions: { - '2.0.0': provenance, + '1.6.0': provenance, '1.5.0': {}, }, } @@ -93,6 +94,22 @@ t.test('uses publish order rather than semver order', t => { t.end() }) +t.test('does not compare trust evidence across major release lines', t => { + const meta = { + name: 'semver', + time: { + '7.0.0': '2026-01-01T00:00:00.000Z', + '6.14.19': '2026-02-01T00:00:00.000Z', + }, + versions: { + '7.0.0': provenance, + '6.14.19': {}, + }, + } + t.doesNotThrow(() => checkTrustDowngrade(meta, '6.14.19')) + t.end() +}) + t.test('stable releases ignore prior prerelease trust evidence', t => { const meta = { name: 'example-package', @@ -126,24 +143,24 @@ t.test('prereleases compare against earlier prereleases', t => { }) t.test('supports package and version exclusions', t => { - t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.0.0'), true) - t.equal(isTrustPolicyExcluded(['example-package@2.0.0'], 'example-package', '2.0.0'), true) + t.equal(isTrustPolicyExcluded(['example-package'], 'example-package', '2.1.0'), true) + t.equal(isTrustPolicyExcluded(['example-package@2.1.0'], 'example-package', '2.1.0'), true) t.equal(isTrustPolicyExcluded(['example-package@^2'], 'example-package', '2.1.0'), true) t.equal(isTrustPolicyExcluded(['webpack@4.47.0 || 5.102.1'], 'webpack', '5.102.1'), true) - t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.0.0'), false) - t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { - exclude: ['example-package@2.0.0'], + t.equal(isTrustPolicyExcluded(['other-package'], 'example-package', '2.1.0'), false) + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.1.0', { + exclude: ['example-package@2.1.0'], })) t.end() }) t.test('ignore-after skips old selected versions', t => { const now = Date.parse('2026-02-02T00:00:00.000Z') - t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.0.0', { + t.doesNotThrow(() => checkTrustDowngrade(packument(), '2.1.0', { ignoreAfter: 60, now, })) - t.throws(() => checkTrustDowngrade(packument(), '2.0.0', { + t.throws(() => checkTrustDowngrade(packument(), '2.1.0', { ignoreAfter: 60 * 24 * 2, now, }), { code: 'ETRUSTDOWNGRADE' }) @@ -152,13 +169,13 @@ t.test('ignore-after skips old selected versions', t => { t.test('fails closed when selected version metadata is incomplete', t => { const meta = packument() - delete meta.time['2.0.0'] + delete meta.time['2.1.0'] t.throws( - () => checkTrustDowngrade(meta, '2.0.0'), + () => checkTrustDowngrade(meta, '2.1.0'), { code: 'ETRUSTPOLICYMETADATA', package: 'example-package', - version: '2.0.0', + version: '2.1.0', } ) t.end() From 13e4062f6500be187a042f3684da30c420a33bc4 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 22:46:15 +1000 Subject: [PATCH 36/36] test(arborist): keep integration downgrade within major --- .../arborist/test/arborist/trust-policy.js | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/workspaces/arborist/test/arborist/trust-policy.js b/workspaces/arborist/test/arborist/trust-policy.js index 0ba01ceaae018..a6a56365c7376 100644 --- a/workspaces/arborist/test/arborist/trust-policy.js +++ b/workspaces/arborist/test/arborist/trust-policy.js @@ -1,3 +1,4 @@ + const t = require('tap') const Arborist = require('../..') const MockRegistry = require('@npmcli/mock-registry') @@ -26,12 +27,12 @@ const mockDowngradedPackage = async (t, { times = 1 } = {}) => { const registry = createRegistry(t) const manifest = registry.manifest({ name: 'example-package', - packuments: registry.packuments(['1.0.0', '2.0.0'], 'example-package'), + packuments: registry.packuments(['2.0.0', '2.1.0'], 'example-package'), }) - manifest.time['1.0.0'] = '2026-01-01T00:00:00.000Z' - manifest.time['2.0.0'] = '2026-02-01T00:00:00.000Z' - manifest.versions['1.0.0'].dist.attestations = { - provenance: { url: 'https://registry.example.test/attestations/1.0.0' }, + manifest.time['2.0.0'] = '2026-01-01T00:00:00.000Z' + manifest.time['2.1.0'] = '2026-02-01T00:00:00.000Z' + manifest.versions['2.0.0'].dist.attestations = { + provenance: { url: 'https://registry.example.test/attestations/2.0.0' }, } await registry.package({ manifest, times }) return registry @@ -42,7 +43,7 @@ t.test('buildIdealTree rejects a registry trust downgrade before reify', async t const path = t.testdir({ 'package.json': JSON.stringify({ name: 'root', - dependencies: { 'example-package': '2.0.0' }, + dependencies: { 'example-package': '2.1.0' }, }), }) @@ -52,7 +53,7 @@ t.test('buildIdealTree rejects a registry trust downgrade before reify', async t }), { code: 'ETRUSTDOWNGRADE', package: 'example-package', - version: '2.0.0', + version: '2.1.0', previousTrust: 'provenance', currentTrust: 'none', }) @@ -63,7 +64,7 @@ t.test('buildIdealTree honors trust policy from constructor options for ci-style const path = t.testdir({ 'package.json': JSON.stringify({ name: 'root', - dependencies: { 'example-package': '2.0.0' }, + dependencies: { 'example-package': '2.1.0' }, }), }) @@ -81,17 +82,17 @@ t.test('buildIdealTree honors trust policy from constructor options for ci-style })(), { code: 'ETRUSTDOWNGRADE', package: 'example-package', - version: '2.0.0', + version: '2.1.0', }) }) t.test('locked dependency is still checked for trust downgrade', async t => { const registry = await mockDowngradedPackage(t) - const tarball = registry.origin + '/example-package/-/example-package-2.0.0.tgz' + const tarball = registry.origin + '/example-package/-/example-package-2.1.0.tgz' const path = t.testdir({ 'package.json': JSON.stringify({ name: 'root', - dependencies: { 'example-package': '2.0.0' }, + dependencies: { 'example-package': '2.1.0' }, }), 'package-lock.json': JSON.stringify({ name: 'root', @@ -99,10 +100,10 @@ t.test('locked dependency is still checked for trust downgrade', async t => { requires: true, packages: { '': { - dependencies: { 'example-package': '2.0.0' }, + dependencies: { 'example-package': '2.1.0' }, }, 'node_modules/example-package': { - version: '2.0.0', + version: '2.1.0', resolved: tarball, }, }, @@ -122,6 +123,6 @@ t.test('locked dependency is still checked for trust downgrade', async t => { })(), { code: 'ETRUSTDOWNGRADE', package: 'example-package', - version: '2.0.0', + version: '2.1.0', }) })