From 07b960337454815f640d696b74c52a554f33b8d2 Mon Sep 17 00:00:00 2001 From: lincoln Green Date: Sun, 27 Sep 2026 19:12:03 -0400 Subject: [PATCH 1/2] fix: report signature checks skipped without registry keys --- docs/lib/content/commands/npm-audit.md | 5 +++ lib/utils/verify-signatures.js | 21 ++++++++++++ test/lib/commands/audit.js | 46 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/docs/lib/content/commands/npm-audit.md b/docs/lib/content/commands/npm-audit.md index 737caea05537f..f0ffb2d5601c3 100644 --- a/docs/lib/content/commands/npm-audit.md +++ b/docs/lib/content/commands/npm-audit.md @@ -39,6 +39,11 @@ $ npm audit signatures ``` The `audit signatures` command will also verify the provenance attestations of downloaded packages. +When an installed package comes from a registry without signing keys, its +registry signature cannot be checked. If other packages can be checked, the +output reports how many signature checks were skipped for each such registry. +With `--json`, the `skipped` array contains each registry and its skipped count. +Skipped checks alone do not change the command's exit code. Because provenance attestations are such a new feature, security features may be added to (or changed in) the attestation format over time. To ensure that you're always able to verify attestation signatures check that you're running the latest version of the npm CLI. Please note this often means updating npm beyond the version that ships with Node.js. diff --git a/lib/utils/verify-signatures.js b/lib/utils/verify-signatures.js index 49e1d80df32f8..b73e2647567ae 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -3,6 +3,7 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const npa = require('npm-package-arg') const pacote = require('pacote') const tufClient = require('@sigstore/tuf') +const { redact } = require('@npmcli/redact') const { log, output } = require('proc-log') const sortAlphabetically = (a, b) => localeCompare(a.name, b.name) @@ -17,6 +18,7 @@ class VerifySignatures { this.invalid = [] this.missing = [] this.checkedPackages = new Set() + this.skippedNoKeys = new Map() this.verified = [] this.auditedWithKeysCount = 0 this.verifiedSignatureCount = 0 @@ -52,6 +54,10 @@ class VerifySignatures { const invalid = this.invalid.sort(sortAlphabetically) const missing = this.missing.sort(sortAlphabetically) + const skipped = [...this.skippedNoKeys].map(([registry, count]) => ({ + registry: redact(registry), + count, + })).sort((a, b) => localeCompare(a.registry, b.registry)) const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0 @@ -61,6 +67,9 @@ class VerifySignatures { if (this.npm.config.get('json')) { const result = { invalid, missing } + if (skipped.length) { + result.skipped = skipped + } if (this.npm.config.get('include-attestations')) { result.verified = this.verified } @@ -76,6 +85,16 @@ class VerifySignatures { output.standard(timing) output.standard() + if (skipped.length) { + const skippedCount = skipped.reduce((count, item) => count + item.count, 0) + const plural = skippedCount === 1 ? '' : 's' + output.standard(`${skippedCount} package${plural} skipped registry signature checks because signing keys were unavailable:`) + for (const { registry, count } of skipped) { + output.standard(` ${count} from ${registry}`) + } + output.standard() + } + const verifiedBold = this.npm.chalk.bold('verified') if (this.verifiedSignatureCount) { if (this.verifiedSignatureCount === 1) { @@ -336,6 +355,8 @@ class VerifySignatures { const keys = this.keys.get(registry) || [] if (keys.length) { this.auditedWithKeysCount += 1 + } else { + this.skippedNoKeys.set(registry, (this.skippedNoKeys.get(registry) || 0) + 1) } try { diff --git a/test/lib/commands/audit.js b/test/lib/commands/audit.js index 196888c9b675e..46aa8893f34dc 100644 --- a/test/lib/commands/audit.js +++ b/test/lib/commands/audit.js @@ -1746,6 +1746,52 @@ t.test('audit signatures', async t => { t.matchSnapshot(joinedOutput()) }) + for (const json of [false, true]) { + t.test(`mixed registries report signature checks skipped without keys${json ? ' (json)' : ''}`, async t => { + const registryUrl = 'https://verdaccio-clone.org' + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: { + ...installWithMultipleRegistries, + '.npmrc': `@npmcli:registry=${registryUrl}\n`, + }, + config: { json }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + const thirdPartyRegistry = new MockRegistry({ tap: t, registry: registryUrl }) + await manifestWithValidSigs({ registry }) + await thirdPartyRegistry.package({ + manifest: thirdPartyRegistry.manifest({ + name: '@npmcli/arborist', + packuments: [{ + version: '1.0.14', + dist: { + tarball: 'https://verdaccio-clone.org/@npmcli/arborist/-/arborist-1.0.14.tgz', + integrity: 'sha512-caa8hv5rW9VpQKk6tyNRvSaVDySVjo9GkI7Wj/wcsFyxPm3tYrE' + + 'sFyTjSnJH8HCIfEGVQNjqqKXaXLFVp7UBag==', + }, + }], + }), + }) + mockTUF({ npm, target: TUF_VALID_KEYS_TARGET }) + thirdPartyRegistry.nock.get('/-/npm/v1/keys').reply(404) + + await npm.exec('audit', ['signatures']) + + t.notOk(process.exitCode, 'packages without keys do not change the exit status') + if (json) { + t.match(JSON.parse(joinedOutput()), { + invalid: [], + missing: [], + skipped: [{ registry: registryUrl, count: 1 }], + }) + } else { + t.match(joinedOutput(), /audited 1 package/) + t.match(joinedOutput(), /1 package skipped registry signature checks/) + t.match(joinedOutput(), /1 from https:\/\/verdaccio-clone\.org/) + } + }) + } + t.test('errors with an empty install', async t => { const { npm } = await loadMockNpm(t, { prefixDir: { From 8ba69a5712e6147e9cc8483f0c18880fb9845291 Mon Sep 17 00:00:00 2001 From: lincoln Green Date: Mon, 28 Sep 2026 11:57:27 -0400 Subject: [PATCH 2/2] fix: omit registry URL secrets from signature audit output --- docs/lib/content/commands/npm-audit.md | 5 +++-- lib/utils/verify-signatures.js | 11 +++++++---- test/lib/commands/audit.js | 13 ++++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/lib/content/commands/npm-audit.md b/docs/lib/content/commands/npm-audit.md index f0ffb2d5601c3..ec53f30d98eae 100644 --- a/docs/lib/content/commands/npm-audit.md +++ b/docs/lib/content/commands/npm-audit.md @@ -41,8 +41,9 @@ $ npm audit signatures The `audit signatures` command will also verify the provenance attestations of downloaded packages. When an installed package comes from a registry without signing keys, its registry signature cannot be checked. If other packages can be checked, the -output reports how many signature checks were skipped for each such registry. -With `--json`, the `skipped` array contains each registry and its skipped count. +output reports how many signature checks were skipped for each such registry host. +With `--json`, the `skipped` array contains each registry's origin (scheme and +host, without its path or credentials) and its skipped count. Skipped checks alone do not change the command's exit code. Because provenance attestations are such a new feature, security features may be added to (or changed in) the attestation format over time. To ensure that you're always able to verify attestation signatures check that you're running the latest version of the npm CLI. Please note this often means updating npm beyond the version that ships with Node.js. diff --git a/lib/utils/verify-signatures.js b/lib/utils/verify-signatures.js index b73e2647567ae..5ddeec2a2df02 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -3,10 +3,12 @@ const localeCompare = require('@isaacs/string-locale-compare')('en') const npa = require('npm-package-arg') const pacote = require('pacote') const tufClient = require('@sigstore/tuf') -const { redact } = require('@npmcli/redact') const { log, output } = require('proc-log') const sortAlphabetically = (a, b) => localeCompare(a.name, b.name) +// Registry URLs may contain credentials in their path or query. Only show the +// origin when reporting skipped checks so successful audits do not print them. +const registryOrigin = registry => new URL(registry).origin class VerifySignatures { constructor (tree, filterSet, npm, opts) { @@ -55,7 +57,7 @@ class VerifySignatures { const invalid = this.invalid.sort(sortAlphabetically) const missing = this.missing.sort(sortAlphabetically) const skipped = [...this.skippedNoKeys].map(([registry, count]) => ({ - registry: redact(registry), + registry, count, })).sort((a, b) => localeCompare(a.registry, b.registry)) @@ -218,7 +220,7 @@ class VerifySignatures { // If keys not found in Sigstore TUF repo, fall back to registry keys API if (!keys) { - log.warn(`Fetching verification keys using TUF failed. Fetching directly from ${registry}.`) + log.warn(`Fetching verification keys using TUF failed. Fetching directly from ${registryOrigin(registry)}.`) keys = await npmFetch.json('/-/npm/v1/keys', { ...this.npm.flatOptions, registry, @@ -356,7 +358,8 @@ class VerifySignatures { if (keys.length) { this.auditedWithKeysCount += 1 } else { - this.skippedNoKeys.set(registry, (this.skippedNoKeys.get(registry) || 0) + 1) + const origin = registryOrigin(registry) + this.skippedNoKeys.set(origin, (this.skippedNoKeys.get(origin) || 0) + 1) } try { diff --git a/test/lib/commands/audit.js b/test/lib/commands/audit.js index 46aa8893f34dc..6b454617e6c1b 100644 --- a/test/lib/commands/audit.js +++ b/test/lib/commands/audit.js @@ -1059,7 +1059,7 @@ t.test('audit signatures', async t => { t.notOk(process.exitCode, 'should exit successfully') t.match(joinedOutput(), /audited 1 package/) - t.match(logs.warn, ['Fetching verification keys using TUF failed. Fetching directly from https://registry.npmjs.org/.']) + t.match(logs.warn, ['Fetching verification keys using TUF failed. Fetching directly from https://registry.npmjs.org.']) t.matchSnapshot(joinedOutput()) }) @@ -1748,8 +1748,9 @@ t.test('audit signatures', async t => { for (const json of [false, true]) { t.test(`mixed registries report signature checks skipped without keys${json ? ' (json)' : ''}`, async t => { - const registryUrl = 'https://verdaccio-clone.org' - const { npm, joinedOutput } = await loadMockNpm(t, { + const registryOrigin = 'https://verdaccio-clone.org' + const registryUrl = `${registryOrigin}/private-registry-token/` + const { logs, npm, joinedOutput } = await loadMockNpm(t, { prefixDir: { ...installWithMultipleRegistries, '.npmrc': `@npmcli:registry=${registryUrl}\n`, @@ -1773,7 +1774,7 @@ t.test('audit signatures', async t => { }), }) mockTUF({ npm, target: TUF_VALID_KEYS_TARGET }) - thirdPartyRegistry.nock.get('/-/npm/v1/keys').reply(404) + thirdPartyRegistry.nock.get(thirdPartyRegistry.fullPath('/-/npm/v1/keys')).reply(404) await npm.exec('audit', ['signatures']) @@ -1782,13 +1783,15 @@ t.test('audit signatures', async t => { t.match(JSON.parse(joinedOutput()), { invalid: [], missing: [], - skipped: [{ registry: registryUrl, count: 1 }], + skipped: [{ registry: registryOrigin, count: 1 }], }) } else { t.match(joinedOutput(), /audited 1 package/) t.match(joinedOutput(), /1 package skipped registry signature checks/) t.match(joinedOutput(), /1 from https:\/\/verdaccio-clone\.org/) } + t.notMatch(joinedOutput(), /private-registry-token/, 'registry URL credentials are not printed') + t.notMatch(logs.warn.join('\n'), /private-registry-token/, 'warnings omit registry URL credentials') }) }