Skip to content

Commit 8eff5fb

Browse files
authored
feat(audit): add --include-attestations flag to output sigstore bundles (#9049)
1 parent 98ccf92 commit 8eff5fb

File tree

9 files changed

+161
-5
lines changed

9 files changed

+161
-5
lines changed

docs/lib/content/commands/npm-audit.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ The `audit signatures` command will also verify the provenance attestations of d
4242
Because provenance attestations are such a new feature, security features may be added to (or changed in) the attestation format over time.
4343
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.
4444

45+
To include the full sigstore attestation bundles in JSON output, use:
46+
47+
```bash
48+
$ npm audit signatures --json --include-attestations
49+
```
50+
51+
This adds a `verified` array to the JSON output containing the attestation
52+
bundles (DSSE envelopes, verification material, and transparency log entries)
53+
for each verified package.
54+
4555
The npm CLI supports registry signatures and signing keys provided by any registry if the following conventions are followed:
4656

4757
1. Signatures are provided in the package's `packument` in each published version within the `dist` object:

lib/commands/audit.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class Audit extends ArboristWorkspaceCmd {
1919
'include',
2020
'foreground-scripts',
2121
'ignore-scripts',
22+
'include-attestations',
2223
...super.params,
2324
]
2425

lib/utils/verify-signatures.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class VerifySignatures {
1717
this.invalid = []
1818
this.missing = []
1919
this.checkedPackages = new Set()
20+
this.verified = []
2021
this.auditedWithKeysCount = 0
2122
this.verifiedSignatureCount = 0
2223
this.verifiedAttestationCount = 0
@@ -59,7 +60,11 @@ class VerifySignatures {
5960
}
6061

6162
if (this.npm.config.get('json')) {
62-
output.buffer({ invalid, missing })
63+
const result = { invalid, missing }
64+
if (this.npm.config.get('include-attestations')) {
65+
result.verified = this.verified
66+
}
67+
output.buffer(result)
6368
return
6469
}
6570
const end = process.hrtime.bigint()
@@ -87,6 +92,9 @@ class VerifySignatures {
8792
} else {
8893
output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`)
8994
}
95+
if (!this.npm.config.get('include-attestations')) {
96+
output.standard('(use --json --include-attestations to view attestation details)')
97+
}
9098
output.standard()
9199
}
92100

@@ -288,6 +296,7 @@ class VerifySignatures {
288296
_integrity: integrity,
289297
_signatures,
290298
_attestations,
299+
_attestationBundles,
291300
_resolved: resolved,
292301
} = await pacote.manifest(`${name}@${version}`, {
293302
verifySignatures: true,
@@ -300,6 +309,7 @@ class VerifySignatures {
300309
integrity,
301310
signatures,
302311
attestations: _attestations,
312+
attestationBundles: _attestationBundles,
303313
resolved,
304314
}
305315
return result
@@ -324,9 +334,8 @@ class VerifySignatures {
324334
}
325335

326336
try {
327-
const { integrity, signatures, attestations, resolved } = await this.verifySignatures(
328-
name, version, registry
329-
)
337+
const { integrity, signatures, attestations, attestationBundles, resolved } =
338+
await this.verifySignatures(name, version, registry)
330339

331340
// Currently we only care about missing signatures on registries that provide a public key
332341
// We could make this configurable in the future with a strict/paranoid mode
@@ -346,6 +355,16 @@ class VerifySignatures {
346355
// Track verified attestations separately to registry signatures, as all packages on registries with signing keys are expected to have registry signatures, but not all packages have provenance and publish attestations.
347356
if (attestations) {
348357
this.verifiedAttestationCount += 1
358+
if (this.npm.config.get('include-attestations')) {
359+
this.verified.push({
360+
name,
361+
version,
362+
location,
363+
registry,
364+
attestations,
365+
attestationBundles,
366+
})
367+
}
349368
}
350369
} catch (e) {
351370
if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {

tap-snapshots/test/lib/commands/audit.js.test.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ audited 1 package in xxx
305305
1 package has a verified registry signature
306306
307307
1 package has a verified attestation
308+
(use --json --include-attestations to view attestation details)
308309
`
309310

310311
exports[`test/lib/commands/audit.js TAP audit signatures with valid signatures > must match snapshot 1`] = `

tap-snapshots/test/lib/commands/config.js.test.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
7272
"include": [],
7373
"include-staged": false,
7474
"include-workspace-root": false,
75+
"include-attestations": false,
7576
"init-author-email": "",
7677
"init-author-name": "",
7778
"init-author-url": "",
@@ -248,6 +249,7 @@ https-proxy = null
248249
if-present = false
249250
ignore-scripts = false
250251
include = []
252+
include-attestations = false
251253
include-staged = false
252254
include-workspace-root = false
253255
init-author-email = ""

tap-snapshots/test/lib/docs.js.test.cjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,18 @@ the order in which omit/include are specified on the command-line.
821821
822822
823823
824+
#### \`include-attestations\`
825+
826+
* Default: false
827+
* Type: Boolean
828+
829+
When used with \`npm audit signatures --json\`, includes the full sigstore
830+
attestation bundles in the JSON output for each verified package. The
831+
bundles contain DSSE envelopes, verification material, and transparency log
832+
entries.
833+
834+
835+
824836
#### \`include-staged\`
825837
826838
* Default: false
@@ -2302,6 +2314,7 @@ Array [
23022314
"include",
23032315
"include-staged",
23042316
"include-workspace-root",
2317+
"include-attestations",
23052318
"init-author-email",
23062319
"init-author-name",
23072320
"init-author-url",
@@ -2476,6 +2489,7 @@ Array [
24762489
"include",
24772490
"include-staged",
24782491
"include-workspace-root",
2492+
"include-attestations",
24792493
"init-private",
24802494
"install-links",
24812495
"install-strategy",
@@ -2643,6 +2657,7 @@ Object {
26432657
"httpsProxy": null,
26442658
"ifPresent": false,
26452659
"ignoreScripts": false,
2660+
"includeAttestations": false,
26462661
"includeStaged": false,
26472662
"includeWorkspaceRoot": false,
26482663
"initPrivate": false,
@@ -2869,7 +2884,7 @@ Options:
28692884
[--json] [--package-lock-only] [--no-package-lock]
28702885
[--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
28712886
[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
2872-
[--foreground-scripts] [--ignore-scripts]
2887+
[--foreground-scripts] [--ignore-scripts] [--include-attestations]
28732888
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
28742889
[--workspaces] [--include-workspace-root] [--install-links]
28752890
@@ -2903,6 +2918,9 @@ Options:
29032918
--ignore-scripts
29042919
If true, npm does not run scripts specified in package.json files.
29052920
2921+
--include-attestations
2922+
When used with \`npm audit signatures --json\`, includes the full
2923+
29062924
-w|--workspace
29072925
Enable running a command in the context of the configured workspaces of the
29082926
@@ -2932,6 +2950,7 @@ npm audit [fix|signatures]
29322950
#### \`include\`
29332951
#### \`foreground-scripts\`
29342952
#### \`ignore-scripts\`
2953+
#### \`include-attestations\`
29352954
#### \`workspace\`
29362955
#### \`workspaces\`
29372956
#### \`include-workspace-root\`

test/lib/commands/audit.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,9 +1850,99 @@ t.test('audit signatures', async t => {
18501850

18511851
t.notOk(process.exitCode, 'should exit successfully')
18521852
t.match(joinedOutput(), /1 package has a verified attestation/)
1853+
t.match(joinedOutput(), /use --json --include-attestations to view attestation details/)
18531854
t.matchSnapshot(joinedOutput())
18541855
})
18551856

1857+
t.test('with valid attestations and --include-attestations (human-readable)', async t => {
1858+
const { npm, joinedOutput } = await loadMockNpm(t, {
1859+
prefixDir: installWithValidAttestations,
1860+
config: {
1861+
'include-attestations': true,
1862+
},
1863+
mocks: {
1864+
pacote: t.mock('pacote', {
1865+
sigstore: { verify: async () => true },
1866+
}),
1867+
},
1868+
})
1869+
const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') })
1870+
await manifestWithValidAttestations({ registry })
1871+
const fixture = fs.readFileSync(
1872+
path.resolve(__dirname, '../../fixtures/sigstore/valid-sigstore-attestations.json'),
1873+
'utf8'
1874+
)
1875+
registry.nock.get('/-/npm/v1/attestations/sigstore@1.0.0').reply(200, fixture)
1876+
mockTUF({ npm, target: TUF_VALID_KEYS_TARGET })
1877+
1878+
await npm.exec('audit', ['signatures'])
1879+
1880+
t.notOk(process.exitCode, 'should exit successfully')
1881+
t.match(joinedOutput(), /1 package has a verified attestation/)
1882+
t.notMatch(joinedOutput(), /use --json --include-attestations to view attestation details/)
1883+
})
1884+
1885+
t.test('with valid attestations --json --include-attestations', async t => {
1886+
const { npm, joinedOutput } = await loadMockNpm(t, {
1887+
prefixDir: installWithValidAttestations,
1888+
config: {
1889+
json: true,
1890+
'include-attestations': true,
1891+
},
1892+
mocks: {
1893+
pacote: t.mock('pacote', {
1894+
sigstore: { verify: async () => true },
1895+
}),
1896+
},
1897+
})
1898+
const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') })
1899+
await manifestWithValidAttestations({ registry })
1900+
const fixture = fs.readFileSync(
1901+
path.resolve(__dirname, '../../fixtures/sigstore/valid-sigstore-attestations.json'),
1902+
'utf8'
1903+
)
1904+
registry.nock.get('/-/npm/v1/attestations/sigstore@1.0.0').reply(200, fixture)
1905+
mockTUF({ npm, target: TUF_VALID_KEYS_TARGET })
1906+
1907+
await npm.exec('audit', ['signatures'])
1908+
1909+
t.notOk(process.exitCode, 'should exit successfully')
1910+
const jsonOutput = JSON.parse(joinedOutput())
1911+
t.ok(jsonOutput.verified, 'should include verified array')
1912+
t.equal(jsonOutput.verified.length, 1, 'should have one verified package')
1913+
t.equal(jsonOutput.verified[0].name, 'sigstore', 'should have correct package name')
1914+
t.equal(jsonOutput.verified[0].version, '1.0.0', 'should have correct version')
1915+
t.ok(jsonOutput.verified[0].attestations, 'should include attestations')
1916+
})
1917+
1918+
t.test('with valid attestations --json without --include-attestations', async t => {
1919+
const { npm, joinedOutput } = await loadMockNpm(t, {
1920+
prefixDir: installWithValidAttestations,
1921+
config: {
1922+
json: true,
1923+
},
1924+
mocks: {
1925+
pacote: t.mock('pacote', {
1926+
sigstore: { verify: async () => true },
1927+
}),
1928+
},
1929+
})
1930+
const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry') })
1931+
await manifestWithValidAttestations({ registry })
1932+
const fixture = fs.readFileSync(
1933+
path.resolve(__dirname, '../../fixtures/sigstore/valid-sigstore-attestations.json'),
1934+
'utf8'
1935+
)
1936+
registry.nock.get('/-/npm/v1/attestations/sigstore@1.0.0').reply(200, fixture)
1937+
mockTUF({ npm, target: TUF_VALID_KEYS_TARGET })
1938+
1939+
await npm.exec('audit', ['signatures'])
1940+
1941+
t.notOk(process.exitCode, 'should exit successfully')
1942+
const jsonOutput = JSON.parse(joinedOutput())
1943+
t.notOk(jsonOutput.verified, 'should not include verified array')
1944+
})
1945+
18561946
t.test('with keyless attestations and no registry keys', async t => {
18571947
const { npm, joinedOutput } = await loadMockNpm(t, {
18581948
prefixDir: installWithValidAttestations,

workspaces/config/lib/definitions/definitions.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,17 @@ const definitions = {
946946
`,
947947
flatten,
948948
}),
949+
'include-attestations': new Definition('include-attestations', {
950+
default: false,
951+
type: Boolean,
952+
description: `
953+
When used with \`npm audit signatures --json\`, includes the full
954+
sigstore attestation bundles in the JSON output for each verified
955+
package. The bundles contain DSSE envelopes, verification material,
956+
and transparency log entries.
957+
`,
958+
flatten,
959+
}),
949960
'init-author-email': new Definition('init-author-email', {
950961
default: '',
951962
hint: '<email>',

workspaces/config/tap-snapshots/test/type-description.js.test.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ Object {
221221
"optional",
222222
"peer",
223223
],
224+
"include-attestations": Array [
225+
"boolean value (true or false)",
226+
],
224227
"include-staged": Array [
225228
"boolean value (true or false)",
226229
],

0 commit comments

Comments
 (0)