UNPKG

@cyclonedx/cyclonedx-npm

Version:

Create CycloneDX Software Bill of Materials (SBOM) from NPM projects.

501 lines (496 loc) 22.7 kB
"use strict"; /*! This file is part of CycloneDX generator for NPM projects. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0 Copyright (c) OWASP Foundation. All Rights Reserved. */ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var _BomBuilder_LICENSE_FILENAME_PATTERN; Object.defineProperty(exports, "__esModule", { value: true }); exports.TreeBuilder = exports.BomBuilder = void 0; const cyclonedx_library_1 = require("@cyclonedx/cyclonedx-library"); const fs_1 = require("fs"); const normalizePackageData = require("normalize-package-data"); const path = require("path"); const path_1 = require("path"); const _helpers_1 = require("./_helpers"); const cdx_1 = require("./cdx"); const npmRunner_1 = require("./npmRunner"); class BomBuilder { constructor(componentBuilder, treeBuilder, purlFactory, options, console_) { this.integrityRE = new Map([ [cyclonedx_library_1.Enums.HashAlgorithm['SHA-512'], /^sha512-([a-z0-9+/]{86}==)$/i], [cyclonedx_library_1.Enums.HashAlgorithm['SHA-1'], /^sha1-([a-z0-9+/]{27}=)$/i], [cyclonedx_library_1.Enums.HashAlgorithm['SHA-256'], /^sha256-([a-z0-9+/]{43}=)$/i], [cyclonedx_library_1.Enums.HashAlgorithm['SHA-384'], /^sha384-([a-z0-9+/]{64})$/i] ]); this.resolvedRE_ignore = /^(?:ignore|file):/i; _BomBuilder_LICENSE_FILENAME_PATTERN.set(this, /^(?:UN)?LICEN[CS]E|.\.LICEN[CS]E$|^NOTICE$/i); this.componentBuilder = componentBuilder; this.treeBuilder = treeBuilder; this.purlFactory = purlFactory; this.ignoreNpmErrors = options.ignoreNpmErrors ?? false; this.metaComponentType = options.metaComponentType ?? cyclonedx_library_1.Enums.ComponentType.Library; this.packageLockOnly = options.packageLockOnly ?? false; this.omitDependencyTypes = new Set(options.omitDependencyTypes ?? []); this.reproducible = options.reproducible ?? false; this.flattenComponents = options.flattenComponents ?? false; this.shortPURLs = options.shortPURLs ?? false; this.gatherLicenseTexts = options.gatherLicenseTexts ?? false; this.workspace = options.workspace ?? []; this.includeWorkspaceRoot = options.includeWorkspaceRoot; this.workspaces = options.workspaces; this.console = console_; } buildFromProjectDir(projectDir, process) { return this.buildFromNpmLs(...this.fetchNpmLs(projectDir, process)); } versionTuple(value) { return value.split('.').map(v => Number(v)); } getNpmVersion(npmRunner, process_) { let version; this.console.info('INFO | detecting NPM version ...'); try { version = npmRunner(['--version'], { env: process_.env, encoding: 'buffer', maxBuffer: Number.MAX_SAFE_INTEGER }).toString().trim(); } catch (runError) { this.console.group('DEBUG | npm-ls: STDOUT'); this.console.debug('%s', runError.stdout); this.console.groupEnd(); this.console.group('WARN | npm-ls: MESSAGE'); this.console.warn('%s', runError.message); this.console.groupEnd(); this.console.group('ERROR | npm-ls: STDERR'); this.console.error('%s', runError.stderr); this.console.groupEnd(); throw runError; } this.console.debug('DEBUG | detected NPM version %j', version); return version; } fetchNpmLs(projectDir, process_) { const npmRunner = (0, npmRunner_1.makeNpmRunner)(process_, this.console); const npmVersionR = this.getNpmVersion(npmRunner, process_); const npmVersionT = this.versionTuple(npmVersionR); const args = [ 'ls', '--json', '--long', npmVersionT[0] >= 7 ? '--all' : '--depth=255' ]; if (this.packageLockOnly) { if (npmVersionT[0] >= 7) { args.push('--package-lock-only'); } else { this.console.warn('WARN | your NPM does not support "--package-lock-only", internally skipped this option'); } } if ((0, _helpers_1.versionCompare)(npmVersionT, [8, 7]) >= 0) { for (const odt of this.omitDependencyTypes) { args.push(`--omit=${odt}`); } } else { for (const odt of this.omitDependencyTypes) { switch (odt) { case 'dev': this.console.warn('WARN | your NPM does not support "--omit=%s", internally using "--production" to mitigate', odt); args.push('--production'); break; case 'peer': case 'optional': this.console.warn('WARN | your NPM does not support "--omit=%s", internally skipped this option', odt); break; } } } if (npmVersionT[0] <= 7) { if (this.workspace.length > 0 || this.workspaces !== undefined || this.includeWorkspaceRoot !== undefined) { this.console.warn('WARN | your NPM does not fully support workspaces functionality, internally skipping workspace related options'); } } else { for (const workspace of this.workspace) { args.push(`--workspace=${workspace}`); } if (this.includeWorkspaceRoot !== undefined) { args.push(`--include-workspace-root=${this.includeWorkspaceRoot}`); } if (this.workspaces !== undefined) { args.push(`--workspaces=${this.workspaces}`); } } this.console.info('INFO | gathering dependency tree ...'); this.console.debug('DEBUG | npm-ls: run npm with %j in %j', args, projectDir); let npmLsReturns; try { npmLsReturns = npmRunner(args, { cwd: projectDir, env: process_.env, encoding: 'buffer', maxBuffer: Number.MAX_SAFE_INTEGER }); } catch (runError) { this.console.group('WARN | npm-ls: MESSAGE'); this.console.warn('%s', runError.message); this.console.groupEnd(); this.console.group('ERROR | npm-ls: STDERR'); this.console.error('%s', runError.stderr); this.console.groupEnd(); if (!this.ignoreNpmErrors) { throw new Error(`npm-ls exited with errors: ${runError.status ?? 'noStatus'} ${runError.signal ?? 'noSignal'}`); } this.console.debug('DEBUG | npm-ls exited with errors that are to be ignored.'); npmLsReturns = runError.stdout ?? Buffer.alloc(0); } try { return [ JSON.parse(npmLsReturns.toString()), npmVersionR ]; } catch (jsonParseError) { throw new Error('failed to parse npm-ls response', { cause: jsonParseError }); } } buildFromNpmLs(data, npmVersion) { this.console.info('INFO | building BOM ...'); const rootComponent = this.makeComponent(data, this.metaComponentType) || new DummyComponent(this.metaComponentType, 'RootComponent'); const allComponents = new Map([[data.path, rootComponent]]); this.gatherDependencies(allComponents, data, rootComponent.dependencies); this.finalizePathProperties(data.path, allComponents.values()); const bom = new cyclonedx_library_1.Models.Bom(); bom.metadata.component = rootComponent; bom.metadata.tools.components.add(new cyclonedx_library_1.Models.Component(cyclonedx_library_1.Enums.ComponentType.Application, 'npm', { version: npmVersion })); for (const toolC of this.makeToolCs()) { bom.metadata.tools.components.add(toolC); } if (!this.reproducible) { bom.serialNumber = cyclonedx_library_1.Utils.BomUtility.randomSerialNumber(); bom.metadata.timestamp = new Date(); } bom.components = this.nestComponents(new Map(Array.from(allComponents.entries()).filter(([, c]) => c !== rootComponent)), this.treeBuilder.fromPaths(new Set(allComponents.keys()), data.path[0] === '/' ? '/' : '\\')); bom.components.forEach(c => { this.adjustNestedBomRefs(c, ''); }); rootComponent.components.clear(); if (this.flattenComponents) { for (const component of allComponents.values()) { component.components.clear(); if (component !== rootComponent) { bom.components.add(component); } } } return bom; } adjustNestedBomRefs(component, pref) { if (component.bomRef.value === undefined) { return; } component.bomRef.value = pref + component.bomRef.value; const fill = component.bomRef.value + '|'; component.components.forEach(c => { this.adjustNestedBomRefs(c, fill); }); } nestComponents(allComponents, tree) { const children = new cyclonedx_library_1.Models.ComponentRepository(); for (const [p, pTree] of tree) { const component = allComponents.get(p); const components = this.nestComponents(allComponents, pTree); if (component === undefined) { components.forEach(c => children.add(c)); } else { component.components = components; children.add(component); } } return children; } gatherDependencies(allComponents, data, directDepRefs) { for (const [depName, depData] of Object.entries(data.dependencies ?? {})) { if (depData === null || typeof depData !== 'object') { this.console.debug('DEBUG | skip malformed component %j in %j', depName, depData); continue; } const depPath = depData.path; if (!(0, _helpers_1.isString)(depPath)) { this.console.debug('DEBUG | skip missing component %j in %j', depName, depPath); continue; } let dep = allComponents.get(depPath); if (dep === undefined) { const _dep = this.makeComponent(depData); if (_dep === false) { this.console.debug('DEBUG | skip impossible component %j in %j', depName, depPath); continue; } dep = _dep ?? new DummyComponent(cyclonedx_library_1.Enums.ComponentType.Library, `InterferedDependency.${depName}`); if (dep instanceof DummyComponent) { this.console.warn('WARN | InterferedDependency %j in %j', depName, depPath); } else { this.console.debug('DEBUG | built component %j in %j: %j', depName, depPath, dep); } this.console.info('INFO | add component for %j in %j', depName, depPath); allComponents.set(depPath, dep); } directDepRefs.add(dep.bomRef); this.gatherDependencies(allComponents, depData, dep.dependencies); } } enhancedPackageData(data) { if (!path.isAbsolute(data.path)) { this.console.debug('DEBUG | skip loading package manifest in %j', data.path); return data; } const packageJsonPath = path.join(data.path, 'package.json'); try { return Object.assign((0, _helpers_1.loadJsonFile)(packageJsonPath) ?? {}, data); } catch (err) { this.console.debug('DEBUG | failed loading package manifest %j: %s', packageJsonPath, err); return data; } } makeComponent(data, type) { const isOptional = (data.optional ?? data._optional) === true; if (isOptional && this.omitDependencyTypes.has('optional')) { this.console.debug('DEBUG | omit optional component: %j %j', data.name, data._id); return false; } const isDev = (data.dev ?? data._development) === true; if (isDev && this.omitDependencyTypes.has('dev')) { this.console.debug('DEBUG | omit dev component: %j %j', data.name, data._id); return false; } const isDevOptional = data.devOptional === true; if (isDevOptional && this.omitDependencyTypes.has('dev') && this.omitDependencyTypes.has('optional')) { this.console.debug('DEBUG | omit devOptional component: %j %j', data.name, data._id); return false; } let _dataC = (0, _helpers_1.structuredClonePolyfill)(data); if (!this.packageLockOnly) { _dataC = this.enhancedPackageData(_dataC); } normalizePackageData(_dataC); if ((0, _helpers_1.isString)(data.version)) { _dataC.version = data.version.trim(); } const component = this.componentBuilder.makeComponent(_dataC, type); if (component === undefined) { this.console.debug('DEBUG | skip broken component: %j %j', data.name, data._id); return undefined; } component.licenses.forEach(l => { l.acknowledgement = cyclonedx_library_1.Enums.LicenseAcknowledgement.Declared; }); if (this.gatherLicenseTexts) { if (this.packageLockOnly) { this.console.warn('WARN | Adding license text is ignored (package-lock-only is configured!) for %j', data.name); } else { component.evidence = new cyclonedx_library_1.Models.ComponentEvidence(); for (const license of this.fetchLicenseEvidence(data?.path)) { if (license != null) { if (component.evidence == null) { component.evidence = new cyclonedx_library_1.Models.ComponentEvidence(); } component.evidence.licenses.add(license); } } } } if (isOptional || isDevOptional) { component.scope = cyclonedx_library_1.Enums.ComponentScope.Optional; } if ((0, _helpers_1.isString)(data.path)) { component.properties.add(new cyclonedx_library_1.Models.Property(cdx_1.PropertyNames.PackageInstallPath, data.path)); } if (isDev || isDevOptional) { component.properties.add(new cyclonedx_library_1.Models.Property(cdx_1.PropertyNames.PackageDevelopment, cdx_1.PropertyValueBool.True)); } if (data.extraneous === true) { component.properties.add(new cyclonedx_library_1.Models.Property(cdx_1.PropertyNames.PackageExtraneous, cdx_1.PropertyValueBool.True)); } if (data.private === true || _dataC.private === true) { component.properties.add(new cyclonedx_library_1.Models.Property(cdx_1.PropertyNames.PackagePrivate, cdx_1.PropertyValueBool.True)); } if ((data.inBundle ?? data._inBundle) === true) { component.properties.add(new cyclonedx_library_1.Models.Property(cdx_1.PropertyNames.PackageBundled, cdx_1.PropertyValueBool.True)); } const resolved = data.resolved ?? data._resolved; if ((0, _helpers_1.isString)(resolved) && !this.resolvedRE_ignore.test(resolved)) { const hashes = new cyclonedx_library_1.Models.HashDictionary(); const integrity = data.integrity ?? data._integrity; if ((0, _helpers_1.isString)(integrity)) { for (const [hashAlgorithm, hashRE] of this.integrityRE) { const hashMatchBase64 = hashRE.exec(integrity) ?? []; if (hashMatchBase64?.length === 2) { hashes.set(hashAlgorithm, Buffer.from(hashMatchBase64[1], 'base64').toString('hex')); break; } } } component.externalReferences.add(new cyclonedx_library_1.Models.ExternalReference((0, _helpers_1.tryRemoveSecretsFromUrl)(resolved), cyclonedx_library_1.Enums.ExternalReferenceType.Distribution, { hashes, comment: 'as detected from npm-ls property "resolved"' + (hashes.size > 0 ? ' and property "integrity"' : '') })); } component.purl = this.makePurl(component); component.bomRef.value = ((0, _helpers_1.isString)(data._id) ? data._id : undefined) || `${component.group || '-'}/${component.name}@${component.version || '-'}`; return component; } makePurl(component) { const purl = this.purlFactory.makeFromComponent(component, this.reproducible); if (purl === undefined) { return undefined; } if (this.shortPURLs) { purl.qualifiers = undefined; purl.subpath = undefined; } return purl; } finalizePathProperties(rootPath, components) { if (!(0, _helpers_1.isString)(rootPath) || rootPath === '') { return; } const [relativePath, dirSepRE] = rootPath[0] === '/' ? [path.posix.relative, /\//g] : [path.win32.relative, /\\/g]; for (const component of components) { for (const property of component.properties) { if (property.name !== cdx_1.PropertyNames.PackageInstallPath) { continue; } if (property.value === '') { component.properties.delete(property); continue; } property.value = relativePath(rootPath, property.value).replace(dirSepRE, '/'); } } } *makeToolCs() { const packageJsonPaths = [ [path.resolve(module.path, '..', 'package.json'), cyclonedx_library_1.Enums.ComponentType.Application] ]; const libs = [ '@cyclonedx/cyclonedx-library' ].map(s => s.split('/', 2)); const nodeModulePaths = require.resolve.paths('__some_none-native_package__') ?? []; libsLoop: for (const lib of libs) { for (const nodeModulePath of nodeModulePaths) { const packageJsonPath = path.resolve(nodeModulePath, ...lib, 'package.json'); if ((0, fs_1.existsSync)(packageJsonPath)) { packageJsonPaths.push([packageJsonPath, cyclonedx_library_1.Enums.ComponentType.Library]); continue libsLoop; } } } for (const [packageJsonPath, cType] of packageJsonPaths) { const packageData = (0, _helpers_1.loadJsonFile)(packageJsonPath) ?? {}; normalizePackageData(packageData); const toolC = this.componentBuilder.makeComponent(packageData, cType); if (toolC !== undefined) { yield toolC; } } } *fetchLicenseEvidence(path) { const files = (0, fs_1.readdirSync)(path); for (const file of files) { if (!__classPrivateFieldGet(this, _BomBuilder_LICENSE_FILENAME_PATTERN, "f").test(file)) { continue; } const contentType = (0, _helpers_1.getMimeForLicenseFile)(file); if (contentType === undefined) { continue; } const fp = (0, path_1.join)(path, file); yield new cyclonedx_library_1.Models.NamedLicense(`file: ${file}`, { text: new cyclonedx_library_1.Models.Attachment((0, fs_1.readFileSync)(fp).toString('base64'), { contentType, encoding: cyclonedx_library_1.Enums.AttachmentEncoding.Base64 }) }); } } } exports.BomBuilder = BomBuilder; _BomBuilder_LICENSE_FILENAME_PATTERN = new WeakMap(); class DummyComponent extends cyclonedx_library_1.Models.Component { constructor(type, name) { super(type, `DummyComponent.${name}`, { bomRef: `DummyComponent.${name}`, description: `This is a dummy component "${name}" that fills the gap where the actual built failed.` }); } } class TreeBuilder { fromPaths(paths, dirSeparator) { const tree = new Map(Array.from(paths, p => [p + dirSeparator, new Map()])); this.nestPT(tree); this.renderPR(tree, ''); return tree; } renderPR(tree, pref) { for (const [p, pTree] of [...tree.entries()]) { tree.delete(p); const pFull = pref + p; this.renderPR(pTree, pFull); tree.set(pFull.slice(undefined, -1), pTree); } } nestPT(tree) { if (tree.size < 2) { return; } for (const [a, aTree] of tree) { for (const [b, bTree] of tree) { if (a === b) { continue; } if (b.startsWith(a)) { aTree.set(b.slice(a.length), bTree); tree.delete(b); } else if (a.startsWith(b)) { bTree.set(a.slice(b.length), aTree); tree.delete(a); } } } for (const c of tree.values()) { this.nestPT(c); } } } exports.TreeBuilder = TreeBuilder;