UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

1,640 lines (1,593 loc) 96.8 kB
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs"; import { platform as _platform, homedir } from "node:os"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import process from "node:process"; import { PackageURL } from "packageurl-js"; import { createContainerRiskProperties } from "../helpers/containerRisk.js"; import { createGtfoBinsProperties } from "../helpers/gtfobins.js"; import { resolveCdxgenPlugins, resolvePluginBinary, setPluginsPathEnv, } from "../helpers/plugins.js"; import { adjustLicenseInformation, attachIdentityTools, collectExecutables, collectSharedLibs, DEBUG_MODE, extractPathEnv, extractToolRefs, findLicenseId, getTmpDir, hasDangerousUnicode, isDryRun, isSpdxLicenseExpression, isValidDriveRoot, multiChecksumFile, recordActivity, recordSymlinkResolution, safeExistsSync, safeMkdirSync, safeMkdtempSync, safeRmSync, safeSpawnSync, } from "../helpers/utils.js"; import { getDirs } from "./containerutils.js"; const isWin = _platform() === "win32"; const OS_PURL_TYPES = ["deb", "apk", "rpm", "alpm", "qpkg"]; const pluginRuntime = setPluginsPathEnv(resolveCdxgenPlugins()); const platform = pluginRuntime.platform; const CDXGEN_PLUGINS_DIR = pluginRuntime.pluginsDir; const PLUGIN_MANIFEST_FILE = pluginRuntime.pluginManifestFile; let pluginManifest; const MAX_PLUGIN_MANIFEST_BYTES = 1024 * 1024; const MAX_PLUGIN_MANIFEST_PLUGINS = 32; const MAX_PLUGIN_COMPONENT_HASHES = 16; const MAX_PLUGIN_COMPONENT_REFERENCES = 32; const MAX_PLUGIN_COMPONENT_PROPERTIES = 128; const MAX_PLUGIN_COMPONENT_LICENSES = 8; const MAX_PLUGIN_STRING_LENGTH = 4096; const MAX_PLUGIN_LONG_STRING_LENGTH = 16384; function sanitizeManifestString(value, maxLength = MAX_PLUGIN_STRING_LENGTH) { if (typeof value !== "string") { return undefined; } const trimmedValue = value.trim(); if (!trimmedValue || trimmedValue.length > maxLength) { return undefined; } return trimmedValue; } function sanitizeManifestObjectList(values, limit, mapper) { if (!Array.isArray(values) || !values.length) { return undefined; } const mappedValues = values .slice(0, limit) .map((value) => mapper(value)) .filter(Boolean); return mappedValues.length ? mappedValues : undefined; } function sanitizeManifestHash(hash) { const alg = sanitizeManifestString(hash?.alg, 64); const content = sanitizeManifestString(hash?.content, 512); if (!alg || !content) { return undefined; } return { alg, content }; } function sanitizeManifestProperty(property) { const name = sanitizeManifestString(property?.name, 256); const value = sanitizeManifestString( property?.value, MAX_PLUGIN_LONG_STRING_LENGTH, ); if (!name || !value) { return undefined; } return { name, value }; } function sanitizeManifestExternalReference(reference) { const type = sanitizeManifestString(reference?.type, 64); const url = sanitizeManifestString( reference?.url, MAX_PLUGIN_LONG_STRING_LENGTH, ); if (!type || !url) { return undefined; } const sanitizedReference = { type, url }; const comment = sanitizeManifestString(reference?.comment, 512); if (comment) { sanitizedReference.comment = comment; } return sanitizedReference; } function sanitizeManifestLicense(licenseEntry) { const licenseId = sanitizeManifestString(licenseEntry?.license?.id, 128); const licenseName = sanitizeManifestString(licenseEntry?.license?.name, 256); const licenseUrl = sanitizeManifestString( licenseEntry?.license?.url, MAX_PLUGIN_LONG_STRING_LENGTH, ); if (!licenseId && !licenseName) { return undefined; } const license = {}; if (licenseId) { license.id = licenseId; } if (licenseName) { license.name = licenseName; } if (licenseUrl) { license.url = licenseUrl; } return { license }; } function sanitizeManifestComponent(component, fallbackName) { if (!component || typeof component !== "object") { return undefined; } const sanitizedComponent = {}; const stringFields = { group: 256, name: 256, version: 256, description: MAX_PLUGIN_LONG_STRING_LENGTH, publisher: 256, purl: MAX_PLUGIN_LONG_STRING_LENGTH, "bom-ref": MAX_PLUGIN_LONG_STRING_LENGTH, type: 64, }; for (const [field, maxLength] of Object.entries(stringFields)) { const sanitizedValue = sanitizeManifestString(component[field], maxLength); if (sanitizedValue) { sanitizedComponent[field] = sanitizedValue; } } if (!sanitizedComponent.name && fallbackName) { sanitizedComponent.name = fallbackName; } if (!sanitizedComponent.name || !sanitizedComponent["bom-ref"]) { return undefined; } const hashes = sanitizeManifestObjectList( component.hashes, MAX_PLUGIN_COMPONENT_HASHES, sanitizeManifestHash, ); const externalReferences = sanitizeManifestObjectList( component.externalReferences, MAX_PLUGIN_COMPONENT_REFERENCES, sanitizeManifestExternalReference, ); const properties = sanitizeManifestObjectList( component.properties, MAX_PLUGIN_COMPONENT_PROPERTIES, sanitizeManifestProperty, ); const licenses = sanitizeManifestObjectList( component.licenses, MAX_PLUGIN_COMPONENT_LICENSES, sanitizeManifestLicense, ); if (hashes) { sanitizedComponent.hashes = hashes; } if (externalReferences) { sanitizedComponent.externalReferences = externalReferences; } if (properties) { sanitizedComponent.properties = properties; } if (licenses) { sanitizedComponent.licenses = licenses; } return sanitizedComponent; } function sanitizePluginManifest(manifest) { if (!manifest || typeof manifest !== "object") { return null; } const sanitizedManifest = { plugins: [], }; const generatedAt = sanitizeManifestString(manifest.generatedAt, 128); if (generatedAt) { sanitizedManifest.generatedAt = generatedAt; } if (manifest.package && typeof manifest.package === "object") { const sanitizedPackage = {}; for (const [field, maxLength] of Object.entries({ name: 256, version: 256, repository: MAX_PLUGIN_LONG_STRING_LENGTH, homepage: MAX_PLUGIN_LONG_STRING_LENGTH, })) { const sanitizedValue = sanitizeManifestString( manifest.package[field], maxLength, ); if (sanitizedValue) { sanitizedPackage[field] = sanitizedValue; } } if (Object.keys(sanitizedPackage).length) { sanitizedManifest.package = sanitizedPackage; } } for (const pluginEntry of Array.isArray(manifest.plugins) ? manifest.plugins.slice(0, MAX_PLUGIN_MANIFEST_PLUGINS) : []) { const name = sanitizeManifestString(pluginEntry?.name, 128); const component = sanitizeManifestComponent(pluginEntry?.component, name); if (!name || !component) { continue; } const sanitizedPlugin = { name, component }; for (const [field, maxLength] of Object.entries({ version: 256, binaryPath: MAX_PLUGIN_LONG_STRING_LENGTH, sbomFile: MAX_PLUGIN_LONG_STRING_LENGTH, sha256: 256, })) { const sanitizedValue = sanitizeManifestString( pluginEntry?.[field], maxLength, ); if (sanitizedValue) { sanitizedPlugin[field] = sanitizedValue; } } sanitizedManifest.plugins.push(sanitizedPlugin); } return sanitizedManifest.plugins.length ? sanitizedManifest : null; } function loadPluginManifest() { if (pluginManifest !== undefined) { return pluginManifest; } if (!PLUGIN_MANIFEST_FILE) { pluginManifest = null; return pluginManifest; } try { const manifestRealPath = realpathSync(PLUGIN_MANIFEST_FILE); const manifestDirectory = dirname(manifestRealPath); const expectedManifestDirectory = realpathSync(CDXGEN_PLUGINS_DIR); const manifestStats = statSync(manifestRealPath); if ( basename(manifestRealPath) !== "plugins-manifest.json" || manifestDirectory !== expectedManifestDirectory || !manifestStats.isFile() || manifestStats.size > MAX_PLUGIN_MANIFEST_BYTES ) { pluginManifest = null; return pluginManifest; } pluginManifest = sanitizePluginManifest( JSON.parse(readFileSync(manifestRealPath, { encoding: "utf-8" })), ); } catch (_err) { pluginManifest = null; } return pluginManifest; } function cloneSerializable(value) { if (!value || typeof value !== "object") { return value; } return JSON.parse(JSON.stringify(value)); } function getPluginManifestEntry(toolName) { const manifest = loadPluginManifest(); if (!manifest?.plugins?.length) { return undefined; } return manifest.plugins.find((entry) => entry?.name === toolName); } function mergeToolComponent(manifestComponent, existingComponent) { if (!manifestComponent) { return cloneSerializable(existingComponent); } if (!existingComponent) { return cloneSerializable(manifestComponent); } const merged = { ...cloneSerializable(manifestComponent), ...cloneSerializable(existingComponent), }; for (const key of [ "group", "name", "version", "description", "publisher", "purl", "bom-ref", "type", ]) { if (manifestComponent?.[key]) { merged[key] = manifestComponent[key]; } } merged.hashes = manifestComponent?.hashes?.length ? manifestComponent.hashes : existingComponent?.hashes; merged.externalReferences = uniqueExternalReferences( (manifestComponent?.externalReferences || []).concat( existingComponent?.externalReferences || [], ), ); merged.properties = uniqueProperties( (manifestComponent?.properties || []).concat( existingComponent?.properties || [], ), ); merged.licenses = manifestComponent?.licenses?.length || !existingComponent?.licenses?.length ? manifestComponent?.licenses : existingComponent.licenses; merged.evidence = manifestComponent?.evidence || existingComponent?.evidence; return merged; } function uniqueExternalReferences(references) { const seen = new Set(); const uniqueValues = []; for (const reference of references || []) { if (!reference?.url || !reference?.type) { continue; } const key = `${reference.type}\u0000${reference.url}`; if (seen.has(key)) { continue; } seen.add(key); uniqueValues.push(reference); } return uniqueValues; } export function getPluginToolComponents(toolNames = []) { const components = []; const seenRefs = new Set(); for (const toolName of uniqueSortedStrings(toolNames)) { const component = cloneSerializable( getPluginManifestEntry(toolName)?.component, ); if (!component?.["bom-ref"] || seenRefs.has(component["bom-ref"])) { continue; } seenRefs.add(component["bom-ref"]); components.push(component); } return components; } function enrichToolComponents(existingTools = [], toolNames = []) { const manifestTools = getPluginToolComponents(toolNames); if (!existingTools?.length) { return manifestTools; } const toolMap = new Map(); for (const tool of existingTools) { if (!tool) { continue; } toolMap.set(tool["bom-ref"] || tool.name || JSON.stringify(tool), tool); } for (const manifestTool of manifestTools) { const matchKey = Array.from(toolMap.keys()).find((key) => { const existing = toolMap.get(key); return ( existing?.["bom-ref"] === manifestTool["bom-ref"] || existing?.name === manifestTool.name ); }); if (matchKey) { toolMap.set( matchKey, mergeToolComponent(manifestTool, toolMap.get(matchKey)), ); continue; } toolMap.set(manifestTool["bom-ref"], manifestTool); } return Array.from(toolMap.values()); } const TRIVY_BIN = resolvePluginBinary("trivy", pluginRuntime); const CARGO_AUDITABLE_BIN = resolvePluginBinary( "cargo-auditable", pluginRuntime, ); const OSQUERY_BIN = resolvePluginBinary("osquery", pluginRuntime); const DOSAI_BIN = resolvePluginBinary("dosai", pluginRuntime); const TRUSTINSPECTOR_BIN = resolvePluginBinary("trustinspector", pluginRuntime); // Blint bin const BLINT_BIN = process.env.BLINT_CMD || "blint"; // sourcekitten const SOURCEKITTEN_BIN = resolvePluginBinary("sourcekitten", pluginRuntime); // Keep this list updated every year const OS_DISTRO_ALIAS = { "ubuntu-4.10": "warty", "ubuntu-5.04": "hoary", "ubuntu-5.10": "breezy", "ubuntu-6.06": "dapper", "ubuntu-6.10": "edgy", "ubuntu-7.04": "feisty", "ubuntu-7.10": "gutsy", "ubuntu-8.04": "hardy", "ubuntu-8.10": "intrepid", "ubuntu-9.04": "jaunty", "ubuntu-9.10": "karmic", "ubuntu-10.04": "lucid", "ubuntu-10.10": "maverick", "ubuntu-11.04": "natty", "ubuntu-11.10": "oneiric", "ubuntu-12.04": "precise", "ubuntu-12.10": "quantal", "ubuntu-13.04": "raring", "ubuntu-13.10": "saucy", "ubuntu-14.04": "trusty", "ubuntu-14.10": "utopic", "ubuntu-15.04": "vivid", "ubuntu-15.10": "wily", "ubuntu-16.04": "xenial", "ubuntu-16.10": "yakkety", "ubuntu-17.04": "zesty", "ubuntu-17.10": "artful", "ubuntu-18.04": "bionic", "ubuntu-18.10": "cosmic", "ubuntu-19.04": "disco", "ubuntu-19.10": "eoan", "ubuntu-20.04": "focal", "ubuntu-20.10": "groovy", "ubuntu-21.04": "hirsute", "ubuntu-21.10": "impish", "ubuntu-22.04": "jammy", "ubuntu-22.10": "kinetic", "ubuntu-23.04": "lunar", "ubuntu-23.10": "mantic", "ubuntu-24.04": "noble", "ubuntu-24.10": "oracular", "ubuntu-25.04": "plucky", "ubuntu-25.10": "questing", "debian-15": "duke", "debian-14": "forky", "debian-14.5": "forky", "debian-13": "trixie", "debian-13.5": "trixie", "debian-12": "bookworm", "debian-12.5": "bookworm", "debian-12.6": "bookworm", "debian-11": "bullseye", "debian-11.5": "bullseye", "debian-10": "buster", "debian-10.5": "buster", "debian-9": "stretch", "debian-9.5": "stretch", "debian-8": "jessie", "debian-8.5": "jessie", "debian-7": "wheezy", "debian-7.5": "wheezy", "debian-6": "squeeze", "debian-5": "lenny", "debian-4": "etch", "debian-3.1": "sarge", "debian-3": "woody", "debian-2.2": "potato", "debian-2.1": "slink", "debian-2": "hamm", "debian-1.3": "bo", "debian-1.2": "rex", "debian-1.1": "buzz", "red hat enterprise linux": "rhel", "red hat enterprise linux 6": "rhel-6", "red hat enterprise linux 7": "rhel-7", "red hat enterprise linux 8": "rhel-8", "red hat enterprise linux 9": "rhel-9", "red hat enterprise linux 10": "rhel-10", }; // TODO: Move the lists to a config file const COMMON_RUNTIMES = [ "java", "node", "nodejs", "nodejs-current", "deno", "bun", "python", "python3", "ruby", "ruby3", "php", "php7", "php8", "perl", "openjdk", "openjdk8", "openjdk11", "openjdk17", "openjdk21", "openjdk22", "openjdk23", "openjdk24", "openjdk25", "openjdk8-jdk", "openjdk11-jdk", "openjdk17-jdk", "openjdk21-jdk", "openjdk22-jdk", "openjdk23-jdk", "openjdk24-jdk", "openjdk25-jdk", "openjdk8-jre", "openjdk11-jre", "openjdk17-jre", "openjdk21-jre", "openjdk22-jre", "openjdk23-jre", "openjdk24-jre", "openjdk25-jre", ]; export function getCargoAuditableInfo(src) { if (CARGO_AUDITABLE_BIN) { const result = safeSpawnSync(CARGO_AUDITABLE_BIN, [src]); if (result.status !== 0 || result.error) { if (result.stdout || result.stderr) { console.error(result.stdout, result.stderr); } } if (result) { const stdout = result.stdout; if (stdout) { return stdout; } } } return undefined; } /** * Execute sourcekitten plugin with the given arguments * * @param args {Array} Arguments * @returns {undefined|Object} Command output */ export function executeSourcekitten(args) { if (SOURCEKITTEN_BIN) { const result = safeSpawnSync(SOURCEKITTEN_BIN, args); if (result.status !== 0 || result.error) { if (result.stdout || result.stderr) { console.error(result.stdout, result.stderr); } } if (result) { const stdout = result.stdout; if (stdout) { return JSON.parse(stdout); } } } return undefined; } /** * Get the packages installed in the container image filesystem. * * @param src {String} Source directory containing the extracted filesystem. * @param imageConfig {Object} Image configuration containing environment variables, command, entrypoints etc * * @returns {Object} Metadata containing packages, dependencies, etc */ export async function getOSPackages(src, imageConfig) { if (isDryRun) { recordActivity({ kind: "container", reason: "Dry run mode blocks Trivy-based OS package generation because it executes external tools and writes temporary output.", status: "blocked", target: src, }); return { allTypes: new Set(), binPaths: [], bundledRuntimes: new Set(), bundledSdks: new Set(), dependenciesList: [], executables: [], osPackages: [], osPackageFiles: [], sharedLibs: [], services: [], tools: [], }; } const pkgList = []; const osPackageEntries = []; const dependenciesList = []; const allTypes = new Set(); const bundledSdks = new Set(); const bundledRuntimes = new Set(); let osPackageFiles = []; let services = []; let tools = []; let binPaths = extractPathEnv(imageConfig?.Env); if (!binPaths?.length) { const rootBinPaths = getDirs(src, "{sbin,bin}", true, false); const usrBinPaths = getDirs( src, "/{app,opt,usr,home}/**/{sbin,bin}", true, true, ); binPaths = Array.from( new Set( rootBinPaths .concat(usrBinPaths) .map((f) => relative(src, f)) .filter(Boolean), ), ).sort(); if (DEBUG_MODE && binPaths.length) { console.log( `Falling back to inferred binary paths for ${src}: ${binPaths.join(", ")}`, ); } } if (TRIVY_BIN) { let imageType = "image"; const trivyCacheDir = join(homedir(), ".cache", "trivy"); try { safeMkdirSync(join(trivyCacheDir, "db"), { recursive: true }); safeMkdirSync(join(trivyCacheDir, "java-db"), { recursive: true }); } catch (_err) { // ignore errors } if (safeExistsSync(src)) { imageType = "rootfs"; } const tempDir = safeMkdtempSync(join(getTmpDir(), "trivy-cdxgen-")); const bomJsonFile = join(tempDir, "trivy-bom.json"); const args = [ imageType, "--cache-dir", trivyCacheDir, "--output", bomJsonFile, ]; if (DEBUG_MODE) { args.push("--debug"); } args.push(src); if (DEBUG_MODE) { console.log("Executing", TRIVY_BIN, args.join(" ")); } const result = safeSpawnSync(TRIVY_BIN, args); if (result.status !== 0 || result.error) { if (result.stdout || result.stderr) { console.error(result.stdout, result.stderr); } } if (safeExistsSync(bomJsonFile)) { let tmpBom = {}; try { tmpBom = JSON.parse( readFileSync(bomJsonFile, { encoding: "utf-8", }), ); } catch (_e) { // ignore errors } // Clean up if (tempDir?.startsWith(getTmpDir())) { if (DEBUG_MODE) { console.log(`Cleaning up ${tempDir}`); } safeRmSync(tempDir, { recursive: true, force: true }); } const osReleaseData = {}; let osReleaseFile; // Let's try to read the os-release file from various locations if (safeExistsSync(join(src, "etc", "os-release"))) { osReleaseFile = join(src, "etc", "os-release"); } else if (safeExistsSync(join(src, "usr", "lib", "os-release"))) { osReleaseFile = join(src, "usr", "lib", "os-release"); } if (osReleaseFile) { const osReleaseInfo = readFileSync(osReleaseFile, "utf-8"); if (osReleaseInfo) { osReleaseInfo.split("\n").forEach((l) => { if (!l.startsWith("#") && l.includes("=")) { const tmpA = l.split("="); osReleaseData[tmpA[0]] = tmpA[1].replace(/"/g, ""); } }); } } if (DEBUG_MODE) { console.log(osReleaseData); } let distro_codename = osReleaseData["VERSION_CODENAME"] || osReleaseData["CENTOS_MANTISBT_PROJECT"] || osReleaseData["REDHAT_BUGZILLA_PRODUCT"] || osReleaseData["REDHAT_SUPPORT_PRODUCT"] || ""; distro_codename = distro_codename.toLowerCase(); if (distro_codename.includes(" ") && OS_DISTRO_ALIAS[distro_codename]) { distro_codename = OS_DISTRO_ALIAS[distro_codename]; } let distro_id = osReleaseData["ID"] || ""; const distro_id_like = osReleaseData["ID_LIKE"] || ""; let purl_type = "rpm"; switch (distro_id) { case "debian": case "ubuntu": case "pop": purl_type = "deb"; break; case "sles": case "suse": case "opensuse": purl_type = "rpm"; break; case "alpine": purl_type = "apk"; if (osReleaseData.VERSION_ID) { const versionParts = osReleaseData["VERSION_ID"].split("."); if (versionParts.length >= 2) { distro_codename = `alpine-${versionParts[0]}.${versionParts[1]}`; } } break; default: if (distro_id_like.includes("debian")) { purl_type = "deb"; } else if ( distro_id_like.includes("rhel") || distro_id_like.includes("centos") || distro_id_like.includes("fedora") ) { purl_type = "rpm"; } break; } if (osReleaseData["VERSION_ID"]) { distro_id = `${distro_id}-${osReleaseData["VERSION_ID"]}`; if (OS_DISTRO_ALIAS[distro_id]) { distro_codename = OS_DISTRO_ALIAS[distro_id]; } } const tmpDependencies = {}; tools = enrichToolComponents( (Array.isArray(tmpBom?.metadata?.tools) ? tmpBom.metadata.tools : tmpBom?.metadata?.tools?.components || [] ).filter((tool) => tool?.["bom-ref"] && tool?.name !== "cdxgen"), ["trivy"].concat(imageType === "rootfs" ? ["trustinspector"] : []), ); const toolRefs = extractToolRefs( { components: tools }, (tool) => tool?.name !== "cdxgen", ); (tmpBom.dependencies || []).forEach((d) => { tmpDependencies[d.ref] = d.dependsOn; }); if (tmpBom?.components) { for (const comp of tmpBom.components) { if (comp.purl) { const origBomRef = comp["bom-ref"]; // Fix the group let group = dirname(comp.name); const name = basename(comp.name); let purlObj; if (group === ".") { group = ""; } comp.group = group; comp.name = name; try { purlObj = PackageURL.fromString(comp.purl); purlObj.qualifiers = purlObj.qualifiers || {}; } catch (_err) { // continue regardless of error } if (group === "" && OS_PURL_TYPES.includes(purlObj["type"])) { try { if (purlObj?.namespace && purlObj.namespace !== "") { group = purlObj.namespace; comp.group = group; purlObj.namespace = group; } if (distro_id?.length) { purlObj.qualifiers["distro"] = distro_id; } if (distro_codename?.length) { purlObj.qualifiers["distro_name"] = distro_codename; } // Bug fix for mageia and oracle linux // Type is being returned as none for ubuntu as well! if (purlObj?.type === "none") { purlObj["type"] = purl_type; purlObj["namespace"] = ""; comp.group = ""; if (comp.purl?.includes(".mga")) { purlObj["namespace"] = "mageia"; comp.group = "mageia"; purlObj.qualifiers["distro"] = "mageia"; distro_codename = "mga"; } comp.purl = new PackageURL( purlObj.type, purlObj.namespace, name, purlObj.version, purlObj.qualifiers, purlObj.subpath, ).toString(); comp["bom-ref"] = decodeURIComponent(comp.purl); } if (purlObj?.type !== "none") { allTypes.add(purlObj.type); } // Prefix distro codename for ubuntu if (purlObj?.qualifiers?.distro) { allTypes.add(purlObj.qualifiers.distro); if (OS_DISTRO_ALIAS[purlObj.qualifiers.distro]) { distro_codename = OS_DISTRO_ALIAS[purlObj.qualifiers.distro]; } else if (group === "alpine") { const dtmpA = purlObj.qualifiers.distro.split("."); if (dtmpA && dtmpA.length > 2) { distro_codename = `${dtmpA[0]}.${dtmpA[1]}`; } } else if (group === "photon") { const dtmpA = purlObj.qualifiers.distro.split("-"); if (dtmpA && dtmpA.length > 1) { distro_codename = dtmpA[0]; } } else if (group === "redhat") { const dtmpA = purlObj.qualifiers.distro.split("."); if (dtmpA && dtmpA.length > 1) { distro_codename = dtmpA[0].replace( "redhat", "enterprise_linux", ); } } } if (distro_codename !== "") { allTypes.add(distro_codename); allTypes.add(purlObj.namespace); comp.purl = new PackageURL( purlObj.type, purlObj.namespace, name, purlObj.version, purlObj.qualifiers, purlObj.subpath, ).toString(); comp["bom-ref"] = decodeURIComponent(comp.purl); } } catch (_err) { // continue regardless of error } } if (comp.purl.includes("epoch=")) { try { const epoch = purlObj.qualifiers?.epoch; // trivy seems to be removing the epoch from the version and moving it to a qualifier // let's fix this hack to improve confidence. if (epoch) { purlObj.version = `${epoch}:${purlObj.version}`; comp.version = purlObj.version; } comp.evidence = { identity: [ { field: "purl", confidence: 1, methods: [ { technique: "other", confidence: 1, value: comp.purl, }, ], }, ], }; if (distro_id?.length) { purlObj.qualifiers["distro"] = distro_id; } if (distro_codename?.length) { purlObj.qualifiers["distro_name"] = distro_codename; } allTypes.add(purlObj.namespace); comp.purl = new PackageURL( purlObj.type, purlObj.namespace, name, purlObj.version, purlObj.qualifiers, purlObj.subpath, ).toString(); comp["bom-ref"] = decodeURIComponent(comp.purl); } catch (err) { // continue regardless of error console.log(err); } } attachIdentityTools(comp, toolRefs); // Fix licenses if ( comp.licenses && Array.isArray(comp.licenses) && comp.licenses.length ) { const newLicenses = []; for (const aLic of comp.licenses) { if (aLic?.license?.name) { if (isSpdxLicenseExpression(aLic.license.name)) { newLicenses.push({ expression: aLic.license.name }); } else { const possibleId = findLicenseId(aLic.license.name); if (possibleId !== aLic.license.name) { newLicenses.push({ license: { id: possibleId } }); } else { newLicenses.push({ license: { name: aLic.license.name }, }); } } } else if ( aLic?.license && Object.keys(aLic).length && Object.keys(aLic.license).length ) { newLicenses.push(aLic); } } comp.licenses = adjustLicenseInformation(newLicenses); } // Fix hashes if ( comp.hashes && Array.isArray(comp.hashes) && comp.hashes.length ) { const hashContent = comp.hashes[0].content; if (!hashContent || hashContent.length < 32) { delete comp.hashes; } } const compProperties = comp.properties; const trivyMetadata = extractTrivyOsPackageMetadata(compProperties); const fallbackIdentityProperties = promoteTrivyOsPackageIdentity( comp, trivyMetadata, ); let { srcName, srcVersion, srcRelease, epoch } = trivyMetadata; // See issue #2067 if (srcVersion && srcRelease) { srcVersion = `${srcVersion}-${srcRelease}`; } if (epoch) { srcVersion = `${epoch}:${srcVersion}`; } if ( trivyMetadata.retainedProperties.length || fallbackIdentityProperties.length ) { comp.properties = uniqueProperties( trivyMetadata.retainedProperties.concat( fallbackIdentityProperties, ), ); } else { delete comp.properties; } // Bug fix: We can get bom-ref like this: pkg:rpm/sles/libstdc%2B%2B6@14.2.0+git10526-150000.1.6.1?arch=x86_64&distro=sles-15.5 if ( comp["bom-ref"] && comp.purl && comp["bom-ref"] !== decodeURIComponent(comp.purl) ) { comp["bom-ref"] = decodeURIComponent(comp.purl); } pkgList.push(comp); if (trivyMetadata.installedFiles.length) { osPackageEntries.push({ capabilities: trivyMetadata.capabilities, commandPaths: trivyMetadata.installedCommandPaths, commands: trivyMetadata.installedCommands, files: trivyMetadata.installedFiles, packageName: comp.name, packageRef: comp["bom-ref"], packageVersion: comp.version, }); } detectSdksRuntimes(comp, bundledSdks, bundledRuntimes); const compDeps = retrieveDependencies( tmpDependencies, origBomRef, comp, ); if (compDeps) { dependenciesList.push(compDeps); } // HACK: Many vulnerability databases, including vdb, track vulnerabilities based on source package names :( // If there is a source package defined we include it as well to make such SCA scanners work. // As a compromise, we reduce the confidence to zero so that there is a way to filter these out. if (srcName && srcVersion && srcName !== comp.name) { const newComp = Object.assign({}, comp); newComp.name = srcName; newComp.version = srcVersion; newComp.tags = ["source"]; newComp.evidence = { identity: [ { field: "purl", confidence: 0, methods: [ { technique: "filename", confidence: 0, value: comp.name, }, ], }, ], }; // Track upstream and source versions as qualifiers if (purlObj) { const newCompQualifiers = { ...purlObj.qualifiers, }; delete newCompQualifiers.epoch; if (epoch) { newCompQualifiers.epoch = epoch; } newComp.purl = new PackageURL( purlObj.type, purlObj.namespace, srcName, srcVersion, newCompQualifiers, purlObj.subpath, ).toString(); } newComp["bom-ref"] = decodeURIComponent(newComp.purl); delete newComp.properties; attachIdentityTools(newComp, toolRefs); pkgList.push(newComp); detectSdksRuntimes(newComp, bundledSdks, bundledRuntimes); } } } } } } const rootfsRepositoryInventory = await collectRootfsRepositoryInventory(src); if (rootfsRepositoryInventory.components.length) { pkgList.push(...rootfsRepositoryInventory.components); } if (rootfsRepositoryInventory.dependenciesList.length) { dependenciesList.push(...rootfsRepositoryInventory.dependenciesList); } const { components: ownedFileComponents, dependenciesList: ownedFileDependencies, ownedFilePaths, services: ownedServices, } = await createOSPackageFileComponents(src, osPackageEntries); if (ownedFileComponents.length) { osPackageFiles = ownedFileComponents; } if (ownedFileDependencies.length) { dependenciesList.push(...ownedFileDependencies); } if (ownedServices.length) { services = ownedServices; } let executables = []; if (binPaths?.length) { executables = await fileComponents( src, collectExecutables(src, binPaths, ownedFilePaths), "executable", ); } // Directories containing shared libraries const defaultLibPaths = [ "/lib", "/lib64", "/usr/lib", "/usr/lib64", "/usr/local/lib64", "/usr/local/lib", "/lib/x86_64-linux-gnu", "/usr/lib/x86_64-linux-gnu", "/lib/i386-linux-gnu", "/usr/lib/i386-linux-gnu", "/lib/arm-linux-gnueabihf", "/usr/lib/arm-linux-gnueabihf", "/opt/**/lib", "/root/**/lib", ]; const sharedLibs = await fileComponents( src, collectSharedLibs( src, defaultLibPaths, "/etc/ld.so.conf", "/etc/ld.so.conf.d/*.conf", ownedFilePaths, ), "shared_library", ); return { osPackages: pkgList, osPackageFiles, dependenciesList, allTypes: Array.from(allTypes).sort(), bundledSdks: Array.from(bundledSdks).sort(), bundledRuntimes: Array.from(bundledRuntimes).sort(), binPaths, executables, sharedLibs, services, tools, }; } function extractTrivyOsPackageMetadata(compProperties) { const metadata = { capabilities: [], installedCommandPaths: [], installedCommands: [], installedFiles: [], packageMaintainer: undefined, packageVendor: undefined, retainedProperties: [], srcName: undefined, srcRelease: undefined, srcVersion: undefined, epoch: undefined, }; if (!Array.isArray(compProperties) || !compProperties.length) { return metadata; } for (const aprop of compProperties) { if (!aprop?.name) { continue; } if (aprop.name.endsWith("SrcName")) { metadata.srcName = aprop.value; continue; } if (aprop.name.endsWith("SrcVersion")) { metadata.srcVersion = aprop.value; continue; } if (aprop.name.endsWith("SrcRelease")) { metadata.srcRelease = aprop.value; continue; } if (aprop.name.endsWith("SrcEpoch")) { metadata.epoch = aprop.value; continue; } if (aprop.name.endsWith("PackageMaintainer")) { metadata.packageMaintainer = aprop.value; continue; } if (aprop.name.endsWith("PackageVendor")) { metadata.packageVendor = aprop.value; continue; } if (aprop.name.endsWith("InstalledFile")) { metadata.installedFiles.push(aprop.value); continue; } if (aprop.name.endsWith("InstalledCommandPath")) { metadata.installedCommandPaths.push(aprop.value); metadata.retainedProperties.push(aprop); continue; } if (aprop.name.endsWith("InstalledCommand")) { metadata.installedCommands.push(aprop.value); metadata.retainedProperties.push(aprop); continue; } if (aprop.name.endsWith("Capability")) { metadata.capabilities.push(aprop.value); metadata.retainedProperties.push(aprop); continue; } if ( aprop.name.endsWith("CapabilityCount") || aprop.name.endsWith("InstalledFileCount") || aprop.name.endsWith("InstalledCommandCount") ) { metadata.retainedProperties.push(aprop); continue; } metadata.retainedProperties.push(aprop); } metadata.capabilities = uniqueSortedStrings(metadata.capabilities); metadata.installedCommandPaths = uniqueSortedStrings( metadata.installedCommandPaths, ); metadata.installedCommands = uniqueSortedStrings(metadata.installedCommands); metadata.installedFiles = uniqueSortedStrings(metadata.installedFiles); metadata.retainedProperties = uniqueProperties(metadata.retainedProperties); return metadata; } function getOrganizationalEntityName(entity) { if (!entity) { return undefined; } if (typeof entity === "string") { return entity.trim() || undefined; } if (typeof entity === "object" && typeof entity.name === "string") { return entity.name.trim() || undefined; } return undefined; } function sameOrganizationalEntity(entity, expectedName) { const currentName = getOrganizationalEntityName(entity); return Boolean( currentName && expectedName && currentName.localeCompare(expectedName, undefined, { sensitivity: "accent", }) === 0, ); } function mergeOrganizationalEntityField(component, fieldName, entityName) { const normalizedName = `${entityName || ""}`.trim(); if (!normalizedName) { return { applied: false, represented: false }; } if (!component?.[fieldName]) { component[fieldName] = { name: normalizedName }; return { applied: true, represented: true }; } if (sameOrganizationalEntity(component[fieldName], normalizedName)) { return { applied: false, represented: true }; } return { applied: false, represented: false }; } function parseOrganizationalContact(value) { const normalizedValue = `${value || ""}`.trim(); if (!normalizedValue) { return undefined; } const match = normalizedValue.match(/^([^<>]+?)\s*<([^<>\s]+@[^<>\s]+)>$/); if (match) { return { name: match[1].trim(), email: match[2].trim(), }; } return { name: normalizedValue }; } function sameOrganizationalContact(left, right) { if (!left || !right) { return false; } const leftName = `${left.name || ""}`.trim(); const rightName = `${right.name || ""}`.trim(); const leftEmail = `${left.email || ""}`.trim().toLowerCase(); const rightEmail = `${right.email || ""}`.trim().toLowerCase(); return leftName === rightName && leftEmail === rightEmail; } function mergeAuthorsFromMaintainer(component, maintainerValue) { const authorContact = parseOrganizationalContact(maintainerValue); if (!authorContact?.name) { return { applied: false, represented: false }; } if (!Array.isArray(component?.authors) || !component.authors.length) { component.authors = [authorContact]; return { applied: true, represented: true }; } if ( component.authors.some((author) => sameOrganizationalContact(author, authorContact), ) ) { return { applied: false, represented: true }; } return { applied: false, represented: false }; } function promoteTrivyOsPackageIdentity(component, trivyMetadata) { const fallbackProperties = []; const vendorValue = `${trivyMetadata?.packageVendor || ""}`.trim(); const supplierName = getOrganizationalEntityName(component?.supplier); const maintainerValue = `${ trivyMetadata?.packageMaintainer || supplierName || "" }`.trim(); const maintainerAuthorResult = mergeAuthorsFromMaintainer( component, maintainerValue, ); const maintainerSupplierResult = mergeOrganizationalEntityField( component, "supplier", maintainerValue, ); if ( trivyMetadata?.packageMaintainer && !maintainerAuthorResult.represented && !maintainerSupplierResult.represented ) { fallbackProperties.push({ name: "PackageMaintainer", value: trivyMetadata.packageMaintainer, }); } const vendorSupplierResult = mergeOrganizationalEntityField( component, "supplier", vendorValue, ); const vendorManufacturerResult = mergeOrganizationalEntityField( component, "manufacturer", vendorValue, ); if ( vendorValue && !vendorSupplierResult.represented && !vendorManufacturerResult.represented ) { fallbackProperties.push({ name: "PackageVendor", value: vendorValue, }); } return fallbackProperties; } async function collectRootfsRepositoryInventory(basePath) { let { components: trustedKeyComponents, refsByPath } = await collectTrustedKeyComponents(basePath); trustedKeyComponents = applyTrustMaterialEnhancements( trustedKeyComponents, collectTrustInspectorRootfsInventory(basePath), ); refsByPath = new Map( trustedKeyComponents .map((component) => { const srcFile = (component.properties || []).find( (property) => property.name === "SrcFile", )?.value; return srcFile ? [normalizeContainerPath(srcFile), component["bom-ref"]] : undefined; }) .filter(Boolean), ); const repositoryEntries = uniqueRepositoryEntries( parseAptRepositorySources(basePath).concat( parseYumRepositorySources(basePath), ), ); const components = [...trustedKeyComponents]; const dependenciesList = []; const seenComponentRefs = new Set(components.map((comp) => comp["bom-ref"])); for (const entry of repositoryEntries) { const component = createRepositorySourceComponent(entry); if (seenComponentRefs.has(component["bom-ref"])) { continue; } seenComponentRefs.add(component["bom-ref"]); components.push(component); const dependsOn = uniqueSortedStrings( (entry.keyReferences || []) .map((keyRef) => normalizeLocalRepositoryReference(keyRef)) .map((keyRef) => refsByPath.get(keyRef)) .filter(Boolean), ); if (dependsOn.length) { dependenciesList.push({ ref: component["bom-ref"], dependsOn, }); } } return { components, dependenciesList }; } async function collectTrustedKeyComponents(basePath) { const refsByPath = new Map(); const components = []; for (const normalizedPath of collectTrustedKeyPaths(basePath)) { const component = await createTrustedKeyComponent(basePath, normalizedPath); if (!component?.["bom-ref"]) { continue; } refsByPath.set(normalizedPath, component["bom-ref"]); components.push(component); } return { components, refsByPath }; } function collectTrustedKeyPaths(basePath) { const results = new Set(); for (const candidate of [ "/etc/apt/trusted.gpg", "/etc/apt/trusted.gpg.d", "/usr/share/keyrings", "/etc/pki/rpm-gpg", "/usr/share/distribution-gpg-keys", "/etc/apk/keys", ]) { const normalizedCandidate = normalizeContainerPath(candidate); const absoluteCandidate = join( basePath, normalizedCandidate.replace(/^\/+/, ""), ); if (!safeExistsSync(absoluteCandidate)) { continue; } const stats = statSync(absoluteCandidate, { throwIfNoEntry: false }); if (!stats) { continue; } if (stats.isDirectory()) { for (const filePath of walkRootfsFiles(basePath, normalizedCandidate)) { if (isTrustedKeyPath(filePath)) { results.add(filePath); } } continue; } if (isTrustedKeyPath(normalizedCandidate)) { results.add(normalizedCandidate); } } return Array.from(results).sort(); } function walkRootfsFiles(basePath, normalizedDir) { const results = []; const absoluteDir = join(basePath, normalizedDir.replace(/^\/+/, "")); if (!safeExistsSync(absoluteDir)) { return results; } for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) { const normalizedPath = normalizeContainerPath( `${normalizedDir.replace(/\/+$/, "")}/${entry.name}`, ); if (entry.isDirectory()) { results.push(...walkRootfsFiles(basePath, normalizedPath)); continue; } if (entry.isFile()) { results.push(normalizedPath); } } return results; } function isTrustedKeyPath(normalizedPath) { const lowerPath = normalizeContainerPath(normalizedPath)?.toLowerCase(); if (!lowerPath) { return false; } return ( lowerPath === "/etc/apt/trusted.gpg" || lowerPath.includes("/trusted.gpg.d/") || lowerPath.includes("/keyrings/") || lowerPath.includes("/rpm-gpg/") || lowerPath.includes("/distribution-gpg-keys/") || lowerPath.includes("/apk/keys/") ); } async function createTrustedKeyComponent(basePath, normalizedPath) { const absolutePath = join(basePath, normalizedPath.replace(/^\/+/, "")); const stats = statSync(absolutePath, { throwIfNoEntry: false }); if (!stats || stats.isDirectory()) { return undefined; } let hashValues = {}; try { hashValues = await multiChecksumFile(["sha1", "sha256"], absolutePath); } catch (_err) { // ignore } const version = hashValues.sha256 || hashValues.sha1 || `${stats.mtimeMs}`; const hashes = []; if (hashValues.sha1) { hashes.push({ alg: "SHA-1", content: hashValues.sha1 }); } if (hashValues.sha256) { hashes.push({ alg: "SHA-256", content: hashValues.sha256 }); } return { "bom-ref": `crypto/related-crypto-material/public-key/${encodeURIComponent(normalizedPath)}@${hashValues.sha256 ? `sha256:${hashValues.sha256}` : version}`, name: basename(normalizedPath), type: "cryptographic-asset", version, hashes, cryptoProperties: { assetType: "related-crypto-material", relatedCryptoMaterialProperties: { type: "public-key", id: hashValues.sha256 || hashValues.sha1 || normalizedPath, state: "active", }, }, properties: uniqueProperties([ { name: "SrcFile", value: normalizedPath }, { name: "cdx:crypto:trustDomain", value: deriveTrustedKeyDomain(normalizedPath), }, { name: "cdx:crypto:keyPath", value: normalizedPath }, { name: "cdx:crypto:fileExtension", value: extname(normalizedPath).replace(/^\./, "") || "gpg", }, ]), }; } function deriveTrustedKeyDomain(normalizedPath) { const lowerPath = normalizeContainerPath(normalizedPath)?.toLowerCase() || ""; if (lowerPath.includes("/apt/") || lowerPath.includes("/keyrings/")) { return "apt"; } if ( lowerPath.includes("/rpm-gpg/") || lowerPath.includes("/distribution-gpg-keys/") ) { return "rpm"; } if (lowerPath.includes("/apk/keys/")) { return "apk"; } return "generic"; } function trustInspectorToolRefs() { return extractToolRefs({ components: getPluginToolComponents(["trustinspector"]), }); } function executeTrustInspector(args, activity) { if (isDryRun) { recordActivity({ kind: "trustinspector", reason: "Dry run mode blocks trustinspector execution and reports the requested inspection instead.", status: "blocked", ...activity, }); return undefined; } if (!TRUSTINSPECTOR_BIN) { return undefined; } if (DEBUG_MODE) { console.log("Executing", TRUSTINSPECTOR_BIN, args.join(" ")); } const result = safeSpawnSync(TRUSTINSPECTOR_BIN, args); if (result?.status !== 0 || result?.error) { if (DEBUG_MODE && (result?.stdout || result?.stderr)) { console.error(result.stdout, result.stderr); } return undefined; } if (!result?.stdout) { return undefined; } try { return JSON.parse(result.stdout); } catch (_err) { return undefined; } } function normalizeTrustInspectorTargetPath(basePath) { if (typeof basePath !== "string"