UNPKG

@cyclonedx/cdxgen

Version:

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

322 lines (301 loc) 8.87 kB
import { spawnSync } from "node:child_process"; import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import process from "node:process"; import { URL } from "node:url"; import bodyParser from "body-parser"; import connect from "connect"; import { createBom, submitBom } from "../cli/index.js"; import { getTmpDir, isSecureMode } from "../helpers/utils.js"; import { postProcess } from "../stages/postgen/postgen.js"; import compression from "compression"; // Timeout milliseconds. Default 10 mins const TIMEOUT_MS = Number.parseInt(process.env.CDXGEN_SERVER_TIMEOUT_MS) || 10 * 60 * 1000; const app = connect(); app.use( bodyParser.json({ deflate: true, limit: "1mb", }), ); app.use(compression()); function isAllowedHost(hostname) { if (!process.env.CDXGEN_SERVER_ALLOWED_HOSTS) { return true; } return (process.env.CDXGEN_SERVER_ALLOWED_HOSTS || "") .split(",") .includes(hostname); } function isAllowedPath(p) { if (!process.env.CDXGEN_SERVER_ALLOWED_PATHS) { return true; } return (process.env.CDXGEN_SERVER_ALLOWED_PATHS || "") .split(",") .some((ap) => p.startsWith(ap)); } const gitClone = (repoUrl, branch = null) => { const tempDir = fs.mkdtempSync( path.join(getTmpDir(), path.basename(repoUrl)), ); const gitArgs = ["clone", repoUrl, "--depth", "1", tempDir]; if (branch) { gitArgs.splice(2, 0, "--branch", branch); } console.log( `Cloning Repo${branch ? ` with branch ${branch}` : ""} to ${tempDir}`, ); const result = spawnSync("git", gitArgs, { encoding: "utf-8", shell: false, }); if (result.status !== 0) { console.log(result.stderr); } return tempDir; }; const parseQueryString = (q, body, options = {}) => { if (body && Object.keys(body).length) { options = Object.assign(options, body); } const queryParams = [ "type", "multiProject", "requiredOnly", "noBabel", "installDeps", "projectId", "projectName", "projectGroup", "projectVersion", "parentUUID", "serverUrl", "apiKey", "specVersion", "filter", "only", "autoCompositions", "gitBranch", "lifecycle", "deep", "profile", "exclude", "includeFormulation", "includeCrypto", "standard", ]; for (const param of queryParams) { if (q[param]) { let value = q[param]; // Convert string to boolean if (value === "true") { value = true; } else if (value === "false") { value = false; } options[param] = value; } } options.projectType = options.type?.split(","); delete options.type; if (options.lifecycle === "pre-build") { options.installDeps = false; } if (options.profile) { applyProfileOptions(options); } return options; }; const applyProfileOptions = (options) => { switch (options.profile) { case "appsec": options.deep = true; break; case "research": options.deep = true; options.evidence = true; options.includeCrypto = true; break; default: break; } }; const configureServer = (cdxgenServer) => { cdxgenServer.headersTimeout = TIMEOUT_MS; cdxgenServer.requestTimeout = TIMEOUT_MS; cdxgenServer.timeout = 0; cdxgenServer.keepAliveTimeout = 0; }; const start = (options) => { console.log( "Listening on", options.serverHost, options.serverPort, "without authentication!", ); if (["0.0.0.0", "::", "::/128", "::/0"].includes(options.serverHost)) { console.log("Exposing cdxgen server on all IP address is a security risk!"); if (isSecureMode) { process.exit(1); } } if (+options.serverPort < 1024) { console.log( "Running cdxgen server with a privileged port is a security risk!", ); if (isSecureMode) { process.exit(1); } } if ( process.getuid && process.getuid() === 0 && process.env?.CDXGEN_IN_CONTAINER !== "true" ) { console.log("Running cdxgen server as root is a security risk!"); if (isSecureMode) { process.exit(1); } } if (!process.env.CDXGEN_SERVER_ALLOWED_HOSTS) { console.log( "No allowlist for hosts has been specified. This is a security risk that could expose the system to SSRF vulnerabilities!", ); if (isSecureMode) { process.exit(1); } } const cdxgenServer = http .createServer(app) .listen(options.serverPort, options.serverHost); configureServer(cdxgenServer); app.use("/health", (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ status: "OK" }, null, 2)); }); app.use("/sbom", async (req, res) => { // Limit to only GET and POST requests if (req.method && !["GET", "POST"].includes(req.method.toUpperCase())) { res.writeHead(405, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Method Not Allowed", }), ); } const requestUrl = new URL(req.url, `http://${req.headers.host}`); const q = Object.fromEntries(requestUrl.searchParams.entries()); let cleanup = false; const reqOptions = parseQueryString( q, req.body, Object.assign({}, options), ); const filePath = q.path || q.url || req.body.path || req.body.url; if (!filePath) { res.writeHead(500, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Path or URL is required.", }), ); } let srcDir = filePath; if (filePath.startsWith("http") || filePath.startsWith("git")) { // Validate the hostnames const gitUrlObj = new URL(filePath); if (!isAllowedHost(gitUrlObj.hostname)) { res.writeHead(403, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Host Not Allowed", details: "The Git URL host is not allowed as per the allowlist.", }), ); } srcDir = gitClone(filePath, reqOptions.gitBranch); cleanup = true; } else { if (!isAllowedPath(path.resolve(srcDir))) { res.writeHead(403, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Path Not Allowed", details: "Path is not allowed as per the allowlist.", }), ); } } if (srcDir !== path.resolve(srcDir)) { console.log( `Invoke the API with an absolute path '${path.resolve(srcDir)}' to reduce security risks.`, ); if (isSecureMode) { res.writeHead(500, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Absolute path needed", details: "Relative paths are not supported in secure mode.", }), ); } } console.log("Generating SBOM for", srcDir); let bomNSData = (await createBom(srcDir, reqOptions)) || {}; bomNSData = postProcess(bomNSData, reqOptions); if (reqOptions.serverUrl && reqOptions.apiKey) { if (!isAllowedHost(reqOptions.serverUrl)) { res.writeHead(403, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Host Not Allowed", details: "The URL host is not allowed as per the allowlist.", }), ); } if (isSecureMode && !reqOptions.serverUrl?.startsWith("https://")) { console.log( "Dependency Track API server is used with a non-https url, which poses a security risk.", ); } console.log( `Publishing SBOM ${reqOptions.projectName} to Dependency Track`, reqOptions.serverUrl, ); try { await submitBom(reqOptions, bomNSData.bomJson); } catch (error) { const errorMessages = error.response?.body?.errors; if (errorMessages) { res.writeHead(500, { "Content-Type": "application/json" }); return res.end( JSON.stringify({ error: "Unable to submit the SBOM to the Dependency-Track server", details: errorMessages, }), ); } } } res.writeHead(200, { "Content-Type": "application/json" }); if (bomNSData.bomJson) { if ( typeof bomNSData.bomJson === "string" || bomNSData.bomJson instanceof String ) { res.write(bomNSData.bomJson); } else { res.write(JSON.stringify(bomNSData.bomJson, null, null)); } } res.end("\n"); if (cleanup && srcDir && srcDir.startsWith(getTmpDir()) && fs.rmSync) { console.log(`Cleaning up ${srcDir}`); fs.rmSync(srcDir, { recursive: true, force: true }); } }); }; export { configureServer, start };