@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
739 lines (711 loc) • 21.8 kB
JavaScript
import { constants, readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";
import { globSync } from "glob";
import { PackageURL } from "packageurl-js";
import propertiesReader from "properties-reader";
import { valid } from "semver";
import {
cdxgenAgent,
DEBUG_MODE,
getAllFiles,
multiChecksumFile,
readEnvironmentVariable,
safeCopyFileSync,
safeExistsSync,
safeUnlinkSync,
safeWriteSync,
} from "./utils.js";
/**
* Returns a default location of the plugins file.
*
* @param {string} projectPath Path to the SBT project
*/
export function sbtPluginsPath(projectPath) {
return join(projectPath, "project", "plugins.sbt");
}
/**
* Determine the version of SBT used in compilation of this project.
* By default it looks into a standard SBT location i.e.
* <path-project>/project/build.properties
* Returns `null` if the version cannot be determined.
*
* @param {string} projectPath Path to the SBT project
*/
export function determineSbtVersion(projectPath) {
const buildPropFile = join(projectPath, "project", "build.properties");
if (DEBUG_MODE) {
console.log("Looking for", buildPropFile);
}
if (safeExistsSync(buildPropFile)) {
const properties = propertiesReader(buildPropFile);
const property = properties.get("sbt.version");
if (property != null && valid(property)) {
return property;
}
}
return null;
}
/**
* Adds a new plugin to the SBT project by amending its plugins list.
* Only recommended for SBT < 1.2.0 or otherwise use `addPluginSbtFile`
* parameter.
* The change manipulates the existing plugins' file by creating a copy of it
* and returning a path where it is moved to.
* Once the SBT task is complete one must always call `cleanupPlugin` to remove
* the modifications made in place.
*
* @param {string} projectPath Path to the SBT project
* @param {string} plugin Name of the plugin to add
*/
export function addPlugin(projectPath, plugin) {
const pluginsFile = sbtPluginsPath(projectPath);
let originalPluginsFile = null;
if (safeExistsSync(pluginsFile)) {
originalPluginsFile = `${pluginsFile}.cdxgen`;
safeCopyFileSync(
pluginsFile,
originalPluginsFile,
constants.COPYFILE_FICLONE,
);
}
safeWriteSync(pluginsFile, plugin, { flag: "a" });
return originalPluginsFile;
}
/**
* Cleans up modifications to the project's plugins' file made by the
* `addPlugin` function.
*
* @param {string} projectPath Path to the SBT project
* @param {string} originalPluginsFile Location of the original plugins file, if any
*/
export function cleanupPlugin(projectPath, originalPluginsFile) {
const pluginsFile = sbtPluginsPath(projectPath);
if (safeExistsSync(pluginsFile)) {
if (!originalPluginsFile) {
// just remove the file, it was never there
safeUnlinkSync(pluginsFile);
return !safeExistsSync(pluginsFile);
}
// Bring back the original file
safeCopyFileSync(
originalPluginsFile,
pluginsFile,
constants.COPYFILE_FICLONE,
);
safeUnlinkSync(originalPluginsFile);
return true;
}
return false;
}
/**
* Find the repository URL from the local Coursier cache for a given Maven package.
*
* @param {string} group Maven groupId
* @param {string} name Maven artifactId (original name with suffix if applicable)
* @param {string} version Package version
* @returns {string|null} The repository URL or null if not found
*/
export function findCoursierRegistryUrl(group, name, version) {
if (!group || !name || !version) {
return null;
}
const home =
readEnvironmentVariable("HOME") || readEnvironmentVariable("USERPROFILE");
if (!home) {
return null;
}
// Determine cache root
let cacheRoot = readEnvironmentVariable("COURSIER_CACHE");
if (!cacheRoot) {
if (process.platform === "darwin") {
cacheRoot = join(home, "Library", "Caches", "Coursier", "v1");
} else if (process.platform === "win32") {
const localAppData = readEnvironmentVariable("LOCALAPPDATA");
if (localAppData) {
cacheRoot = join(localAppData, "Coursier", "Cache", "v1");
} else {
cacheRoot = join(home, "AppData", "Local", "Coursier", "Cache", "v1");
}
} else {
cacheRoot = join(home, ".cache", "coursier", "v1");
}
}
if (!safeExistsSync(cacheRoot)) {
return null;
}
const groupPath = group.replace(/\./g, "/");
const pattern = `**/${groupPath}/${name}/${version}/*`;
try {
const matches = globSync(pattern, { cwd: cacheRoot });
if (matches && matches.length > 0) {
// Find the first match and extract the repository URL
const match = matches[0].replace(/\\/g, "/"); // normalize backslashes on Windows
const idx = match.indexOf(groupPath);
if (idx !== -1) {
const prefix = match.substring(0, idx);
const parts = prefix.split("/").filter((p) => p);
if (parts.length >= 2) {
const protocol = parts[0];
if (protocol === "file") {
let localPath = parts.slice(1).join("/");
if (!localPath.startsWith("/")) {
localPath = `/${localPath}`;
}
return `file://${localPath}`;
}
const host = parts[1];
const repoPath = parts.slice(2).join("/");
let repoUrl = `${protocol}://${host}`;
if (repoPath) {
repoUrl += `/${repoPath}`;
}
if (repoUrl.endsWith("/")) {
repoUrl = repoUrl.substring(0, repoUrl.length - 1);
}
return repoUrl;
}
}
}
} catch (_err) {
// ignore
}
return null;
}
/**
* Test if a given URL exists (returns 2xx/3xx for http/https, or exists on disk for file)
*
* @param {string} url URL to test
* @returns {Promise<boolean>} true if URL exists
*/
export async function testUrlExists(url) {
if (!url) {
return false;
}
if (url.startsWith("file://")) {
let localPath = url.substring(7);
if (process.platform === "win32" && localPath.startsWith("/")) {
localPath = localPath.substring(1);
}
return safeExistsSync(localPath);
}
try {
const response = await cdxgenAgent.head(url, {
timeout: { request: 3000 },
retry: { limit: 0 },
followRedirect: true,
});
return response.statusCode >= 200 && response.statusCode < 400;
} catch (_err) {
return false;
}
}
/**
* Find the local jar path in Coursier cache if it exists.
*
* @param {string} group Maven groupId
* @param {string} name Maven artifactId (original name with suffix)
* @param {string} version Package version
* @returns {string|null} local jar path or null
*/
export function findLocalJarPath(group, name, version) {
if (!group || !name || !version) {
return null;
}
const home =
readEnvironmentVariable("HOME") || readEnvironmentVariable("USERPROFILE");
if (!home) {
return null;
}
// Determine cache root
let cacheRoot = readEnvironmentVariable("COURSIER_CACHE");
if (!cacheRoot) {
if (process.platform === "darwin") {
cacheRoot = join(home, "Library", "Caches", "Coursier", "v1");
} else if (process.platform === "win32") {
const localAppData = readEnvironmentVariable("LOCALAPPDATA");
if (localAppData) {
cacheRoot = join(localAppData, "Coursier", "Cache", "v1");
} else {
cacheRoot = join(home, "AppData", "Local", "Coursier", "Cache", "v1");
}
} else {
cacheRoot = join(home, ".cache", "coursier", "v1");
}
}
if (!safeExistsSync(cacheRoot)) {
return null;
}
const groupPath = group.replace(/\./g, "/");
const pattern = `**/${groupPath}/${name}/${version}/*`;
try {
const matches = globSync(pattern, { cwd: cacheRoot });
if (matches && matches.length > 0) {
// Find the first match that looks like the jar file or just construct the path
for (const m of matches) {
const match = m.replace(/\\/g, "/");
if (match.endsWith(".jar")) {
const localPath = join(cacheRoot, match);
if (safeExistsSync(localPath)) {
return localPath;
}
}
}
// Fallback construction
const match = matches[0].replace(/\\/g, "/");
const idx = match.indexOf(groupPath);
if (idx !== -1) {
const prefix = match.substring(0, idx);
const localPath = join(
cacheRoot,
prefix,
groupPath,
name,
version,
`${name}-${version}.jar`,
);
if (safeExistsSync(localPath)) {
return localPath;
}
}
}
} catch (_err) {
// ignore
}
return null;
}
/**
* Resolves the direct download URL for a Maven jar package if found in the local cache,
* and validates that the URL exists.
*
* @param {string} group Maven groupId
* @param {string} name Maven artifactId (original name with suffix)
* @param {string} version Package version
* @returns {Promise<{ repoUrl: string, jarUrl: string, hashes?: Array }|null>} resolved URLs or null
*/
export async function resolveJarDistribution(group, name, version) {
const repoUrl = findCoursierRegistryUrl(group, name, version);
if (!repoUrl) {
return null;
}
const groupPath = group.replace(/\./g, "/");
const jarUrl = `${repoUrl}/${groupPath}/${name}/${version}/${name}-${version}.jar`;
const result = { repoUrl, jarUrl };
const localJarPath = findLocalJarPath(group, name, version);
if (localJarPath) {
try {
const hashValues = await multiChecksumFile(
["md5", "sha1", "sha256", "sha512"],
localJarPath,
);
result.hashes = [
{ alg: "MD5", content: hashValues["md5"] },
{ alg: "SHA-1", content: hashValues["sha1"] },
{ alg: "SHA-256", content: hashValues["sha256"] },
{ alg: "SHA-512", content: hashValues["sha512"] },
];
} catch (_err) {
// ignore
}
}
return result;
}
/**
* Parse an sbt dependency tree output file and return the package list and dependency tree.
*
* Reads a file produced by the sbt `dependencyTree` command and extracts Maven artifact
* coordinates, building a hierarchical dependency graph. Evicted packages and ranges are ignored.
*
* @param {string} sbtTreeFile Path to the sbt dependency tree output file
* @returns {{ pkgList: Object[], dependenciesList: Object[] }}
*/
export async function parseSbtTree(sbtTreeFile) {
const pkgList = [];
const dependenciesList = [];
const keys_cache = {};
const level_trees = {};
const tmpA = readFileSync(sbtTreeFile, { encoding: "utf-8" }).split("\n");
let last_level = 0;
let last_purl = "";
let stack = [];
let first_purl = "";
for (let l of tmpA) {
l = l.replace("\r", "");
// Ignore evicted packages and packages with multiple version matches indicated with a comma
// | +-org.scala-lang:scala3-library_3:3.1.3 (evicted by: 3.3.0)
// | | | | | | +-org.eclipse.platform:org.eclipse.equinox.common:[3.15.100,4.0...
// | | | | | | | +-org.eclipse.platform:org.eclipse.equinox.common:3.17.100 (ev..
if (l.includes("(evicted") || l.includes(",")) {
continue;
}
let level = 0;
let isLibrary = false;
if (l.endsWith("[S]")) {
isLibrary = true;
}
const tmpB = l.split("+-");
if (tmpB.length > 1) {
level = Math.floor(tmpB[0].length / 2);
}
const pkgLine = tmpB[tmpB.length - 1].split(" ")[0];
if (!pkgLine.includes(":")) {
continue;
}
const pkgParts = pkgLine.split(":");
let group = "";
let name = "";
let version = "";
if (pkgParts.length === 3) {
group = pkgParts[0];
name = pkgParts[1];
version = pkgParts[2];
} else if (pkgParts.length === 2) {
// unlikely for scala
name = pkgParts[0];
version = pkgParts[1];
}
if (!name?.length) {
console.log(pkgLine, "was not parsed correctly!");
continue;
}
const originalName = name;
const scalaSuffixRegex = /_(2\.\d+|3)$/;
const match = name.match(scalaSuffixRegex);
let compilerVersion = null;
if (match) {
compilerVersion = match[1];
name = name.replace(scalaSuffixRegex, "");
}
const distInfo = await resolveJarDistribution(group, originalName, version);
const qualifiers = { type: "jar" };
if (distInfo && !distInfo.repoUrl.startsWith("file://")) {
qualifiers.repository_url = distInfo.repoUrl;
}
const purlString = new PackageURL(
"maven",
group,
name,
version,
qualifiers,
null,
).toString();
// Filter duplicates
if (!keys_cache[purlString]) {
const adep = {
group,
name,
version,
purl: purlString,
"bom-ref": decodeURIComponent(purlString),
evidence: {
identity: {
field: "purl",
confidence: 1,
concludedValue: purlString,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: sbtTreeFile,
},
],
},
},
};
if (isLibrary) {
adep["type"] = "library";
}
const props = [];
if (compilerVersion) {
props.push({
name: "cdx:scala:compilerVersion",
value: compilerVersion,
});
}
if (props.length > 0) {
adep.properties = props;
}
if (distInfo) {
if (!distInfo.jarUrl.startsWith("file://")) {
adep.externalReferences = [
{
type: "distribution",
url: distInfo.jarUrl,
},
];
}
if (distInfo.hashes) {
adep.hashes = distInfo.hashes;
}
}
pkgList.push(adep);
keys_cache[purlString] = true;
}
// From here the logic is similar to parsing gradle tree
if (!level_trees[purlString]) {
level_trees[purlString] = [];
}
if (level === 0) {
first_purl = purlString;
stack = [purlString];
} else if (last_purl === "") {
stack.push(purlString);
} else if (level > last_level) {
const cnodes = level_trees[last_purl] || [];
if (!cnodes.includes(purlString)) {
cnodes.push(purlString);
}
level_trees[last_purl] = cnodes;
if (stack[stack.length - 1] !== purlString) {
stack.push(purlString);
}
} else {
for (let i = 0; i < last_level - level + 1; i++) {
stack.pop();
}
const last_stack =
stack.length > 0 ? stack[stack.length - 1] : first_purl;
const cnodes = level_trees[last_stack] || [];
if (!cnodes.includes(purlString)) {
cnodes.push(purlString);
}
level_trees[last_stack] = cnodes;
stack.push(purlString);
}
last_level = level;
last_purl = purlString;
}
for (const lk of Object.keys(level_trees)) {
dependenciesList.push({
ref: lk,
dependsOn: [...new Set(level_trees[lk])].sort(),
});
}
return { pkgList, dependenciesList };
}
/**
* Parse sbt lock file
*
* @param {string} pkgLockFile build.sbt.lock file
*/
export async function parseSbtLock(pkgLockFile) {
const pkgList = [];
if (safeExistsSync(pkgLockFile)) {
const lockData = JSON.parse(
readFileSync(pkgLockFile, { encoding: "utf-8" }),
);
if (lockData?.dependencies) {
for (const pkg of lockData.dependencies) {
const artifacts = pkg.artifacts || undefined;
let integrity = "";
if (artifacts?.length) {
integrity = artifacts[0].hash.replace("sha1:", "sha1-");
}
let compScope;
if (pkg.configurations) {
if (pkg.configurations.includes("runtime")) {
compScope = "required";
} else {
compScope = "optional";
}
}
const originalName = pkg.name;
let name = pkg.name;
const scalaSuffixRegex = /_(2\.\d+|3)$/;
const match = name.match(scalaSuffixRegex);
let compilerVersion = null;
if (match) {
compilerVersion = match[1];
name = name.replace(scalaSuffixRegex, "");
}
const distInfo = await resolveJarDistribution(
pkg.org,
originalName,
pkg.version,
);
const props = [
{
name: "SrcFile",
value: pkgLockFile,
},
];
if (compilerVersion) {
props.push({
name: "cdx:scala:compilerVersion",
value: compilerVersion,
});
}
const qualifiers = { type: "jar" };
if (distInfo && !distInfo.repoUrl.startsWith("file://")) {
qualifiers.repository_url = distInfo.repoUrl;
}
const purlString = new PackageURL(
"maven",
pkg.org,
name,
pkg.version,
qualifiers,
null,
).toString();
const adep = {
group: pkg.org,
name,
version: pkg.version,
_integrity: integrity,
scope: compScope,
properties: props,
purl: purlString,
"bom-ref": decodeURIComponent(purlString),
evidence: {
identity: {
field: "purl",
confidence: 1,
concludedValue: purlString,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: pkgLockFile,
},
],
},
},
};
if (distInfo) {
if (!distInfo.jarUrl.startsWith("file://")) {
adep.externalReferences = [
{
type: "distribution",
url: distInfo.jarUrl,
},
];
}
if (distInfo.hashes) {
adep.hashes = distInfo.hashes;
}
}
pkgList.push(adep);
}
}
}
return pkgList;
}
/**
* Parse the root build.sbt to extract the aggregate project name, organization, and version.
*
* @param {string} projectPath Directory path of the project
* @returns {{ name: string, group: string, version: string }|null}
*/
export function parseSbtRootProject(projectPath) {
const buildSbt = join(projectPath, "build.sbt");
if (!safeExistsSync(buildSbt)) {
return null;
}
try {
const content = readFileSync(buildSbt, { encoding: "utf-8" });
const nameMatch = content.match(/^name\s*:=\s*"([^"]+)"/m);
const orgMatch = content.match(
/ThisBuild\s*\/\s*organization\s*:=\s*"([^"]+)"/m,
);
const versionMatch = content.match(
/ThisBuild\s*\/\s*version\s*:=\s*"([^"]+)"/m,
);
if (!nameMatch || !versionMatch) {
return null;
}
return {
name: nameMatch[1],
group: orgMatch ? orgMatch[1] : "",
version: versionMatch[1],
};
} catch (_err) {
return null;
}
}
/**
* Discover SBT subproject names statically by parsing build.sbt and project files.
*
* @param {string} projectPath Directory path of the project
* @returns {string[]} List of discovered subproject names
*/
export function discoverSbtProjects(projectPath) {
const projects = new Set();
const sbtFiles = getAllFiles(projectPath, "**/*.sbt");
const scalaFiles = getAllFiles(projectPath, "project/**/*.scala");
const allFiles = [...sbtFiles, ...scalaFiles];
const projectRegex =
/(?:lazy\s+val|val)\s+([a-zA-Z0-9_-]+)\s*=\s*(?:project|Projects\.|(project\s+in))/g;
for (const file of allFiles) {
try {
const content = readFileSync(file, { encoding: "utf-8" });
let match;
projectRegex.lastIndex = 0;
while ((match = projectRegex.exec(content)) !== null) {
const projName = match[1].trim();
if (projName && projName !== "root") {
projects.add(projName);
}
}
} catch (_err) {
// ignore
}
}
return [...projects];
}
/**
* Parse plugins.sbt files to extract sbt plugins as development dependencies.
*
* @param {string} projectPath Directory path of the project
* @returns {Object[]} List of parsed dependency components
*/
export function parseSbtPlugins(projectPath) {
const plugins = [];
const pluginFiles = getAllFiles(projectPath, "**/plugins.sbt");
const pluginRegex =
/addSbtPlugin\(\s*(["'])([^"'\s]+)\1\s*(%%?)\s*(["'])([^"'\s]+)\4\s*%\s*(["'])([^"'\s]+)\6\s*\)/g;
for (const file of pluginFiles) {
try {
const content = readFileSync(file, { encoding: "utf-8" });
let match;
pluginRegex.lastIndex = 0;
while ((match = pluginRegex.exec(content)) !== null) {
const group = match[2];
const name = match[5];
const version = match[7];
const purl = `pkg:maven/${group}/${name}@${version}?type=jar`;
const adep = {
group,
name,
version,
purl,
"bom-ref": decodeURIComponent(purl),
scope: "optional",
properties: [
{
name: "cdx:sbt:package:development",
value: "true",
},
],
evidence: {
identity: {
field: "purl",
confidence: 1,
concludedValue: purl,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: file,
},
],
},
},
};
plugins.push(adep);
}
} catch (_err) {
// ignore
}
}
return plugins;
}