diff --git a/README.md b/README.md index 97e92cc5..081343e8 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 a928527c..5b00cca8 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(projectDir, 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 00000000..46431c97 --- /dev/null +++ b/src/licensetexts.ts @@ -0,0 +1,124 @@ +/** +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 * as minimatch from 'minimatch' +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', 'licence', 'notice', 'unlicense', 'unlicence'] + const licenseContentTypes = { 'text/plain': '', 'text/txt': '.txt', 'text/markdown': '.md', 'text/xml': '.xml' } + const potentialFilenames = fs.readdirSync(pkgPath) + for (const typicalFilename of typicalFilenames) { + for (const filenameVariant of [typicalFilename, typicalFilename + '.' + licenseName, typicalFilename + '-' + licenseName]) { + for (const [licenseContentType, fileExtension] of Object.entries(licenseContentTypes)) { + for (const filename of minimatch.match(potentialFilenames, filenameVariant + fileExtension, { nocase: true, noglobstar: true, noext: true })) { + licenseFilenamesWType.set(join(pkgPath, 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 {projectDir} string +* @param {Models.Component} component +*/ +function addLicenseTextToComponent (projectDir: string, 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, join(projectDir, getComponentInstallPath(component))) + } + } +} + +/** + * Go through component tree and add license texts + * + * @param {projectDir} string + * @param {Models.ComponentRepository} components + */ +function addLicenseTextsToComponents (projectDir: string, components: Models.ComponentRepository): void { + for (const component of components) { + addLicenseTextToComponent(projectDir, component) + // Handle sub components + addLicenseTextsToComponents(projectDir, component.components) + } +} + +/** + * Entry function to add license texts to the components in the SBoM + * + * @export + * @param {projectDir} string + * @param {Models.Bom} bom + */ +export function addLicenseTextsToBom (projectDir: string, bom: Models.Bom): void { + addLicenseTextsToComponents(projectDir, bom.components) +}