sbom-spdx.js 5.2 KB

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