diff --git a/docs/lib/content/commands/npm-audit.md b/docs/lib/content/commands/npm-audit.md index 737caea05537f..ec53f30d98eae 100644 --- a/docs/lib/content/commands/npm-audit.md +++ b/docs/lib/content/commands/npm-audit.md @@ -39,6 +39,12 @@ $ 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 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 49e1d80df32f8..5ddeec2a2df02 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -6,6 +6,9 @@ const tufClient = require('@sigstore/tuf') 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) { @@ -17,6 +20,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 +56,10 @@ class VerifySignatures { const invalid = this.invalid.sort(sortAlphabetically) const missing = this.missing.sort(sortAlphabetically) + const skipped = [...this.skippedNoKeys].map(([registry, count]) => ({ + registry, + count, + })).sort((a, b) => localeCompare(a.registry, b.registry)) const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0 @@ -61,6 +69,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 +87,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) { @@ -199,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, @@ -336,6 +357,9 @@ class VerifySignatures { const keys = this.keys.get(registry) || [] if (keys.length) { this.auditedWithKeysCount += 1 + } else { + 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 196888c9b675e..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()) }) @@ -1746,6 +1746,55 @@ 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 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`, + }, + 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(thirdPartyRegistry.fullPath('/-/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: 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') + }) + } + t.test('errors with an empty install', async t => { const { npm } = await loadMockNpm(t, { prefixDir: {