@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
821 lines (804 loc) • 25.8 kB
JavaScript
import { readFileSync } from "node:fs";
import { join } from "node:path";
import Ajv from "ajv";
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { PackageURL } from "packageurl-js";
import {
isCycloneDxSpecVersionAtLeast,
toCycloneDxSpecVersionString,
} from "../helpers/bomUtils.js";
import { thoughtLog } from "../helpers/logger.js";
import { DEBUG_MODE, dirNameStr, isPartialTree } from "../helpers/utils.js";
import {
SPDX_JSONLD_CONTEXT,
SPDX_SPEC_VERSION,
} from "../stages/postgen/spdxConverter.js";
const dirName = dirNameStr;
const SUPPORTED_CYCLONEDX_SCHEMA_VERSIONS = new Set([
"1.4",
"1.5",
"1.6",
"1.7",
"2.0",
]);
const PLACEHOLDER_COMPONENT_NAMES = new Set(["app", "application", "project"]);
const SPDX_EXPORT_TYPES = new Set([
"CreationInfo",
"Relationship",
"SpdxDocument",
"software_File",
"software_Package",
]);
let spdxExportSchemaValidator;
const cycloneDxSchemaValidators = new Map();
const AJV_OPTIONS = {
strict: false,
logger: false,
verbose: true,
code: {
source: true,
lines: true,
optimize: true,
},
};
const readJsonSchema = (fileName) =>
JSON.parse(readFileSync(join(dirName, "data", fileName), "utf-8"));
const addDraft2020BundledSchema = (ajv, schema, schemaId) => {
const bundledSchema = { ...schema };
delete bundledSchema.$schema;
bundledSchema.$id = schemaId;
ajv.addSchema(bundledSchema);
};
const getCycloneDxSchemaValidator = (specVersion) => {
if (cycloneDxSchemaValidators.has(specVersion)) {
return cycloneDxSchemaValidators.get(specVersion);
}
let validate;
if (isCycloneDxSpecVersionAtLeast(specVersion, "2.0")) {
const ajv = new Ajv2020(AJV_OPTIONS);
addFormats(ajv);
addDraft2020BundledSchema(
ajv,
readJsonSchema("cryptography-defs.schema.json"),
"https://cyclonedx.org/schema/cryptography-defs.schema.json",
);
addDraft2020BundledSchema(
ajv,
readJsonSchema("jsf-0.82.schema.json"),
"https://cyclonedx.org/schema/jsf-0.82.schema.json",
);
addDraft2020BundledSchema(
ajv,
readJsonSchema("spdx.schema.json"),
"https://cyclonedx.org/schema/spdx.schema.json",
);
validate = ajv.compile(readJsonSchema("cyclonedx-2.0-bundled.schema.json"));
} else {
const schemas = [
readJsonSchema(`bom-${specVersion}.schema.json`),
readJsonSchema("jsf-0.82.schema.json"),
readJsonSchema("spdx.schema.json"),
];
if (isCycloneDxSpecVersionAtLeast(specVersion, "1.7")) {
schemas.push(readJsonSchema("cryptography-defs.schema.json"));
}
const ajv = new Ajv({
...AJV_OPTIONS,
schemas,
});
addFormats(ajv);
validate = ajv.getSchema(
`http://cyclonedx.org/schema/bom-${specVersion}.schema.json`,
);
}
cycloneDxSchemaValidators.set(specVersion, validate);
return validate;
};
const getSpdxElementId = (element) => element?.spdxId || element?.["@id"];
const getSpdxExportSchemaValidator = () => {
if (spdxExportSchemaValidator !== undefined) {
return spdxExportSchemaValidator;
}
try {
const spdxExportSchema = JSON.parse(
readFileSync(join(dirName, "data", "spdx-export.schema.json"), "utf-8"),
);
const ajv = new Ajv2020({
strict: false,
validateSchema: false,
logger: false,
verbose: true,
code: {
source: true,
lines: true,
optimize: true,
},
});
addFormats(ajv);
spdxExportSchemaValidator = ajv.compile(spdxExportSchema);
} catch (_error) {
spdxExportSchemaValidator = null;
}
return spdxExportSchemaValidator;
};
/**
* Validate the generated bom using jsonschema
*
* @param {object} bomJson content
*
* @returns {Boolean} true if the BOM is valid. false otherwise.
*/
export const validateBom = (bomJson) => {
if (!bomJson) {
return true;
}
const specVersion = toCycloneDxSpecVersionString(bomJson.specVersion);
if (!SUPPORTED_CYCLONEDX_SCHEMA_VERSIONS.has(specVersion)) {
console.log(
`Unsupported CycloneDX specVersion '${bomJson.specVersion}'. Supported versions are ${[
...SUPPORTED_CYCLONEDX_SCHEMA_VERSIONS,
].join(", ")}.`,
);
return false;
}
const validate = getCycloneDxSchemaValidator(specVersion);
const isValid = validate(bomJson);
if (!isValid) {
if (bomJson.metadata?.component?.name) {
console.log(
`Schema validation failed for ${bomJson.metadata.component.name}`,
);
} else {
console.log("Schema validation failed");
}
console.log(validate.errors);
return false;
}
// Deep validation tests
return (
validateMetadata(bomJson) &&
validatePurls(bomJson) &&
validateRefs(bomJson) &&
validateProps(bomJson)
);
};
/**
* Validate the generated SPDX export.
*
* @param {object|string} spdxJson SPDX json object
* @returns {boolean} true if the SPDX export is valid
*/
export const validateSpdx = (spdxJson) => {
if (!spdxJson) {
return true;
}
if (typeof spdxJson === "string" || spdxJson instanceof String) {
spdxJson = JSON.parse(spdxJson);
}
const spdxDocument = (spdxJson?.["@graph"] || []).find(
(element) => element?.type === "SpdxDocument",
);
const validateSchema = getSpdxExportSchemaValidator();
if (!validateSchema) {
console.log("The bundled SPDX export schema is empty or malformed.");
return false;
}
if (!validateSchema(spdxJson)) {
console.log(
`SPDX 3.0.1 schema validation failed${spdxDocument?.name ? ` for ${spdxDocument.name}` : ""}`,
);
console.log(validateSchema.errors);
return false;
}
const errorList = [];
if (spdxJson?.["@context"] !== SPDX_JSONLD_CONTEXT) {
errorList.push(`@context must be '${SPDX_JSONLD_CONTEXT}'.`);
}
if (!Array.isArray(spdxJson?.["@graph"]) || !spdxJson["@graph"].length) {
errorList.push("@graph must be a non-empty array.");
}
const ids = new Set();
const knownRefs = new Set();
let creationInfoCount = 0;
let documentCount = 0;
let usesExtensionProfile = false;
for (const element of spdxJson?.["@graph"] || []) {
if (!SPDX_EXPORT_TYPES.has(element?.type)) {
errorList.push(`Unsupported SPDX export type '${element?.type}'.`);
}
const elementId = getSpdxElementId(element);
if (!elementId || typeof elementId !== "string") {
errorList.push(
`Missing SPDX identifier for type '${element?.type || "unknown"}'.`,
);
continue;
}
if (ids.has(elementId)) {
errorList.push(`Duplicate SPDX identifier '${elementId}'.`);
}
ids.add(elementId);
knownRefs.add(elementId);
if (Array.isArray(element?.extension) && element.extension.length) {
usesExtensionProfile = true;
}
switch (element.type) {
case "CreationInfo":
creationInfoCount += 1;
if (element.specVersion !== SPDX_SPEC_VERSION) {
errorList.push(
`CreationInfo '${elementId}' has unexpected specVersion '${element.specVersion}'.`,
);
}
if (!Array.isArray(element.createdBy) || !element.createdBy.length) {
errorList.push(`CreationInfo '${elementId}' must include createdBy.`);
}
if (!element.created) {
errorList.push(`CreationInfo '${elementId}' must include created.`);
}
break;
case "SpdxDocument":
documentCount += 1;
if (!element.creationInfo) {
errorList.push(
`SpdxDocument '${elementId}' must include creationInfo.`,
);
}
if (!Array.isArray(element.element) || !element.element.length) {
errorList.push(
`SpdxDocument '${elementId}' must include element refs.`,
);
}
if (
element.rootElement &&
(!Array.isArray(element.rootElement) || !element.rootElement.length)
) {
errorList.push(
`SpdxDocument '${elementId}' rootElement must be a non-empty array when present.`,
);
}
break;
case "Relationship":
if (
!element.creationInfo ||
!element.from ||
typeof element.from !== "string"
) {
errorList.push(
`Relationship '${elementId}' must include creationInfo and from.`,
);
}
if (!element.to || !Array.isArray(element.to) || !element.to.length) {
errorList.push(`Relationship '${elementId}' must include to refs.`);
}
if (element.relationshipType !== "dependsOn") {
errorList.push(
`Relationship '${elementId}' has unsupported relationshipType '${element.relationshipType}'.`,
);
}
break;
case "software_File":
case "software_Package":
if (!element.creationInfo || !element.name) {
errorList.push(
`${element.type} '${elementId}' must include creationInfo and name.`,
);
}
break;
default:
break;
}
}
if (creationInfoCount !== 1) {
errorList.push(
`Expected exactly one CreationInfo, found ${creationInfoCount}.`,
);
}
if (documentCount !== 1) {
errorList.push(
`Expected exactly one SpdxDocument, found ${documentCount}.`,
);
}
for (const element of spdxJson?.["@graph"] || []) {
const elementId = getSpdxElementId(element);
if (element.creationInfo && !knownRefs.has(element.creationInfo)) {
errorList.push(
`Element '${elementId || "unknown"}' references unknown creationInfo '${element.creationInfo}'.`,
);
}
if (Array.isArray(element.element)) {
for (const ref of element.element) {
if (!knownRefs.has(ref)) {
errorList.push(
`SpdxDocument '${elementId || "unknown"}' references unknown element '${ref}'.`,
);
}
}
}
if (Array.isArray(element.rootElement)) {
for (const ref of element.rootElement) {
if (!knownRefs.has(ref)) {
errorList.push(
`SpdxDocument '${elementId || "unknown"}' references unknown rootElement '${ref}'.`,
);
}
}
}
if (typeof element.from === "string" && !knownRefs.has(element.from)) {
errorList.push(
`Relationship '${elementId || "unknown"}' references unknown from '${element.from}'.`,
);
}
if (Array.isArray(element.to)) {
for (const ref of element.to) {
if (!knownRefs.has(ref)) {
errorList.push(
`Relationship '${elementId || "unknown"}' references unknown to '${ref}'.`,
);
}
}
}
}
if (usesExtensionProfile) {
if (!spdxDocument?.profileConformance?.includes("extension")) {
errorList.push(
`SpdxDocument '${spdxDocument?.spdxId || "unknown"}' must declare the extension profile when extension data is present.`,
);
}
}
if (errorList.length > 0) {
console.log("SPDX 3.0.1 validation failed");
console.log(errorList);
return false;
}
return true;
};
/**
* Validate the metadata object
*
* @param {object} bomJson Bom json object
*/
export const validateMetadata = (bomJson) => {
const errorList = [];
const warningsList = [];
if (bomJson?.metadata) {
if (
!bomJson.metadata.component ||
!Object.keys(bomJson.metadata.component).length
) {
warningsList.push(
"metadata.component is missing. Run cdxgen with both --project-name and --project-version argument.",
);
}
if (bomJson.metadata.component) {
// Do we have a purl and bom-ref for metadata.component
if (!bomJson.metadata.component.purl) {
warningsList.push("purl is missing for metadata.component");
}
if (!bomJson.metadata.component["bom-ref"]) {
warningsList.push("bom-ref is missing for metadata.component");
}
// Do we have a version for metadata.component
if (!bomJson.metadata.component.version) {
warningsList.push(
"Version is missing for metadata.component. Pass the version using --project-version argument.",
);
}
const metadataName = bomJson.metadata.component.name
?.trim()
.toLowerCase();
if (metadataName && PLACEHOLDER_COMPONENT_NAMES.has(metadataName)) {
warningsList.push(
`metadata.component.name appears to be a placeholder ('${bomJson.metadata.component.name}'). Pass --project-name to set the correct parent component name.`,
);
}
// Is the same component getting repeated inside the components block
if (bomJson.metadata.component.components?.length) {
for (const comp of bomJson.metadata.component.components) {
if (comp["bom-ref"] === bomJson.metadata.component["bom-ref"]) {
warningsList.push(
`Found parent component with ref ${comp["bom-ref"]} in metadata.component.components`,
);
} else if (
(!comp["bom-ref"] || !bomJson.metadata.component["bom-ref"]) &&
comp["name"] === bomJson.metadata.component["name"]
) {
warningsList.push(
`Found parent component with name ${comp["name"]} in metadata.component.components`,
);
}
}
}
}
}
if (DEBUG_MODE && warningsList.length !== 0) {
console.log("===== WARNINGS =====");
console.log(warningsList);
thoughtLog(
"**VALIDATION**: There are some warnings regarding the BOM Metadata.",
);
}
if (errorList.length !== 0) {
console.log(errorList);
return false;
}
return true;
};
/**
* Validate the format of all purls
*
* @param {object} bomJson Bom json object
*/
export const validatePurls = (bomJson) => {
const errorList = [];
const warningsList = [];
let frameworksCount = 0;
if (bomJson?.components) {
for (const comp of bomJson.components) {
if (comp.type === "framework") {
frameworksCount += 1;
}
if (comp.type === "cryptographic-asset") {
if (comp.purl?.length) {
errorList.push(
`purl should not be defined for cryptographic-asset ${comp.purl}`,
);
}
if (!comp.cryptoProperties) {
errorList.push(
`cryptoProperties is missing for cryptographic-asset ${comp.purl}`,
);
} else if (
comp.cryptoProperties.assetType === "algorithm" &&
!comp.cryptoProperties.oid
) {
errorList.push(
`cryptoProperties.oid is missing for cryptographic-asset of type algorithm ${comp.purl}`,
);
} else if (
comp.cryptoProperties.assetType === "certificate" &&
!comp.cryptoProperties.algorithmProperties
) {
errorList.push(
`cryptoProperties.algorithmProperties is missing for cryptographic-asset of type certificate ${comp.purl}`,
);
}
} else {
try {
if (comp.purl) {
const purlObj = PackageURL.fromString(comp.purl);
if (purlObj.type && purlObj.type !== purlObj.type.toLowerCase()) {
warningsList.push(
`purl type is not normalized to lower case ${comp.purl}`,
);
}
if (
["npm", "golang"].includes(purlObj.type) &&
purlObj.name.includes("%2F") &&
!purlObj.namespace
) {
errorList.push(
`purl does not include namespace but includes encoded slash in name for npm type. ${comp.purl}`,
);
}
// Catch the trivy version hack that removes the epoch from version
const qualifiers = purlObj.qualifiers || {};
if (
Object.keys(qualifiers).length &&
[
"cargo",
"cocoapods",
"composer",
"cran",
"github",
"golang",
"hackage",
"nuget",
"opam",
"pub",
"qpkg",
"swift",
].includes(purlObj.type)
) {
warningsList.push(
`SPEC VIOLATION: Qualifiers are not expected for ${purlObj.type} type. Purl: ${comp.purl}, Qualifier(s): ${Object.keys(qualifiers).join(", ")}.`,
);
}
if (
qualifiers.epoch &&
!comp.version.startsWith(`${qualifiers.epoch}:`)
) {
errorList.push(
`'${comp.name}' version '${comp.version}' doesn't include epoch '${qualifiers.epoch}'.`,
);
}
}
} catch (_ex) {
errorList.push(`Invalid purl ${comp.purl}`);
}
}
}
}
if (frameworksCount > 20) {
warningsList.push(
`BOM likey has too many framework components. Count: ${frameworksCount}`,
);
}
if (DEBUG_MODE && warningsList.length !== 0) {
console.log("===== WARNINGS =====");
console.log(warningsList);
thoughtLog(
"**VALIDATION**: There are some warnings regarding the purls in our SBOM. These could be bugs.",
);
}
if (errorList.length !== 0) {
console.log(errorList);
return false;
}
return true;
};
const buildRefs = (bomJson) => {
const refMap = {};
if (bomJson) {
if (bomJson.metadata) {
if (bomJson.metadata.component) {
refMap[bomJson.metadata.component["bom-ref"]] = true;
if (bomJson.metadata.component.components) {
for (const comp of bomJson.metadata.component.components) {
refMap[comp["bom-ref"]] = true;
}
}
}
}
if (bomJson.components) {
for (const comp of bomJson.components) {
refMap[comp["bom-ref"]] = true;
}
}
if (bomJson?.formulation) {
for (const aformulation of bomJson.formulation) {
if (aformulation?.components?.length) {
for (const formComp of aformulation.components) {
refMap[formComp["bom-ref"]] = true;
}
}
if (aformulation?.workflows?.length) {
for (const formWf of aformulation.workflows) {
refMap[formWf["bom-ref"]] = true;
if (formWf?.tasks?.length) {
for (const atask of formWf.tasks) {
refMap[atask["bom-ref"]] = true;
}
}
}
}
}
}
}
return refMap;
};
/**
* Validate the refs in dependencies block
*
* @param {object} bomJson Bom json object
*/
export const validateRefs = (bomJson) => {
const errorList = [];
const warningsList = [];
const refMap = buildRefs(bomJson);
const parentComponentRef = bomJson?.metadata?.component?.["bom-ref"];
if (bomJson?.dependencies) {
if (isPartialTree(bomJson.dependencies, bomJson?.components?.length)) {
warningsList.push(
"Dependency tree has multiple empty dependsOn attributes.",
);
}
for (const dep of bomJson.dependencies) {
if (
dep.ref.includes("%40") ||
dep.ref.includes("%3A") ||
dep.ref.includes("%2F")
) {
errorList.push(`Invalid encoded ref in dependencies ${dep.ref}`);
}
if (!refMap[dep.ref]) {
warningsList.push(`Invalid ref in dependencies ${dep.ref}`);
}
let parentPurlType;
try {
const purlObj = PackageURL.fromString(dep.ref);
parentPurlType = purlObj.type;
} catch (_e) {
// pass
}
if (
parentComponentRef &&
dep.ref === parentComponentRef &&
dep.dependsOn.length === 0 &&
bomJson.dependencies.length > 1
) {
warningsList.push(
`Parent component ${parentComponentRef} doesn't have any children. The dependency tree must contain dangling nodes, which are unsupported by tools such as Dependency-Track.`,
);
}
if (dep.dependsOn) {
for (const don of dep.dependsOn) {
if (!refMap[don]) {
warningsList.push(
`Invalid ref in dependencies.dependsOn ${don}. Parent: ${dep.ref}`,
);
}
let childPurlType;
try {
const purlObj = PackageURL.fromString(don);
childPurlType = purlObj.type;
} catch (_e) {
// pass
}
if (
parentPurlType &&
childPurlType &&
parentPurlType !== childPurlType &&
!["oci", "generic", "container"].includes(parentPurlType)
) {
warningsList.push(
`The parent package '${dep.ref}' (type ${parentPurlType}) depends on the child package '${don}' (type ${childPurlType}). This is a bug in cdxgen if this project is not a monorepo.`,
);
}
}
}
if (dep.provides) {
for (const don of dep.provides) {
if (!refMap[don]) {
warningsList.push(`Invalid ref in dependencies.provides ${don}`);
}
}
}
}
}
if (DEBUG_MODE && warningsList.length !== 0) {
console.log("===== WARNINGS =====");
console.log(warningsList);
thoughtLog(
"**VALIDATION**: There are some warnings regarding the dependency tree in our BOM.",
);
}
if (errorList.length !== 0) {
console.log(errorList);
return false;
}
return true;
};
/**
* Validate the component properties
*
* @param {object} bomJson Bom json object
*/
export function validateProps(bomJson) {
const errorList = [];
const warningsList = [];
let isWorkspaceMode = false;
let lacksProperties = false;
let lacksEvidence = false;
let lacksRelativePath = false;
let npmComponentsWithoutTarball = 0;
let npmComponentsWithTarball = 0;
if (
!["application", "framework", "library"].includes(
bomJson?.metadata?.component?.type,
)
) {
return true;
}
if (bomJson?.components) {
const npmPkgs =
bomJson.components?.filter((c) => c.purl?.startsWith("pkg:npm")) || [];
const nativeByName = new Set(
npmPkgs
.filter((c) =>
c.properties?.some(
(p) => p.name === "cdx:npm:native_addon" && p.value === "true",
),
)
.map((c) => c.name),
);
const suspicious = npmPkgs.filter(
(c) =>
(c.name.includes("native") || c.name.includes("bindings")) &&
!nativeByName.has(c.name) &&
!c.properties?.some((p) => p.name === "cdx:npm:native_addon"),
);
if (suspicious.length > 0 && DEBUG_MODE) {
warningsList.push(
`Found ${suspicious.length} packages with native-sounding names but no native_addon flag: ${suspicious.map((c) => c.name).join(", ")}. May need deeper inspection.`,
);
}
for (const comp of bomJson.components) {
if (!["library", "framework"].includes(comp.type)) {
continue;
}
// Limit to only npm and pypi for now
if (
!comp.purl?.startsWith("pkg:npm") &&
!comp.purl?.startsWith("pkg:pypi")
) {
continue;
}
if (comp.purl?.startsWith("pkg:npm")) {
const hasDistributionRef = comp.externalReferences?.some(
(ref) => ref.type === "distribution" && ref.url,
);
if (hasDistributionRef) {
npmComponentsWithTarball++;
} else {
npmComponentsWithoutTarball++;
}
}
if (!comp.properties) {
if (!lacksProperties) {
warningsList.push(`${comp["bom-ref"]} lacks properties.`);
lacksProperties = true;
}
} else {
let srcFilePropFound = false;
let workspacePropFound = false;
for (const p of comp.properties) {
if (p.name === "SrcFile") {
srcFilePropFound = true;
// Quick linux/unix only check for relative paths.
if (!lacksRelativePath && p.value?.startsWith("/")) {
lacksRelativePath = true;
}
}
if (p.name === "internal:workspaceRef") {
isWorkspaceMode = true;
workspacePropFound = true;
}
}
if (
isWorkspaceMode &&
!workspacePropFound &&
!srcFilePropFound &&
comp?.scope !== "optional"
) {
warningsList.push(
`${comp["bom-ref"]} lacks workspace-related properties.`,
);
}
if (!srcFilePropFound && !lacksProperties) {
warningsList.push(`${comp["bom-ref"]} lacks SrcFile property.`);
lacksProperties = true;
}
}
if (!comp.evidence && !lacksEvidence) {
lacksEvidence = true;
warningsList.push(`${comp["bom-ref"]} lacks evidence.`);
}
}
}
if (npmComponentsWithoutTarball > 0 && npmComponentsWithTarball === 0) {
warningsList.push(
`Found ${npmComponentsWithoutTarball} pkg:npm components without externalReferences.distribution. Please file a bug, if your package-lock.json or pnpm-lock.yaml includes the tarball url.`,
);
}
if (lacksRelativePath) {
warningsList.push(
"BOM includes absolute paths for properties like SrcFile.",
);
thoughtLog(
"BOM still includes absolute paths for properties like SrcFile. My postgen optimizations didn't work completely.",
);
}
if (DEBUG_MODE && warningsList.length !== 0) {
console.log("===== WARNINGS =====");
console.log(warningsList);
thoughtLog(
"**VALIDATION**: There are some warnings regarding the evidence attribute in our BOM, which can be safely ignored.",
);
}
if (errorList.length !== 0) {
console.log(errorList);
return false;
}
return true;
}