| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- const npmFetch = require('npm-registry-fetch')
- const localeCompare = require('@isaacs/string-locale-compare')('en')
- const npa = require('npm-package-arg')
- const pacote = require('pacote')
- const tufClient = require('@sigstore/tuf')
- const { log, output } = require('proc-log')
- const sortAlphabetically = (a, b) => localeCompare(a.name, b.name)
- class VerifySignatures {
- constructor (tree, filterSet, npm, opts) {
- this.tree = tree
- this.filterSet = filterSet
- this.npm = npm
- this.opts = opts
- this.keys = new Map()
- this.invalid = []
- this.missing = []
- this.checkedPackages = new Set()
- this.verified = []
- this.auditedWithKeysCount = 0
- this.verifiedSignatureCount = 0
- this.verifiedAttestationCount = 0
- this.exitCode = 0
- }
- async run () {
- const start = process.hrtime.bigint()
- const { default: pMap } = await import('p-map')
- // Find all deps in tree
- const { edges, registries } = this.getEdgesOut(this.tree.inventory.values(), this.filterSet)
- if (edges.size === 0) {
- throw new Error('found no installed dependencies to audit')
- }
- const tuf = await tufClient.initTUF({
- cachePath: this.opts.tufCache,
- retry: this.opts.retry,
- timeout: this.opts.timeout,
- })
- await Promise.all([...registries].map(registry => this.setKeys({ registry, tuf })))
- log.verbose('verifying registry signatures')
- await pMap(edges, (e) => this.getVerifiedInfo(e), { concurrency: 20, stopOnError: true })
- // Didn't find any dependencies that could be verified, e.g. only local deps, missing version, not on a registry etc.
- if (!this.auditedWithKeysCount && !this.verifiedAttestationCount) {
- throw new Error('found no dependencies to audit that were installed from ' +
- 'a supported registry')
- }
- const invalid = this.invalid.sort(sortAlphabetically)
- const missing = this.missing.sort(sortAlphabetically)
- const hasNoInvalidOrMissing = invalid.length === 0 && missing.length === 0
- if (!hasNoInvalidOrMissing) {
- process.exitCode = 1
- }
- if (this.npm.config.get('json')) {
- const result = { invalid, missing }
- if (this.npm.config.get('include-attestations')) {
- result.verified = this.verified
- }
- output.buffer(result)
- return
- }
- const end = process.hrtime.bigint()
- const elapsed = end - start
- const auditedPlural = this.auditedWithKeysCount > 1 ? 's' : ''
- const timing = `audited ${this.auditedWithKeysCount} package${auditedPlural} in ` +
- `${Math.floor(Number(elapsed) / 1e9)}s`
- output.standard(timing)
- output.standard()
- const verifiedBold = this.npm.chalk.bold('verified')
- if (this.verifiedSignatureCount) {
- if (this.verifiedSignatureCount === 1) {
- output.standard(`${this.verifiedSignatureCount} package has a ${verifiedBold} registry signature`)
- } else {
- output.standard(`${this.verifiedSignatureCount} packages have ${verifiedBold} registry signatures`)
- }
- output.standard()
- }
- if (this.verifiedAttestationCount) {
- if (this.verifiedAttestationCount === 1) {
- output.standard(`${this.verifiedAttestationCount} package has a ${verifiedBold} attestation`)
- } 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()
- }
- if (missing.length) {
- const missingClr = this.npm.chalk.redBright('missing')
- if (missing.length === 1) {
- output.standard(`1 package has a ${missingClr} registry signature but the registry is providing signing keys:`)
- } else {
- output.standard(`${missing.length} packages have ${missingClr} registry signatures but the registry is providing signing keys:`)
- }
- output.standard()
- missing.map(m =>
- output.standard(`${this.npm.chalk.red(`${m.name}@${m.version}`)} (${m.registry})`)
- )
- }
- if (invalid.length) {
- if (missing.length) {
- output.standard()
- }
- const invalidClr = this.npm.chalk.redBright('invalid')
- // We can have either invalid signatures or invalid provenance
- const invalidSignatures = this.invalid.filter(i => i.code === 'EINTEGRITYSIGNATURE')
- if (invalidSignatures.length) {
- if (invalidSignatures.length === 1) {
- output.standard(`1 package has an ${invalidClr} registry signature:`)
- } else {
- output.standard(`${invalidSignatures.length} packages have ${invalidClr} registry signatures:`)
- }
- output.standard()
- invalidSignatures.map(i =>
- output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
- )
- output.standard()
- }
- const invalidAttestations = this.invalid.filter(i => i.code === 'EATTESTATIONVERIFY')
- if (invalidAttestations.length) {
- if (invalidAttestations.length === 1) {
- output.standard(`1 package has an ${invalidClr} attestation:`)
- } else {
- output.standard(`${invalidAttestations.length} packages have ${invalidClr} attestations:`)
- }
- output.standard()
- invalidAttestations.map(i =>
- output.standard(`${this.npm.chalk.red(`${i.name}@${i.version}`)} (${i.registry})`)
- )
- output.standard()
- }
- if (invalid.length === 1) {
- output.standard(`Someone might have tampered with this package since it was published on the registry!`)
- } else {
- output.standard(`Someone might have tampered with these packages since they were published on the registry!`)
- }
- output.standard()
- }
- }
- getEdgesOut (nodes, filterSet) {
- const edges = new Set()
- const registries = new Set()
- for (const node of nodes) {
- for (const edge of node.edgesOut.values()) {
- const filteredOut =
- edge.from
- && filterSet
- && filterSet.size > 0
- && !filterSet.has(edge.from.target)
- if (!filteredOut) {
- const spec = this.getEdgeSpec(edge)
- if (spec) {
- // Prefetch and cache public keys from used registries
- registries.add(this.getSpecRegistry(spec))
- }
- edges.add(edge)
- }
- }
- }
- return { edges, registries }
- }
- async setKeys ({ registry, tuf }) {
- const { host, pathname } = new URL(registry)
- // Strip any trailing slashes from pathname
- const regKey = `${host}${pathname.replace(/\/$/, '')}/keys.json`
- let keys = await tuf.getTarget(regKey)
- .then((target) => JSON.parse(target))
- .then(({ keys: ks }) => ks.map((key) => ({
- ...key,
- keyid: key.keyId,
- pemkey: `-----BEGIN PUBLIC KEY-----\n${key.publicKey.rawBytes}\n-----END PUBLIC KEY-----`,
- expires: key.publicKey.validFor.end || null,
- }))).catch(err => {
- if (err.code === 'TUF_FIND_TARGET_ERROR') {
- return null
- } else {
- throw err
- }
- })
- // If keys not found in Sigstore TUF repo, fall back to registry keys API
- if (!keys) {
- log.warn(`Fetching verification keys using TUF failed. Fetching directly from ${registry}.`)
- keys = await npmFetch.json('/-/npm/v1/keys', {
- ...this.npm.flatOptions,
- registry,
- }).then(({ keys: ks }) => ks.map((key) => ({
- ...key,
- pemkey: `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----`,
- }))).catch(err => {
- if (err.code === 'E404' || err.code === 'E400') {
- return null
- } else {
- throw err
- }
- })
- }
- if (keys) {
- this.keys.set(registry, keys)
- }
- }
- getEdgeType (edge) {
- return edge.optional ? 'optionalDependencies'
- : edge.peer ? 'peerDependencies'
- : edge.dev ? 'devDependencies'
- : 'dependencies'
- }
- getEdgeSpec (edge) {
- let name = edge.name
- try {
- name = npa(edge.spec).subSpec.name
- } catch {
- // leave it as edge.name
- }
- try {
- return npa(`${name}@${edge.spec}`)
- } catch {
- // Skip packages with invalid spec
- }
- }
- buildRegistryConfig (registry) {
- const keys = this.keys.get(registry) || []
- const parsedRegistry = new URL(registry)
- const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
- return {
- [`${regKey}:_keys`]: keys,
- }
- }
- getSpecRegistry (spec) {
- return npmFetch.pickRegistry(spec, this.npm.flatOptions)
- }
- getValidPackageInfo (edge) {
- const type = this.getEdgeType(edge)
- // Skip potentially optional packages that are not on disk, as these could
- // be omitted during install
- if (edge.error === 'MISSING' && type !== 'dependencies') {
- return
- }
- const spec = this.getEdgeSpec(edge)
- // Skip invalid version requirements
- if (!spec) {
- return
- }
- const node = edge.to || edge
- const { version } = node.package || {}
- if (node.isWorkspace || // Skip local workspaces packages
- !version || // Skip packages that don't have an installed version, e.g. optional dependencies
- !spec.registry) { // Skip if not from registry, e.g. git package
- return
- }
- for (const omitType of this.npm.config.get('omit')) {
- if (node[omitType]) {
- return
- }
- }
- return {
- name: spec.name,
- version,
- type,
- location: node.location,
- registry: this.getSpecRegistry(spec),
- }
- }
- async verifySignatures (name, version, registry) {
- const {
- _integrity: integrity,
- _signatures,
- _attestations,
- _attestationBundles,
- _resolved: resolved,
- } = await pacote.manifest(`${name}@${version}`, {
- verifySignatures: true,
- verifyAttestations: true,
- ...this.buildRegistryConfig(registry),
- ...this.npm.flatOptions,
- })
- const signatures = _signatures || []
- const result = {
- integrity,
- signatures,
- attestations: _attestations,
- attestationBundles: _attestationBundles,
- resolved,
- }
- return result
- }
- async getVerifiedInfo (edge) {
- const info = this.getValidPackageInfo(edge)
- if (!info) {
- return
- }
- const { name, version, location, registry, type } = info
- if (this.checkedPackages.has(location)) {
- // we already did or are doing this one
- return
- }
- this.checkedPackages.add(location)
- // We only "audit" or verify the signature, or the presence of it, on packages whose registry returns signing keys
- const keys = this.keys.get(registry) || []
- if (keys.length) {
- this.auditedWithKeysCount += 1
- }
- try {
- const { integrity, signatures, attestations, attestationBundles, resolved } =
- await this.verifySignatures(name, version, registry)
- // Currently we only care about missing signatures on registries that provide a public key
- // We could make this configurable in the future with a strict/paranoid mode
- if (signatures.length) {
- this.verifiedSignatureCount += 1
- } else if (keys.length) {
- this.missing.push({
- integrity,
- location,
- name,
- registry,
- resolved,
- version,
- })
- }
- // 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.
- if (attestations) {
- this.verifiedAttestationCount += 1
- if (this.npm.config.get('include-attestations')) {
- this.verified.push({
- name,
- version,
- location,
- registry,
- attestations,
- attestationBundles,
- })
- }
- }
- } catch (e) {
- if (e.code === 'EINTEGRITYSIGNATURE' || e.code === 'EATTESTATIONVERIFY') {
- this.invalid.push({
- code: e.code,
- message: e.message,
- integrity: e.integrity,
- keyid: e.keyid,
- location,
- name,
- registry,
- resolved: e.resolved,
- signature: e.signature,
- predicateType: e.predicateType,
- type,
- version,
- })
- } else {
- throw e
- }
- }
- }
- }
- module.exports = VerifySignatures
|