codekingpro/portable-devtools
114k
1const npmFetch = require('npm-registry-fetch')
2const localeCompare = require('@isaacs/string-locale-compare')('en')
3const npa = require('npm-package-arg')
4const pacote = require('pacote')
5const tufClient = require('@sigstore/tuf')
6const { log, output } = require('proc-log')
7
8const sortAlphabetically = (a, b) => localeCompare(a.name, b.name)
9
10class VerifySignatures {
11 constructor (tree, filterSet, npm, opts) {
12 this.tree = tree
13 this.filterSet = filterSet
14 this.npm = npm
15 this.opts = opts
16 this.keys = new Map()
17 this.invalid = []
18 this.missing = []
19 this.checkedPackages = new Set()
20 this.verified = []
21 this.auditedWithKeysCount = 0
22 this.verifiedSignatureCount = 0
23 this.verifiedAttestationCount = 0
24 this.exitCode = 0
25 }
26
27 async run () {
28 const start = process.hrtime.bigint()
29 const { default: pMap } = await import('p-map')
30
31 // Find all deps in tree
32 const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet)
33 if (edges.size === 0) {
34 throw new Error('found no installed dependencies to audit')
35 }
36
37 const tuf = await tufClient.initTUF({
38 cachePath: this.opts.tufCache,
39 retry: this.opts.retry,
40 timeout: this.opts.timeout,
41 })
42 await Promise.all([...registries].map(registry => this.setKeys({ registry, tuf })))
43
44 log.verbose('verifying registry signatures')
45 await pMap(edges, (e) => this.getVerifiedInfo(e), { concurrency: 20, stopOnError: true })
46
47 // Didn't find any dependencies that could be verified, e.g. only local deps, missing version, not on a registry etc.
48 if (!this.auditedWithKeysCount && !this.verifiedAttestationCount) {
49 throw new Error('found no dependencies to audit that were installed from ' +
50 'a supported registry')
51 }
52
53 const invalid = this.invalid.sort(sortAlphabetically)
54 const missing = this.missing.sort(sortAlphabetically)
55
56 const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0
57
58 if (!hasNoInvalidOrMissing) {
59 process.exitCode = 1
60 }
61
62 if (this.npm.config.get('json')) {
63 const result = { invalid, missing }
64 if (this.npm.config.get('include-attestations')) {
65 result.verified = this.verified
66 }
67 output.buffer(result)
68 return
69 }
70 const end = process.hrtime.bigint()
71 const elapsed = end - start
72
73 const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : ''
74 const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` +
75 `${Math.floor(Number(elapsed) / 1e9)}s`
76 output.standard(timing)
77 output.standard()
78
79 const verifiedBold = this.npm.chalk.bold('verified')
80 if (this.verifiedSignatureCount) {
81 if (this.verifiedSignatureCount === 1) {
82 output.standard(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`)
83 } else {
84 output.standard(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`)
85 }
86 output.standard()
87 }
88
89 if (this.verifiedAttestationCount) {
90 if (this.verifiedAttestationCount === 1) {
91 output.standard(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`)
92 } else {
93 output.standard(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`)
94 }
95 if (!this.npm.config.get('include-attestations')) {
96 output.standard('(use --json --include-attestations to view attestation details)')
97 }
98 output.standard()
99 }
100
101 if (missing.length) {
102 const missingClr = this.npm.chalk.redBright('missing')
103 if (missing.length === 1) {
104 output.standard(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`)
105 } else {
106 output.standard(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`)
107 }
108 output.standard()
109 missing.map(m =>
110 output.standard(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`)
111 )
112 }
113
114 if (invalid.length) {
115 if (missing.length) {
116 output.standard()
117 }
118 const invalidClr = this.npm.chalk.redBright('invalid')
119 // We can have either invalid signatures or invalid provenance
120 const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE')
121 if (invalidSignatures.length) {
122 if (invalidSignatures.length === 1) {
123 output.standard(`1 package has an ${invalidClr} registry signature:`)
124 } else {
125 output.standard(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`)
126 }
127 output.standard()
128 invalidSignatures.map(i =>
129 output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
130 )
131 output.standard()
132 }
133
134 const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY')
135 if (invalidAttestations.length) {
136 if (invalidAttestations.length === 1) {
137 output.standard(`1 package has an ${invalidClr} attestation:`)
138 } else {
139 output.standard(`${invalidAttestations.length} packages have ${invalidClr} attestations:`)
140 }
141 output.standard()
142 invalidAttestations.map(i =>
143 output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
144 )
145 output.standard()
146 }
147
148 if (invalid.length === 1) {
149 output.standard(`Someone might have tampered with this package since it was published on the registry!`)
150 } else {
151 output.standard(`Someone might have tampered with these packages since they were published on the registry!`)
152 }
153 output.standard()
154 }
155 }
156
157 getEdgesOut (nodes, filterSet) {
158 const edges = new Set()
159 const registries = new Set()
160 for (const node of nodes) {
161 for (const edge of node.edgesOut.values()) {
162 const filteredOut =
163 edge.from
164 && filterSet
165 && filterSet.size > 0
166 && !filterSet.has(edge.from.target)
167
168 if (!filteredOut) {
169 const spec = this.getEdgeSpec(edge)
170 if (spec) {
171 // Prefetch and cache public keys from used registries
172 registries.add(this.getSpecRegistry(spec))
173 }
174 edges.add(edge)
175 }
176 }
177 }
178 return { edges, registries }
179 }
180
181 async setKeys ({ registry, tuf }) {
182 const { host, pathname } = new URL(registry)
183 // Strip any trailing slashes from pathname
184 const regKey = `${host}${pathname.replace(/\/$/, '')}/keys.json`
185 let keys = await tuf.getTarget(regKey)
186 .then((target) => JSON.parse(target))
187 .then(({ keys: ks }) => ks.map((key) => ({
188 ...key,
189 keyid: key.keyId,
190 pemkey: `-----BEGIN PUBLIC KEY-----\n${key.publicKey.rawBytes}\n-----END PUBLIC KEY-----`,
191 expires: key.publicKey.validFor.end || null,
192 }))).catch(err => {
193 if (err.code === 'TUF_FIND_TARGET_ERROR') {
194 return null
195 } else {
196 throw err
197 }
198 })
199
200 // If keys not found in Sigstore TUF repo, fall back to registry keys API
201 if (!keys) {
202 log.warn(`Fetching verification keys using TUF failed. Fetching directly from ${registry}.`)
203 keys = await npmFetch.json('/-/npm/v1/keys', {
204 ...this.npm.flatOptions,
205 registry,
206 }).then(({ keys: ks }) => ks.map((key) => ({
207 ...key,
208 pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
209 }))).catch(err => {
210 if (err.code === 'E404' || err.code === 'E400') {
211 return null
212 } else {
213 throw err
214 }
215 })
216 }
217
218 if (keys) {
219 this.keys.set(registry, keys)
220 }
221 }
222
223 getEdgeType (edge) {
224 return edge.optional ? 'optionalDependencies'
225 : edge.peer ? 'peerDependencies'
226 : edge.dev ? 'devDependencies'
227 : 'dependencies'
228 }
229
230 getEdgeSpec (edge) {
231 let name = edge.name
232 try {
233 name = npa(edge.spec).subSpec.name
234 } catch {
235 // leave it as edge.name
236 }
237 try {
238 return npa(`${name}@${edge.spec}`)
239 } catch {
240 // Skip packages with invalid spec
241 }
242 }
243
244 buildRegistryConfig (registry) {
245 const keys = this.keys.get(registry) || []
246 const parsedRegistry = new URL(registry)
247 const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
248 return {
249 [`${regKey}:_keys`]: keys,
250 }
251 }
252
253 getSpecRegistry (spec) {
254 return npmFetch.pickRegistry(spec, this.npm.flatOptions)
255 }
256
257 getValidPackageInfo (edge) {
258 const type = this.getEdgeType(edge)
259 // Skip potentially optional packages that are not on disk, as these could
260 // be omitted during install
261 if (edge.error === 'MISSING' && type !== 'dependencies') {
262 return
263 }
264
265 const spec = this.getEdgeSpec(edge)
266 // Skip invalid version requirements
267 if (!spec) {
268 return
269 }
270 const node = edge.to || edge
271 const { version } = node.package || {}
272
273 if (node.isWorkspace || // Skip local workspaces packages
274 !version || // Skip packages that don't have an installed version, e.g. optional dependencies
275 !spec.registry) { // Skip if not from registry, e.g. git package
276 return
277 }
278
279 for (const omitType of this.npm.config.get('omit')) {
280 if (node[omitType]) {
281 return
282 }
283 }
284
285 return {
286 name: spec.name,
287 version,
288 type,
289 location: node.location,
290 registry: this.getSpecRegistry(spec),
291 }
292 }
293
294 async verifySignatures (name, version, registry) {
295 const {
296 _integrity: integrity,
297 _signatures,
298 _attestations,
299 _attestationBundles,
300 _resolved: resolved,
301 } = await pacote.manifest(`${name}@${version}`, {
302 verifySignatures: true,
303 verifyAttestations: true,
304 ...this.buildRegistryConfig(registry),
305 ...this.npm.flatOptions,
306 })
307 const signatures = _signatures || []
308 const result = {
309 integrity,
310 signatures,
311 attestations: _attestations,
312 attestationBundles: _attestationBundles,
313 resolved,
314 }
315 return result
316 }
317
318 async getVerifiedInfo (edge) {
319 const info = this.getValidPackageInfo(edge)
320 if (!info) {
321 return
322 }
323 const { name, version, location, registry, type } = info
324 if (this.checkedPackages.has(location)) {
325 // we already did or are doing this one
326 return
327 }
328 this.checkedPackages.add(location)
329
330 // We only "audit" or verify the signature, or the presence of it, on packages whose registry returns signing keys
331 const keys = this.keys.get(registry) || []
332 if (keys.length) {
333 this.auditedWithKeysCount += 1
334 }
335
336 try {
337 const { integrity, signatures, attestations, attestationBundles, resolved } =
338 await this.verifySignatures(name, version, registry)
339
340 // Currently we only care about missing signatures on registries that provide a public key
341 // We could make this configurable in the future with a strict/paranoid mode
342 if (signatures.length) {
343 this.verifiedSignatureCount += 1
344 } else if (keys.length) {
345 this.missing.push({
346 integrity,
347 location,
348 name,
349 registry,
350 resolved,
351 version,
352 })
353 }
354
355 // 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.
356 if (attestations) {
357 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 }
368 }
369 } catch (e) {
370 if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {
371 this.invalid.push({
372 code: e.code,
373 message: e.message,
374 integrity: e.integrity,
375 keyid: e.keyid,
376 location,
377 name,
378 registry,
379 resolved: e.resolved,
380 signature: e.signature,
381 predicateType: e.predicateType,
382 type,
383 version,
384 })
385 } else {
386 throw e
387 }
388 }
389 }
390}
391
392module.exports = VerifySignatures
393 