-
Notifications
You must be signed in to change notification settings - Fork 3.4k
/
Copy pathsbom.js
132 lines (107 loc) · 4.42 KB
/
sbom.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
const localeCompare = require('@isaacs/string-locale-compare')('en')
const BaseCommand = require('../base-cmd.js')
const { log, output } = 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.buffer(this.#response)
}
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