xref: /third_party/node/deps/npm/lib/commands/sbom.js (revision 1cb0ef41)
1'use strict'
2
3const { EOL } = require('os')
4const localeCompare = require('@isaacs/string-locale-compare')('en')
5const BaseCommand = require('../base-command.js')
6const log = require('../utils/log-shim.js')
7const { cyclonedxOutput } = require('../utils/sbom-cyclonedx.js')
8const { spdxOutput } = require('../utils/sbom-spdx.js')
9
10const SBOM_FORMATS = ['cyclonedx', 'spdx']
11
12class SBOM extends BaseCommand {
13  #response = {} // response is the sbom response
14
15  static description = 'Generate a Software Bill of Materials (SBOM)'
16  static name = 'sbom'
17  static workspaces = true
18
19  static params = [
20    'omit',
21    'package-lock-only',
22    'sbom-format',
23    'sbom-type',
24    'workspace',
25    'workspaces',
26  ]
27
28  get #parsedResponse () {
29    return JSON.stringify(this.#response, null, 2)
30  }
31
32  async exec () {
33    const sbomFormat = this.npm.config.get('sbom-format')
34    const packageLockOnly = this.npm.config.get('package-lock-only')
35
36    if (!sbomFormat) {
37      /* eslint-disable-next-line max-len */
38      throw this.usageError(`Must specify --sbom-format flag with one of: ${SBOM_FORMATS.join(', ')}.`)
39    }
40
41    const Arborist = require('@npmcli/arborist')
42
43    const opts = {
44      ...this.npm.flatOptions,
45      path: this.npm.prefix,
46      forceActual: true,
47    }
48    const arb = new Arborist(opts)
49
50    let tree
51    if (packageLockOnly) {
52      try {
53        tree = await arb.loadVirtual(opts)
54      } catch (err) {
55        /* eslint-disable-next-line max-len */
56        throw this.usageError('A package lock or shrinkwrap file is required in package-lock-only mode')
57      }
58    } else {
59      tree = await arb.loadActual(opts)
60    }
61
62    // Collect the list of selected workspaces in the project
63    let wsNodes
64    if (this.workspaceNames && this.workspaceNames.length) {
65      wsNodes = arb.workspaceNodes(tree, this.workspaceNames)
66    }
67
68    // Build the selector and query the tree for the list of nodes
69    const selector = this.#buildSelector({ wsNodes })
70    log.info('sbom', `Using dependency selector: ${selector}`)
71    const items = await tree.querySelectorAll(selector)
72
73    const errors = new Set()
74    for (const node of items) {
75      detectErrors(node).forEach(error => errors.add(error))
76    }
77
78    if (errors.size > 0) {
79      throw Object.assign(
80        new Error([...errors].join(EOL)),
81        { code: 'ESBOMPROBLEMS' }
82      )
83    }
84
85    // Populate the response with the list of unique nodes (sorted by location)
86    this.#buildResponse(
87      items
88        .sort((a, b) => localeCompare(a.location, b.location))
89    )
90    this.npm.output(this.#parsedResponse)
91  }
92
93  async execWorkspaces (args) {
94    await this.setWorkspaces()
95    return this.exec(args)
96  }
97
98  // Build the selector from all of the specified filter options
99  #buildSelector ({ wsNodes }) {
100    let selector
101    const omit = this.npm.flatOptions.omit
102    const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
103
104    // If omit is specified, omit all nodes and their children which match the
105    // specified selectors
106    const omits = omit.reduce((acc, o) => `${acc}:not(.${o})`, '')
107
108    if (!workspacesEnabled) {
109      // If workspaces are disabled, omit all workspace nodes and their children
110      selector = `:root > :not(.workspace)${omits},:root > :not(.workspace) *${omits},:extraneous`
111    } else if (wsNodes && wsNodes.length > 0) {
112      // If one or more workspaces are selected, select only those workspaces and their children
113      selector = wsNodes.map(ws => `#${ws.name},#${ws.name} *${omits}`).join(',')
114    } else {
115      selector = `:root *${omits},:extraneous`
116    }
117
118    // Always include the root node
119    return `:root,${selector}`
120  }
121
122  // builds a normalized inventory
123  #buildResponse (items) {
124    const sbomFormat = this.npm.config.get('sbom-format')
125    const packageType = this.npm.config.get('sbom-type')
126    const packageLockOnly = this.npm.config.get('package-lock-only')
127
128    this.#response =
129        sbomFormat === 'cyclonedx'
130          ? cyclonedxOutput({ npm: this.npm, nodes: items, packageType, packageLockOnly })
131          : spdxOutput({ npm: this.npm, nodes: items, packageType })
132  }
133}
134
135const detectErrors = (node) => {
136  const errors = []
137
138  // Look for missing dependencies (that are NOT optional), or invalid dependencies
139  for (const edge of node.edgesOut.values()) {
140    if (edge.missing && !(edge.type === 'optional' || edge.type === 'peerOptional')) {
141      errors.push(`missing: ${edge.name}@${edge.spec}, required by ${edge.from.pkgid}`)
142    }
143
144    if (edge.invalid) {
145      /* istanbul ignore next */
146      const spec = edge.spec || '*'
147      const from = edge.from.pkgid
148      errors.push(`invalid: ${edge.to.pkgid}, ${spec} required by ${from}`)
149    }
150  }
151
152  return errors
153}
154
155module.exports = SBOM
156