Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
sbom.js131 linesDownload Raw Back to commands
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