@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
798 lines (730 loc) • 20 kB
JavaScript
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { resolvePluginBinary } from "./plugins.js";
import {
DEBUG_MODE,
getTmpDir,
safeExistsSync,
safeMkdtempSync,
safeRmSync,
safeSpawnSync,
} from "./utils.js";
const RUST_LANGUAGES = new Set(["rust", "rs", "rust-lang"]);
/**
* Resolves the rusi binary.
*
* @returns {string} The path to the rusi binary.
*/
function rusiBin() {
return resolvePluginBinary("rusi");
}
function appendUniqueProperty(properties, name, value) {
if (value === undefined || value === null || value === "") {
return;
}
const propertyValue = String(value);
if (
!properties.some(
(property) => property.name === name && property.value === propertyValue,
)
) {
properties.push({ name, value: propertyValue });
}
}
function addSetValue(map, key, value) {
if (!key || !value) {
return;
}
map[key] ??= new Set();
map[key].add(value);
}
function addPropertyValue(map, key, name, value) {
if (!key || value === undefined || value === null || value === "") {
return;
}
map[key] ??= [];
appendUniqueProperty(map[key], name, value);
}
function positionLocation(position) {
if (!position?.filename) {
return undefined;
}
if (position.line && position.line > 0) {
return `${position.filename}#${position.line}`;
}
return position.filename;
}
function purlWithoutVersion(purl) {
return purl?.split("?")[0].split("#")[0].split("@")[0];
}
function createPurlAliasMap(components = []) {
const purlAliasMap = new Map();
for (const component of components) {
if (!component?.purl) {
continue;
}
purlAliasMap.set(component.purl, component.purl);
const noVersionPurl = purlWithoutVersion(component.purl);
if (noVersionPurl && !purlAliasMap.has(noVersionPurl)) {
purlAliasMap.set(noVersionPurl, component.purl);
}
}
return purlAliasMap;
}
function resolveComponentPurl(purl, purlAliasMap) {
if (!purl) {
return undefined;
}
return purlAliasMap.get(purl) || purlAliasMap.get(purlWithoutVersion(purl));
}
function addResolvedPurls(purls, values, purlAliasMap) {
for (const value of values || []) {
const resolvedPurl = resolveComponentPurl(value, purlAliasMap);
if (resolvedPurl) {
purls.add(resolvedPurl);
}
}
}
function frameFromPosition(position, fallbackData = {}) {
if (!position?.filename) {
return undefined;
}
return {
package: fallbackData.packagePath || "",
module: fallbackData.kind || "",
function: fallbackData.name || "",
line: position.line || undefined,
column: position.column || undefined,
fullFilename: position.filename,
};
}
function frameLocationKey(frame) {
if (!frame?.fullFilename) {
return undefined;
}
return `${frame.fullFilename}#${frame.line || ""}#${frame.column || ""}`;
}
function dedupeFrames(frames = []) {
const seen = new Set();
const out = [];
for (const frame of frames) {
const key = frameLocationKey(frame);
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
out.push(frame);
}
return out;
}
function addFrame(dataFlowFrames, purl, frame) {
if (!purl || !frame) {
return;
}
dataFlowFrames[purl] ??= [];
dataFlowFrames[purl].push([frame]);
}
function addCountProperty(properties, name, count) {
if (count && count > 0) {
appendUniqueProperty(properties, name, count);
}
}
function incrementCount(map, key) {
if (!key) {
return;
}
map[key] = (map[key] || 0) + 1;
}
function cryptoBomRef(kind, name, detail) {
return `crypto/${kind}/${encodeURIComponent(name)}@${encodeURIComponent(detail || name)}`;
}
function appendCryptoComponentProperty(component, name, value) {
component.properties ??= [];
appendUniqueProperty(component.properties, name, value);
}
function mergeCryptoComponent(componentsByRef, component, item) {
const existing = componentsByRef.get(component["bom-ref"]);
if (!existing) {
componentsByRef.set(component["bom-ref"], component);
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:sourceLocation",
positionLocation(item?.position),
);
return component;
}
appendCryptoComponentProperty(
existing,
"cdx:rusi:crypto:sourceLocation",
positionLocation(item?.position),
);
return existing;
}
function cryptoAlgorithmComponent(asset) {
const component = {
type: "cryptographic-asset",
name: asset.algorithm || asset.kind || "unknown",
"bom-ref": cryptoBomRef(
"algorithm",
asset.algorithm || asset.kind,
asset.provider || "unknown",
),
description:
"Cryptographic algorithm/component detected by rusi source analysis",
cryptoProperties: {
assetType: "algorithm",
algorithmProperties: { primitive: asset.kind || "unknown" },
},
properties: [],
};
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:provider",
asset.provider,
);
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:operation",
asset.operation,
);
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:symbol",
asset.symbol,
);
return component;
}
function cryptoMaterialComponent(material) {
const materialType = material.kind || "unknown";
const component = {
type: "cryptographic-asset",
name: material.name || materialType,
"bom-ref": cryptoBomRef(
"material",
material.name || materialType,
materialType,
),
description:
"Related cryptographic material indicator detected by rusi source analysis",
cryptoProperties: {
assetType: "related-crypto-material",
relatedCryptoMaterialProperties: { type: materialType },
},
properties: [],
};
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:function",
material.function,
);
appendCryptoComponentProperty(
component,
"cdx:rusi:crypto:confidence",
material.confidence,
);
return component;
}
function addMetadataProperties(properties, rusiReport = {}) {
appendUniqueProperty(
properties,
"cdx:rusi:schemaVersion",
rusiReport.schema_version,
);
appendUniqueProperty(
properties,
"cdx:rusi:toolVersion",
rusiReport.tool?.version,
);
appendUniqueProperty(
properties,
"cdx:rusi:rustcVersion",
rusiReport.runtime?.rustc_version,
);
appendUniqueProperty(
properties,
"cdx:rusi:cargoVersion",
rusiReport.runtime?.cargo_version,
);
appendUniqueProperty(properties, "cdx:rusi:host", rusiReport.runtime?.host);
const options = rusiReport.options || {};
appendUniqueProperty(properties, "cdx:rusi:backend", options.backend);
appendUniqueProperty(
properties,
"cdx:rusi:analysisScope",
options.analysis_scope,
);
appendUniqueProperty(
properties,
"cdx:rusi:callGraphMode",
options.call_graph_mode,
);
appendUniqueProperty(
properties,
"cdx:rusi:dataFlowMode",
options.data_flow_mode,
);
const stats = rusiReport.stats || {};
addCountProperty(properties, "cdx:rusi:packageCount", stats.package_count);
addCountProperty(properties, "cdx:rusi:fileCount", stats.file_count);
addCountProperty(properties, "cdx:rusi:importCount", stats.import_count);
addCountProperty(
properties,
"cdx:rusi:declarationCount",
stats.declaration_count,
);
addCountProperty(properties, "cdx:rusi:usageCount", stats.usage_count);
addCountProperty(
properties,
"cdx:rusi:securitySignalCount",
stats.security_signal_count,
);
addCountProperty(
properties,
"cdx:rusi:cryptoLibraryCount",
stats.crypto_library_count,
);
addCountProperty(
properties,
"cdx:rusi:cryptoComponentCount",
stats.crypto_component_count,
);
addCountProperty(
properties,
"cdx:rusi:cryptoFindingCount",
stats.crypto_finding_count,
);
addCountProperty(
properties,
"cdx:rusi:callGraphNodeCount",
stats.call_graph_node_count,
);
addCountProperty(
properties,
"cdx:rusi:callGraphEdgeCount",
stats.call_graph_edge_count,
);
addCountProperty(
properties,
"cdx:rusi:dataFlowNodeCount",
stats.data_flow_node_count,
);
addCountProperty(
properties,
"cdx:rusi:dataFlowEdgeCount",
stats.data_flow_edge_count,
);
addCountProperty(
properties,
"cdx:rusi:dataFlowSliceCount",
stats.data_flow_slice_count,
);
}
function addImportEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
componentPropertiesMap,
) {
for (const importUsage of rusiReport.imports || []) {
const purl = resolveComponentPurl(importUsage.purl, purlAliasMap);
if (!purl) {
continue;
}
addSetValue(purlLocationMap, purl, positionLocation(importUsage.position));
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:importPath",
importUsage.path,
);
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:importAlias",
importUsage.alias,
);
}
}
function addUsageEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
componentPropertiesMap,
) {
for (const usage of rusiReport.usages || []) {
const purl = resolveComponentPurl(usage.purl, purlAliasMap);
if (!purl) {
continue;
}
addSetValue(purlLocationMap, purl, positionLocation(usage.position));
addFrame(
dataFlowFrames,
purl,
frameFromPosition(usage.position, {
packagePath: usage.package_path,
kind: usage.kind,
name: usage.name,
}),
);
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:usageKind",
usage.kind,
);
}
}
function addCallGraphEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
) {
const callGraph = rusiReport.call_graph || {};
const nodeMap = new Map();
for (const node of callGraph.nodes || []) {
if (node.id) {
nodeMap.set(node.id, node);
}
}
for (const edge of callGraph.edges || []) {
const purls = new Set();
addResolvedPurls(purls, edge.purls, purlAliasMap);
addResolvedPurls(purls, [edge.sourcePurl, edge.targetPurl], purlAliasMap);
if (!purls.size) {
continue;
}
const sourceNode = nodeMap.get(edge.source_id);
const targetNode = nodeMap.get(edge.target_id);
for (const purl of purls) {
addSetValue(purlLocationMap, purl, positionLocation(edge.position));
const sourceFrame = frameFromPosition(edge.position, {
packagePath: sourceNode?.package_path,
kind: edge.call_type,
name: edge.source_name,
});
const targetFrame = targetNode
? frameFromPosition(targetNode.position, {
packagePath: targetNode.package_path,
kind: targetNode.kind,
name: edge.target_name,
})
: undefined;
const edgeFrames = dedupeFrames(
[sourceFrame, targetFrame].filter(Boolean),
);
if (edgeFrames.length) {
dataFlowFrames[purl] ??= [];
dataFlowFrames[purl].push(edgeFrames);
}
}
}
}
function addDataFlowEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
componentPropertiesMap,
) {
const dataFlow = rusiReport.data_flow || {};
const nodeMap = new Map();
for (const node of dataFlow.nodes || []) {
nodeMap.set(node.id, node);
}
const dataFlowCounts = {};
for (const slice of dataFlow.slices || []) {
const purls = new Set();
addResolvedPurls(purls, slice.purls, purlAliasMap);
addResolvedPurls(purls, [slice.sourcePurl, slice.targetPurl], purlAliasMap);
for (const nodeId of slice.node_ids || []) {
const node = nodeMap.get(nodeId);
if (node?.purl) {
addResolvedPurls(purls, [node.purl], purlAliasMap);
}
}
if (!purls.size) {
continue;
}
const frames = [];
for (const nodeId of slice.node_ids || []) {
const node = nodeMap.get(nodeId);
if (node) {
const frame = frameFromPosition(node.position, {
packagePath: node.package_path,
kind: node.category || node.kind,
name: node.name || node.function,
});
if (frame) frames.push(frame);
}
}
const category = [slice.source_category, slice.sink_category]
.filter(Boolean)
.join("->");
for (const purl of purls) {
incrementCount(dataFlowCounts, purl);
const sourceNode = nodeMap.get(slice.source_id);
const sinkNode = nodeMap.get(slice.sink_id);
addSetValue(
purlLocationMap,
purl,
positionLocation(sourceNode?.position),
);
addSetValue(purlLocationMap, purl, positionLocation(sinkNode?.position));
if (frames.length) {
dataFlowFrames[purl] ??= [];
dataFlowFrames[purl].push(frames);
}
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:dataFlowCategories",
category,
);
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:dataFlowRuleName",
slice.rule_name,
);
}
}
for (const [purl, count] of Object.entries(dataFlowCounts)) {
addCountProperty(
(componentPropertiesMap[purl] ??= []),
"cdx:rusi:dataFlowSliceCount",
count,
);
}
}
function addSecuritySignals(rusiReport, purlAliasMap, componentPropertiesMap) {
for (const signal of rusiReport.security_signals || []) {
const purl = resolveComponentPurl(signal.purl, purlAliasMap);
if (!purl) {
continue;
}
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:securitySignalCategory",
signal.category,
);
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:securitySignalSeverity",
signal.severity,
);
}
}
function addCryptoEvidence(
rusiReport,
purlAliasMap,
cryptoComponentsByRef,
cryptoGeneratePurls,
componentPropertiesMap,
) {
const crypto = rusiReport.crypto;
if (!crypto) {
return;
}
for (const comp of crypto.components || []) {
const component = cryptoAlgorithmComponent(comp);
if (component) {
mergeCryptoComponent(cryptoComponentsByRef, component, comp);
const purl = resolveComponentPurl(comp.purl, purlAliasMap);
if (purl) {
cryptoGeneratePurls[purl] ??= new Set();
cryptoGeneratePurls[purl].add(component["bom-ref"]);
}
}
}
for (const material of crypto.materials || []) {
const component = cryptoMaterialComponent(material);
if (component) {
mergeCryptoComponent(cryptoComponentsByRef, component, material);
}
}
for (const finding of crypto.findings || []) {
const purl = resolveComponentPurl(finding.purl, purlAliasMap);
if (!purl) {
continue;
}
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:cryptoFindingCategory",
finding.category,
);
addPropertyValue(
componentPropertiesMap,
purl,
"cdx:rusi:cryptoFindingSeverity",
finding.severity,
);
}
}
/**
* Checks if the provided language is a Rust language alias.
*
* @param {string} language The language to check.
* @returns {boolean} True if the language is Rust.
*/
export function isRusiRustLanguage(language) {
return RUST_LANGUAGES.has(String(language || "").toLowerCase());
}
/**
* Reads and parses the JSON output generated by rusi.
*
* @param {string} jsonFile Path to the rusi output file.
* @returns {Object|undefined} Parsed JSON report or undefined.
*/
export function readRusiJsonFile(jsonFile) {
if (!jsonFile || !safeExistsSync(jsonFile)) {
return undefined;
}
try {
return JSON.parse(readFileSync(jsonFile, "utf-8"));
} catch (_err) {
return undefined;
}
}
/**
* Invokes the rusi binary to analyze a codebase.
*
* @param {string} src Directory to analyze.
* @param {string} outputFile Path to store the resulting JSON file.
* @param {Object} options Options containing rusi configurations.
* @returns {boolean} True if successful.
*/
export function runRusiAnalysis(src, outputFile, options = {}) {
const executable = options.rusiCommand || rusiBin();
if (!executable || !src || !outputFile) {
return false;
}
const rusiMode = options.rusiMode === "cryptos" ? "cryptos" : "analyze";
const args = [rusiMode, "--dir", resolve(src), "--out", resolve(outputFile)];
// Set callgraph and data-flow args
args.push("--callgraph", options.rusiCallgraph || "static");
let dataFlow = options.rusiDataflow;
if (!dataFlow) {
dataFlow =
options.withDataFlow || options.profile === "research"
? "security"
: "none";
}
args.push("--dataflow", dataFlow);
if (options.rusiBackend) {
args.push("--backend", options.rusiBackend);
}
if (options.rusiToolchain) {
args.push("--toolchain", options.rusiToolchain);
}
if (options.rusiPatterns) {
args.push("--patterns", resolve(options.rusiPatterns));
}
if (DEBUG_MODE) {
console.log("Executing", executable, args.join(" "));
}
const result = safeSpawnSync(executable, args, {
cwd: resolve(src),
shell: false,
});
if (result?.status !== 0 || result?.error || !safeExistsSync(outputFile)) {
if (DEBUG_MODE) {
if (result?.stdout || result?.stderr) {
console.error(result.stdout, result.stderr);
} else {
console.log("Check if the rusi plugin was installed successfully.");
}
}
return false;
}
return true;
}
/**
* Orchestrates the execution of rusi and returns the parsed report.
*
* @param {string} src Directory to analyze.
* @param {Object} options Configuration options.
* @returns {Object|undefined} Parsed rusi report or undefined.
*/
export function analyzeRusiProject(src, options = {}) {
const tempDir = safeMkdtempSync(join(getTmpDir(), "rusi-"));
const outputFile = join(tempDir, "rusi.json");
try {
if (!runRusiAnalysis(src, outputFile, options)) {
return undefined;
}
return readRusiJsonFile(outputFile);
} finally {
if (tempDir?.startsWith(getTmpDir())) {
safeRmSync(tempDir, { recursive: true, force: true });
}
}
}
/**
* Extracts and maps CycloneDX evidence structures from a rusi report.
*
* @param {Object} rusiReport The parsed JSON generated by rusi.
* @param {Array} components The components present in the SBOM.
* @returns {Object} Maps representing evidence structures.
*/
export function collectRusiEvidence(rusiReport = {}, components = []) {
const purlAliasMap = createPurlAliasMap(components);
const purlLocationMap = {};
const dataFlowFrames = {};
const componentPropertiesMap = {};
const metadataProperties = [];
const cryptoComponentsByRef = new Map();
const cryptoGeneratePurls = {};
addMetadataProperties(metadataProperties, rusiReport);
addImportEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
componentPropertiesMap,
);
addUsageEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
componentPropertiesMap,
);
addCallGraphEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
);
addDataFlowEvidence(
rusiReport,
purlAliasMap,
purlLocationMap,
dataFlowFrames,
componentPropertiesMap,
);
addSecuritySignals(rusiReport, purlAliasMap, componentPropertiesMap);
addCryptoEvidence(
rusiReport,
purlAliasMap,
cryptoComponentsByRef,
cryptoGeneratePurls,
componentPropertiesMap,
);
return {
componentPropertiesMap,
cryptoComponents: Array.from(cryptoComponentsByRef.values()).sort(
(left, right) =>
`${left.name}:${left["bom-ref"]}`.localeCompare(
`${right.name}:${right["bom-ref"]}`,
),
),
cryptoGeneratePurls,
dataFlowFrames,
metadataProperties,
purlLocationMap,
};
}