verify-signatures.js 12 KB

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