@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
322 lines (301 loc) • 8.87 kB
JavaScript
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 };