@cyclonedx/cyclonedx-npm
Version:
Create CycloneDX Software Bill of Materials (SBOM) from NPM projects.
222 lines (217 loc) • 14.4 kB
JavaScript
;
/*!
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 __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.run = run;
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const FromNodePackageJson_1 = require("@cyclonedx/cyclonedx-library/Contrib/FromNodePackageJson");
const License_1 = require("@cyclonedx/cyclonedx-library/Contrib/License");
const Enums_1 = require("@cyclonedx/cyclonedx-library/Enums");
const Serialize_1 = require("@cyclonedx/cyclonedx-library/Serialize");
const Spec_1 = require("@cyclonedx/cyclonedx-library/Spec");
const Validation_1 = require("@cyclonedx/cyclonedx-library/Validation");
const commander_1 = require("commander");
const spdx_expression_parse_1 = __importDefault(require("spdx-expression-parse"));
const _helpers_1 = require("./_helpers");
const builders_1 = require("./builders");
const factories_1 = require("./factories");
const logger_1 = require("./logger");
const npmRunner_1 = require("./npmRunner");
var OutputFormat;
(function (OutputFormat) {
OutputFormat["JSON"] = "JSON";
OutputFormat["XML"] = "XML";
})(OutputFormat || (OutputFormat = {}));
var Omittable;
(function (Omittable) {
Omittable["Dev"] = "dev";
Omittable["Optional"] = "optional";
Omittable["Peer"] = "peer";
})(Omittable || (Omittable = {}));
const OutputStdOut = '-';
function makeCommand(process_) {
return new commander_1.Command().description('Create CycloneDX Software Bill of Materials (SBOM) from Node.js NPM projects.').usage('[options] [--] [<package-manifest>]').version((0, _helpers_1.loadJsonFile)((0, node_path_1.resolve)(module.path, '..', 'package.json')).version).addOption(new commander_1.Option('--ignore-npm-errors', 'Whether to ignore errors of NPM.\n' +
'This might be used, if "npm install" was run with "--force" or "--legacy-peer-deps".').default(false)).addOption(new commander_1.Option('--package-lock-only', 'Whether to only use the lock file, ignoring "node_modules".\n' +
'This means the output will be based only on the few details in and the tree described by the "npm-shrinkwrap.json" or "package-lock.json", rather than the contents of "node_modules" directory.').default(false)).addOption(new commander_1.Option('--omit <type...>', 'Dependency types to omit from the installation tree.' +
' (can be set multiple times)').choices(Object.values(Omittable).sort()).default(process_.env.NODE_ENV === 'production'
? [Omittable.Dev]
: [], `"${Omittable.Dev}" if the NODE_ENV environment variable is set to "production", otherwise empty`)).addOption(new commander_1.Option('-w, --workspace <workspace...>', 'Only include dependencies for specific workspaces.' +
' (can be set multiple times)' +
'\nThis feature is experimental.').default([], 'empty')).addOption(new commander_1.Option('--no-workspaces', 'Do not include dependencies for workspaces.\n' +
'Default behaviour is to include dependencies for all configured workspaces.\n' +
'This cannot be used if workspaces have been explicitly defined using `--workspace`.' +
'\nThis feature is experimental.').default(undefined).conflicts('workspace')).addOption(new commander_1.Option('--include-workspace-root', "Include workspace root dependencies along with explicitly defined workspaces' dependencies. " +
'This can only be used if you have explicitly defined workspaces using `--workspace`.\n' +
'Default behaviour is to not include the workspace root when workspaces are explicitly defined using `--workspace`.' +
'\nThis feature is experimental.').default(undefined)).addOption(new commander_1.Option('--no-include-workspace-root', 'Do not include workspace root dependencies. ' +
'This only has an effect if you have one or more workspaces configured in your project.\n' +
'This is useful if you want to include all dependencies for all workspaces without explicitly defining them with `--workspace` (default behaviour) but you do not want the workspace root dependencies included.' +
'\nThis feature is experimental.').default(undefined)).addOption(new commander_1.Option('--gather-license-texts', 'Search for license files in components and include them as license evidence.' +
'\nThis feature is experimental.').default(false)).addOption(new commander_1.Option('--flatten-components', 'Whether to flatten the components.\n' +
'This means the actual nesting of node packages is not represented in the SBOM result.').default(false)).addOption(new commander_1.Option('--short-PURLs', 'Omit all qualifiers from PackageURLs.\n' +
'This causes information loss in trade-off shorter PURLs, which might improve ingesting these strings.').default(false)).addOption(new commander_1.Option('--sv, --spec-version <version>', 'Which version of CycloneDX spec to use.').choices(Object.keys(Spec_1.SpecVersionDict).sort()).default(Spec_1.Version.v1dot6)).addOption(new commander_1.Option('--output-reproducible', 'Whether to go the extra mile and make the output reproducible.\n' +
'This requires more resources, and might result in loss of time- and random-based-values.').env('BOM_REPRODUCIBLE')).addOption((() => {
const o = new commander_1.Option('--of, --output-format <format>', 'Which output format to use.').choices(Object.values(OutputFormat).sort()).default(OutputFormat.JSON);
const oldParseArg = o.parseArg ??
(v => v);
o.parseArg = (v, p) => oldParseArg(v.toUpperCase(), p);
return o;
})()).addOption(new commander_1.Option('-o, --output-file <file>', 'Path to the output file.\n' +
`Set to "${OutputStdOut}" to write to STDOUT.`).default(OutputStdOut, 'write to STDOUT')).addOption(new commander_1.Option('--validate', 'Validate resulting BOM before outputting.\n' +
'Validation is skipped, if requirements not met. See the README.').default(undefined)).addOption(new commander_1.Option('--no-validate', 'Disable validation of resulting BOM.')).addOption(new commander_1.Option('--mc-type <type>', 'Type of the main component.').choices([
Enums_1.ComponentType.Application,
Enums_1.ComponentType.Firmware,
Enums_1.ComponentType.Library
].sort()).default(Enums_1.ComponentType.Application)).addOption(new commander_1.Option('-v, --verbose', 'Increase the verbosity of messages.\n' +
'Use multiple times to increase the verbosity even more.').argParser((_, previous) => previous + 1).default(0)).addArgument(new commander_1.Argument('[<package-manifest>]', "Path to project's manifest file.").default('package.json', '"package.json" file in current working directory')).allowExcessArguments(false);
}
const npmMinVersion = Object.freeze([9, 0, 0]);
async function run(process_) {
process_.title = 'cyclonedx-node-npm';
const program = makeCommand(process_);
program.parse(process_.argv);
const options = program.opts();
const myConsole = (0, logger_1.makeConsoleLogger)(process_, options.verbose);
myConsole.debug('DEBUG | options: %j', options);
myConsole.debug('DEBUG | args: %j', program.args);
const npmRunner = new npmRunner_1.NpmRunner(process_, myConsole);
const npmVersion = npmRunner.getVersion({ env: process_.env });
if ((0, _helpers_1.versionCompare)((0, _helpers_1.versionTuple)(npmVersion), npmMinVersion) < 0) {
throw new RangeError('Unsupported NPM version. ' +
`Expected >= ${npmMinVersion.join('.')}, got ${npmVersion}`);
}
myConsole.debug('DEBUG | found NPM version %j', npmVersion);
if (options.workspaces === true) {
options.workspaces = undefined;
}
if (options.includeWorkspaceRoot === true && options.workspace.length === 0) {
throw new Error("option '--include-workspace-root' cannot be used without option '-w, --workspace <workspace...>'");
}
const packageFile = (0, node_path_1.resolve)(process_.cwd(), program.args[0] ?? 'package.json');
if (!(0, node_fs_1.existsSync)(packageFile)) {
throw new Error(`missing project's manifest file: ${packageFile}`);
}
myConsole.debug('DEBUG | packageFile:', packageFile);
const projectDir = (0, node_path_1.dirname)(packageFile);
myConsole.info('INFO | projectDir:', projectDir);
if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(projectDir, 'npm-shrinkwrap.json'))) {
myConsole.info('INFO | detected a npm shrinkwrap file');
}
else if ((0, node_fs_1.existsSync)((0, node_path_1.resolve)(projectDir, 'package-lock.json'))) {
myConsole.info('INFO | detected a package lock file');
}
else if (!options.packageLockOnly && (0, node_fs_1.existsSync)((0, node_path_1.resolve)(projectDir, 'node_modules'))) {
myConsole.info('INFO | detected a `node_modules` dir');
}
else {
myConsole.warn('WARN | ? Did you forget to run `npm install` on your project accordingly ?');
myConsole.error('ERROR | No evidence: no package lock file nor npm shrinkwrap file');
if (!options.packageLockOnly) {
myConsole.error('ERROR | No evidence: no `node_modules` dir');
}
throw new Error('missing evidence');
}
if (options.gatherLicenseTexts && options.packageLockOnly) {
myConsole.warn('WARN | Adding license text is ignored (package-lock-only is configured!)');
options.gatherLicenseTexts = false;
}
myConsole.log('LOG | gathering BOM data ...');
const bom = new builders_1.BomBuilder(npmRunner, new FromNodePackageJson_1.Builders.ComponentBuilder(new FromNodePackageJson_1.Factories.ExternalReferenceFactory(), new License_1.Factories.LicenseFactory(spdx_expression_parse_1.default)), new builders_1.TreeBuilder(), new factories_1.PackageUrlFactory(), new License_1.Utils.LicenseEvidenceGatherer(), {
ignoreNpmErrors: options.ignoreNpmErrors,
metaComponentType: options.mcType,
packageLockOnly: options.packageLockOnly,
omitDependencyTypes: options.omit,
gatherLicenseTexts: options.gatherLicenseTexts,
reproducible: options.outputReproducible,
flattenComponents: options.flattenComponents,
shortPURLs: options.shortPURLs,
workspace: options.workspace,
includeWorkspaceRoot: options.includeWorkspaceRoot,
workspaces: options.workspaces
}, myConsole).buildFromProjectDir(projectDir, process_);
const spec = Spec_1.SpecVersionDict[options.specVersion];
if (undefined === spec) {
throw new Error('unsupported spec-version');
}
let serializer;
let validator;
switch (options.outputFormat) {
case OutputFormat.XML:
serializer = new Serialize_1.XmlSerializer(new Serialize_1.XML.Normalize.Factory(spec));
validator = new Validation_1.XmlValidator(spec.version);
break;
case OutputFormat.JSON:
serializer = new Serialize_1.JsonSerializer(new Serialize_1.JSON.Normalize.Factory(spec));
validator = new Validation_1.JsonStrictValidator(spec.version);
break;
}
myConsole.log('LOG | serializing BOM ...');
const serialized = serializer.serialize(bom, {
sortLists: options.outputReproducible,
space: 2
});
if (options.validate ?? true) {
myConsole.log('LOG | try validating BOM result ...');
try {
const validationErrors = await validator.validate(serialized);
if (validationErrors === null) {
myConsole.info('INFO | BOM result appears valid');
}
else {
myConsole.debug('DEBUG | BOM result invalid. details: ', validationErrors);
myConsole.error('ERROR | Failed to generate valid BOM.');
myConsole.warn('WARN | Please report the issue and provide the npm lock file of the current project to:\n' +
' | https://github.com/CycloneDX/cyclonedx-node-npm/issues/new?template=ValidationError-report.md&labels=ValidationError&title=%5BValidationError%5D');
return 1;
}
}
catch (err) {
if (err instanceof Validation_1.MissingOptionalDependencyError) {
if (options.validate === true) {
myConsole.warn('WARN | skipped validating BOM:', err.message);
}
else {
myConsole.info('INFO | skipped validating BOM:', err.message);
}
}
else {
myConsole.debug('DEBUG | unexpected error. details: ', err);
myConsole.error('ERROR | unexpected error');
throw err;
}
}
}
let outputFD = process_.stdout.fd;
if (options.outputFile !== OutputStdOut) {
const outputFPn = (0, node_path_1.resolve)(process_.cwd(), options.outputFile);
myConsole.debug('DEBUG | outputFPn:', outputFPn);
const outputFDir = (0, node_path_1.dirname)(outputFPn);
if (!(0, node_fs_1.existsSync)(outputFDir)) {
myConsole.info('INFO | creating directory', outputFDir);
(0, node_fs_1.mkdirSync)(outputFDir, { recursive: true });
}
outputFD = (0, node_fs_1.openSync)(outputFPn, 'w');
}
myConsole.log('LOG | writing BOM to: %s', options.outputFile);
const written = await (0, _helpers_1.writeAllSync)(outputFD, serialized);
myConsole.info('INFO | wrote %d bytes to %s', written, options.outputFile);
return written > 0
? 0
: 1;
}