diff --git a/lib/commands/ci.js b/lib/commands/ci.js index 17307badede98..03fa0b9e0db8f 100644 --- a/lib/commands/ci.js +++ b/lib/commands/ci.js @@ -1,6 +1,7 @@ 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 +32,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 +117,8 @@ class CI extends ArboristWorkspaceCmd { ) } + await trustPolicyPreflight({ arb, options: opts }) + if (!dryRun) { const workspacePaths = await getWorkspaces([], { path: this.npm.localPrefix, diff --git a/lib/commands/install.js b/lib/commands/install.js index 2fd9bc8d5cd7a..6763be0f811e4 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -7,6 +7,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 +42,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 +177,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) { diff --git a/lib/commands/update.js b/lib/commands/update.js index 64c2c5128bb04..8c33d571a0f9a 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -3,6 +3,7 @@ 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 +30,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 +75,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) } 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 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, + }) +}) 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, +} diff --git a/workspaces/arborist/lib/trust-policy.js b/workspaces/arborist/lib/trust-policy.js new file mode 100644 index 0000000000000..c469f4f2bb9bb --- /dev/null +++ b/workspaces/arborist/lib/trust-policy.js @@ -0,0 +1,152 @@ + +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 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)) { + if (priorVersion === version) { + continue + } + + const priorSemver = semver.parse(priorVersion) + if (!priorSemver || priorSemver.major !== currentSemver.major) { + continue + } + + if (!currentIsPrerelease && priorSemver.prerelease.length) { + 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, +} diff --git a/workspaces/arborist/test/arborist/trust-policy.js b/workspaces/arborist/test/arborist/trust-policy.js new file mode 100644 index 0000000000000..a6a56365c7376 --- /dev/null +++ b/workspaces/arborist/test/arborist/trust-policy.js @@ -0,0 +1,128 @@ + +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(['2.0.0', '2.1.0'], 'example-package'), + }) + 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 +} + +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.1.0' }, + }), + }) + + await t.rejects(buildIdeal(path, { + registry: registry.origin, + trustPolicy: 'no-downgrade', + }), { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.1.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.1.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.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.1.0.tgz' + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + dependencies: { 'example-package': '2.1.0' }, + }), + 'package-lock.json': JSON.stringify({ + name: 'root', + lockfileVersion: 3, + requires: true, + packages: { + '': { + dependencies: { 'example-package': '2.1.0' }, + }, + 'node_modules/example-package': { + version: '2.1.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.1.0', + }) +}) 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/', + }]) +}) diff --git a/workspaces/arborist/test/trust-policy.js b/workspaces/arborist/test/trust-policy.js new file mode 100644 index 0000000000000..91191f475c6cf --- /dev/null +++ b/workspaces/arborist/test/trust-policy.js @@ -0,0 +1,182 @@ + +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: { + '2.0.0': '2026-01-01T00:00:00.000Z', + '2.1.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '2.0.0': prior, + '2.1.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.1.0'), + { + code: 'ETRUSTDOWNGRADE', + package: 'example-package', + version: '2.1.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.1.0'), + { + code: 'ETRUSTDOWNGRADE', + previousTrust: 'trustedPublisher', + currentTrust: 'provenance', + } + ) + t.end() +}) + +t.test('accepts equal or stronger trust', t => { + 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 within the same major release line', t => { + const meta = { + name: 'example-package', + time: { + '1.6.0': '2026-01-01T00:00:00.000Z', + '1.5.0': '2026-02-01T00:00:00.000Z', + }, + versions: { + '1.6.0': provenance, + '1.5.0': {}, + }, + } + t.throws(() => checkTrustDowngrade(meta, '1.5.0'), { code: 'ETRUSTDOWNGRADE' }) + 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', + 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.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.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.1.0', { + ignoreAfter: 60, + now, + })) + t.throws(() => checkTrustDowngrade(packument(), '2.1.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.1.0'] + t.throws( + () => checkTrustDowngrade(meta, '2.1.0'), + { + code: 'ETRUSTPOLICYMETADATA', + package: 'example-package', + version: '2.1.0', + } + ) + t.end() +}) diff --git a/workspaces/config/lib/definitions/index.js b/workspaces/config/lib/definitions/index.js index b5b63bf2fce12..d002c1038d8b7 100644 --- a/workspaces/config/lib/definitions/index.js +++ b/workspaces/config/lib/definitions/index.js @@ -1,4 +1,10 @@ -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 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] + }, + }), +} diff --git a/workspaces/config/test/definitions/index.js b/workspaces/config/test/definitions/index.js index fec23c625fdee..1c1ba3e514e6c 100644 --- a/workspaces/config/test/definitions/index.js +++ b/workspaces/config/test/definitions/index.js @@ -7,6 +7,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 +45,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() +})