diff --git a/docs/lib/content/commands/npm-audit.md b/docs/lib/content/commands/npm-audit.md index 6cd26b1406b4a..30975a0686cfd 100644 --- a/docs/lib/content/commands/npm-audit.md +++ b/docs/lib/content/commands/npm-audit.md @@ -42,6 +42,16 @@ The `audit signatures` command will also verify the provenance attestations of d 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. +To include the full sigstore attestation bundles in JSON output, use: + +```bash +$ npm audit signatures --json --include-attestations +``` + +This adds a `verified` array to the JSON output containing the attestation +bundles (DSSE envelopes, verification material, and transparency log entries) +for each verified package. + The npm CLI supports registry signatures and signing keys provided by any registry if the following conventions are followed: 1. Signatures are provided in the package's `packument` in each published version within the `dist` object: diff --git a/lib/commands/audit.js b/lib/commands/audit.js index 97d0729da9618..6d0fa1bcbd30d 100644 --- a/lib/commands/audit.js +++ b/lib/commands/audit.js @@ -19,6 +19,7 @@ class Audit extends ArboristWorkspaceCmd { 'include', 'foreground-scripts', 'ignore-scripts', + 'include-attestations', ...super.params, ] diff --git a/lib/utils/verify-signatures.js b/lib/utils/verify-signatures.js index c9b39591eadc8..91246bc70dd3e 100644 --- a/lib/utils/verify-signatures.js +++ b/lib/utils/verify-signatures.js @@ -17,6 +17,7 @@ class VerifySignatures { this.invalid = [] this.missing = [] this.checkedPackages = new Set() + this.verified = [] this.auditedWithKeysCount = 0 this.verifiedSignatureCount = 0 this.verifiedAttestationCount = 0 @@ -60,7 +61,11 @@ class VerifySignatures { } if (this.npm.config.get('json')) { - output.buffer({ invalid, missing }) + const result = { invalid, missing } + if (this.npm.config.get('include-attestations')) { + result.verified = this.verified + } + output.buffer(result) return } const end = process.hrtime.bigint() @@ -88,6 +93,9 @@ class VerifySignatures { } else { output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`) } + if (!this.npm.config.get('include-attestations')) { + output.standard('(use --json --include-attestations to view attestation details)') + } output.standard() } @@ -350,6 +358,15 @@ class VerifySignatures { // signatures, but not all packages have provenance and publish attestations. if (attestations) { this.verifiedAttestationCount += 1 + if (this.npm.config.get('include-attestations')) { + this.verified.push({ + name, + version, + location, + registry, + attestations, + }) + } } } catch (e) { if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') { diff --git a/tap-snapshots/test/lib/commands/audit.js.test.cjs b/tap-snapshots/test/lib/commands/audit.js.test.cjs index 843cf7c8dd370..3a4cec0d6cead 100644 --- a/tap-snapshots/test/lib/commands/audit.js.test.cjs +++ b/tap-snapshots/test/lib/commands/audit.js.test.cjs @@ -305,6 +305,7 @@ audited 1 package in xxx 1 package has a verified registry signature 1 package has a verified attestation +(use --json --include-attestations to view attestation details) ` exports[`test/lib/commands/audit.js TAP audit signatures with valid signatures > must match snapshot 1`] = ` diff --git a/test/lib/commands/audit.js b/test/lib/commands/audit.js index 4103ff8dec8c1..077ba49eebe02 100644 --- a/test/lib/commands/audit.js +++ b/test/lib/commands/audit.js @@ -1850,9 +1850,71 @@ t.test('audit signatures', async t => { t.notOk(process.exitCode, 'should exit successfully') t.match(joinedOutput(), /1 package has a verified attestation/) + t.match(joinedOutput(), /use --json --include-attestations to view attestation details/) t.matchSnapshot(joinedOutput()) }) + t.test('with valid attestations --json --include-attestations', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidAttestations, + config: { + json: true, + 'include-attestations': true, + }, + mocks: { + pacote: t.mock('pacote', { + sigstore: { verify: async () => true }, + }), + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidAttestations({ registry }) + const fixture = fs.readFileSync( + path.resolve(__dirname, '../../fixtures/sigstore/valid-sigstore-attestations.json'), + 'utf8' + ) + registry.nock.get('/-/npm/v1/attestations/sigstore@1.0.0').reply(200, fixture) + mockTUF({ npm, target: TUF_VALID_KEYS_TARGET }) + + await npm.exec('audit', ['signatures']) + + t.notOk(process.exitCode, 'should exit successfully') + const jsonOutput = JSON.parse(joinedOutput()) + t.ok(jsonOutput.verified, 'should include verified array') + t.equal(jsonOutput.verified.length, 1, 'should have one verified package') + t.equal(jsonOutput.verified[0].name, 'sigstore', 'should have correct package name') + t.equal(jsonOutput.verified[0].version, '1.0.0', 'should have correct version') + t.ok(jsonOutput.verified[0].attestations, 'should include attestations') + }) + + t.test('with valid attestations --json without --include-attestations', async t => { + const { npm, joinedOutput } = await loadMockNpm(t, { + prefixDir: installWithValidAttestations, + config: { + json: true, + }, + mocks: { + pacote: t.mock('pacote', { + sigstore: { verify: async () => true }, + }), + }, + }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') }) + await manifestWithValidAttestations({ registry }) + const fixture = fs.readFileSync( + path.resolve(__dirname, '../../fixtures/sigstore/valid-sigstore-attestations.json'), + 'utf8' + ) + registry.nock.get('/-/npm/v1/attestations/sigstore@1.0.0').reply(200, fixture) + mockTUF({ npm, target: TUF_VALID_KEYS_TARGET }) + + await npm.exec('audit', ['signatures']) + + t.notOk(process.exitCode, 'should exit successfully') + const jsonOutput = JSON.parse(joinedOutput()) + t.notOk(jsonOutput.verified, 'should not include verified array') + }) + t.test('with keyless attestations and no registry keys', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { prefixDir: installWithValidAttestations, diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 4c234699e2287..e185ee71f53b6 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -946,6 +946,17 @@ const definitions = { `, flatten, }), + 'include-attestations': new Definition('include-attestations', { + default: false, + type: Boolean, + description: ` + When used with \`npm audit signatures --json\`, includes the full + sigstore attestation bundles in the JSON output for each verified + package. The bundles contain DSSE envelopes, verification material, + and transparency log entries. + `, + flatten, + }), 'init-author-email': new Definition('init-author-email', { default: '', hint: '',