1const npmAuditReport = require('npm-audit-report') 2const fetch = require('npm-registry-fetch') 3const localeCompare = require('@isaacs/string-locale-compare')('en') 4const npa = require('npm-package-arg') 5const pacote = require('pacote') 6const pMap = require('p-map') 7const { sigstore } = require('sigstore') 8 9const ArboristWorkspaceCmd = require('../arborist-cmd.js') 10const auditError = require('../utils/audit-error.js') 11const log = require('../utils/log-shim.js') 12const reifyFinish = require('../utils/reify-finish.js') 13 14const sortAlphabetically = (a, b) => localeCompare(a.name, b.name) 15 16class VerifySignatures { 17 constructor (tree, filterSet, npm, opts) { 18 this.tree = tree 19 this.filterSet = filterSet 20 this.npm = npm 21 this.opts = opts 22 this.keys = new Map() 23 this.invalid = [] 24 this.missing = [] 25 this.checkedPackages = new Set() 26 this.auditedWithKeysCount = 0 27 this.verifiedSignatureCount = 0 28 this.verifiedAttestationCount = 0 29 this.exitCode = 0 30 } 31 32 async run () { 33 const start = process.hrtime.bigint() 34 35 // Find all deps in tree 36 const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet) 37 if (edges.size === 0) { 38 throw new Error('found no installed dependencies to audit') 39 } 40 41 const tuf = await sigstore.tuf.client({ 42 tufCachePath: this.opts.tufCache, 43 retry: this.opts.retry, 44 timeout: this.opts.timeout, 45 }) 46 await Promise.all([...registries].map(registry => this.setKeys({ registry, tuf }))) 47 48 const progress = log.newItem('verifying registry signatures', edges.size) 49 const mapper = async (edge) => { 50 progress.completeWork(1) 51 await this.getVerifiedInfo(edge) 52 } 53 await pMap(edges, mapper, { concurrency: 20, stopOnError: true }) 54 55 // Didn't find any dependencies that could be verified, e.g. only local 56 // deps, missing version, not on a registry etc. 57 if (!this.auditedWithKeysCount) { 58 throw new Error('found no dependencies to audit that where installed from ' + 59 'a supported registry') 60 } 61 62 const invalid = this.invalid.sort(sortAlphabetically) 63 const missing = this.missing.sort(sortAlphabetically) 64 65 const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0 66 67 if (!hasNoInvalidOrMissing) { 68 process.exitCode = 1 69 } 70 71 if (this.npm.config.get('json')) { 72 this.npm.output(JSON.stringify({ 73 invalid, 74 missing, 75 }, null, 2)) 76 return 77 } 78 const end = process.hrtime.bigint() 79 const elapsed = end - start 80 81 const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : '' 82 const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` + 83 `${Math.floor(Number(elapsed) / 1e9)}s` 84 this.npm.output(timing) 85 this.npm.output('') 86 87 const verifiedBold = this.npm.chalk.bold('verified') 88 if (this.verifiedSignatureCount) { 89 if (this.verifiedSignatureCount === 1) { 90 /* eslint-disable-next-line max-len */ 91 this.npm.output(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`) 92 } else { 93 /* eslint-disable-next-line max-len */ 94 this.npm.output(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`) 95 } 96 this.npm.output('') 97 } 98 99 if (this.verifiedAttestationCount) { 100 if (this.verifiedAttestationCount === 1) { 101 /* eslint-disable-next-line max-len */ 102 this.npm.output(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`) 103 } else { 104 /* eslint-disable-next-line max-len */ 105 this.npm.output(`${this.verifiedAttestationCount} packages have ${verifiedBold} attestations`) 106 } 107 this.npm.output('') 108 } 109 110 if (missing.length) { 111 const missingClr = this.npm.chalk.bold(this.npm.chalk.red('missing')) 112 if (missing.length === 1) { 113 /* eslint-disable-next-line max-len */ 114 this.npm.output(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`) 115 } else { 116 /* eslint-disable-next-line max-len */ 117 this.npm.output(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`) 118 } 119 this.npm.output('') 120 missing.map(m => 121 this.npm.output(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`) 122 ) 123 } 124 125 if (invalid.length) { 126 if (missing.length) { 127 this.npm.output('') 128 } 129 const invalidClr = this.npm.chalk.bold(this.npm.chalk.red('invalid')) 130 // We can have either invalid signatures or invalid provenance 131 const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE') 132 if (invalidSignatures.length) { 133 if (invalidSignatures.length === 1) { 134 this.npm.output(`1 package has an ${invalidClr} registry signature:`) 135 } else { 136 /* eslint-disable-next-line max-len */ 137 this.npm.output(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`) 138 } 139 this.npm.output('') 140 invalidSignatures.map(i => 141 this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) 142 ) 143 this.npm.output('') 144 } 145 146 const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY') 147 if (invalidAttestations.length) { 148 if (invalidAttestations.length === 1) { 149 this.npm.output(`1 package has an ${invalidClr} attestation:`) 150 } else { 151 /* eslint-disable-next-line max-len */ 152 this.npm.output(`${invalidAttestations.length} packages have ${invalidClr} attestations:`) 153 } 154 this.npm.output('') 155 invalidAttestations.map(i => 156 this.npm.output(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`) 157 ) 158 this.npm.output('') 159 } 160 161 if (invalid.length === 1) { 162 /* eslint-disable-next-line max-len */ 163 this.npm.output(`Someone might have tampered with this package since it was published on the registry!`) 164 } else { 165 /* eslint-disable-next-line max-len */ 166 this.npm.output(`Someone might have tampered with these packages since they were published on the registry!`) 167 } 168 this.npm.output('') 169 } 170 } 171 172 getEdgesOut (nodes, filterSet) { 173 const edges = new Set() 174 const registries = new Set() 175 for (const node of nodes) { 176 for (const edge of node.edgesOut.values()) { 177 const filteredOut = 178 edge.from 179 && filterSet 180 && filterSet.size > 0 181 && !filterSet.has(edge.from.target) 182 183 if (!filteredOut) { 184 const spec = this.getEdgeSpec(edge) 185 if (spec) { 186 // Prefetch and cache public keys from used registries 187 registries.add(this.getSpecRegistry(spec)) 188 } 189 edges.add(edge) 190 } 191 } 192 } 193 return { edges, registries } 194 } 195 196 async setKeys ({ registry, tuf }) { 197 const { host, pathname } = new URL(registry) 198 // Strip any trailing slashes from pathname 199 const regKey = `${host}${pathname.replace(/\/$/, '')}/keys.json` 200 let keys = await tuf.getTarget(regKey) 201 .then((target) => JSON.parse(target)) 202 .then(({ keys: ks }) => ks.map((key) => ({ 203 ...key, 204 keyid: key.keyId, 205 pemkey: `-----BEGIN PUBLIC KEY-----\n${key.publicKey.rawBytes}\n-----END PUBLIC KEY-----`, 206 expires: key.publicKey.validFor.end || null, 207 }))).catch(err => { 208 if (err.code === 'TUF_FIND_TARGET_ERROR') { 209 return null 210 } else { 211 throw err 212 } 213 }) 214 215 // If keys not found in Sigstore TUF repo, fallback to registry keys API 216 if (!keys) { 217 keys = await fetch.json('/-/npm/v1/keys', { 218 ...this.npm.flatOptions, 219 registry, 220 }).then(({ keys: ks }) => ks.map((key) => ({ 221 ...key, 222 pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`, 223 }))).catch(err => { 224 if (err.code === 'E404' || err.code === 'E400') { 225 return null 226 } else { 227 throw err 228 } 229 }) 230 } 231 232 if (keys) { 233 this.keys.set(registry, keys) 234 } 235 } 236 237 getEdgeType (edge) { 238 return edge.optional ? 'optionalDependencies' 239 : edge.peer ? 'peerDependencies' 240 : edge.dev ? 'devDependencies' 241 : 'dependencies' 242 } 243 244 getEdgeSpec (edge) { 245 let name = edge.name 246 try { 247 name = npa(edge.spec).subSpec.name 248 } catch { 249 // leave it as edge.name 250 } 251 try { 252 return npa(`${name}@${edge.spec}`) 253 } catch { 254 // Skip packages with invalid spec 255 } 256 } 257 258 buildRegistryConfig (registry) { 259 const keys = this.keys.get(registry) || [] 260 const parsedRegistry = new URL(registry) 261 const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}` 262 return { 263 [`${regKey}:_keys`]: keys, 264 } 265 } 266 267 getSpecRegistry (spec) { 268 return fetch.pickRegistry(spec, this.npm.flatOptions) 269 } 270 271 getValidPackageInfo (edge) { 272 const type = this.getEdgeType(edge) 273 // Skip potentially optional packages that are not on disk, as these could 274 // be omitted during install 275 if (edge.error === 'MISSING' && type !== 'dependencies') { 276 return 277 } 278 279 const spec = this.getEdgeSpec(edge) 280 // Skip invalid version requirements 281 if (!spec) { 282 return 283 } 284 const node = edge.to || edge 285 const { version } = node.package || {} 286 287 if (node.isWorkspace || // Skip local workspaces packages 288 !version || // Skip packages that don't have a installed version, e.g. optonal dependencies 289 !spec.registry) { // Skip if not from registry, e.g. git package 290 return 291 } 292 293 for (const omitType of this.npm.config.get('omit')) { 294 if (node[omitType]) { 295 return 296 } 297 } 298 299 return { 300 name: spec.name, 301 version, 302 type, 303 location: node.location, 304 registry: this.getSpecRegistry(spec), 305 } 306 } 307 308 async verifySignatures (name, version, registry) { 309 const { 310 _integrity: integrity, 311 _signatures, 312 _attestations, 313 _resolved: resolved, 314 } = await pacote.manifest(`${name}@${version}`, { 315 verifySignatures: true, 316 verifyAttestations: true, 317 ...this.buildRegistryConfig(registry), 318 ...this.npm.flatOptions, 319 }) 320 const signatures = _signatures || [] 321 const result = { 322 integrity, 323 signatures, 324 attestations: _attestations, 325 resolved, 326 } 327 return result 328 } 329 330 async getVerifiedInfo (edge) { 331 const info = this.getValidPackageInfo(edge) 332 if (!info) { 333 return 334 } 335 const { name, version, location, registry, type } = info 336 if (this.checkedPackages.has(location)) { 337 // we already did or are doing this one 338 return 339 } 340 this.checkedPackages.add(location) 341 342 // We only "audit" or verify the signature, or the presence of it, on 343 // packages whose registry returns signing keys 344 const keys = this.keys.get(registry) || [] 345 if (keys.length) { 346 this.auditedWithKeysCount += 1 347 } 348 349 try { 350 const { integrity, signatures, attestations, resolved } = await this.verifySignatures( 351 name, version, registry 352 ) 353 354 // Currently we only care about missing signatures on registries that provide a public key 355 // We could make this configurable in the future with a strict/paranoid mode 356 if (signatures.length) { 357 this.verifiedSignatureCount += 1 358 } else if (keys.length) { 359 this.missing.push({ 360 integrity, 361 location, 362 name, 363 registry, 364 resolved, 365 version, 366 }) 367 } 368 369 // Track verified attestations separately to registry signatures, as all 370 // packages on registries with signing keys are expected to have registry 371 // signatures, but not all packages have provenance and publish attestations. 372 if (attestations) { 373 this.verifiedAttestationCount += 1 374 } 375 } catch (e) { 376 if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') { 377 this.invalid.push({ 378 code: e.code, 379 message: e.message, 380 integrity: e.integrity, 381 keyid: e.keyid, 382 location, 383 name, 384 registry, 385 resolved: e.resolved, 386 signature: e.signature, 387 predicateType: e.predicateType, 388 type, 389 version, 390 }) 391 } else { 392 throw e 393 } 394 } 395 } 396} 397 398class Audit extends ArboristWorkspaceCmd { 399 static description = 'Run a security audit' 400 static name = 'audit' 401 static params = [ 402 'audit-level', 403 'dry-run', 404 'force', 405 'json', 406 'package-lock-only', 407 'omit', 408 'foreground-scripts', 409 'ignore-scripts', 410 ...super.params, 411 ] 412 413 static usage = ['[fix|signatures]'] 414 415 static async completion (opts) { 416 const argv = opts.conf.argv.remain 417 418 if (argv.length === 2) { 419 return ['fix', 'signatures'] 420 } 421 422 switch (argv[2]) { 423 case 'fix': 424 case 'signatures': 425 return [] 426 default: 427 throw Object.assign(new Error(argv[2] + ' not recognized'), { 428 code: 'EUSAGE', 429 }) 430 } 431 } 432 433 async exec (args) { 434 if (args[0] === 'signatures') { 435 await this.auditSignatures() 436 } else { 437 await this.auditAdvisories(args) 438 } 439 } 440 441 async auditAdvisories (args) { 442 const reporter = this.npm.config.get('json') ? 'json' : 'detail' 443 const Arborist = require('@npmcli/arborist') 444 const opts = { 445 ...this.npm.flatOptions, 446 audit: true, 447 path: this.npm.prefix, 448 reporter, 449 workspaces: this.workspaceNames, 450 } 451 452 const arb = new Arborist(opts) 453 const fix = args[0] === 'fix' 454 await arb.audit({ fix }) 455 if (fix) { 456 await reifyFinish(this.npm, arb) 457 } else { 458 // will throw if there's an error, because this is an audit command 459 auditError(this.npm, arb.auditReport) 460 const result = npmAuditReport(arb.auditReport, { 461 ...opts, 462 chalk: this.npm.chalk, 463 }) 464 process.exitCode = process.exitCode || result.exitCode 465 this.npm.output(result.report) 466 } 467 } 468 469 async auditSignatures () { 470 if (this.npm.global) { 471 throw Object.assign( 472 new Error('`npm audit signatures` does not support global packages'), { 473 code: 'EAUDITGLOBAL', 474 } 475 ) 476 } 477 478 log.verbose('loading installed dependencies') 479 const Arborist = require('@npmcli/arborist') 480 const opts = { 481 ...this.npm.flatOptions, 482 path: this.npm.prefix, 483 workspaces: this.workspaceNames, 484 } 485 486 const arb = new Arborist(opts) 487 const tree = await arb.loadActual() 488 let filterSet = new Set() 489 if (opts.workspaces && opts.workspaces.length) { 490 filterSet = 491 arb.workspaceDependencySet( 492 tree, 493 opts.workspaces, 494 this.npm.flatOptions.includeWorkspaceRoot 495 ) 496 } else if (!this.npm.flatOptions.workspacesEnabled) { 497 filterSet = 498 arb.excludeWorkspacesDependencySet(tree) 499 } 500 501 const verify = new VerifySignatures(tree, filterSet, this.npm, { ...opts }) 502 await verify.run() 503 } 504} 505 506module.exports = Audit 507