| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130 |
- const localeCompare = require('@isaacs/string-locale-compare')('en')
- const BaseCommand = require('../base-cmd.js')
- const { log, output, META } = require('proc-log')
- const { cyclonedxOutput } = require('../utils/sbom-cyclonedx.js')
- const { spdxOutput } = require('../utils/sbom-spdx.js')
- const SBOM_FORMATS = ['cyclonedx', 'spdx']
- class SBOM extends BaseCommand {
- #response = {} // response is the sbom response
- static description = 'Generate a Software Bill of Materials (SBOM)'
- static name = 'sbom'
- static workspaces = true
- static params = [
- 'omit',
- 'package-lock-only',
- 'sbom-format',
- 'sbom-type',
- 'workspace',
- 'workspaces',
- ]
- async exec () {
- const sbomFormat = this.npm.config.get('sbom-format')
- const packageLockOnly = this.npm.config.get('package-lock-only')
- if (!sbomFormat) {
- throw this.usageError(`Must specify --sbom-format flag with one of: ${SBOM_FORMATS.join(', ')}.`)
- }
- const opts = {
- ...this.npm.flatOptions,
- path: this.npm.prefix,
- forceActual: true,
- }
- const Arborist = require('@npmcli/arborist')
- const arb = new Arborist(opts)
- const tree = packageLockOnly ? await arb.loadVirtual(opts).catch(() => {
- throw this.usageError('A package lock or shrinkwrap file is required in package-lock-only mode')
- }) : await arb.loadActual(opts)
- // Collect the list of selected workspaces in the project
- const wsNodes = this.workspaceNames?.length
- ? arb.workspaceNodes(tree, this.workspaceNames)
- : null
- // Build the selector and query the tree for the list of nodes
- const selector = this.#buildSelector({ wsNodes })
- log.info('sbom', `Using dependency selector: ${selector}`)
- const items = await tree.querySelectorAll(selector)
- const errors = items.flatMap(node => detectErrors(node))
- if (errors.length) {
- throw Object.assign(new Error([...new Set(errors)].join('\n')), {
- code: 'ESBOMPROBLEMS',
- })
- }
- // Populate the response with the list of unique nodes (sorted by location)
- this.#buildResponse(items.sort((a, b) => localeCompare(a.location, b.location)))
- // TODO(BREAKING_CHANGE): all sbom output is in json mode but setting it before any of the errors will cause those to be thrown in json mode.
- this.npm.config.set('json', true)
- output.standard(JSON.stringify(this.#response, null, 2), { [META]: true, redact: false })
- }
- async execWorkspaces (args) {
- await this.setWorkspaces()
- return this.exec(args)
- }
- // Build the selector from all of the specified filter options
- #buildSelector ({ wsNodes }) {
- let selector
- const omit = this.npm.flatOptions.omit
- const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
- // If omit is specified, omit all nodes and their children which match the specified selectors
- const omits = omit.reduce((acc, o) => `${acc}:not(.${o})`, '')
- if (!workspacesEnabled) {
- // If workspaces are disabled, omit all workspace nodes and their children
- selector = `:root > :not(.workspace)${omits},:root > :not(.workspace) *${omits},:extraneous`
- } else if (wsNodes && wsNodes.length > 0) {
- // If one or more workspaces are selected, select only those workspaces and their children
- selector = wsNodes.map(ws => `#${ws.name},#${ws.name} *${omits}`).join(',')
- } else {
- selector = `:root *${omits},:extraneous`
- }
- // Always include the root node
- return `:root,${selector}`
- }
- // builds a normalized inventory
- #buildResponse (items) {
- const sbomFormat = this.npm.config.get('sbom-format')
- const packageType = this.npm.config.get('sbom-type')
- const packageLockOnly = this.npm.config.get('package-lock-only')
- this.#response = sbomFormat === 'cyclonedx'
- ? cyclonedxOutput({ npm: this.npm, nodes: items, packageType, packageLockOnly })
- : spdxOutput({ npm: this.npm, nodes: items, packageType })
- }
- }
- const detectErrors = (node) => {
- const errors = []
- // Look for missing dependencies (that are NOT optional), or invalid dependencies
- for (const edge of node.edgesOut.values()) {
- if (edge.missing && !(edge.type === 'optional' || edge.type === 'peerOptional')) {
- errors.push(`missing: ${edge.name}@${edge.spec}, required by ${edge.from.pkgid}`)
- }
- if (edge.invalid) {
- /* istanbul ignore next */
- const spec = edge.spec || '*'
- const from = edge.from.pkgid
- errors.push(`invalid: ${edge.to.pkgid}, ${spec} required by ${from}`)
- }
- }
- return errors
- }
- module.exports = SBOM
|