@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
407 lines (390 loc) • 12.8 kB
JavaScript
import { readFileSync } from "node:fs";
import { dirname } from "node:path";
import { PackageURL } from "packageurl-js";
import {
buildNpmGitDistributionIntakeRefs,
buildNpmGitPurlQualifiers,
buildNpmRegistryTarballUrl,
classifyNpmManifestSource,
loadNpmrcConfig,
normalizeNpmRegistryUrl,
setNpmDevelopmentProperty,
setNpmOptionalProperty,
setNpmPeerProperty,
} from "./npmutils.js";
import {
DEBUG_MODE,
getNpmMetadata,
safeExistsSync,
shouldFetchLicense,
shouldFetchVCS,
} from "./utils.js";
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
/**
* Split a bun.lock package descriptor (eg `@babel/parser@7.29.7`,
* `left-pad@1.3.0` or `foo@git+https://github.com/foo/bar#abcdef`) into its
* group, name and version/specifier components.
*
* @param {string} descriptor The `name@specifier` descriptor string.
* @returns {{group: string, name: string, version: string}} Parsed pieces. The
* version is returned verbatim, so non-registry specifiers (git/tarball URLs)
* are preserved for the caller to handle.
*/
export function parseBunDescriptor(descriptor) {
// The name may itself start with `@` (scoped package), so look for the `@`
// that separates name from specifier, i.e. the first one not at index 0.
const atIndex = descriptor.indexOf("@", 1);
let fullName = descriptor;
let version = "";
if (atIndex > 0) {
fullName = descriptor.substring(0, atIndex);
version = descriptor.substring(atIndex + 1);
}
let group = "";
let name = fullName;
if (fullName.startsWith("@")) {
const slashIndex = fullName.indexOf("/");
if (slashIndex > 0) {
group = fullName.substring(0, slashIndex);
name = fullName.substring(slashIndex + 1);
}
}
return { group, name, version };
}
/**
* Determine whether a bun version specifier points at a non-registry source
* (git, tarball URL, workspace or local path).
*
* @param {string} version The specifier extracted from the descriptor.
* @returns {boolean} True when the specifier is not a plain semver version.
*/
function isNonRegistrySpecifier(version) {
if (!version) {
return false;
}
return (
version.startsWith("git") ||
version.includes("://") ||
version.startsWith("github:") ||
version.startsWith("gitlab:") ||
version.startsWith("bitbucket:") ||
version.startsWith("workspace:") ||
version.startsWith("file:") ||
version.startsWith("link:")
);
}
/**
* Parse a bun text lockfile (`bun.lock`, lockfileVersion 1).
*
* Bun's text lockfile is JSONC (JSON with trailing commas). It records the
* workspace roots under `workspaces` and the fully resolved dependency tree
* under `packages`, where each entry is an array of the form
* `["name@version", "registry", { dependencies, optionalDependencies, bin,
* os, cpu, ... }, "sha512-..."]`. Nested duplicate versions are keyed by their
* dependency path (eg `"parent/child"`).
*
* The binary lockfile (`bun.lockb`) is intentionally not supported - callers
* should ask users to regenerate it with `bun install --save-text-lockfile`.
*
* @param {string} bunLockFile Path to the bun.lock file.
* @param {Object} [options] Parsing options (`parentComponent`).
* @returns {Promise<{pkgList: Array, dependenciesList: Array}>} Parsed packages
* and dependency graph, matching the shape of the other lockfile parsers.
*/
export async function parseBunLock(bunLockFile, options = {}) {
let pkgList = [];
const dependenciesList = [];
if (!safeExistsSync(bunLockFile)) {
return { pkgList, dependenciesList };
}
const npmrcConfig = loadNpmrcConfig(
options.projectRoot || dirname(bunLockFile),
);
const defaultRegistry =
normalizeNpmRegistryUrl(npmrcConfig.registry) || DEFAULT_NPM_REGISTRY;
const rawData = readFileSync(bunLockFile, "utf8");
let lockData;
try {
// Strip JSONC trailing commas (bun.lock does not use comments) before
// parsing. Package names, versions and integrity hashes never contain the
// `,}`/`,]` sequences this targets, so the replacement is safe.
const jsonText = rawData.replace(/,(\s*[}\]])/g, "$1");
lockData = JSON.parse(jsonText);
} catch (err) {
if (DEBUG_MODE) {
console.log(`Unable to parse ${bunLockFile}`, err);
}
return { pkgList, dependenciesList };
}
const packages = lockData.packages || {};
const workspaces = lockData.workspaces || {};
// First pass: build per-key metadata and purl/bom-ref lookups. Bun keys
// nested duplicate versions by dependency path (eg `parent/child`), which we
// handle per-lookup in resolveDepRef below.
const infoForKey = new Map();
const refToKey = new Map();
for (const [key, entry] of Object.entries(packages)) {
if (!Array.isArray(entry) || !entry.length) {
continue;
}
const descriptor = entry[0];
const registry = entry.length > 1 ? entry[1] : "";
const meta = entry.length > 2 && entry[2] ? entry[2] : {};
const integrity = entry.length > 3 ? entry[3] : undefined;
const { group, name, version } = parseBunDescriptor(descriptor);
if (!name || !version) {
continue;
}
const isGitDep =
version.startsWith("git") ||
version.startsWith("github:") ||
version.startsWith("gitlab:") ||
version.startsWith("bitbucket:");
let qualifiers = null;
if (isGitDep) {
qualifiers = buildNpmGitPurlQualifiers(version, group, npmrcConfig);
} else if (isNonRegistrySpecifier(version)) {
qualifiers = { download_url: version };
}
const purlString = new PackageURL(
"npm",
group,
name,
version,
qualifiers,
null,
).toString();
const bomRef = decodeURIComponent(purlString);
infoForKey.set(key, {
group,
name,
version,
registry,
meta,
integrity,
purlString,
bomRef,
isGitDep,
isNonRegistry: isNonRegistrySpecifier(version),
});
if (!refToKey.has(bomRef)) {
refToKey.set(bomRef, key);
}
}
// Resolve a dependency name referenced from `parentKey` to the bom-ref of the
// concrete package bun installed for it. Prefer the nested path key, then
// fall back to the top-level entry.
const resolveDepRef = (parentKey, depName) => {
const nestedKey = parentKey ? `${parentKey}/${depName}` : depName;
if (infoForKey.has(nestedKey)) {
return infoForKey.get(nestedKey).bomRef;
}
if (infoForKey.has(depName)) {
return infoForKey.get(depName).bomRef;
}
return undefined;
};
// Track packages surfaced as optional / peer dependencies anywhere in the
// tree so they can be annotated with the matching cdx properties.
const optionalRefs = new Set();
const peerRefs = new Set();
// Collect the runtime dependency refs declared by a package's metadata.
const runtimeDepRefs = (parentKey, meta) => {
const refs = new Set();
const collect = (depBlock, markSet) => {
if (!depBlock) {
return;
}
for (const depName of Object.keys(depBlock)) {
const ref = resolveDepRef(parentKey, depName);
if (ref) {
refs.add(ref);
if (markSet) {
markSet.add(ref);
}
}
}
};
collect(meta.dependencies, null);
collect(meta.optionalDependencies, optionalRefs);
collect(meta.peerDependencies, peerRefs);
return refs;
};
// Seed a production-reachability walk from the non-dev dependencies of every
// workspace root so dev-only packages can be scoped as optional.
const rootProdRefs = new Set();
for (const wsEntry of Object.values(workspaces)) {
if (!wsEntry) {
continue;
}
for (const depName of Object.keys(wsEntry.dependencies || {})) {
const ref = resolveDepRef("", depName);
if (ref) {
rootProdRefs.add(ref);
}
}
for (const depName of Object.keys(wsEntry.optionalDependencies || {})) {
const ref = resolveDepRef("", depName);
if (ref) {
rootProdRefs.add(ref);
optionalRefs.add(ref);
}
}
for (const depName of Object.keys(wsEntry.peerDependencies || {})) {
const ref = resolveDepRef("", depName);
if (ref) {
peerRefs.add(ref);
}
}
}
const prodRefs = new Set();
const queue = [...rootProdRefs];
while (queue.length) {
const ref = queue.shift();
if (prodRefs.has(ref)) {
continue;
}
prodRefs.add(ref);
const key = refToKey.get(ref);
if (!key) {
continue;
}
for (const childRef of runtimeDepRefs(key, infoForKey.get(key).meta)) {
if (!prodRefs.has(childRef)) {
queue.push(childRef);
}
}
}
// Second pass: emit the package list and dependency graph.
const seenRefs = new Set();
for (const [key, info] of infoForKey.entries()) {
if (seenRefs.has(info.bomRef)) {
continue;
}
seenRefs.add(info.bomRef);
const { group, name, version, registry, meta, integrity, purlString } =
info;
const properties = [{ name: "SrcFile", value: bunLockFile }];
const externalReferences = [];
// Resolve the distribution (tarball) URL. Bun leaves the registry field
// empty for the default npm registry, so synthesise the tarball URL in
// that case; otherwise use the recorded resolution.
let resolvedUrl;
if (registry && typeof registry === "string" && registry.length) {
resolvedUrl = registry;
} else if (!info.isNonRegistry) {
resolvedUrl = buildNpmRegistryTarballUrl(
defaultRegistry,
group,
name,
version,
);
}
if (resolvedUrl) {
properties.push({ name: "ResolvedUrl", value: resolvedUrl });
externalReferences.push({ type: "distribution", url: resolvedUrl });
}
if (info.isGitDep) {
const gitIntakeRefs = buildNpmGitDistributionIntakeRefs(
group,
name,
version,
npmrcConfig,
);
if (gitIntakeRefs) {
externalReferences.push(...gitIntakeRefs);
}
const manifestSource = classifyNpmManifestSource(version);
if (manifestSource) {
properties.push({
name: "cdx:npm:manifestSourceType",
value: manifestSource.type,
});
properties.push({
name: "cdx:npm:manifestSource",
value: manifestSource.value,
});
}
}
if (info.isNonRegistry) {
properties.push({ name: "cdx:npm:isRegistryDependency", value: "false" });
}
if (meta.bin) {
const binValue =
typeof meta.bin === "object"
? Object.keys(meta.bin).join(", ")
: meta.bin;
properties.push({ name: "cdx:npm:bin", value: binValue });
properties.push({ name: "cdx:npm:has_binary", value: "true" });
}
if (Array.isArray(meta.os) && meta.os.length) {
properties.push({ name: "cdx:npm:os", value: meta.os.join(", ") });
}
if (Array.isArray(meta.cpu) && meta.cpu.length) {
properties.push({ name: "cdx:npm:cpu", value: meta.cpu.join(", ") });
}
const pkgObj = {
group: group || "",
name,
version,
purl: purlString,
"bom-ref": info.bomRef,
_integrity: integrity || undefined,
properties,
evidence: {
identity: {
field: "purl",
confidence: 1,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: bunLockFile,
},
],
},
},
};
if (externalReferences.length) {
pkgObj.externalReferences = externalReferences;
}
// Packages that are not reachable through production dependencies are
// development-only tooling.
if (!prodRefs.has(info.bomRef)) {
pkgObj.scope = "optional";
setNpmDevelopmentProperty(pkgObj);
}
if (optionalRefs.has(info.bomRef)) {
pkgObj.scope = "optional";
setNpmOptionalProperty(pkgObj);
}
if (peerRefs.has(info.bomRef)) {
setNpmPeerProperty(pkgObj);
}
pkgList.push(pkgObj);
dependenciesList.push({
ref: info.bomRef,
dependsOn: [...runtimeDepRefs(key, meta)].sort(),
});
}
// Add the dependency entry for the workspace root.
if (options.parentComponent?.["bom-ref"]) {
dependenciesList.push({
ref: options.parentComponent["bom-ref"],
dependsOn: [...rootProdRefs].sort(),
});
}
if (
shouldFetchLicense() ||
shouldFetchVCS() ||
process.env.FETCH_LICENSE === "true"
) {
if (DEBUG_MODE) {
console.log(
`About to fetch npm registry metadata for ${pkgList.length} packages in parseBunLock`,
);
}
pkgList = await getNpmMetadata(pkgList);
}
return { pkgList, dependenciesList };
}