diff --git a/README.md b/README.md index 97e92cc5e..081343e86 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ Options: --output-reproducible Whether to go the extra mile and make the output reproducible. This requires more resources, and might result in loss of time- and random-based-values. (env: BOM_REPRODUCIBLE) + --add-license-text Whether to go the extra mile and add license texts from the package files. + This requires more resources, and results in much bigger output and + trust the package that the text in a license file corresponds to the one in package.json. (default: false) --output-format Which output format to use. (choices: "JSON", "XML", default: "JSON") --output-file Path to the output file. diff --git a/src/cli.ts b/src/cli.ts index a928527c3..9dbeac636 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,6 +23,7 @@ import { existsSync, openSync, writeSync } from 'fs' import { dirname, resolve } from 'path' import { BomBuilder, TreeBuilder } from './builders' +import { addLicenseTextsToBom } from './licensetexts.js' enum OutputFormat { JSON = 'JSON', @@ -45,6 +46,7 @@ interface CommandOptions { flattenComponents: boolean shortPURLs: boolean outputReproducible: boolean + addLicenseText: boolean outputFormat: OutputFormat outputFile: string mcType: Enums.ComponentType @@ -111,6 +113,13 @@ function makeCommand (process: NodeJS.Process): Command { ).env( 'BOM_REPRODUCIBLE' ) + ).addOption( + new Option( + '--add-license-text', + 'Whether to go the extra mile and add license texts from the package files.\n' + + 'This requires more resources, and results in much bigger output and \n' + + 'trust the package that the text in a license file corresponds to the one in package.json.' + ).default(false) ).addOption( (function () { const o = new Option( @@ -226,6 +235,10 @@ export function run (process: NodeJS.Process): void { myConsole ).buildFromProjectDir(projectDir, process) + if (options.addLicenseText) { + addLicenseTextsToBom(bom) + } + const spec = Spec.SpecVersionDict[options.specVersion] if (undefined === spec) { throw new Error('unsupported spec-version') diff --git a/src/licensetexts.ts b/src/licensetexts.ts new file mode 100644 index 000000000..eca8c42e9 --- /dev/null +++ b/src/licensetexts.ts @@ -0,0 +1,120 @@ +/*! +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. +*/ + +import { Enums, Models } from '@cyclonedx/cyclonedx-library' +import * as fs from 'fs' +import { join } from 'path' + +import { PropertyNames } from './properties' + +/** + * Returns the local installation path of the component, which is mentioned in the component + * + * @param {Models.Component} component + * @returns {string} installation path + */ +function getComponentInstallPath (component: Models.Component): string { + for (const property of component.properties) { + if (property.name === PropertyNames.PackageInstallPath) { + return (property.value) + } + } + return '' +} + +/** + * Searches typical files in the package path which have typical a license text inside + * + * @param {string} pkgPath + * @param {string} licenseName + * @returns {Map} filepath as key and guessed content type as value + */ +function searchLicenseSources (pkgPath: string, licenseName: string): Map { + const licenseFilenamesWType = new Map() + if (pkgPath.length < 1) { + return licenseFilenamesWType + } + const typicalFilenames = ['LICENSE', 'License', 'license', 'LICENCE', 'Licence', 'licence', 'NOTICE', 'Notice', 'notice'] + const licenseContentTypes = { 'text/plain': '', 'text/txt': '.txt', 'text/markdown': '.md', 'text/xml': '.xml' } + for (const typicalFilename of typicalFilenames) { + for (const filenameVariant of [typicalFilename, typicalFilename + '.' + licenseName, typicalFilename + '-' + licenseName]) { + for (const [licenseContentType, fileExtension] of Object.entries(licenseContentTypes)) { + const filename = join(pkgPath, filenameVariant + fileExtension) + if (fs.existsSync(filename) && fs.realpathSync.native(filename).endsWith(filename)) { // needed to fix case-insensitivity on Windows + licenseFilenamesWType.set(filename, licenseContentType) + } + } + } + } + return licenseFilenamesWType +} + +/** + * Adds the content of a guessed license file to the license as license text in base 64 format + * + * @param {Models.DisjunctiveLicense} license + * @param {string} installPath + */ +function addLicTextBasedOnLicenseFiles (license: Models.DisjunctiveLicense, installPath: string): void { + const licenseFilenamesWType = searchLicenseSources(installPath, '') + for (const [licenseFilename, licenseContentType] of licenseFilenamesWType) { + const licContent = fs.readFileSync(licenseFilename, { encoding: 'base64' }) + license.text = new Models.Attachment(licContent, { + encoding: Enums.AttachmentEncoding.Base64, + contentType: licenseContentType + }) + } +} + +/** + * Add license texts to the license parts of the component + * + * @param {Models.Component} component + */ +function addLicenseTextToComponent (component: Models.Component): void { + if (component.licenses.size === 1) { + const license = component.licenses.values().next().value + if (license instanceof Models.NamedLicense || license instanceof Models.SpdxLicense) { + addLicTextBasedOnLicenseFiles(license, getComponentInstallPath(component)) + } + } +} + +/** + * Go through component tree and add license texts + * + * @param {Models.ComponentRepository} components + */ +function addLicenseTextsToComponents (components: Models.ComponentRepository): void { + for (const component of components) { + addLicenseTextToComponent(component) + // Handle sub components + addLicenseTextsToComponents(component.components) + } +} + +/** + * Entry function to add license texts to the components in the SBoM + * + * @export + * @param {Models.Bom} bom + */ +export function addLicenseTextsToBom (bom: Models.Bom): void { + addLicenseTextsToComponents(bom.components) +}