• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const crypto = require('crypto')
2const normalizeData = require('normalize-package-data')
3const parseLicense = require('spdx-expression-parse')
4const npa = require('npm-package-arg')
5const ssri = require('ssri')
6
7const CYCLONEDX_SCHEMA = 'http://cyclonedx.org/schema/bom-1.5.schema.json'
8const CYCLONEDX_FORMAT = 'CycloneDX'
9const CYCLONEDX_SCHEMA_VERSION = '1.5'
10
11const PROP_PATH = 'cdx:npm:package:path'
12const PROP_BUNDLED = 'cdx:npm:package:bundled'
13const PROP_DEVELOPMENT = 'cdx:npm:package:development'
14const PROP_EXTRANEOUS = 'cdx:npm:package:extraneous'
15const PROP_PRIVATE = 'cdx:npm:package:private'
16
17const REF_VCS = 'vcs'
18const REF_WEBSITE = 'website'
19const REF_ISSUE_TRACKER = 'issue-tracker'
20const REF_DISTRIBUTION = 'distribution'
21
22const ALGO_MAP = {
23  sha1: 'SHA-1',
24  sha256: 'SHA-256',
25  sha384: 'SHA-384',
26  sha512: 'SHA-512',
27}
28
29const 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
75const 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    let license = node.package?.license
90    if (license) {
91      if (typeof license === 'object') {
92        license = license.type
93      }
94    }
95
96    parsedLicense = parseLicense(license)
97  } catch (err) {
98    parsedLicense = null
99  }
100
101  const component = {
102    'bom-ref': toCyclonedxID(node),
103    type: packageType,
104    name: node.name,
105    version: node.version,
106    scope: (node.optional || node.devOptional) ? 'optional' : 'required',
107    author: (typeof node.package?.author === 'object')
108      ? node.package.author.name
109      : (node.package?.author || undefined),
110    description: node.package?.description || undefined,
111    purl: purl,
112    properties: [{
113      name: PROP_PATH,
114      value: node.location,
115    }],
116    externalReferences: [],
117  }
118
119  if (node.integrity) {
120    const integrity = ssri.parse(node.integrity, { single: true })
121    component.hashes = [{
122      alg: ALGO_MAP[integrity.algorithm] || /* istanbul ignore next */ 'SHA-512',
123      content: integrity.hexDigest(),
124    }]
125  }
126
127  if (node.dev === true) {
128    component.properties.push(prop(PROP_DEVELOPMENT))
129  }
130
131  if (node.package?.private === true) {
132    component.properties.push(prop(PROP_PRIVATE))
133  }
134
135  if (node.extraneous === true) {
136    component.properties.push(prop(PROP_EXTRANEOUS))
137  }
138
139  if (node.inBundle === true) {
140    component.properties.push(prop(PROP_BUNDLED))
141  }
142
143  if (!node.isLink && node.resolved) {
144    component.externalReferences.push(extRef(REF_DISTRIBUTION, node.resolved))
145  }
146
147  if (node.package?.repository?.url) {
148    component.externalReferences.push(extRef(REF_VCS, node.package.repository.url))
149  }
150
151  if (node.package?.homepage) {
152    component.externalReferences.push(extRef(REF_WEBSITE, node.package.homepage))
153  }
154
155  if (node.package?.bugs?.url) {
156    component.externalReferences.push(extRef(REF_ISSUE_TRACKER, node.package.bugs.url))
157  }
158
159  // If license is a single SPDX license, use the license field
160  if (parsedLicense?.license) {
161    component.licenses = [{ license: { id: parsedLicense.license } }]
162    // If license is a conjunction, use the expression field
163  } else if (parsedLicense?.conjunction) {
164    component.licenses = [{ expression: node.package.license }]
165  }
166
167  return component
168}
169
170const toCyclonedxDependency = (node, nodes) => {
171  return {
172    ref: toCyclonedxID(node),
173    dependsOn: [...node.edgesOut.values()]
174      // Filter out edges that are linking to nodes not in the list
175      .filter(edge => nodes.find(n => n === edge.to))
176      .map(edge => toCyclonedxID(edge.to))
177      .filter(id => id),
178  }
179}
180
181const toCyclonedxID = (node) => `${node.packageName}@${node.version}`
182
183const prop = (name) => ({ name, value: 'true' })
184
185const extRef = (type, url) => ({ type, url })
186
187const isGitNode = (node) => {
188  if (!node.resolved) {
189    return
190  }
191
192  try {
193    const { type } = npa(node.resolved)
194    return type === 'git' || type === 'hosted'
195  } catch (err) {
196    /* istanbul ignore next */
197    return false
198  }
199}
200
201module.exports = { cyclonedxOutput }
202