UNPKG

@cyclonedx/cdxgen

Version:

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

612 lines (588 loc) 18.1 kB
/** * JSONata-powered rule engine for audits * Loads YAML rules and evaluates them against CycloneDX BOMs */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { parse as loadYaml } from "yaml"; import { DEBUG_MODE, safeExistsSync } from "../../helpers/utils.js"; let jsonata; try { ({ default: jsonata } = await import("jsonata")); } catch { jsonata = () => { throw new Error( "BOM audit rule evaluation requires the optional `jsonata` dependency. Install optional dependencies or add `jsonata` to use `--bom-audit`.", ); }; } /** * Helper: Extract property value from CycloneDX properties array * Usage in JSONata: $prop(component, 'cdx:github:action:isShaPinned') * Returns string value or null if not found */ function extractProperty(obj, propName) { if (!obj?.properties || !Array.isArray(obj.properties)) { return null; } const prop = obj.properties.find((p) => p?.name === propName); return prop?.value ?? null; } function dedupeObjectsByIdentity(items) { const seen = new Set(); const deduped = []; (items || []).forEach((item) => { if (!item) { return; } const key = item["bom-ref"] || item.purl || `${item.type || "unknown"}:${item.name || "unnamed"}:${item.version || ""}`; if (seen.has(key)) { return; } seen.add(key); deduped.push(item); }); return deduped; } function getFormulationEntries(bomJson) { return Array.isArray(bomJson?.formulation) ? bomJson.formulation : []; } function getFormulationComponents(bomJson) { return getFormulationEntries(bomJson).flatMap( (entry) => entry?.components || [], ); } function getAuditComponents(bomJson) { return dedupeObjectsByIdentity([ ...(Array.isArray(bomJson?.components) ? bomJson.components : []), ...getFormulationComponents(bomJson), ]); } function getAuditWorkflows(bomJson) { return dedupeObjectsByIdentity( getFormulationEntries(bomJson).flatMap((entry) => entry?.workflows || []), ); } function flattenServices(services, result = []) { if (!Array.isArray(services)) { return result; } for (const service of services) { if (!service) { continue; } result.push(service); if (Array.isArray(service.services) && service.services.length) { flattenServices(service.services, result); } } return result; } function getAuditServices(bomJson) { const formulationServices = getFormulationEntries(bomJson).flatMap( (entry) => entry?.services || [], ); return dedupeObjectsByIdentity( flattenServices([...(bomJson?.services || []), ...formulationServices]), ); } function normalizeAttackMetadata(rule) { const tactics = Array.isArray(rule?.attack?.tactics) ? rule.attack.tactics : []; const techniques = Array.isArray(rule?.attack?.techniques) ? rule.attack.techniques : []; return { tactics: tactics .filter((value) => typeof value === "string" && value.trim().length > 0) .map((value) => value.trim()), techniques: techniques .filter((value) => typeof value === "string" && value.trim().length > 0) .map((value) => value.trim()), }; } function normalizeStandardsMetadata(rule) { if (!rule?.standards || typeof rule.standards !== "object") { return undefined; } const normalized = {}; for (const [standardName, entries] of Object.entries(rule.standards)) { const values = Array.isArray(entries) ? entries : [entries]; const filtered = values .filter((value) => typeof value === "string" && value.trim().length > 0) .map((value) => value.trim()); if (filtered.length) { normalized[standardName] = filtered; } } return Object.keys(normalized).length ? normalized : undefined; } function normalizeDryRunSupport(rule) { const rawValue = typeof rule?.["dry-run-support"] === "string" ? rule["dry-run-support"] : typeof rule?.dryRunSupport === "string" ? rule.dryRunSupport : undefined; if (rawValue !== undefined && !["no", "partial", "full"].includes(rawValue)) { if (DEBUG_MODE) { console.warn( `Rule ${rule?.id || "unknown"} has invalid dry-run-support '${rawValue}'; defaulting to 'partial'`, ); } return "partial"; } return ["no", "partial", "full"].includes(rawValue) ? rawValue : "partial"; } /** * Helper: Check if property exists and equals expected value * Usage: $hasProp(component, 'cdx:foo', 'bar') */ function hasProperty(obj, propName, expectedValue) { const value = extractProperty(obj, propName); if (expectedValue === undefined) { return value !== null; } return value === String(expectedValue); } /** * Helper: Safe JSONata evaluation with timeout protection */ async function safeEvaluate(expression, context, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`JSONata evaluation timeout after ${timeoutMs}ms`)); }, timeoutMs); expression .evaluate(context) .then((result) => { clearTimeout(timer); resolve(result); }) .catch((err) => { clearTimeout(timer); reject(err); }); }); } /** * Register custom JSONata functions for CycloneDX property access */ function registerCdxHelpers(expression) { expression.registerFunction("prop", (obj, propName) => extractProperty(obj, propName), ); expression.registerFunction("nullSafeProp", (obj, propName) => { const value = extractProperty(obj, propName); return value === null ? "" : value; }); expression.registerFunction("hasProp", (obj, propName, expectedValue) => hasProperty(obj, propName, expectedValue), ); expression.registerFunction("p", (obj, propName) => extractProperty(obj, propName), ); expression.registerFunction("hasP", (obj, propName, expectedValue) => hasProperty(obj, propName, expectedValue), ); expression.registerFunction("startsWith", (str, prefix) => { if (typeof str !== "string" || typeof prefix !== "string") { return false; } return str.startsWith(prefix); }); expression.registerFunction("endsWith", (str, suffix) => { if (typeof str !== "string" || typeof suffix !== "string") { return false; } return str.endsWith(suffix); }); expression.registerFunction("arrayContains", (arr, value) => { if (!Array.isArray(arr)) return false; return arr.includes(value); }); expression.registerFunction("propBool", (obj, propName) => { const val = extractProperty(obj, propName); if (val === null || val === undefined) { return null; } if (typeof val === "boolean") { return val; } if (typeof val === "string") { const normalized = val.trim().toLowerCase(); if (normalized === "true") { return true; } if (normalized === "false") { return false; } } return null; }); expression.registerFunction("propList", (obj, propName) => { const val = extractProperty(obj, propName); if (!val || typeof val !== "string") return []; return val .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); }); expression.registerFunction("listContains", (val, target) => { if (Array.isArray(val)) { return val.some((item) => String(item).trim() === String(target).trim()); } if (typeof val === "string") { return val .split(",") .some((item) => item.trim() === String(target).trim()); } return false; }); expression.registerFunction("safeStr", (val) => { return val === null || val === undefined ? "" : String(val).trim(); }); expression.registerFunction("parseSizeBytes", (val) => { if (val === null || val === undefined || val === "") { return null; } if (typeof val === "number") { return Number.isFinite(val) ? val : null; } const normalizedValue = String(val).trim(); if (!normalizedValue) { return null; } const matchedValue = normalizedValue.match( /^(\d+(?:\.\d+)?)\s*([kmgtpe]?i?b)?$/iu, ); if (!matchedValue) { return null; } const numericValue = Number.parseFloat(matchedValue[1]); if (!Number.isFinite(numericValue)) { return null; } const unit = (matchedValue[2] || "").toLowerCase(); const unitMap = { b: 1, kb: 1000, kib: 1024, mb: 1000 ** 2, mib: 1024 ** 2, gb: 1000 ** 3, gib: 1024 ** 3, tb: 1000 ** 4, tib: 1024 ** 4, pb: 1000 ** 5, pib: 1024 ** 5, eb: 1000 ** 6, eib: 1024 ** 6, }; const multiplier = unitMap[unit || "b"]; if (!multiplier) { return null; } return numericValue * multiplier; }); expression.registerFunction("firstNonEmpty", (...values) => { for (const value of values) { if (value === null || value === undefined) { continue; } if (Array.isArray(value)) { const candidate = value .map((entry) => entry === null || entry === undefined ? "" : String(entry).trim(), ) .filter(Boolean) .join(", "); if (candidate) { return candidate; } continue; } const candidate = String(value).trim(); if (candidate) { return candidate; } } return ""; }); expression.registerFunction("isDarwinSystemPath", (value) => { if (typeof value !== "string") { return false; } const normalized = value.trim(); return ( normalized.startsWith("/bin/") || normalized.startsWith("/sbin/") || normalized.startsWith("/System/") || normalized.startsWith("/usr/bin/") || normalized.startsWith("/usr/libexec/") || normalized.startsWith("/usr/sbin/") || normalized.startsWith("/Library/Apple/System/") || normalized.startsWith("/System/Volumes/Preboot/Cryptexes/") ); }); expression.registerFunction("isWindowsUserControlledPath", (value) => { if (typeof value !== "string") { return false; } const normalized = value.trim().replaceAll("/", "\\").toLowerCase(); return ( normalized.includes("\\users\\") || normalized.includes("\\programdata\\") || normalized.includes("\\appdata\\") || normalized.includes("\\downloads\\") || normalized.includes("\\desktop\\") || normalized.includes("\\temp\\") ); }); expression.registerFunction("auditComponents", (bomJson) => getAuditComponents(bomJson), ); expression.registerFunction("auditWorkflows", (bomJson) => getAuditWorkflows(bomJson), ); expression.registerFunction("auditServices", (bomJson) => getAuditServices(bomJson), ); expression.registerFunction("formulationComponents", (bomJson) => getFormulationComponents(bomJson), ); return expression; } /** * Load and validate rules from a directory of YAML files * @param {string} rulesDir - Path to directory containing .yaml rule files * @returns {Promise<Array>} Array of parsed rule objects */ export async function loadRules(rulesDir) { const rules = []; if (!safeExistsSync(rulesDir)) { if (DEBUG_MODE) { console.warn(`Rules directory not found: ${rulesDir}`); } return rules; } try { if (!statSync(rulesDir)?.isDirectory()) { if (DEBUG_MODE) { console.warn(`Rules path is not a directory: ${rulesDir}`); } return rules; } } catch (err) { if (DEBUG_MODE) { console.warn(`Cannot stat rules directory ${rulesDir}:`, err.message); } return rules; } let ruleFiles = []; try { ruleFiles = readdirSync(rulesDir); } catch (err) { if (DEBUG_MODE) { console.warn(`Cannot read rules directory ${rulesDir}:`, err.message); } return rules; } for (const file of ruleFiles) { if (!file.endsWith(".yaml") && !file.endsWith(".yml")) { continue; } const filePath = join(rulesDir, file); try { if (!statSync(filePath).isFile()) { continue; } } catch (_err) { continue; } try { const content = loadYaml(readFileSync(filePath, "utf-8")); const fileRules = Array.isArray(content) ? content : [content]; for (const rule of fileRules) { if (!rule.id || typeof rule.id !== "string") { console.warn(`Rule in ${file} missing required field: id (string)`); continue; } if (!rule.condition || typeof rule.condition !== "string") { console.warn( `Rule ${rule.id} missing required field: condition (string)`, ); continue; } if (!rule.message || typeof rule.message !== "string") { console.warn( `Rule ${rule.id} missing required field: message (string)`, ); continue; } rule.severity = rule.severity || "medium"; rule.category = rule.category || "unknown"; rule.dryRunSupport = normalizeDryRunSupport(rule); const attack = normalizeAttackMetadata(rule); if (attack.tactics.length || attack.techniques.length) { rule.attack = attack; } if (!["critical", "high", "medium", "low"].includes(rule.severity)) { console.warn( `Rule ${rule.id} has invalid severity '${rule.severity}'; defaulting to 'medium'`, ); rule.severity = "medium"; } rules.push(rule); } } catch (err) { console.warn(`Failed to load rule file ${filePath}:`, err.message); } } if (DEBUG_MODE) { console.log(`Loaded ${rules.length} audit rules from ${rulesDir}`); } return rules; } /** * Interpolate template strings with JSONata expressions * Supports {{ expression }} syntax for dynamic message/evidence generation */ async function interpolateTemplate(template, context) { if (!template || typeof template !== "string") { return template; } const templateRegex = /\{\{\s*([^}]+)\s*}}/g; let result = template; const matches = [...template.matchAll(templateRegex)]; for (const match of matches) { const [fullMatch, expr] = match; try { const expression = jsonata(expr.trim()); registerCdxHelpers(expression); const value = await safeEvaluate(expression, context); const replacement = value !== undefined ? String(value) : fullMatch; result = result.replace(fullMatch, replacement); } catch (err) { if (DEBUG_MODE) { console.warn( `Template interpolation failed for '{{${expr}}}':`, err.message, ); } } } return result; } /** * Evaluate a single rule against the BOM using JSONata * @param {Object} rule - Parsed rule object * @param {Object} bomJson - Full CycloneDX BOM object * @returns {Promise<Array>} Array of matched findings */ export async function evaluateRule(rule, bomJson) { const findings = []; try { const conditionExpr = jsonata(rule.condition); registerCdxHelpers(conditionExpr); const conditionResult = await safeEvaluate(conditionExpr, bomJson); const matches = Array.isArray(conditionResult) ? conditionResult.filter((m) => m !== null && m !== undefined) : conditionResult ? [conditionResult] : []; if (matches.length === 0) { return findings; } for (const item of matches) { const attack = normalizeAttackMetadata(rule); const standards = normalizeStandardsMetadata(rule); const context = { ...item, bom: bomJson, components: getAuditComponents(bomJson), workflows: getAuditWorkflows(bomJson), auditServices: getAuditServices(bomJson), formulationComponents: getFormulationComponents(bomJson), services: getAuditServices(bomJson), metadata: bomJson.metadata || {}, }; const message = await interpolateTemplate(rule.message, context); let location = null; if (rule.location) { try { const locationExpr = jsonata(rule.location); registerCdxHelpers(locationExpr); location = await safeEvaluate(locationExpr, context); } catch (err) { if (DEBUG_MODE) { console.warn( `Failed to extract location for rule ${rule.id}:`, err.message, ); } } } let evidence = null; if (rule.evidence) { try { const evidenceExpr = jsonata(rule.evidence); registerCdxHelpers(evidenceExpr); evidence = await safeEvaluate(evidenceExpr, context); } catch (err) { if (DEBUG_MODE) { console.warn( `Failed to extract evidence for rule ${rule.id}:`, err.message, ); } } } findings.push({ attack, attackTactics: attack.tactics, attackTechniques: attack.techniques, standards, ruleId: rule.id, name: rule.name || rule.id, description: rule.description, severity: rule.severity, category: rule.category, message, mitigation: rule.mitigation, location, evidence, _match: item, }); } } catch (err) { console.warn( `Failed to evaluate rule ${rule?.id || "unknown"}:`, err.message, ); if (DEBUG_MODE && err.stack) { console.debug(err.stack); } } return findings; } /** * Evaluate all rules against a BOM */ export async function evaluateRules(rules, bomJson) { const allFindings = []; for (const rule of rules) { const findings = await evaluateRule(rule, bomJson); allFindings.push(...findings); } const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; allFindings.sort((a, b) => { const sevDiff = severityOrder[a.severity] - severityOrder[b.severity]; return sevDiff !== 0 ? sevDiff : a.ruleId.localeCompare(b.ruleId); }); return allFindings; }