@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
330 lines (315 loc) • 9.45 kB
JavaScript
/**
* Post-generation BOM audit orchestrator
* Evaluates security rules against CI/CD and dependency data in the BOM
*/
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildAnnotationText } from "../../helpers/annotationFormatter.js";
import {
expandBomAuditCategories,
validateBomAuditCategories,
} from "../../helpers/auditCategories.js";
import { isHbomLikeBom as isHbomLikeBomDocument } from "../../helpers/hbomAnalysis.js";
import { table } from "../../helpers/table.js";
import {
DEBUG_MODE,
getTimestamp,
safeExistsSync,
} from "../../helpers/utils.js";
import { evaluateRules, loadRules } from "./ruleEngine.js";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const BUILTIN_RULES_DIR = join(__dirname, "..", "..", "..", "data", "rules");
async function loadConfiguredBomAuditRules(options = {}) {
const rules = await loadRules(BUILTIN_RULES_DIR);
if (options.bomAuditRulesDir && safeExistsSync(options.bomAuditRulesDir)) {
const userRulesDir = resolve(options.bomAuditRulesDir);
const userRules = await loadRules(userRulesDir);
if (DEBUG_MODE) {
console.log(`Loaded ${userRules.length} user rules from ${userRulesDir}`);
}
rules.push(...userRules);
}
if (!rules.length) {
return {
activeRules: [],
rules,
};
}
let activeRules = rules;
if (options.bomAuditCategories) {
const { categories, expandedCategories } = validateBomAuditCategories(
options.bomAuditCategories,
rules,
);
if (categories.length > 0) {
activeRules = rules.filter((r) =>
expandedCategories.includes(r.category),
);
if (DEBUG_MODE) {
console.log(
`Filtering rules by categories: ${categories.join(", ")} -> ${expandBomAuditCategories(categories).join(", ")} (${activeRules.length} active)`,
);
}
}
}
return {
activeRules,
rules,
};
}
/**
* Detect whether a BOM looks like an HBOM inventory.
*
* @param {object} bomJson CycloneDX BOM
* @returns {boolean} True when the BOM appears to represent hardware inventory
*/
export function isHbomLikeBom(bomJson) {
return isHbomLikeBomDocument(bomJson);
}
/**
* Detect whether a BOM looks like an OBOM/runtime inventory.
*
* @param {object} bomJson CycloneDX BOM
* @returns {boolean} True when the BOM appears to represent operations/runtime data
*/
export function isObomLikeBom(bomJson) {
if (!bomJson) {
return false;
}
if (isHbomLikeBom(bomJson)) {
return false;
}
if (
bomJson?.metadata?.component?.type === "operating-system" ||
bomJson?.metadata?.component?.type === "device"
) {
return true;
}
if (
Array.isArray(bomJson?.metadata?.lifecycles) &&
bomJson.metadata.lifecycles.some(
(lifecycle) => lifecycle?.phase === "operations",
)
) {
return true;
}
return (bomJson?.components || []).some((component) =>
(component?.properties || []).some(
(property) => property?.name === "cdx:osquery:category",
),
);
}
function summarizeDryRunSupport(activeRules = []) {
const summary = {
fullCount: 0,
noCount: 0,
partialCount: 0,
totalRules: activeRules.length,
};
for (const rule of activeRules) {
if (rule?.dryRunSupport === "no") {
summary.noCount += 1;
continue;
}
if (rule?.dryRunSupport === "full") {
summary.fullCount += 1;
continue;
}
summary.partialCount += 1;
}
return summary;
}
export async function getBomAuditDryRunSupportSummary(options = {}) {
const { activeRules } = await loadConfiguredBomAuditRules(options);
return summarizeDryRunSupport(activeRules);
}
export function formatDryRunSupportSummary(summary) {
if (!summary) {
return "";
}
return `BOM audit dry-run summary: ${summary.noCount} rule(s) do not support dry-run, ${summary.partialCount} rule(s) have partial dry-run support, ${summary.totalRules} active rule(s) total.`;
}
/**
* Audit BOM formulation section using JSONata-powered rule engine
* @param {Object} bomJson - Generated CycloneDX BOM
* @param {Object} options - CLI options
* @returns {Promise<Array>} Array of audit findings
*/
export async function auditBom(bomJson, options) {
if (!bomJson) {
return [];
}
const findings = [];
const { activeRules, rules } = await loadConfiguredBomAuditRules(options);
if (rules.length === 0) {
if (DEBUG_MODE) {
console.log("No audit rules loaded; formulation audit skipped");
}
return findings;
}
const allFindings = await evaluateRules(activeRules, bomJson);
if (options.bomAuditMinSeverity) {
const minSeverity = options.bomAuditMinSeverity.toLowerCase();
const severityThreshold = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityThreshold[minSeverity] ?? 0;
findings.push(
...allFindings.filter((f) => severityThreshold[f.severity] >= threshold),
);
} else {
findings.push(...allFindings);
}
if (DEBUG_MODE) {
console.log(
`Formulation audit complete: ${findings.length} finding(s) from ${activeRules.length} rule(s)`,
);
}
return findings;
}
/**
* Format findings for console output with color-coded severity
*/
export function renderBomAuditConsoleReport(findings) {
if (!findings?.length) {
return "";
}
const config = {
columnDefault: { wrapWord: true, width: 100 },
columns: [
{ width: 10 },
{ width: 26 },
{ width: 35 },
{ width: 50 },
{ width: 50 },
{ width: 60 },
],
header: {
alignment: "center",
content: "BOM Audit Findings\nGenerated with \u2665 by cdxgen",
},
};
const data = [["Rule", "ATT&CK", "Message", "Description", "Ref", "File"]];
for (const f of findings) {
const line = [];
line.push(f.ruleId);
line.push(
[...(f.attackTactics || []), ...(f.attackTechniques || [])].join(", "),
);
line.push(f.message);
line.push(f.description || "");
line.push(f.location?.purl || f.location?.bomRef || "");
line.push(f.location?.file || "");
data.push(line);
}
return table(data, config);
}
/**
* Format findings for console output with color-coded severity
*/
export function formatConsoleOutput(findings) {
const output = renderBomAuditConsoleReport(findings);
if (output) {
console.log(output);
}
return output;
}
/**
* Convert findings to CycloneDX annotations
*/
export function formatAnnotations(findings, bomJson) {
if (!findings?.length) {
return [];
}
const cdxgenAnnotator =
bomJson?.metadata?.tools?.components?.filter((c) => c.name === "cdxgen") ||
[];
if (!cdxgenAnnotator.length) {
if (DEBUG_MODE) {
console.warn(
"Cannot create audit annotations: cdxgen tool component not found in metadata",
);
}
return [];
}
return findings.map((f) => {
const subjects = [bomJson.serialNumber];
const properties = [
{ name: "cdx:audit:ruleId", value: f.ruleId },
{ name: "cdx:audit:severity", value: f.severity },
{ name: "cdx:audit:category", value: f.category },
];
if (f.name) {
properties.push({ name: "cdx:audit:name", value: f.name });
}
if (f.mitigation) {
properties.push({ name: "cdx:audit:mitigation", value: f.mitigation });
}
if (f.attackTactics?.length) {
properties.push({
name: "cdx:audit:attack:tactics",
value: f.attackTactics.join(","),
});
}
if (f.attackTechniques?.length) {
properties.push({
name: "cdx:audit:attack:techniques",
value: f.attackTechniques.join(","),
});
}
if (f.standards && typeof f.standards === "object") {
for (const [standardName, entries] of Object.entries(f.standards)) {
properties.push({
name: `cdx:audit:standards:${standardName}`,
value: Array.isArray(entries) ? entries.join(",") : String(entries),
});
}
}
if (f?.location?.purl) {
properties.push({
name: "cdx:audit:location:purl",
value: f.location.purl,
});
}
if (f.location?.file) {
properties.push({
name: "cdx:audit:location:file",
value: f.location.file,
});
}
if (f.location?.bomRef) {
properties.push({
name: "cdx:audit:location:bomRef",
value: f.location.bomRef,
});
}
if (f.evidence && typeof f.evidence === "object") {
for (const [key, value] of Object.entries(f.evidence)) {
const propValue =
typeof value === "object" ? JSON.stringify(value) : String(value);
properties.push({
name: `cdx:audit:evidence:${key}`,
value: propValue,
});
}
}
return {
subjects,
annotator: {
component: cdxgenAnnotator[0],
},
timestamp: getTimestamp(),
text: buildAnnotationText(f.message, properties),
};
});
}
/**
* Check if any findings meet the severity threshold for secure mode failure
*/
export function hasCriticalFindings(findings, options) {
if (!findings?.length) {
return false;
}
const failSeverity = options.bomAuditFailSeverity || "high";
const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
const threshold = severityOrder[failSeverity] ?? severityOrder.high;
return findings.some((f) => (severityOrder[f.severity] ?? 0) >= threshold);
}