Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/lib/content/commands/npm-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
26 changes: 25 additions & 1 deletion lib/utils/verify-signatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 50 additions & 1 deletion test/lib/commands/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})

Expand Down Expand Up @@ -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: {
Expand Down