UNPKG

@cyclonedx/cdxgen

Version:

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

287 lines (260 loc) 8.11 kB
import { readFileSync } from "node:fs"; import { v4 as uuidv4 } from "uuid"; import { parse as _load } from "yaml"; import { disambiguateSteps } from "./common.js"; /** * Parse a single CircleCI config file and return formulation-shaped data. * * @param {string} f Absolute path to the config file * @param {Object} _options CLI options * @returns {{ workflows: Object[], components: Object[], services: Object[], properties: Object[], dependencies: Object[] }} */ function parseCircleCiFile(f, _options) { const workflows = []; const components = []; const dependencies = []; let raw; try { raw = readFileSync(f, { encoding: "utf-8" }); } catch (_e) { return { workflows, components, services: [], properties: [], dependencies, }; } let yamlObj; try { yamlObj = _load(raw); } catch (_e) { return { workflows, components, services: [], properties: [], dependencies, }; } if (!yamlObj || typeof yamlObj !== "object") { return { workflows, components, services: [], properties: [], dependencies, }; } // Collect orbs as components if (yamlObj.orbs && typeof yamlObj.orbs === "object") { for (const [orbAlias, orbRef] of Object.entries(yamlObj.orbs)) { if (typeof orbRef === "string") { const atIdx = orbRef.lastIndexOf("@"); const fullName = atIdx >= 0 ? orbRef.substring(0, atIdx) : orbRef; const version = atIdx >= 0 ? orbRef.substring(atIdx + 1) : ""; const slashIdx = fullName.indexOf("/"); const namespace = slashIdx >= 0 ? fullName.substring(0, slashIdx) : ""; const name = slashIdx >= 0 ? fullName.substring(slashIdx + 1) : fullName; components.push({ "bom-ref": orbRef, type: "application", group: namespace, name, version, properties: [ { name: "SrcFile", value: f }, { name: "cdx:circleci:orb:alias", value: orbAlias }, ], }); } } } // Collect executor images as components if (yamlObj.executors && typeof yamlObj.executors === "object") { for (const [exName, exDef] of Object.entries(yamlObj.executors)) { const image = exDef?.docker?.[0]?.image || exDef?.machine?.image || exDef?.macos?.xcode || ""; if (image) { components.push({ type: "container", name: image, properties: [ { name: "SrcFile", value: f }, { name: "cdx:circleci:executor:name", value: exName }, ], }); } } } // Build a workflow/task tree per CircleCI workflow const circleCiWorkflows = yamlObj.workflows && typeof yamlObj.workflows === "object" ? Object.entries(yamlObj.workflows).filter(([key]) => key !== "version") : []; for (const [wfName, wfDef] of circleCiWorkflows) { if (!wfDef || typeof wfDef !== "object") { continue; } const workflowRef = uuidv4(); const tasks = []; const workflowDependsOn = []; const wfJobs = Array.isArray(wfDef.jobs) ? wfDef.jobs : []; for (const jobEntry of wfJobs) { // Each entry is either a string (job name) or { jobName: { requires, ... } } let jobName; let jobConfig = {}; if (typeof jobEntry === "string") { jobName = jobEntry; } else if (typeof jobEntry === "object") { jobName = Object.keys(jobEntry)[0]; jobConfig = jobEntry[jobName] || {}; } if (!jobName) { continue; } const taskRef = uuidv4(); const taskProperties = [ { name: "cdx:circleci:job:name", value: jobName }, ]; const requires = Array.isArray(jobConfig.requires) ? jobConfig.requires : []; if (requires.length) { taskProperties.push({ name: "cdx:circleci:job:requires", value: requires.join(","), }); } const jobFilters = jobConfig.filters; if (jobFilters?.branches) { const only = Array.isArray(jobFilters.branches.only) ? jobFilters.branches.only.join(",") : jobFilters.branches.only || ""; if (only) { taskProperties.push({ name: "cdx:circleci:job:branch:only", value: only, }); } } // Look up job definition for steps const jobDef = yamlObj.jobs?.[jobName] || {}; const steps = []; for (const step of Array.isArray(jobDef.steps) ? jobDef.steps : []) { if (typeof step === "string") { steps.push({ name: step }); } else if (typeof step === "object") { const stepKey = Object.keys(step)[0]; const stepVal = step[stepKey]; const stepName = typeof stepVal?.name === "string" ? stepVal.name : stepKey; const command = typeof stepVal?.command === "string" ? stepVal.command : undefined; steps.push({ name: stepName, commands: command ? [{ executed: command }] : undefined, }); } } tasks.push({ "bom-ref": taskRef, uid: taskRef, name: jobName, taskTypes: ["build"], steps: disambiguateSteps(steps), properties: taskProperties, }); workflowDependsOn.push(taskRef); } const workflow = { "bom-ref": workflowRef, uid: workflowRef, name: wfName, taskTypes: ["build"], tasks: tasks.length ? tasks : undefined, properties: [ { name: "cdx:circleci:config", value: f }, { name: "cdx:circleci:workflow:name", value: wfName }, ], }; workflows.push(workflow); if (workflowDependsOn.length) { dependencies.push({ ref: workflowRef, dependsOn: workflowDependsOn }); } } // Fallback: if no workflows block, create a single workflow from jobs if ( workflows.length === 0 && yamlObj.jobs && typeof yamlObj.jobs === "object" ) { const workflowRef = uuidv4(); const tasks = []; const workflowDependsOn = []; for (const jobName of Object.keys(yamlObj.jobs)) { const taskRef = uuidv4(); tasks.push({ "bom-ref": taskRef, uid: taskRef, name: jobName, taskTypes: ["build"], properties: [{ name: "cdx:circleci:job:name", value: jobName }], }); workflowDependsOn.push(taskRef); } workflows.push({ "bom-ref": workflowRef, uid: workflowRef, name: "CircleCI Pipeline", taskTypes: ["build"], tasks: tasks.length ? tasks : undefined, properties: [{ name: "cdx:circleci:config", value: f }], }); if (workflowDependsOn.length) { dependencies.push({ ref: workflowRef, dependsOn: workflowDependsOn }); } } return { workflows, components, services: [], properties: [], dependencies }; } /** * CircleCI formulation parser. * * Matches `.circleci/config.yml` and `.circleci/config.yaml` and converts them * into CycloneDX formulation workflow objects. Referenced orbs are captured as * components. * * Parser contract: `parse(files, options)` returns * `{ workflows, components, services, properties, dependencies }`. */ export const circleCiParser = { id: "circleci", patterns: [".circleci/config.{yml,yaml}"], /** * @param {string[]} files Matched config file paths * @param {Object} options CLI options * @returns {{ workflows: Object[], components: Object[], services: Object[], properties: Object[], dependencies: Object[] }} */ parse(files, options) { const workflows = []; const components = []; const dependencies = []; for (const f of files) { const result = parseCircleCiFile(f, options); workflows.push(...result.workflows); components.push(...result.components); dependencies.push(...result.dependencies); } return { workflows, components, services: [], properties: [], dependencies, }; }, };