Skip to content

Commit 7c459d2

Browse files
authoredSep 27, 2023
feat: add npm sbom command (#6801)
Signed-off-by: Brian DeHamer <bdehamer@github.com>
1 parent 3ac703c commit 7c459d2

27 files changed

+10317
-56
lines changed
 

‎DEPENDENCIES.md

+6
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ graph LR;
107107
npm-->libnpmversion;
108108
npm-->make-fetch-happen;
109109
npm-->nopt;
110+
npm-->normalize-package-data;
110111
npm-->npm-audit-report;
111112
npm-->npm-install-checks;
112113
npm-->npm-package-arg;
@@ -495,6 +496,9 @@ graph LR;
495496
normalize-package-data-->semver;
496497
normalize-package-data-->validate-npm-package-license;
497498
npm-->abbrev;
499+
npm-->ajv-formats-draft2019;
500+
npm-->ajv-formats;
501+
npm-->ajv;
498502
npm-->archy;
499503
npm-->cacache;
500504
npm-->chalk;
@@ -533,6 +537,7 @@ graph LR;
533537
npm-->nock;
534538
npm-->node-gyp;
535539
npm-->nopt;
540+
npm-->normalize-package-data;
536541
npm-->npm-audit-report;
537542
npm-->npm-install-checks;
538543
npm-->npm-package-arg;
@@ -568,6 +573,7 @@ graph LR;
568573
npm-->semver;
569574
npm-->sigstore-tuf["@sigstore/tuf"];
570575
npm-->spawk;
576+
npm-->spdx-expression-parse;
571577
npm-->ssri;
572578
npm-->strip-ansi;
573579
npm-->supports-color;

‎docs/lib/content/commands/npm-sbom.md

+223
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
---
2+
title: npm-sbom
3+
section: 1
4+
description: Generate a Software Bill of Materials (SBOM)
5+
---
6+
7+
### Synopsis
8+
9+
<!-- AUTOGENERATED USAGE DESCRIPTIONS -->
10+
11+
### Description
12+
13+
The `npm sbom` command generates a Software Bill of Materials (SBOM) listing the
14+
dependencies for the current project. SBOMs can be generated in either
15+
[SPDX](https://spdx.dev/) or [CycloneDX](https://cyclonedx.org/) format.
16+
17+
### Example CycloneDX SBOM
18+
19+
```json
20+
{
21+
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
22+
"bomFormat": "CycloneDX",
23+
"specVersion": "1.5",
24+
"serialNumber": "urn:uuid:09f55116-97e1-49cf-b3b8-44d0207e7730",
25+
"version": 1,
26+
"metadata": {
27+
"timestamp": "2023-09-01T00:00:00.001Z",
28+
"lifecycles": [
29+
{
30+
"phase": "build"
31+
}
32+
],
33+
"tools": [
34+
{
35+
"vendor": "npm",
36+
"name": "cli",
37+
"version": "10.1.0"
38+
}
39+
],
40+
"component": {
41+
"bom-ref": "simple@1.0.0",
42+
"type": "library",
43+
"name": "simple",
44+
"version": "1.0.0",
45+
"scope": "required",
46+
"author": "John Doe",
47+
"description": "simple react app",
48+
"purl": "pkg:npm/simple@1.0.0",
49+
"properties": [
50+
{
51+
"name": "cdx:npm:package:path",
52+
"value": ""
53+
}
54+
],
55+
"externalReferences": [],
56+
"licenses": [
57+
{
58+
"license": {
59+
"id": "MIT"
60+
}
61+
}
62+
]
63+
}
64+
},
65+
"components": [
66+
{
67+
"bom-ref": "lodash@4.17.21",
68+
"type": "library",
69+
"name": "lodash",
70+
"version": "4.17.21",
71+
"scope": "required",
72+
"author": "John-David Dalton",
73+
"description": "Lodash modular utilities.",
74+
"purl": "pkg:npm/lodash@4.17.21",
75+
"properties": [
76+
{
77+
"name": "cdx:npm:package:path",
78+
"value": "node_modules/lodash"
79+
}
80+
],
81+
"externalReferences": [
82+
{
83+
"type": "distribution",
84+
"url": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
85+
},
86+
{
87+
"type": "vcs",
88+
"url": "git+https://github.com/lodash/lodash.git"
89+
},
90+
{
91+
"type": "website",
92+
"url": "https://lodash.com/"
93+
},
94+
{
95+
"type": "issue-tracker",
96+
"url": "https://github.com/lodash/lodash/issues"
97+
}
98+
],
99+
"hashes": [
100+
{
101+
"alg": "SHA-512",
102+
"content": "bf690311ee7b95e713ba568322e3533f2dd1cb880b189e99d4edef13592b81764daec43e2c54c61d5c558dc5cfb35ecb85b65519e74026ff17675b6f8f916f4a"
103+
}
104+
],
105+
"licenses": [
106+
{
107+
"license": {
108+
"id": "MIT"
109+
}
110+
}
111+
]
112+
}
113+
],
114+
"dependencies": [
115+
{
116+
"ref": "simple@1.0.0",
117+
"dependsOn": [
118+
"lodash@4.17.21"
119+
]
120+
},
121+
{
122+
"ref": "lodash@4.17.21",
123+
"dependsOn": []
124+
}
125+
]
126+
}
127+
```
128+
129+
### Example SPDX SBOM
130+
131+
```json
132+
{
133+
"spdxVersion": "SPDX-2.3",
134+
"dataLicense": "CC0-1.0",
135+
"SPDXID": "SPDXRef-DOCUMENT",
136+
"name": "simple@1.0.0",
137+
"documentNamespace": "http://spdx.org/spdxdocs/simple-1.0.0-bf81090e-8bbc-459d-bec9-abeb794e096a",
138+
"creationInfo": {
139+
"created": "2023-09-01T00:00:00.001Z",
140+
"creators": [
141+
"Tool: npm/cli-10.1.0"
142+
]
143+
},
144+
"documentDescribes": [
145+
"SPDXRef-Package-simple-1.0.0"
146+
],
147+
"packages": [
148+
{
149+
"name": "simple",
150+
"SPDXID": "SPDXRef-Package-simple-1.0.0",
151+
"versionInfo": "1.0.0",
152+
"packageFileName": "",
153+
"description": "simple react app",
154+
"primaryPackagePurpose": "LIBRARY",
155+
"downloadLocation": "NOASSERTION",
156+
"filesAnalyzed": false,
157+
"homepage": "NOASSERTION",
158+
"licenseDeclared": "MIT",
159+
"externalRefs": [
160+
{
161+
"referenceCategory": "PACKAGE-MANAGER",
162+
"referenceType": "purl",
163+
"referenceLocator": "pkg:npm/simple@1.0.0"
164+
}
165+
]
166+
},
167+
{
168+
"name": "lodash",
169+
"SPDXID": "SPDXRef-Package-lodash-4.17.21",
170+
"versionInfo": "4.17.21",
171+
"packageFileName": "node_modules/lodash",
172+
"description": "Lodash modular utilities.",
173+
"downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
174+
"filesAnalyzed": false,
175+
"homepage": "https://lodash.com/",
176+
"licenseDeclared": "MIT",
177+
"externalRefs": [
178+
{
179+
"referenceCategory": "PACKAGE-MANAGER",
180+
"referenceType": "purl",
181+
"referenceLocator": "pkg:npm/lodash@4.17.21"
182+
}
183+
],
184+
"checksums": [
185+
{
186+
"algorithm": "SHA512",
187+
"checksumValue": "bf690311ee7b95e713ba568322e3533f2dd1cb880b189e99d4edef13592b81764daec43e2c54c61d5c558dc5cfb35ecb85b65519e74026ff17675b6f8f916f4a"
188+
}
189+
]
190+
}
191+
],
192+
"relationships": [
193+
{
194+
"spdxElementId": "SPDXRef-DOCUMENT",
195+
"relatedSpdxElement": "SPDXRef-Package-simple-1.0.0",
196+
"relationshipType": "DESCRIBES"
197+
},
198+
{
199+
"spdxElementId": "SPDXRef-Package-simple-1.0.0",
200+
"relatedSpdxElement": "SPDXRef-Package-lodash-4.17.21",
201+
"relationshipType": "DEPENDS_ON"
202+
}
203+
]
204+
}
205+
```
206+
207+
### Package lock only mode
208+
209+
If package-lock-only is enabled, only the information in the package
210+
lock (or shrinkwrap) is loaded. This means that information from the
211+
package.json files of your dependencies will not be included in the
212+
result set (e.g. description, homepage, engines).
213+
214+
### Configuration
215+
216+
<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->
217+
## See Also
218+
219+
* [package spec](/using-npm/package-spec)
220+
* [dependency selectors](/using-npm/dependency-selectors)
221+
* [package.json](/configuring-npm/package-json)
222+
* [workspaces](/using-npm/workspaces)
223+

‎docs/lib/content/nav.yml

+3
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@
150150
- title: npm run-script
151151
url: /commands/npm-run-script
152152
description: Run arbitrary package scripts
153+
- title: npm sbom
154+
url: /commands/npm-sbom
155+
description: Generate a Software Bill of Materials (SBOM)
153156
- title: npm search
154157
url: /commands/npm-search
155158
description: Search for packages

‎lib/commands/sbom.js

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
'use strict'
2+
3+
const { EOL } = require('os')
4+
const localeCompare = require('@isaacs/string-locale-compare')('en')
5+
const BaseCommand = require('../base-command.js')
6+
const log = require('../utils/log-shim.js')
7+
const { cyclonedxOutput } = require('../utils/sbom-cyclonedx.js')
8+
const { spdxOutput } = require('../utils/sbom-spdx.js')
9+
10+
const SBOM_FORMATS = ['cyclonedx', 'spdx']
11+
12+
class 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+
135+
const 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+
155+
module.exports = SBOM

‎lib/utils/cmd-list.js

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const commands = [
5252
'restart',
5353
'root',
5454
'run-script',
55+
'sbom',
5556
'search',
5657
'set',
5758
'shrinkwrap',

‎lib/utils/sbom-cyclonedx.js

+194
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
const crypto = require('crypto')
2+
const normalizeData = require('normalize-package-data')
3+
const parseLicense = require('spdx-expression-parse')
4+
const npa = require('npm-package-arg')
5+
const ssri = require('ssri')
6+
7+
const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
8+
const CYCLONEDX_FORMAT = 'CycloneDX'
9+
const CYCLONEDX_SCHEMA_VERSION = '1.5'
10+
11+
const PROP_PATH = 'cdx:npm:package:path'
12+
const PROP_BUNDLED = 'cdx:npm:package:bundled'
13+
const PROP_DEVELOPMENT = 'cdx:npm:package:development'
14+
const PROP_EXTRANEOUS = 'cdx:npm:package:extraneous'
15+
const PROP_PRIVATE = 'cdx:npm:package:private'
16+
17+
const REF_VCS = 'vcs'
18+
const REF_WEBSITE = 'website'
19+
const REF_ISSUE_TRACKER = 'issue-tracker'
20+
const REF_DISTRIBUTION = 'distribution'
21+
22+
const ALGO_MAP = {
23+
sha1: 'SHA-1',
24+
sha256: 'SHA-256',
25+
sha384: 'SHA-384',
26+
sha512: 'SHA-512',
27+
}
28+
29+
const cyclonedxOutput = ({ npm, nodes, packageType, packageLockOnly }) => {
30+
const rootNode = nodes.find(node => node.isRoot)
31+
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
32+
const uuid = crypto.randomUUID()
33+
34+
const deps = []
35+
const seen = new Set()
36+
for (let node of nodes) {
37+
if (node.isLink) {
38+
node = node.target
39+
}
40+
41+
if (seen.has(node)) {
42+
continue
43+
}
44+
seen.add(node)
45+
deps.push(toCyclonedxDependency(node, nodes))
46+
}
47+
48+
const bom = {
49+
$schema: CYCLONEDX_SCHEMA,
50+
bomFormat: CYCLONEDX_FORMAT,
51+
specVersion: CYCLONEDX_SCHEMA_VERSION,
52+
serialNumber: `urn:uuid:${uuid}`,
53+
version: 1,
54+
metadata: {
55+
timestamp: new Date().toISOString(),
56+
lifecycles: [
57+
{ phase: packageLockOnly ? 'pre-build' : 'build' },
58+
],
59+
tools: [
60+
{
61+
vendor: 'npm',
62+
name: 'cli',
63+
version: npm.version,
64+
},
65+
],
66+
component: toCyclonedxItem(rootNode, { packageType }),
67+
},
68+
components: childNodes.map(toCyclonedxItem),
69+
dependencies: deps,
70+
}
71+
72+
return bom
73+
}
74+
75+
const toCyclonedxItem = (node, { packageType }) => {
76+
packageType = packageType || 'library'
77+
78+
// Calculate purl from package spec
79+
let spec = npa(node.pkgid)
80+
spec = (spec.type === 'alias') ? spec.subSpec : spec
81+
const purl = npa.toPurl(spec) + (isGitNode(node) ? `?vcs_url=${node.resolved}` : '')
82+
83+
if (node.package) {
84+
normalizeData(node.package)
85+
}
86+
87+
let parsedLicense
88+
try {
89+
parsedLicense = parseLicense(node.package?.license)
90+
} catch (err) {
91+
parsedLicense = null
92+
}
93+
94+
const component = {
95+
'bom-ref': toCyclonedxID(node),
96+
type: packageType,
97+
name: node.name,
98+
version: node.version,
99+
scope: (node.optional || node.devOptional) ? 'optional' : 'required',
100+
author: (typeof node.package?.author === 'object')
101+
? node.package.author.name
102+
: (node.package?.author || undefined),
103+
description: node.package?.description || undefined,
104+
purl: purl,
105+
properties: [{
106+
name: PROP_PATH,
107+
value: node.location,
108+
}],
109+
externalReferences: [],
110+
}
111+
112+
if (node.integrity) {
113+
const integrity = ssri.parse(node.integrity, { single: true })
114+
component.hashes = [{
115+
alg: ALGO_MAP[integrity.algorithm] || /* istanbul ignore next */ 'SHA-512',
116+
content: integrity.hexDigest(),
117+
}]
118+
}
119+
120+
if (node.dev === true) {
121+
component.properties.push(prop(PROP_DEVELOPMENT))
122+
}
123+
124+
if (node.package?.private === true) {
125+
component.properties.push(prop(PROP_PRIVATE))
126+
}
127+
128+
if (node.extraneous === true) {
129+
component.properties.push(prop(PROP_EXTRANEOUS))
130+
}
131+
132+
if (node.inBundle === true) {
133+
component.properties.push(prop(PROP_BUNDLED))
134+
}
135+
136+
if (!node.isLink && node.resolved) {
137+
component.externalReferences.push(extRef(REF_DISTRIBUTION, node.resolved))
138+
}
139+
140+
if (node.package?.repository?.url) {
141+
component.externalReferences.push(extRef(REF_VCS, node.package.repository.url))
142+
}
143+
144+
if (node.package?.homepage) {
145+
component.externalReferences.push(extRef(REF_WEBSITE, node.package.homepage))
146+
}
147+
148+
if (node.package?.bugs?.url) {
149+
component.externalReferences.push(extRef(REF_ISSUE_TRACKER, node.package.bugs.url))
150+
}
151+
152+
// If license is a single SPDX license, use the license field
153+
if (parsedLicense?.license) {
154+
component.licenses = [{ license: { id: parsedLicense.license } }]
155+
// If license is a conjunction, use the expression field
156+
} else if (parsedLicense?.conjunction) {
157+
component.licenses = [{ expression: node.package.license }]
158+
}
159+
160+
return component
161+
}
162+
163+
const toCyclonedxDependency = (node, nodes) => {
164+
return {
165+
ref: toCyclonedxID(node),
166+
dependsOn: [...node.edgesOut.values()]
167+
// Filter out edges that are linking to nodes not in the list
168+
.filter(edge => nodes.find(n => n === edge.to))
169+
.map(edge => toCyclonedxID(edge.to))
170+
.filter(id => id),
171+
}
172+
}
173+
174+
const toCyclonedxID = (node) => `${node.packageName}@${node.version}`
175+
176+
const prop = (name) => ({ name, value: 'true' })
177+
178+
const extRef = (type, url) => ({ type, url })
179+
180+
const isGitNode = (node) => {
181+
if (!node.resolved) {
182+
return
183+
}
184+
185+
try {
186+
const { type } = npa(node.resolved)
187+
return type === 'git' || type === 'hosted'
188+
} catch (err) {
189+
/* istanbul ignore next */
190+
return false
191+
}
192+
}
193+
194+
module.exports = { cyclonedxOutput }

‎lib/utils/sbom-spdx.js

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
2+
const crypto = require('crypto')
3+
const normalizeData = require('normalize-package-data')
4+
const npa = require('npm-package-arg')
5+
const ssri = require('ssri')
6+
7+
const SPDX_SCHEMA_VERSION = 'SPDX-2.3'
8+
const SPDX_DATA_LICENSE = 'CC0-1.0'
9+
const SPDX_IDENTIFER = 'SPDXRef-DOCUMENT'
10+
11+
const NO_ASSERTION = 'NOASSERTION'
12+
13+
const REL_DESCRIBES = 'DESCRIBES'
14+
const REL_PREREQ = 'HAS_PREREQUISITE'
15+
const REL_OPTIONAL = 'OPTIONAL_DEPENDENCY_OF'
16+
const REL_DEV = 'DEV_DEPENDENCY_OF'
17+
const REL_DEP = 'DEPENDS_ON'
18+
19+
const REF_CAT_PACKAGE_MANAGER = 'PACKAGE-MANAGER'
20+
const REF_TYPE_PURL = 'purl'
21+
22+
const spdxOutput = ({ npm, nodes, packageType }) => {
23+
const rootNode = nodes.find(node => node.isRoot)
24+
const childNodes = nodes.filter(node => !node.isRoot && !node.isLink)
25+
const rootID = rootNode.pkgid
26+
const uuid = crypto.randomUUID()
27+
const ns = `http://spdx.org/spdxdocs/${npa(rootID).escapedName}-${rootNode.version}-${uuid}`
28+
29+
const relationships = []
30+
const seen = new Set()
31+
for (let node of nodes) {
32+
if (node.isLink) {
33+
node = node.target
34+
}
35+
36+
if (seen.has(node)) {
37+
continue
38+
}
39+
seen.add(node)
40+
41+
const rels = [...node.edgesOut.values()]
42+
// Filter out edges that are linking to nodes not in the list
43+
.filter(edge => nodes.find(n => n === edge.to))
44+
.map(edge => toSpdxRelationship(node, edge))
45+
.filter(rel => rel)
46+
47+
relationships.push(...rels)
48+
}
49+
50+
const extraRelationships = nodes.filter(node => node.extraneous)
51+
.map(node => toSpdxRelationship(rootNode, { to: node, type: 'optional' }))
52+
53+
relationships.push(...extraRelationships)
54+
55+
const bom = {
56+
spdxVersion: SPDX_SCHEMA_VERSION,
57+
dataLicense: SPDX_DATA_LICENSE,
58+
SPDXID: SPDX_IDENTIFER,
59+
name: rootID,
60+
documentNamespace: ns,
61+
creationInfo: {
62+
created: new Date().toISOString(),
63+
creators: [
64+
`Tool: npm/cli-${npm.version}`,
65+
],
66+
},
67+
documentDescribes: [toSpdxID(rootNode)],
68+
packages: [toSpdxItem(rootNode, { packageType }), ...childNodes.map(toSpdxItem)],
69+
relationships: [
70+
{
71+
spdxElementId: SPDX_IDENTIFER,
72+
relatedSpdxElement: toSpdxID(rootNode),
73+
relationshipType: REL_DESCRIBES,
74+
},
75+
...relationships,
76+
],
77+
}
78+
79+
return bom
80+
}
81+
82+
const toSpdxItem = (node, { packageType }) => {
83+
normalizeData(node.package)
84+
85+
// Calculate purl from package spec
86+
let spec = npa(node.pkgid)
87+
spec = (spec.type === 'alias') ? spec.subSpec : spec
88+
const purl = npa.toPurl(spec) + (isGitNode(node) ? `?vcs_url=${node.resolved}` : '')
89+
90+
/* For workspace nodes, use the location from their linkNode */
91+
let location = node.location
92+
if (node.isWorkspace && node.linksIn.size > 0) {
93+
location = node.linksIn.values().next().value.location
94+
}
95+
96+
const pkg = {
97+
name: node.packageName,
98+
SPDXID: toSpdxID(node),
99+
versionInfo: node.version,
100+
packageFileName: location,
101+
description: node.package?.description || undefined,
102+
primaryPackagePurpose: packageType ? packageType.toUpperCase() : undefined,
103+
downloadLocation: (node.isLink ? undefined : node.resolved) || NO_ASSERTION,
104+
filesAnalyzed: false,
105+
homepage: node.package?.homepage || NO_ASSERTION,
106+
licenseDeclared: node.package?.license || NO_ASSERTION,
107+
externalRefs: [
108+
{
109+
referenceCategory: REF_CAT_PACKAGE_MANAGER,
110+
referenceType: REF_TYPE_PURL,
111+
referenceLocator: purl,
112+
},
113+
],
114+
}
115+
116+
if (node.integrity) {
117+
const integrity = ssri.parse(node.integrity, { single: true })
118+
pkg.checksums = [{
119+
algorithm: integrity.algorithm.toUpperCase(),
120+
checksumValue: integrity.hexDigest(),
121+
}]
122+
}
123+
return pkg
124+
}
125+
126+
const toSpdxRelationship = (node, edge) => {
127+
let type
128+
switch (edge.type) {
129+
case 'peer':
130+
type = REL_PREREQ
131+
break
132+
case 'optional':
133+
type = REL_OPTIONAL
134+
break
135+
case 'dev':
136+
type = REL_DEV
137+
break
138+
default:
139+
type = REL_DEP
140+
}
141+
142+
return {
143+
spdxElementId: toSpdxID(node),
144+
relatedSpdxElement: toSpdxID(edge.to),
145+
relationshipType: type,
146+
}
147+
}
148+
149+
const toSpdxID = (node) => {
150+
let name = node.packageName
151+
152+
// Strip leading @ for scoped packages
153+
name = name.replace(/^@/, '')
154+
155+
// Replace slashes with dots
156+
name = name.replace(/\//g, '.')
157+
158+
return `SPDXRef-Package-${name}-${node.version}`
159+
}
160+
161+
const isGitNode = (node) => {
162+
if (!node.resolved) {
163+
return
164+
}
165+
166+
try {
167+
const { type } = npa(node.resolved)
168+
return type === 'git' || type === 'hosted'
169+
} catch (err) {
170+
/* istanbul ignore next */
171+
return false
172+
}
173+
}
174+
175+
module.exports = { spdxOutput }

‎package-lock.json

+128
Original file line numberDiff line numberDiff line change

‎package.json

+7
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"ms": "^2.1.2",
9696
"node-gyp": "^9.4.0",
9797
"nopt": "^7.2.0",
98+
"normalize-package-data": "^6.0.0",
9899
"npm-audit-report": "^5.0.0",
99100
"npm-install-checks": "^6.2.0",
100101
"npm-package-arg": "^11.0.0",
@@ -110,6 +111,7 @@
110111
"qrcode-terminal": "^0.12.0",
111112
"read": "^2.1.0",
112113
"semver": "^7.5.4",
114+
"spdx-expression-parse": "^3.0.1",
113115
"ssri": "^10.0.5",
114116
"strip-ansi": "^6.0.1",
115117
"supports-color": "^9.4.0",
@@ -166,6 +168,7 @@
166168
"ms",
167169
"node-gyp",
168170
"nopt",
171+
"normalize-package-data",
169172
"npm-audit-report",
170173
"npm-install-checks",
171174
"npm-package-arg",
@@ -181,6 +184,7 @@
181184
"qrcode-terminal",
182185
"read",
183186
"semver",
187+
"spdx-expression-parse",
184188
"ssri",
185189
"strip-ansi",
186190
"supports-color",
@@ -200,6 +204,9 @@
200204
"@npmcli/mock-registry": "^1.0.0",
201205
"@npmcli/template-oss": "4.19.0",
202206
"@tufjs/repo-mock": "^2.0.0",
207+
"ajv": "^8.12.0",
208+
"ajv-formats": "^2.1.1",
209+
"ajv-formats-draft2019": "^1.6.1",
203210
"diff": "^5.1.0",
204211
"licensee": "^10.0.0",
205212
"nock": "^13.3.3",

‎smoke-tests/tap-snapshots/test/index.js.test.cjs

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ All commands:
2727
help-search, hook, init, install, install-ci-test,
2828
install-test, link, ll, login, logout, ls, org, outdated,
2929
owner, pack, ping, pkg, prefix, profile, prune, publish,
30-
query, rebuild, repo, restart, root, run-script, search,
31-
set, shrinkwrap, star, stars, start, stop, team, test,
32-
token, uninstall, unpublish, unstar, update, version, view,
33-
whoami
30+
query, rebuild, repo, restart, root, run-script, sbom,
31+
search, set, shrinkwrap, star, stars, start, stop, team,
32+
test, token, uninstall, unpublish, unstar, update, version,
33+
view, whoami
3434
3535
Specify configs in the ini-formatted file:
3636
{NPM}/{TESTDIR}/home/.npmrc

‎tap-snapshots/test/lib/commands/completion.js.test.cjs

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Array [
9393
restart
9494
root
9595
run-script
96+
sbom
9697
search
9798
set
9899
shrinkwrap

‎tap-snapshots/test/lib/commands/config.js.test.cjs

+4
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
8989
"legacy-peer-deps": false,
9090
"link": false,
9191
"local-address": null,
92+
"sbom-format": null,
93+
"sbom-type": "library",
9294
"location": "user",
9395
"lockfile-version": null,
9496
"loglevel": "notice",
@@ -290,6 +292,8 @@ save-optional = false
290292
save-peer = false
291293
save-prefix = "^"
292294
save-prod = false
295+
sbom-format = null
296+
sbom-type = "library"
293297
scope = ""
294298
script-shell = null
295299
searchexclude = ""

‎tap-snapshots/test/lib/commands/publish.js.test.cjs

+1
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ Object {
191191
"man/man1/npm-restart.1",
192192
"man/man1/npm-root.1",
193193
"man/man1/npm-run-script.1",
194+
"man/man1/npm-sbom.1",
194195
"man/man1/npm-search.1",
195196
"man/man1/npm-shrinkwrap.1",
196197
"man/man1/npm-star.1",

‎tap-snapshots/test/lib/commands/sbom.js.test.cjs

+1,410
Large diffs are not rendered by default.

‎tap-snapshots/test/lib/docs.js.test.cjs

+54
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ Array [
145145
"restart",
146146
"root",
147147
"run-script",
148+
"sbom",
148149
"search",
149150
"set",
150151
"shrinkwrap",
@@ -1405,6 +1406,26 @@ or \`--save-optional\` are true.
14051406
14061407
14071408
1409+
#### \`sbom-format\`
1410+
1411+
* Default: null
1412+
* Type: "cyclonedx" or "spdx"
1413+
1414+
SBOM format to use when generating SBOMs.
1415+
1416+
1417+
1418+
#### \`sbom-type\`
1419+
1420+
* Default: "library"
1421+
* Type: "library", "application", or "framework"
1422+
1423+
The type of package described by the generated SBOM. For SPDX, this is the
1424+
value for the \`primaryPackagePurpose\` fieled. For CycloneDX, this is the
1425+
value for the \`type\` field.
1426+
1427+
1428+
14081429
#### \`scope\`
14091430
14101431
* Default: the scope of the current project, if any, or ""
@@ -2083,6 +2104,8 @@ Array [
20832104
"legacy-peer-deps",
20842105
"link",
20852106
"local-address",
2107+
"sbom-format",
2108+
"sbom-type",
20862109
"location",
20872110
"lockfile-version",
20882111
"loglevel",
@@ -2225,6 +2248,8 @@ Array [
22252248
"legacy-bundling",
22262249
"legacy-peer-deps",
22272250
"local-address",
2251+
"sbom-format",
2252+
"sbom-type",
22282253
"location",
22292254
"lockfile-version",
22302255
"loglevel",
@@ -2415,6 +2440,8 @@ Object {
24152440
"save": true,
24162441
"saveBundle": false,
24172442
"savePrefix": "^",
2443+
"sbomFormat": null,
2444+
"sbomType": "library",
24182445
"scope": "",
24192446
"scriptShell": undefined,
24202447
"search": Object {
@@ -3965,6 +3992,33 @@ aliases: run, rum, urn
39653992
#### \`script-shell\`
39663993
`
39673994

3995+
exports[`test/lib/docs.js TAP usage sbom > must match snapshot 1`] = `
3996+
Generate a Software Bill of Materials (SBOM)
3997+
3998+
Usage:
3999+
npm sbom
4000+
4001+
Options:
4002+
[--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
4003+
[--package-lock-only] [--sbom-format <cyclonedx|spdx>]
4004+
[--sbom-type <library|application|framework>]
4005+
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
4006+
[-ws|--workspaces]
4007+
4008+
Run "npm help sbom" for more info
4009+
4010+
\`\`\`bash
4011+
npm sbom
4012+
\`\`\`
4013+
4014+
#### \`omit\`
4015+
#### \`package-lock-only\`
4016+
#### \`sbom-format\`
4017+
#### \`sbom-type\`
4018+
#### \`workspace\`
4019+
#### \`workspaces\`
4020+
`
4021+
39684022
exports[`test/lib/docs.js TAP usage search > must match snapshot 1`] = `
39694023
Search for packages
39704024

‎tap-snapshots/test/lib/npm.js.test.cjs

+52-52
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ All commands:
2727
help-search, hook, init, install, install-ci-test,
2828
install-test, link, ll, login, logout, ls, org, outdated,
2929
owner, pack, ping, pkg, prefix, profile, prune, publish,
30-
query, rebuild, repo, restart, root, run-script, search,
31-
set, shrinkwrap, star, stars, start, stop, team, test,
32-
token, uninstall, unpublish, unstar, update, version, view,
33-
whoami
30+
query, rebuild, repo, restart, root, run-script, sbom,
31+
search, set, shrinkwrap, star, stars, start, stop, team,
32+
test, token, uninstall, unpublish, unstar, update, version,
33+
view, whoami
3434
3535
Specify configs in the ini-formatted file:
3636
{USERCONFIG}
@@ -76,13 +76,13 @@ All commands:
7676
profile, prune, publish,
7777
query, rebuild, repo,
7878
restart, root,
79-
run-script, search, set,
80-
shrinkwrap, star, stars,
81-
start, stop, team, test,
82-
token, uninstall,
83-
unpublish, unstar,
84-
update, version, view,
85-
whoami
79+
run-script, sbom,
80+
search, set, shrinkwrap,
81+
star, stars, start,
82+
stop, team, test, token,
83+
uninstall, unpublish,
84+
unstar, update, version,
85+
view, whoami
8686
8787
Specify configs in the ini-formatted file:
8888
{USERCONFIG}
@@ -128,13 +128,13 @@ All commands:
128128
profile, prune, publish,
129129
query, rebuild, repo,
130130
restart, root,
131-
run-script, search, set,
132-
shrinkwrap, star, stars,
133-
start, stop, team, test,
134-
token, uninstall,
135-
unpublish, unstar,
136-
update, version, view,
137-
whoami
131+
run-script, sbom,
132+
search, set, shrinkwrap,
133+
star, stars, start,
134+
stop, team, test, token,
135+
uninstall, unpublish,
136+
unstar, update, version,
137+
view, whoami
138138
139139
Specify configs in the ini-formatted file:
140140
{USERCONFIG}
@@ -168,10 +168,10 @@ All commands:
168168
help-search, hook, init, install, install-ci-test,
169169
install-test, link, ll, login, logout, ls, org, outdated,
170170
owner, pack, ping, pkg, prefix, profile, prune, publish,
171-
query, rebuild, repo, restart, root, run-script, search,
172-
set, shrinkwrap, star, stars, start, stop, team, test,
173-
token, uninstall, unpublish, unstar, update, version, view,
174-
whoami
171+
query, rebuild, repo, restart, root, run-script, sbom,
172+
search, set, shrinkwrap, star, stars, start, stop, team,
173+
test, token, uninstall, unpublish, unstar, update, version,
174+
view, whoami
175175
176176
Specify configs in the ini-formatted file:
177177
{USERCONFIG}
@@ -217,13 +217,13 @@ All commands:
217217
profile, prune, publish,
218218
query, rebuild, repo,
219219
restart, root,
220-
run-script, search, set,
221-
shrinkwrap, star, stars,
222-
start, stop, team, test,
223-
token, uninstall,
224-
unpublish, unstar,
225-
update, version, view,
226-
whoami
220+
run-script, sbom,
221+
search, set, shrinkwrap,
222+
star, stars, start,
223+
stop, team, test, token,
224+
uninstall, unpublish,
225+
unstar, update, version,
226+
view, whoami
227227
228228
Specify configs in the ini-formatted file:
229229
{USERCONFIG}
@@ -269,13 +269,13 @@ All commands:
269269
profile, prune, publish,
270270
query, rebuild, repo,
271271
restart, root,
272-
run-script, search, set,
273-
shrinkwrap, star, stars,
274-
start, stop, team, test,
275-
token, uninstall,
276-
unpublish, unstar,
277-
update, version, view,
278-
whoami
272+
run-script, sbom,
273+
search, set, shrinkwrap,
274+
star, stars, start,
275+
stop, team, test, token,
276+
uninstall, unpublish,
277+
unstar, update, version,
278+
view, whoami
279279
280280
Specify configs in the ini-formatted file:
281281
{USERCONFIG}
@@ -320,10 +320,10 @@ All commands:
320320
profile, prune, publish,
321321
query, rebuild, repo,
322322
restart, root,
323-
run-script, search, set,
324-
shrinkwrap, star, stars,
325-
start, stop, team, test,
326-
token, uninstall,
323+
run-script, sbom, search,
324+
set, shrinkwrap, star,
325+
stars, start, stop, team,
326+
test, token, uninstall,
327327
unpublish, unstar,
328328
update, version, view,
329329
whoami
@@ -360,10 +360,10 @@ All commands:
360360
help-search, hook, init, install, install-ci-test,
361361
install-test, link, ll, login, logout, ls, org, outdated,
362362
owner, pack, ping, pkg, prefix, profile, prune, publish,
363-
query, rebuild, repo, restart, root, run-script, search,
364-
set, shrinkwrap, star, stars, start, stop, team, test,
365-
token, uninstall, unpublish, unstar, update, version, view,
366-
whoami
363+
query, rebuild, repo, restart, root, run-script, sbom,
364+
search, set, shrinkwrap, star, stars, start, stop, team,
365+
test, token, uninstall, unpublish, unstar, update, version,
366+
view, whoami
367367
368368
Specify configs in the ini-formatted file:
369369
{USERCONFIG}
@@ -397,10 +397,10 @@ All commands:
397397
help-search, hook, init, install, install-ci-test,
398398
install-test, link, ll, login, logout, ls, org, outdated,
399399
owner, pack, ping, pkg, prefix, profile, prune, publish,
400-
query, rebuild, repo, restart, root, run-script, search,
401-
set, shrinkwrap, star, stars, start, stop, team, test,
402-
token, uninstall, unpublish, unstar, update, version, view,
403-
whoami
400+
query, rebuild, repo, restart, root, run-script, sbom,
401+
search, set, shrinkwrap, star, stars, start, stop, team,
402+
test, token, uninstall, unpublish, unstar, update, version,
403+
view, whoami
404404
405405
Specify configs in the ini-formatted file:
406406
{USERCONFIG}
@@ -434,10 +434,10 @@ All commands:
434434
help-search, hook, init, install, install-ci-test,
435435
install-test, link, ll, login, logout, ls, org, outdated,
436436
owner, pack, ping, pkg, prefix, profile, prune, publish,
437-
query, rebuild, repo, restart, root, run-script, search,
438-
set, shrinkwrap, star, stars, start, stop, team, test,
439-
token, uninstall, unpublish, unstar, update, version, view,
440-
whoami
437+
query, rebuild, repo, restart, root, run-script, sbom,
438+
search, set, shrinkwrap, star, stars, start, stop, team,
439+
test, token, uninstall, unpublish, unstar, update, version,
440+
view, whoami
441441
442442
Specify configs in the ini-formatted file:
443443
{USERCONFIG}

‎tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs

+1,021
Large diffs are not rendered by default.

‎tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs

+506
Large diffs are not rendered by default.

‎test/lib/commands/sbom.js

+503
Large diffs are not rendered by default.

‎test/lib/utils/sbom-cyclonedx.js

+245
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
const t = require('tap')
2+
const Ajv = require('ajv')
3+
const applyFormats = require('ajv-formats')
4+
const applyDraftFormats = require('ajv-formats-draft2019')
5+
const { cyclonedxOutput } = require('../../../lib/utils/sbom-cyclonedx.js')
6+
7+
const FAKE_UUID = 'urn:uuid:00000000-0000-0000-0000-000000000000'
8+
9+
t.cleanSnapshot = s => {
10+
let sbom
11+
try {
12+
sbom = JSON.parse(s)
13+
} catch (e) {
14+
return s
15+
}
16+
17+
sbom.serialNumber = FAKE_UUID
18+
if (sbom.metadata) {
19+
sbom.metadata.timestamp = '2020-01-01T00:00:00.000Z'
20+
}
21+
22+
return JSON.stringify(sbom, null, 2)
23+
}
24+
25+
const npm = { version: '10.0.0 ' }
26+
27+
const rootPkg = {
28+
author: 'Author',
29+
}
30+
31+
const root = {
32+
name: 'root',
33+
packageName: 'root',
34+
version: '1.0.0',
35+
pkgid: 'root@1.0.0',
36+
isRoot: true,
37+
package: rootPkg,
38+
location: '',
39+
edgesOut: [],
40+
}
41+
42+
const dep1 = {
43+
name: 'dep1',
44+
packageName: 'dep1',
45+
version: '0.0.1',
46+
pkgid: 'dep1@0.0.1',
47+
package: {},
48+
location: 'node_modules/dep1',
49+
edgesOut: [],
50+
}
51+
52+
const dep2 = {
53+
name: 'dep2',
54+
packageName: 'dep2',
55+
version: '0.0.2',
56+
pkgid: 'npm@npm:dep2@0.0.2',
57+
package: {},
58+
location: 'node_modules/dep2',
59+
edgesOut: [{ to: dep1 }],
60+
}
61+
62+
const dep2Link = {
63+
name: 'dep2',
64+
packageName: 'dep2',
65+
version: '0.0.2',
66+
pkgid: 'dep2@0.0.2',
67+
package: {},
68+
location: 'node_modules/dep2',
69+
edgesOut: [],
70+
isLink: true,
71+
target: dep2,
72+
}
73+
74+
t.test('single node - application package type', t => {
75+
const res = cyclonedxOutput({ npm, nodes: [root], packageType: 'application' })
76+
t.matchSnapshot(JSON.stringify(res))
77+
t.end()
78+
})
79+
80+
t.test('single node - package lock only', t => {
81+
const res = cyclonedxOutput({ npm, nodes: [root], packageLockOnly: true })
82+
t.matchSnapshot(JSON.stringify(res))
83+
t.end()
84+
})
85+
86+
t.test('single node - optional ', t => {
87+
const node = { ...root, optional: true }
88+
const res = cyclonedxOutput({ npm, nodes: [node] })
89+
t.matchSnapshot(JSON.stringify(res))
90+
t.end()
91+
})
92+
93+
t.test('single node - with description', t => {
94+
const pkg = { ...rootPkg, description: 'Package description' }
95+
const node = { ...root, package: pkg }
96+
const res = cyclonedxOutput({ npm, nodes: [node] })
97+
t.matchSnapshot(JSON.stringify(res))
98+
t.end()
99+
})
100+
101+
t.test('single node - with author object', t => {
102+
const pkg = { ...rootPkg, author: { name: 'Arthur' } }
103+
const node = { ...root, package: pkg }
104+
const res = cyclonedxOutput({ npm, nodes: [node] })
105+
t.matchSnapshot(JSON.stringify(res))
106+
t.end()
107+
})
108+
109+
t.test('single node - with integrity', t => {
110+
/* eslint-disable-next-line max-len */
111+
const node = { ...root, integrity: 'sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==' }
112+
const res = cyclonedxOutput({ npm, nodes: [node] })
113+
t.matchSnapshot(JSON.stringify(res))
114+
t.end()
115+
})
116+
117+
t.test('single node - development', t => {
118+
const node = { ...root, dev: true }
119+
const res = cyclonedxOutput({ npm, nodes: [node] })
120+
t.matchSnapshot(JSON.stringify(res))
121+
t.end()
122+
})
123+
124+
t.test('single node - extraneous', t => {
125+
const node = { ...root, extraneous: true }
126+
const res = cyclonedxOutput({ npm, nodes: [node] })
127+
t.matchSnapshot(JSON.stringify(res))
128+
t.end()
129+
})
130+
131+
t.test('single node - bundled', t => {
132+
const node = { ...root, inBundle: true }
133+
const res = cyclonedxOutput({ npm, nodes: [node] })
134+
t.matchSnapshot(JSON.stringify(res))
135+
t.end()
136+
})
137+
138+
t.test('single node - private', t => {
139+
const pkg = { ...rootPkg, private: true }
140+
const node = { ...root, package: pkg }
141+
const res = cyclonedxOutput({ npm, nodes: [node] })
142+
t.matchSnapshot(JSON.stringify(res))
143+
t.end()
144+
})
145+
146+
t.test('single node - with repository url', t => {
147+
const pkg = { ...rootPkg, repository: { url: 'https://foo.bar' } }
148+
const node = { ...root, package: pkg }
149+
const res = cyclonedxOutput({ npm, nodes: [node] })
150+
t.matchSnapshot(JSON.stringify(res))
151+
t.end()
152+
})
153+
154+
t.test('single node - with homepage', t => {
155+
const pkg = { ...rootPkg, homepage: 'https://foo.bar/README.md' }
156+
const node = { ...root, package: pkg }
157+
const res = cyclonedxOutput({ npm, nodes: [node] })
158+
t.matchSnapshot(JSON.stringify(res))
159+
t.end()
160+
})
161+
162+
t.test('single node - with issue tracker', t => {
163+
const pkg = { ...rootPkg, bugs: { url: 'https://foo.bar/issues' } }
164+
const node = { ...root, package: pkg }
165+
const res = cyclonedxOutput({ npm, nodes: [node] })
166+
t.matchSnapshot(JSON.stringify(res))
167+
t.end()
168+
})
169+
170+
t.test('single node - with distribution url', t => {
171+
const node = { ...root, resolved: 'https://registry.npmjs.org/root/-/root-1.0.0.tgz' }
172+
const res = cyclonedxOutput({ npm, nodes: [node] })
173+
t.matchSnapshot(JSON.stringify(res))
174+
t.end()
175+
})
176+
177+
t.test('single node - with single license', t => {
178+
const pkg = { ...rootPkg, license: 'ISC' }
179+
const node = { ...root, package: pkg }
180+
const res = cyclonedxOutput({ npm, nodes: [node] })
181+
t.matchSnapshot(JSON.stringify(res))
182+
t.end()
183+
})
184+
185+
t.test('single node - with license expression', t => {
186+
const pkg = { ...rootPkg, license: '(MIT OR Apache-2.0)' }
187+
const node = { ...root, package: pkg }
188+
const res = cyclonedxOutput({ npm, nodes: [node] })
189+
t.matchSnapshot(JSON.stringify(res))
190+
t.end()
191+
})
192+
193+
t.test('single node - from git url', t => {
194+
const node = { ...root, type: 'git', resolved: 'https://github.com/foo/bar#1234' }
195+
const res = cyclonedxOutput({ npm, nodes: [node] })
196+
t.matchSnapshot(JSON.stringify(res))
197+
t.end()
198+
})
199+
200+
t.test('single node - no package info', t => {
201+
const node = { ...root, package: undefined }
202+
const res = cyclonedxOutput({ npm, nodes: [node] })
203+
t.matchSnapshot(JSON.stringify(res))
204+
t.end()
205+
})
206+
207+
t.test('node - with deps', t => {
208+
const node = { ...root,
209+
edgesOut: [
210+
{ to: dep1 },
211+
{ to: dep2 },
212+
{ to: undefined },
213+
{ to: { pkgid: 'foo' } },
214+
] }
215+
const res = cyclonedxOutput({ npm, nodes: [node, dep1, dep2, dep2Link] })
216+
t.matchSnapshot(JSON.stringify(res))
217+
t.end()
218+
})
219+
220+
// Check that all of the generated test snapshots validate against the CycloneDX schema
221+
t.test('schema validation', t => {
222+
// Load schemas
223+
const cdxSchema = require('../../schemas/cyclonedx/bom-1.5.schema.json')
224+
const spdxLicenseSchema = require('../../schemas/cyclonedx/spdx.schema.json')
225+
const jsfSchema = require('../../schemas/cyclonedx/jsf-0.82.schema.json')
226+
227+
const ajv = new Ajv({
228+
strict: false,
229+
schemas: [spdxLicenseSchema, jsfSchema, cdxSchema],
230+
})
231+
applyFormats(ajv)
232+
applyDraftFormats(ajv)
233+
234+
// Retrieve compiled schema
235+
const validate = ajv.getSchema('http://cyclonedx.org/schema/bom-1.5.schema.json')
236+
237+
// Load snapshots for all tests in this file
238+
const sboms = require('../../../tap-snapshots/test/lib/utils/sbom-cyclonedx.js.test.cjs')
239+
240+
// Check that all snapshots validate against the CycloneDX schema
241+
Object.entries(sboms).forEach(([name, sbom]) => {
242+
t.ok(validate(JSON.parse(sbom)), { snapshot: name, error: validate.errors?.[0] })
243+
})
244+
t.end()
245+
})

‎test/lib/utils/sbom-spdx.js

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
const t = require('tap')
2+
const Ajv = require('ajv')
3+
const { spdxOutput } = require('../../../lib/utils/sbom-spdx.js')
4+
5+
t.cleanSnapshot = s => {
6+
let sbom
7+
try {
8+
sbom = JSON.parse(s)
9+
} catch (e) {
10+
return s
11+
}
12+
13+
sbom.documentNamespace = 'docns'
14+
15+
if (sbom.creationInfo) {
16+
sbom.creationInfo.created = '2020-01-01T00:00:00.000Z'
17+
}
18+
19+
return JSON.stringify(sbom, null, 2)
20+
}
21+
22+
const npm = { version: '10.0.0 ' }
23+
24+
const rootPkg = {
25+
author: 'Author',
26+
}
27+
28+
const root = {
29+
packageName: 'root',
30+
version: '1.0.0',
31+
pkgid: 'root@1.0.0',
32+
isRoot: true,
33+
package: rootPkg,
34+
location: '',
35+
edgesOut: [],
36+
}
37+
38+
const dep1 = {
39+
packageName: 'dep1',
40+
version: '0.0.1',
41+
pkgid: 'dep1@0.0.1',
42+
package: {},
43+
location: 'node_modules/dep1',
44+
edgesOut: [],
45+
}
46+
47+
const dep2 = {
48+
packageName: 'dep2',
49+
version: '0.0.2',
50+
pkgid: 'dep2@0.0.2',
51+
package: {},
52+
location: 'node_modules/dep2',
53+
edgesOut: [],
54+
}
55+
56+
const dep3 = {
57+
packageName: 'dep3',
58+
version: '0.0.3',
59+
pkgid: 'dep3@0.0.3',
60+
package: {},
61+
location: 'node_modules/dep3',
62+
edgesOut: [],
63+
}
64+
65+
const dep5 = {
66+
packageName: 'dep5',
67+
version: '0.0.5',
68+
pkgid: 'dep5@0.0.5',
69+
package: {},
70+
location: 'node_modules/dep5',
71+
edgesOut: [],
72+
}
73+
74+
const dep4 = {
75+
packageName: 'dep4',
76+
version: '0.0.4',
77+
pkgid: 'npm@npm:dep4@0.0.4',
78+
package: {},
79+
location: 'dep4',
80+
isWorkspace: true,
81+
edgesOut: [{ to: dep5 }],
82+
}
83+
84+
const dep4Link = {
85+
packageName: 'dep4',
86+
version: '0.0.4',
87+
pkgid: 'dep4@0.0.4',
88+
package: {},
89+
location: 'node_modules/dep4',
90+
isLink: true,
91+
target: dep4,
92+
}
93+
94+
dep4.linksIn = new Set([dep4Link])
95+
96+
const dep6 = {
97+
packageName: 'dep6',
98+
version: '0.0.6',
99+
pkgid: 'dep6@0.0.6',
100+
extraneous: true,
101+
package: {},
102+
location: 'node_modules/dep6',
103+
edgesOut: [],
104+
}
105+
106+
t.test('single node - application package type', t => {
107+
const res = spdxOutput({ npm, nodes: [root], packageType: 'application' })
108+
t.matchSnapshot(JSON.stringify(res))
109+
t.end()
110+
})
111+
112+
t.test('single node - with description', t => {
113+
const pkg = { ...rootPkg, description: 'Package description' }
114+
const node = { ...root, package: pkg }
115+
const res = spdxOutput({ npm, nodes: [node] })
116+
t.matchSnapshot(JSON.stringify(res))
117+
t.end()
118+
})
119+
120+
t.test('single node - with distribution url', t => {
121+
const node = { ...root, resolved: 'https://registry.npmjs.org/root/-/root-1.0.0.tgz' }
122+
const res = spdxOutput({ npm, nodes: [node] })
123+
t.matchSnapshot(JSON.stringify(res))
124+
t.end()
125+
})
126+
127+
t.test('single node - with homepage', t => {
128+
const pkg = { ...rootPkg, homepage: 'https://foo.bar/README.md' }
129+
const node = { ...root, package: pkg }
130+
const res = spdxOutput({ npm, nodes: [node] })
131+
t.matchSnapshot(JSON.stringify(res))
132+
t.end()
133+
})
134+
135+
t.test('single node - with integrity', t => {
136+
/* eslint-disable-next-line max-len */
137+
const node = { ...root, integrity: 'sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==' }
138+
const res = spdxOutput({ npm, nodes: [node] })
139+
t.matchSnapshot(JSON.stringify(res))
140+
t.end()
141+
})
142+
143+
t.test('single node - from git url', t => {
144+
const node = { ...root, type: 'git', resolved: 'https://github.com/foo/bar#1234' }
145+
const res = spdxOutput({ npm, nodes: [node] })
146+
t.matchSnapshot(JSON.stringify(res))
147+
t.end()
148+
})
149+
150+
t.test('single node - linked', t => {
151+
const node = { ...root, isLink: true, target: { edgesOut: [] } }
152+
const res = spdxOutput({ npm, nodes: [node] })
153+
t.matchSnapshot(JSON.stringify(res))
154+
t.end()
155+
})
156+
157+
t.test('node - with deps', t => {
158+
const node = { ...root,
159+
edgesOut: [
160+
{ to: dep1, type: 'peer' },
161+
{ to: dep2, type: 'optional' },
162+
{ to: dep3, type: 'dev' },
163+
{ to: dep4 },
164+
{ to: undefined },
165+
{ to: { packageName: 'foo' } },
166+
] }
167+
const res = spdxOutput({ npm, nodes: [node, dep1, dep2, dep3, dep4Link, dep4, dep5, dep6] })
168+
t.matchSnapshot(JSON.stringify(res))
169+
t.end()
170+
})
171+
172+
// Check that all of the generated test snapshots validate against the SPDX schema
173+
t.test('schema validation', t => {
174+
const ajv = new Ajv()
175+
176+
// Compile schema
177+
const spdxSchema = require('../../schemas/spdx/spdx-2.3.schema.json')
178+
const validate = ajv.compile(spdxSchema)
179+
180+
// Load snapshots for all tests in this file
181+
const sboms = require('../../../tap-snapshots/test/lib/utils/sbom-spdx.js.test.cjs')
182+
183+
// Check that all snapshots validate against the SPDX schema
184+
Object.entries(sboms).forEach(([name, sbom]) => {
185+
t.ok(validate(JSON.parse(sbom)), { snapshot: name, error: validate.errors?.[0] })
186+
})
187+
t.end()
188+
})

‎test/schemas/cyclonedx/bom-1.5.schema.json

+3,799
Large diffs are not rendered by default.
+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "http://cyclonedx.org/schema/jsf-0.82.schema.json",
4+
"type": "object",
5+
"title": "JSON Signature Format (JSF) standard",
6+
"$comment" : "JSON Signature Format schema is published under the terms of the Apache License 2.0. JSF was developed by Anders Rundgren (anders.rundgren.net@gmail.com) as a part of the OpenKeyStore project. This schema supports the entirely of the JSF standard excluding 'extensions'.",
7+
"definitions": {
8+
"signature": {
9+
"type": "object",
10+
"title": "Signature",
11+
"oneOf": [
12+
{
13+
"additionalProperties": false,
14+
"properties": {
15+
"signers": {
16+
"type": "array",
17+
"title": "Signature",
18+
"description": "Unique top level property for Multiple Signatures. (multisignature)",
19+
"items": {"$ref": "#/definitions/signer"}
20+
}
21+
}
22+
},
23+
{
24+
"additionalProperties": false,
25+
"properties": {
26+
"chain": {
27+
"type": "array",
28+
"title": "Signature",
29+
"description": "Unique top level property for Signature Chains. (signaturechain)",
30+
"items": {"$ref": "#/definitions/signer"}
31+
}
32+
}
33+
},
34+
{
35+
"title": "Signature",
36+
"description": "Unique top level property for simple signatures. (signaturecore)",
37+
"$ref": "#/definitions/signer"
38+
}
39+
]
40+
},
41+
"signer": {
42+
"type": "object",
43+
"title": "Signature",
44+
"required": [
45+
"algorithm",
46+
"value"
47+
],
48+
"additionalProperties": false,
49+
"properties": {
50+
"algorithm": {
51+
"oneOf": [
52+
{
53+
"type": "string",
54+
"title": "Algorithm",
55+
"description": "Signature algorithm. The currently recognized JWA [RFC7518] and RFC8037 [RFC8037] asymmetric key algorithms. Note: Unlike RFC8037 [RFC8037] JSF requires explicit Ed* algorithm names instead of \"EdDSA\".",
56+
"enum": [
57+
"RS256",
58+
"RS384",
59+
"RS512",
60+
"PS256",
61+
"PS384",
62+
"PS512",
63+
"ES256",
64+
"ES384",
65+
"ES512",
66+
"Ed25519",
67+
"Ed448",
68+
"HS256",
69+
"HS384",
70+
"HS512"
71+
]
72+
},
73+
{
74+
"type": "string",
75+
"title": "Algorithm",
76+
"description": "Signature algorithm. Note: If proprietary signature algorithms are added, they must be expressed as URIs.",
77+
"format": "uri"
78+
}
79+
]
80+
},
81+
"keyId": {
82+
"type": "string",
83+
"title": "Key ID",
84+
"description": "Optional. Application specific string identifying the signature key."
85+
},
86+
"publicKey": {
87+
"title": "Public key",
88+
"description": "Optional. Public key object.",
89+
"$ref": "#/definitions/publicKey"
90+
},
91+
"certificatePath": {
92+
"type": "array",
93+
"title": "Certificate path",
94+
"description": "Optional. Sorted array of X.509 [RFC5280] certificates, where the first element must contain the signature certificate. The certificate path must be contiguous but is not required to be complete.",
95+
"items": {
96+
"type": "string"
97+
}
98+
},
99+
"excludes": {
100+
"type": "array",
101+
"title": "Excludes",
102+
"description": "Optional. Array holding the names of one or more application level properties that must be excluded from the signature process. Note that the \"excludes\" property itself, must also be excluded from the signature process. Since both the \"excludes\" property and the associated data it points to are unsigned, a conforming JSF implementation must provide options for specifying which properties to accept.",
103+
"items": {
104+
"type": "string"
105+
}
106+
},
107+
"value": {
108+
"type": "string",
109+
"title": "Signature",
110+
"description": "The signature data. Note that the binary representation must follow the JWA [RFC7518] specifications."
111+
}
112+
}
113+
},
114+
"keyType": {
115+
"type": "string",
116+
"title": "Key type",
117+
"description": "Key type indicator.",
118+
"enum": [
119+
"EC",
120+
"OKP",
121+
"RSA"
122+
]
123+
},
124+
"publicKey": {
125+
"title": "Public key",
126+
"description": "Optional. Public key object.",
127+
"type": "object",
128+
"required": [
129+
"kty"
130+
],
131+
"additionalProperties": true,
132+
"properties": {
133+
"kty": {
134+
"$ref": "#/definitions/keyType"
135+
}
136+
},
137+
"allOf": [
138+
{
139+
"if": {
140+
"properties": { "kty": { "const": "EC" } }
141+
},
142+
"then": {
143+
"required": [
144+
"kty",
145+
"crv",
146+
"x",
147+
"y"
148+
],
149+
"additionalProperties": false,
150+
"properties": {
151+
"kty": {
152+
"$ref": "#/definitions/keyType"
153+
},
154+
"crv": {
155+
"type": "string",
156+
"title": "Curve name",
157+
"description": "EC curve name.",
158+
"enum": [
159+
"P-256",
160+
"P-384",
161+
"P-521"
162+
]
163+
},
164+
"x": {
165+
"type": "string",
166+
"title": "Coordinate",
167+
"description": "EC curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-521\", the decoded argument must be 66 bytes."
168+
},
169+
"y": {
170+
"type": "string",
171+
"title": "Coordinate",
172+
"description": "EC curve point Y. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"P-256\", the decoded argument must be 32 bytes."
173+
}
174+
}
175+
}
176+
},
177+
{
178+
"if": {
179+
"properties": { "kty": { "const": "OKP" } }
180+
},
181+
"then": {
182+
"required": [
183+
"kty",
184+
"crv",
185+
"x"
186+
],
187+
"additionalProperties": false,
188+
"properties": {
189+
"kty": {
190+
"$ref": "#/definitions/keyType"
191+
},
192+
"crv": {
193+
"type": "string",
194+
"title": "Curve name",
195+
"description": "EdDSA curve name.",
196+
"enum": [
197+
"Ed25519",
198+
"Ed448"
199+
]
200+
},
201+
"x": {
202+
"type": "string",
203+
"title": "Coordinate",
204+
"description": "EdDSA curve point X. The length of this field must be the full size of a coordinate for the curve specified in the \"crv\" parameter. For example, if the value of \"crv\" is \"Ed25519\", the decoded argument must be 32 bytes."
205+
}
206+
}
207+
}
208+
},
209+
{
210+
"if": {
211+
"properties": { "kty": { "const": "RSA" } }
212+
},
213+
"then": {
214+
"required": [
215+
"kty",
216+
"n",
217+
"e"
218+
],
219+
"additionalProperties": false,
220+
"properties": {
221+
"kty": {
222+
"$ref": "#/definitions/keyType"
223+
},
224+
"n": {
225+
"type": "string",
226+
"title": "Modulus",
227+
"description": "RSA modulus."
228+
},
229+
"e": {
230+
"type": "string",
231+
"title": "Exponent",
232+
"description": "RSA exponent."
233+
}
234+
}
235+
}
236+
}
237+
]
238+
}
239+
}
240+
}

‎test/schemas/cyclonedx/spdx.schema.json

+621
Large diffs are not rendered by default.

‎test/schemas/spdx/spdx-2.3.schema.json

+740
Large diffs are not rendered by default.

‎workspaces/config/lib/definitions/definitions.js

+27
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,33 @@ define('local-address', {
12131213
flatten,
12141214
})
12151215

1216+
define('sbom-format', {
1217+
default: null,
1218+
type: [
1219+
'cyclonedx',
1220+
'spdx',
1221+
],
1222+
description: `
1223+
SBOM format to use when generating SBOMs.
1224+
`,
1225+
flatten,
1226+
})
1227+
1228+
define('sbom-type', {
1229+
default: 'library',
1230+
type: [
1231+
'library',
1232+
'application',
1233+
'framework',
1234+
],
1235+
description: `
1236+
The type of package described by the generated SBOM. For SPDX, this is the
1237+
value for the \`primaryPackagePurpose\` fieled. For CycloneDX, this is the
1238+
value for the \`type\` field.
1239+
`,
1240+
flatten,
1241+
})
1242+
12161243
define('location', {
12171244
default: 'user',
12181245
short: 'L',

‎workspaces/config/tap-snapshots/test/type-description.js.test.cjs

+9
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,15 @@ Object {
435435
"save-prod": Array [
436436
"boolean value (true or false)",
437437
],
438+
"sbom-format": Array [
439+
"cyclonedx",
440+
"spdx",
441+
],
442+
"sbom-type": Array [
443+
"library",
444+
"application",
445+
"framework",
446+
],
438447
"scope": Array [
439448
Function String(),
440449
],

1 commit comments

Comments
 (1)

pitmobboss11 commented on Dec 2, 2023

@pitmobboss11

workspaces/config/tap-snapshots/test/type-description.js.test.cjs

Please sign in to comment.