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