@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
232 lines (207 loc) • 7.17 kB
JavaScript
import { PackageURL } from "packageurl-js";
import { thoughtLog } from "./logger.js";
import { getDistroInfo } from "./osinfo.js";
import { safeSpawnSync } from "./utils.js";
// ---------------------------------------------------------------------------
// Caches
// ---------------------------------------------------------------------------
/** file path → pkgInfo (or undefined when not owned by any package) */
const packageCache = new Map();
/** pkgName (as returned by dpkg-query -S) → pkgInfo */
const pkgNameCache = new Map();
// ---------------------------------------------------------------------------
// Exported for unit tests only — resets all caches.
// ---------------------------------------------------------------------------
export function _resetOsInfoCache() {
packageCache.clear();
pkgNameCache.clear();
}
// ---------------------------------------------------------------------------
// Alpine: parse "musl-1.2.4-r2" → { name: "musl", version: "1.2.4-r2" }
// ---------------------------------------------------------------------------
function parseAlpinePackage(pkgStr) {
const parts = pkgStr.split("-");
let versionIndex = parts.findIndex((p) => /^\d/.test(p));
if (versionIndex === -1) {
versionIndex = parts.length - 1;
}
return {
name: parts.slice(0, versionIndex).join("-"),
version: parts.slice(versionIndex).join("-"),
};
}
// ---------------------------------------------------------------------------
// Build a PackageURL string from resolved package info.
//
// Distro qualifiers (distro, distro_name) are taken from /etc/os-release via
// getDistroInfo() so they are always accurate, never hardcoded.
//
// "brew" is not an official PackageURL type — Homebrew packages are emitted
// as pkg:generic with a package_manager=homebrew qualifier.
// ---------------------------------------------------------------------------
function buildPurl(pkgInfo) {
let purlType = pkgInfo.type;
let namespace;
const qualifiers = {};
if (pkgInfo.arch) {
qualifiers.arch = pkgInfo.arch;
}
if (purlType === "deb" || purlType === "apk" || purlType === "rpm") {
const di = getDistroInfo();
namespace = di.namespace;
// distro qualifier: ID-VERSION_ID (e.g. "fedora-25", "alpine-3.17")
if (di.distroId) {
qualifiers.distro = di.distroId;
}
// distro_name qualifier: codename (e.g. "jammy", "bookworm")
if (di.distroName) {
qualifiers.distro_name = di.distroName;
}
} else if (purlType === "brew") {
// No official brew purl type — use generic with qualifier
purlType = "generic";
qualifiers.package_manager = "homebrew";
}
const finalQualifiers = Object.keys(qualifiers).length
? qualifiers
: undefined;
try {
return new PackageURL(
purlType,
namespace || undefined,
pkgInfo.name,
pkgInfo.version || undefined,
finalQualifiers,
undefined,
).toString();
} catch (_e) {
return "";
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolves a file path to its owning OS package manager package, including a
* correctly computed purl with distro qualifiers derived from /etc/os-release.
*
* @param {string} filePath - Absolute path to the library file
* @returns {{ name: string, version: string, arch: string, type: string, purl: string } | undefined}
*/
export function resolvePackageForFile(filePath) {
if (!filePath) {
return undefined;
}
if (packageCache.has(filePath)) {
return packageCache.get(filePath);
}
let pkgInfo;
try {
if (process.platform === "linux") {
pkgInfo = _resolveLinux(filePath);
} else if (process.platform === "darwin") {
pkgInfo = _resolveDarwin(filePath);
}
} catch (err) {
thoughtLog(
`OS package resolution encountered an error for ${filePath}: ${err}`,
);
}
if (pkgInfo) {
pkgInfo.purl = buildPurl(pkgInfo);
}
packageCache.set(filePath, pkgInfo);
return pkgInfo;
}
// ---------------------------------------------------------------------------
// Platform-specific resolvers
// ---------------------------------------------------------------------------
function _resolveLinux(filePath) {
// 1. Debian/Ubuntu (dpkg-query -S)
const dpkgRes = safeSpawnSync("dpkg-query", ["-S", filePath]);
if (dpkgRes && dpkgRes.status === 0 && dpkgRes.stdout) {
const line = dpkgRes.stdout.split("\n")[0];
const colonIdx = line.indexOf(": ");
if (colonIdx !== -1) {
const rawPkgName = line.substring(0, colonIdx).trim();
if (pkgNameCache.has(rawPkgName)) {
return pkgNameCache.get(rawPkgName);
}
const infoRes = safeSpawnSync("dpkg-query", [
"-W",
// biome-ignore lint/suspicious/noTemplateCurlyInString: dpkg-query format string
"-f=${Version} ${Architecture}",
rawPkgName,
]);
if (infoRes && infoRes.status === 0 && infoRes.stdout) {
const [version, arch] = infoRes.stdout.trim().split(" ");
const info = {
name: rawPkgName.split(":")[0], // strip ":amd64" arch suffix
version,
arch,
type: "deb",
};
pkgNameCache.set(rawPkgName, info);
return info;
}
}
}
// 2. Alpine Linux (apk info -W)
const apkRes = safeSpawnSync("apk", ["info", "-W", filePath]);
if (apkRes && apkRes.status === 0 && apkRes.stdout) {
const line = apkRes.stdout.split("\n")[0].trim();
const marker = " is owned by ";
const markerIdx = line.indexOf(marker);
if (markerIdx !== -1) {
const pkgRaw = line.substring(markerIdx + marker.length).trim();
const { name, version } = parseAlpinePackage(pkgRaw);
return {
name,
version,
arch: process.arch === "x64" ? "x86_64" : process.arch,
type: "apk",
};
}
}
// 3. RPM-based (rpm -qf)
const rpmRes = safeSpawnSync("rpm", [
"-qf",
"--qf",
"%{NAME} %{VERSION}-%{RELEASE} %{ARCH}\n",
filePath,
]);
if (rpmRes && rpmRes.status === 0 && rpmRes.stdout) {
const line = rpmRes.stdout.split("\n")[0].trim();
const parts = line.split(" ");
if (parts.length >= 3) {
return {
name: parts[0],
version: parts[1],
arch: parts[2],
type: "rpm",
};
}
}
return undefined;
}
function _resolveDarwin(filePath) {
// Homebrew installs files under .../Cellar/<name>/<version>/...
// Use path parsing rather than shelling out to brew (which is slow).
const cellarMarkers = ["/Cellar/", "/homebrew/Cellar/"];
for (const marker of cellarMarkers) {
const idx = filePath.indexOf(marker);
if (idx !== -1) {
const rest = filePath.substring(idx + marker.length);
const segments = rest.split("/");
if (segments.length >= 2) {
return {
name: segments[0],
version: segments[1],
arch: process.arch,
type: "brew", // remapped to pkg:generic in buildPurl
};
}
}
}
return undefined;
}