sbom-cyclonedx.js 5.5 KB

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