sbom.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. const localeCompare = require('@isaacs/string-locale-compare')('en')
  2. const BaseCommand = require('../base-cmd.js')
  3. const { log, output, META } = require('proc-log')
  4. const { cyclonedxOutput } = require('../utils/sbom-cyclonedx.js')
  5. const { spdxOutput } = require('../utils/sbom-spdx.js')
  6. const SBOM_FORMATS = ['cyclonedx', 'spdx']
  7. class SBOM extends BaseCommand {
  8. #response = {} // response is the sbom response
  9. static description = 'Generate a Software Bill of Materials (SBOM)'
  10. static name = 'sbom'
  11. static workspaces = true
  12. static params = [
  13. 'omit',
  14. 'package-lock-only',
  15. 'sbom-format',
  16. 'sbom-type',
  17. 'workspace',
  18. 'workspaces',
  19. ]
  20. async exec () {
  21. const sbomFormat = this.npm.config.get('sbom-format')
  22. const packageLockOnly = this.npm.config.get('package-lock-only')
  23. if (!sbomFormat) {
  24. throw this.usageError(`Must specify --sbom-format flag with one of: ${SBOM_FORMATS.join(', ')}.`)
  25. }
  26. const opts = {
  27. ...this.npm.flatOptions,
  28. path: this.npm.prefix,
  29. forceActual: true,
  30. }
  31. const Arborist = require('@npmcli/arborist')
  32. const arb = new Arborist(opts)
  33. const tree = packageLockOnly ? await arb.loadVirtual(opts).catch(() => {
  34. throw this.usageError('A package lock or shrinkwrap file is required in package-lock-only mode')
  35. }) : await arb.loadActual(opts)
  36. // Collect the list of selected workspaces in the project
  37. const wsNodes = this.workspaceNames?.length
  38. ? arb.workspaceNodes(tree, this.workspaceNames)
  39. : null
  40. // Build the selector and query the tree for the list of nodes
  41. const selector = this.#buildSelector({ wsNodes })
  42. log.info('sbom', `Using dependency selector: ${selector}`)
  43. const items = await tree.querySelectorAll(selector)
  44. const errors = items.flatMap(node => detectErrors(node))
  45. if (errors.length) {
  46. throw Object.assign(new Error([...new Set(errors)].join('\n')), {
  47. code: 'ESBOMPROBLEMS',
  48. })
  49. }
  50. // Populate the response with the list of unique nodes (sorted by location)
  51. this.#buildResponse(items.sort((a, b) => localeCompare(a.location, b.location)))
  52. // 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.
  53. this.npm.config.set('json', true)
  54. output.standard(JSON.stringify(this.#response, null, 2), { [META]: true, redact: false })
  55. }
  56. async execWorkspaces (args) {
  57. await this.setWorkspaces()
  58. return this.exec(args)
  59. }
  60. // Build the selector from all of the specified filter options
  61. #buildSelector ({ wsNodes }) {
  62. let selector
  63. const omit = this.npm.flatOptions.omit
  64. const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
  65. // If omit is specified, omit all nodes and their children which match the specified selectors
  66. const omits = omit.reduce((acc, o) => `${acc}:not(.${o})`, '')
  67. if (!workspacesEnabled) {
  68. // If workspaces are disabled, omit all workspace nodes and their children
  69. selector = `:root > :not(.workspace)${omits},:root > :not(.workspace) *${omits},:extraneous`
  70. } else if (wsNodes && wsNodes.length > 0) {
  71. // If one or more workspaces are selected, select only those workspaces and their children
  72. selector = wsNodes.map(ws => `#${ws.name},#${ws.name} *${omits}`).join(',')
  73. } else {
  74. selector = `:root *${omits},:extraneous`
  75. }
  76. // Always include the root node
  77. return `:root,${selector}`
  78. }
  79. // builds a normalized inventory
  80. #buildResponse (items) {
  81. const sbomFormat = this.npm.config.get('sbom-format')
  82. const packageType = this.npm.config.get('sbom-type')
  83. const packageLockOnly = this.npm.config.get('package-lock-only')
  84. this.#response = sbomFormat === 'cyclonedx'
  85. ? cyclonedxOutput({ npm: this.npm, nodes: items, packageType, packageLockOnly })
  86. : spdxOutput({ npm: this.npm, nodes: items, packageType })
  87. }
  88. }
  89. const detectErrors = (node) => {
  90. const errors = []
  91. // Look for missing dependencies (that are NOT optional), or invalid dependencies
  92. for (const edge of node.edgesOut.values()) {
  93. if (edge.missing && !(edge.type === 'optional' || edge.type === 'peerOptional')) {
  94. errors.push(`missing: ${edge.name}@${edge.spec}, required by ${edge.from.pkgid}`)
  95. }
  96. if (edge.invalid) {
  97. /* istanbul ignore next */
  98. const spec = edge.spec || '*'
  99. const from = edge.from.pkgid
  100. errors.push(`invalid: ${edge.to.pkgid}, ${spec} required by ${from}`)
  101. }
  102. }
  103. return errors
  104. }
  105. module.exports = SBOM