1 2const crypto = require('crypto') 3const normalizeData = require('normalize-package-data') 4const npa = require('npm-package-arg') 5const ssri = require('ssri') 6 7const SPDX_SCHEMA_VERSION = 'SPDX-2.3' 8const SPDX_DATA_LICENSE = 'CC0-1.0' 9const SPDX_IDENTIFER = 'SPDXRef-DOCUMENT' 10 11const NO_ASSERTION = 'NOASSERTION' 12 13const REL_DESCRIBES = 'DESCRIBES' 14const REL_PREREQ = 'PREREQUISITE_FOR' 15const REL_OPTIONAL = 'OPTIONAL_DEPENDENCY_OF' 16const REL_DEV = 'DEV_DEPENDENCY_OF' 17const REL_DEP = 'DEPENDENCY_OF' 18 19const REF_CAT_PACKAGE_MANAGER = 'PACKAGE-MANAGER' 20const REF_TYPE_PURL = 'purl' 21 22const 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 82const 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 let license = node.package?.license 97 if (license) { 98 if (typeof license === 'object') { 99 license = license.type 100 } 101 } 102 103 const pkg = { 104 name: node.packageName, 105 SPDXID: toSpdxID(node), 106 versionInfo: node.version, 107 packageFileName: location, 108 description: node.package?.description || undefined, 109 primaryPackagePurpose: packageType ? packageType.toUpperCase() : undefined, 110 downloadLocation: (node.isLink ? undefined : node.resolved) || NO_ASSERTION, 111 filesAnalyzed: false, 112 homepage: node.package?.homepage || NO_ASSERTION, 113 licenseDeclared: license || NO_ASSERTION, 114 externalRefs: [ 115 { 116 referenceCategory: REF_CAT_PACKAGE_MANAGER, 117 referenceType: REF_TYPE_PURL, 118 referenceLocator: purl, 119 }, 120 ], 121 } 122 123 if (node.integrity) { 124 const integrity = ssri.parse(node.integrity, { single: true }) 125 pkg.checksums = [{ 126 algorithm: integrity.algorithm.toUpperCase(), 127 checksumValue: integrity.hexDigest(), 128 }] 129 } 130 return pkg 131} 132 133const toSpdxRelationship = (node, edge) => { 134 let type 135 switch (edge.type) { 136 case 'peer': 137 type = REL_PREREQ 138 break 139 case 'optional': 140 type = REL_OPTIONAL 141 break 142 case 'dev': 143 type = REL_DEV 144 break 145 default: 146 type = REL_DEP 147 } 148 149 return { 150 spdxElementId: toSpdxID(edge.to), 151 relatedSpdxElement: toSpdxID(node), 152 relationshipType: type, 153 } 154} 155 156const toSpdxID = (node) => { 157 let name = node.packageName 158 159 // Strip leading @ for scoped packages 160 name = name.replace(/^@/, '') 161 162 // Replace slashes with dots 163 name = name.replace(/\//g, '.') 164 165 return `SPDXRef-Package-${name}-${node.version}` 166} 167 168const isGitNode = (node) => { 169 if (!node.resolved) { 170 return 171 } 172 173 try { 174 const { type } = npa(node.resolved) 175 return type === 'git' || type === 'hosted' 176 } catch (err) { 177 /* istanbul ignore next */ 178 return false 179 } 180} 181 182module.exports = { spdxOutput } 183