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