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