@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,035 lines (996 loc) • 34.7 kB
JavaScript
import { readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { basename, dirname, join } from "node:path";
import process from "node:process";
import { PackageURL } from "packageurl-js";
import {
buildNpmRegistryTarballUrl,
loadNpmrcConfig,
normalizeNpmRegistryUrl,
} from "./npmutils.js";
// utils.js does not import denoutils.js, so importing from it here is safe and
// does not introduce a cyclic dependency.
import {
cdxgenAgent,
DEBUG_MODE,
getNpmMetadata,
safeExistsSync,
shouldFetchLicense,
shouldFetchVCS,
} from "./utils.js";
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org/";
// jsr's metadata API. Version endpoints expose the SPDX license and package
// endpoints expose the description + GitHub repository.
const JSR_API_URL = "https://api.jsr.io/";
// Small in-module response cache so repeated packages (or multiple lockfiles)
// don't re-fetch the same jsr metadata.
const jsrMetadataCache = new Map();
/**
* purl decision for `jsr:` dependencies.
*
* JSR (https://jsr.io) does not have a ratified PackageURL type yet. JSR
* publishes every package to a real npm scope, `@jsr/<owner>__<package>`, on
* its npm mirror (npm.jsr.io), so a `jsr:@std/assert@1.0.19` import resolves
* to the installable npm package `@jsr/std__assert@1.0.19`.
*
* We therefore map jsr deps to `pkg:npm/@jsr/<owner>__<name>@<version>`. This
* keeps cdxgen aligned with the AppThreat vulnerability-DB / dep-scan lookups,
* which index npm advisories, so jsr packages get vulnerability matching for
* free without needing a dedicated VDB integration. The original jsr identity
* is preserved in the `cdx:deno:jsrKey` property for traceability.
*/
const JSR_NPM_SCOPE = "@jsr";
// jsr publishes an npm-compatible mirror at npm.jsr.io. jsr packages are NOT
// available on registry.npmjs.org, so license/metadata lookups for the
// `@jsr/<owner>__<name>` components must target this registry instead.
const JSR_NPM_REGISTRY = "https://npm.jsr.io/";
const JSR_NPM_REGISTRY_HOST = "npm.jsr.io";
/**
* Remove JSONC (JSON-with-comments) noise from a source string so it can be
* parsed with `JSON.parse`. Handles `//` line comments, `/* block */`
* comments and trailing commas, while leaving string literals untouched.
*
* Implemented as a small single-pass scanner rather than a regex, per the
* cdxgen convention of avoiding hard-to-review/exponential regexes for
* untrusted input.
*
* @param {string} text Raw JSONC source.
* @returns {string} Plain JSON text.
*/
export function stripJsonc(text) {
if (!text) {
return text;
}
let out = "";
let i = 0;
const len = text.length;
while (i < len) {
const ch = text[i];
const next = text[i + 1];
// String literal (including escaped quotes) — copy verbatim.
if (ch === '"') {
out += ch;
i++;
while (i < len) {
const c = text[i];
out += c;
if (c === "\\" && i + 1 < len) {
out += text[i + 1];
i += 2;
continue;
}
if (c === '"') {
i++;
break;
}
i++;
}
continue;
}
// Line comment `// ...`
if (ch === "/" && next === "/") {
i += 2;
while (i < len && text[i] !== "\n") {
i++;
}
continue;
}
// Block comment `/* ... */`
if (ch === "/" && next === "*") {
i += 2;
while (i < len && !(text[i] === "*" && text[i + 1] === "/")) {
i++;
}
i += 2;
continue;
}
out += ch;
i++;
}
// Strip trailing commas that JSONC allows before `}` or `]`. Safe because
// `,}` / `,]` never appear inside string literals after comment stripping.
return out.replace(/,(\s*[}\]])/g, "$1");
}
/**
* Parse a JSON or JSONC file (deno.json or deno.jsonc) into a plain object.
*
* @param {string} jsonFile Path to the deno.json(c) file.
* @returns {Object|undefined} Parsed config, or undefined on failure.
*/
export function parseDenoJsonFile(jsonFile) {
if (!safeExistsSync(jsonFile)) {
return undefined;
}
let rawData;
try {
rawData = readFileSync(jsonFile, "utf8");
} catch (err) {
if (DEBUG_MODE) {
console.log(`Unable to read ${jsonFile}`, err);
}
return undefined;
}
try {
return JSON.parse(stripJsonc(rawData));
} catch (err) {
if (DEBUG_MODE) {
console.log(`Unable to parse ${jsonFile}`, err);
}
return undefined;
}
}
/**
* Locate the deno.json or deno.jsonc manifest for a given directory.
*
* @param {string} dir Directory to search.
* @returns {string|undefined} Path to the manifest, if present.
*/
export function findDenoJson(dir) {
const jsonPath = join(dir, "deno.json");
if (safeExistsSync(jsonPath)) {
return jsonPath;
}
const jsoncPath = join(dir, "deno.jsonc");
if (safeExistsSync(jsoncPath)) {
return jsoncPath;
}
return undefined;
}
/**
* Split a `@scope/name@version` descriptor into its group, name and version.
* Handles scoped names that themselves begin with `@`.
*
* @param {string} descriptor The `[@scope/]name[@version]` descriptor.
* @returns {{group: string, name: string, version: string}} Parsed pieces.
*/
function parseScopedDescriptor(descriptor) {
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 };
}
/**
* Build the JSR npm-compat name (`<owner>__<name>`) for a jsr group/name pair.
* The returned value is the npm *package name* that lives under the `@jsr`
* scope on npm.jsr.io, e.g. `jsr:@std/assert` -> npm `@jsr/std__assert`. The
* `@jsr` scope itself is applied separately as the purl namespace/group so the
* resulting purl is a valid two-segment npm purl
* (`pkg:npm/%40jsr/std__assert@1.0.19`).
*
* @param {string} group Original jsr scope, e.g. `@std`.
* @param {string} name Original jsr name, e.g. `assert`.
* @returns {string} npm package name, e.g. `std__assert`.
*/
function jsrNpmName(group, name) {
const owner = group ? group.replace(/^@/, "") : "";
if (!owner) {
return name;
}
return `${owner}__${name}`;
}
/**
* Parse an npm integrity string into a canonical `_integrity` value. npm
* lockfiles already use the SRI form (`sha512-base64==`); deno's npm entries
* reuse the same form, so this is mostly a passthrough that discards blanks.
*
* @param {string} integrity Raw integrity from the lockfile.
* @returns {string|undefined} Normalised integrity, or undefined.
*/
function toNpmIntegrity(integrity) {
if (!integrity || typeof integrity !== "string") {
return undefined;
}
const trimmed = integrity.trim();
if (!trimmed) {
return undefined;
}
return trimmed;
}
/**
* Normalise a jsr integrity hash (raw sha256 hex, as emitted by deno) into an
* `_integrity`-shaped value. We prefix `sha256-` but keep the hex payload
* verbatim rather than base64-encoding, and store the raw value in a property
* so consumers can distinguish it from a canonical SRI digest.
*
* @param {string} integrity Raw sha256 hex string.
* @returns {string|undefined} Prefixed integrity, or undefined.
*/
function toJsrIntegrity(integrity) {
if (!integrity || typeof integrity !== "string") {
return undefined;
}
const trimmed = integrity.trim();
if (!trimmed) {
return undefined;
}
return trimmed.startsWith("sha256-") ? trimmed : `sha256-${trimmed}`;
}
/**
* Read and JSON-parse a package.json, tolerating any IO/parse failure.
*
* @param {string} pkgJsonFile Path to a package.json.
* @returns {Object|undefined} Parsed manifest, or undefined.
*/
function readPackageJsonSafe(pkgJsonFile) {
if (!safeExistsSync(pkgJsonFile)) {
return undefined;
}
try {
return JSON.parse(readFileSync(pkgJsonFile, "utf8"));
} catch {
return undefined;
}
}
/**
* Add a `vcs` external reference to a component, de-duplicating by URL.
*
* @param {Object} component The component to enrich.
* @param {string} url The repository URL.
*/
function addVcsReference(component, url) {
if (!url) {
return;
}
component.externalReferences = component.externalReferences || [];
if (
!component.externalReferences.some(
(ref) => ref.type === "vcs" && ref.url === url,
)
) {
component.externalReferences.push({ type: "vcs", url });
}
}
/**
* Copy license/description/repository from a package.json manifest onto a
* component, without overwriting values already resolved. The repository is
* emitted as a `vcs` external reference (deno components are added directly to
* the BOM and never pass through the npm `listComponents` normalisation that
* would otherwise convert a `repository` field).
*
* @param {Object} component The component to enrich.
* @param {Object} pj Parsed package.json manifest.
*/
function applyPackageJsonMetadata(component, pj) {
if (!pj) {
return;
}
if (!component.description && pj.description) {
component.description = pj.description;
}
if (!component.license && (pj.license || pj.licenses)) {
component.license = pj.license || pj.licenses;
}
if (pj.repository) {
const url =
typeof pj.repository === "string" ? pj.repository : pj.repository.url;
addVcsReference(component, url);
}
}
/**
* Resolve the Deno cache directory ($DENO_DIR or the per-OS default) used to
* store the npm compatibility layer at `<denoDir>/npm/<registry-host>/...`.
*
* @returns {string|undefined} The Deno cache directory, if resolvable.
*/
function resolveDenoCacheDir() {
if (process.env.DENO_DIR) {
return process.env.DENO_DIR;
}
const home = homedir();
if (!home) {
return undefined;
}
switch (platform()) {
case "darwin":
return join(home, "Library", "Caches", "deno");
case "win32":
return join(
process.env.LOCALAPPDATA || join(home, "AppData", "Local"),
"deno",
);
default:
return join(process.env.XDG_CACHE_HOME || join(home, ".cache"), "deno");
}
}
/**
* Locate an installed package.json for a component by checking, in order:
* 1. the project-local `node_modules` (deno `nodeModulesDir`),
* 2. deno's flattened `.deno` node_modules layout, and
* 3. deno's global npm cache under `<denoDir>/npm/<registry-host>/`.
*
* jsr components resolve under the `@jsr` scope (their npm-compat name), which
* is only present when a project materialises jsr packages into node_modules.
*
* @param {Object} component The component to resolve.
* @param {string} projectRoot The directory containing deno.lock.
* @param {string[]} registryHosts npm registry hosts to probe in the cache.
* @returns {Object|undefined} Parsed package.json, if found.
*/
function findInstalledManifest(component, projectRoot, registryHosts) {
const { group, name, version } = component;
const scopedName = group ? `${group}/${name}` : name;
const candidates = [];
if (projectRoot) {
const nmDir = join(projectRoot, "node_modules");
// 1. Hoisted/symlinked layout: node_modules/<scope>/<name>/package.json
candidates.push(join(nmDir, scopedName, "package.json"));
// 2. Deno's flattened layout: node_modules/.deno/<key>@<version>/node_modules/<scope>/<name>/package.json
const denoKey = `${group ? `${group.replace(/^@/, "")}+` : ""}${name}@${version}`;
candidates.push(
join(nmDir, ".deno", denoKey, "node_modules", scopedName, "package.json"),
);
}
// 3. Deno global npm cache: <denoDir>/npm/<host>/<scope>/<name>/<version>/package.json
const denoCacheDir = resolveDenoCacheDir();
if (denoCacheDir && version) {
for (const host of registryHosts) {
candidates.push(
join(denoCacheDir, "npm", host, scopedName, version, "package.json"),
);
}
}
for (const candidate of candidates) {
const pj = readPackageJsonSafe(candidate);
if (pj) {
return pj;
}
}
return undefined;
}
/**
* Enrich components with license/description/repository metadata mined offline
* from a project's `node_modules` and the global deno npm cache. Runs before
* any network lookup so air-gapped scans still capture available metadata.
*
* @param {Array} pkgList Components to enrich (mutated in place).
* @param {string} projectRoot Directory containing deno.lock.
* @param {string} jsrNpmRegistryHost Host of jsr's npm mirror.
*/
function mineInstalledMetadata(pkgList, projectRoot, jsrNpmRegistryHost) {
for (const component of pkgList) {
if (component.description && component.license) {
continue;
}
// jsr components are cached under jsr's npm-mirror host; npm components
// under the default registry host.
const registryHosts =
component.group === JSR_NPM_SCOPE
? [jsrNpmRegistryHost, "registry.npmjs.org"]
: ["registry.npmjs.org"];
const pj = findInstalledManifest(component, projectRoot, registryHosts);
if (pj) {
applyPackageJsonMetadata(component, pj);
}
}
}
/**
* Fetch license, description and repository metadata for jsr components from
* jsr's metadata API (api.jsr.io). jsr's npm mirror does not expose license
* data, so this is the authoritative source for it.
*
* @param {Array} pkgList jsr components (identified by `cdx:deno:jsrKey`).
* @returns {Promise<Array>} The same component list.
*/
export async function getJsrMetadata(pkgList) {
for (const component of pkgList) {
try {
const jsrKey = (component.properties || []).find(
(p) => p.name === "cdx:deno:jsrKey",
)?.value;
if (!jsrKey) {
continue;
}
// jsrKey looks like `@scope/name@version`.
const atIndex = jsrKey.lastIndexOf("@");
const idPart = atIndex > 0 ? jsrKey.substring(0, atIndex) : jsrKey;
const version =
atIndex > 0 ? jsrKey.substring(atIndex + 1) : component.version;
const match = idPart.match(/^@([^/]+)\/(.+)$/);
if (!match) {
continue;
}
const [, scope, pkgName] = match;
const versionUrl = `${JSR_API_URL}scopes/${scope}/packages/${pkgName}/versions/${version}`;
const packageUrl = `${JSR_API_URL}scopes/${scope}/packages/${pkgName}`;
let versionBody = jsrMetadataCache.get(versionUrl);
if (!versionBody) {
const res = await cdxgenAgent.get(versionUrl, { responseType: "json" });
versionBody = res.body;
jsrMetadataCache.set(versionUrl, versionBody);
}
if (versionBody?.license && !component.license) {
component.license = versionBody.license;
}
let packageBody = jsrMetadataCache.get(packageUrl);
if (!packageBody) {
const res = await cdxgenAgent.get(packageUrl, { responseType: "json" });
packageBody = res.body;
jsrMetadataCache.set(packageUrl, packageBody);
}
if (packageBody?.description && !component.description) {
component.description = packageBody.description;
}
const ghRepo = packageBody?.githubRepository;
if (ghRepo?.owner && ghRepo?.name) {
addVcsReference(
component,
`https://github.com/${ghRepo.owner}/${ghRepo.name}`,
);
}
} catch (_err) {
if (DEBUG_MODE) {
console.log(`${component.name} was not found on jsr`);
}
}
}
return pkgList;
}
/**
* Parse a deno.lock file (versions 2 through 5) and return the package list
* and dependency graph in the same shape as the other lockfile parsers.
*
* Lock version summary:
* - v5 (Deno >= 2.x, current): flat top-level `specifiers`, `jsr`, `npm` and
* optional `remote` maps. jsr entries list their own jsr dependencies; npm
* entries only carry an integrity hash and DO NOT list their own transitive
* npm dependencies (see the limitation note below).
* - v2/v3/v4: `npm.specifiers` + `npm.packages` (packages carry a
* `dependencies` map of `name` -> `name@version`), optional `jsr` map and
* `remote` map.
*
* purl mapping:
* - `jsr:@scope/name@ver` -> `pkg:npm/@jsr/scope__name@ver` (see
* `JSR_NPM_SCOPE` doc comment for the rationale).
* - `npm:name@ver` -> `pkg:npm/name@ver` with the sha512 integrity as
* `_integrity`.
* - `https://...` remote imports -> `pkg:generic/<basename>` with a
* `download_url` external reference.
*
* v5 npm-transitive limitation: in v5 lockfiles the `npm` map is flat and each
* npm entry contains only an integrity hash, never a dependencies list. As a
* result the CycloneDX dependency graph for npm packages under v5 is shallow
* (only the parent component -> direct npm deps). Resolving the full npm
* transitive graph would require hitting the npm registry or the deno
* `node_modules` cache, which cdxgen intentionally does not do by default.
*
* @param {string} lockFile Path to the deno.lock file.
* @param {Object} [options] Parsing options (`parentComponent`, `projectRoot`).
* @returns {Promise<{pkgList: Array, dependenciesList: Array}>} Parsed
* packages and dependency graph.
*/
export async function parseDenoLock(lockFile, options = {}) {
let pkgList = [];
const dependenciesList = [];
if (!safeExistsSync(lockFile)) {
return { pkgList, dependenciesList };
}
let lockData;
try {
lockData = JSON.parse(readFileSync(lockFile, "utf8"));
} catch (err) {
if (DEBUG_MODE) {
console.log(`Unable to parse ${lockFile}`, err);
}
return { pkgList, dependenciesList };
}
const version = `${lockData.version ?? ""}`;
if (DEBUG_MODE) {
console.log(`Parsing deno.lock (${lockFile}) version ${version || "?"}`);
}
const npmrcConfig = loadNpmrcConfig(options.projectRoot || dirname(lockFile));
const defaultRegistry =
normalizeNpmRegistryUrl(npmrcConfig.registry) || DEFAULT_NPM_REGISTRY;
// Map of concrete package key -> bom-ref for dependency resolution.
// Keys are the canonical `@scope/name@version` (jsr) or `name@version` (npm).
const keyToBomRef = new Map();
// Map of bom-ref -> dependency bom-refs (collected during the first pass so
// we can emit the graph without ordering constraints).
const edgesByBomRef = new Map();
// Direct dependencies of the parent/application component (the roots of the
// graph). Populated from the workspace block or the specifiers maps below.
const rootRefs = new Set();
const pkgObjectsByKey = new Map();
const rememberEdge = (fromRef, toRef) => {
if (!fromRef || !toRef || fromRef === toRef) {
return;
}
const set = edgesByBomRef.get(fromRef);
if (set) {
set.add(toRef);
} else {
edgesByBomRef.set(fromRef, new Set([toRef]));
}
};
// ---- jsr packages -----------------------------------------------------
// Resolve a jsr dependency reference (e.g. `jsr:@std/internal@^1.0.12`) to
// the bom-ref of the concrete installed package, using the specifiers map
// to turn the requirement into a pinned version.
const resolveJsrRef = (jsrRef, specifiers) => {
if (!jsrRef) {
return undefined;
}
const req = jsrRef.startsWith("jsr:") ? jsrRef.slice(4) : jsrRef;
// Specifiers may key the exact requirement string (with the `jsr:` prefix)
// so look it up both ways before falling back to a version-less match.
const pinnedVersion = specifiers?.[jsrRef] ?? specifiers?.[`jsr:${req}`];
const { group, name } = parseScopedDescriptor(req);
if (pinnedVersion) {
const concreteKey = `${group ? `${group}/` : ""}${name}@${pinnedVersion}`;
return keyToBomRef.get(concreteKey);
}
// Fall back to any concrete key matching this group/name. Picks the first
// known version — deno does not allow two versions of the same jsr package
// in a single resolve, so this is unambiguous in practice.
for (const [ck, ref] of keyToBomRef) {
const prefix = `${group ? `${group}/` : ""}${name}@`;
if (ck.startsWith(prefix)) {
return ref;
}
}
return undefined;
};
const jsrMap = lockData.jsr || {};
const specifiers = lockData.specifiers || {};
for (const [entryKey, entry] of Object.entries(jsrMap)) {
const { group, name, version } = parseScopedDescriptor(entryKey);
if (!name || !version) {
continue;
}
const npmName = jsrNpmName(group, name);
const purlString = new PackageURL(
"npm",
JSR_NPM_SCOPE,
npmName,
version,
null,
null,
).toString();
const bomRef = decodeURIComponent(purlString);
keyToBomRef.set(entryKey, bomRef);
const integrity = toJsrIntegrity(entry?.integrity);
const properties = [
{ name: "SrcFile", value: lockFile },
{ name: "cdx:deno:source", value: "jsr" },
// Preserve the original jsr identity for traceability, since the purl is
// rewritten to the npm-compat form (`@jsr/<owner>__<name>`).
{ name: "cdx:deno:jsrKey", value: entryKey },
];
if (integrity) {
properties.push({
name: "cdx:deno:integrity",
value: entry?.integrity,
});
}
// Deterministic canonical jsr page for the original specifier, e.g.
// `https://jsr.io/@std/assert@1.0.19`. The npm-mirror tarball URL is only
// available after a registry lookup, so this gives every jsr component a
// stable reference even without --fetch-license.
const jsrHomepage = `https://jsr.io/${group ? `${group}/` : ""}${name}@${version}`;
const pkgObj = {
group: JSR_NPM_SCOPE,
name: npmName,
version,
purl: purlString,
"bom-ref": bomRef,
properties,
externalReferences: [{ type: "website", url: jsrHomepage }],
evidence: {
identity: {
field: "purl",
confidence: 1,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: lockFile,
},
],
},
},
};
if (integrity) {
pkgObj._integrity = integrity;
}
pkgObjectsByKey.set(entryKey, pkgObj);
// Always seed an edge set so leaf jsr packages (no dependencies array)
// still appear in the CycloneDX dependency graph with an empty dependsOn,
// matching how the npm loop below treats every entry.
if (!edgesByBomRef.has(bomRef)) {
edgesByBomRef.set(bomRef, new Set());
}
}
// Wire up jsr -> jsr edges now that every jsr bom-ref is known.
for (const [entryKey, entry] of Object.entries(jsrMap)) {
const fromRef = keyToBomRef.get(entryKey);
if (!fromRef || !Array.isArray(entry?.dependencies)) {
continue;
}
for (const depRef of entry.dependencies) {
const toRef = resolveJsrRef(depRef, specifiers);
if (toRef) {
rememberEdge(fromRef, toRef);
}
}
}
// ---- npm packages -----------------------------------------------------
// v5: flat `npm` map keyed by `name@version` with integrity only.
// v2/v3/v4: nested `npm.packages` map with a `dependencies` object whose
// values are `name@version` references to other entries.
const npmFlat = Array.isArray(lockData.npm) ? {} : lockData.npm || {};
const npmFlatEntries = npmFlat?.packages ? {} : npmFlat;
const npmNested = npmFlat?.packages ? npmFlat.packages : {};
// v5-style flat entries.
for (const [entryKey, entry] of Object.entries(npmFlatEntries)) {
if (entryKey === "specifiers") {
continue;
}
const { group, name, version } = parseScopedDescriptor(entryKey);
if (!name || !version) {
continue;
}
const purlString = new PackageURL(
"npm",
group,
name,
version,
null,
null,
).toString();
const bomRef = decodeURIComponent(purlString);
keyToBomRef.set(entryKey, bomRef);
const integrity = toNpmIntegrity(
entry && typeof entry === "object" ? entry.integrity : entry,
);
const resolvedUrl = buildNpmRegistryTarballUrl(
defaultRegistry,
group,
name,
version,
);
const properties = [
{ name: "SrcFile", value: lockFile },
{ name: "cdx:deno:source", value: "npm" },
];
const externalReferences = [];
if (resolvedUrl) {
properties.push({ name: "ResolvedUrl", value: resolvedUrl });
externalReferences.push({ type: "distribution", url: resolvedUrl });
}
const pkgObj = {
group: group || "",
name,
version,
purl: purlString,
"bom-ref": bomRef,
properties,
evidence: {
identity: {
field: "purl",
confidence: 1,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: lockFile,
},
],
},
},
};
if (integrity) {
pkgObj._integrity = integrity;
}
if (externalReferences.length) {
pkgObj.externalReferences = externalReferences;
}
// v5 npm entries have no dependencies; initialise an empty edge set so
// the dependency graph still references every component.
pkgObjectsByKey.set(entryKey, pkgObj);
if (!edgesByBomRef.has(bomRef)) {
edgesByBomRef.set(bomRef, new Set());
}
}
// v2/v3/v4-style nested packages with explicit dependency maps.
for (const [entryKey, entry] of Object.entries(npmNested)) {
if (entryKey === "specifiers") {
continue;
}
const { group, name, version } = parseScopedDescriptor(entryKey);
if (!name || !version) {
continue;
}
const purlString = new PackageURL(
"npm",
group,
name,
version,
null,
null,
).toString();
const bomRef = decodeURIComponent(purlString);
keyToBomRef.set(entryKey, bomRef);
const integrity = toNpmIntegrity(entry?.integrity);
const resolvedUrl = buildNpmRegistryTarballUrl(
defaultRegistry,
group,
name,
version,
);
const properties = [
{ name: "SrcFile", value: lockFile },
{ name: "cdx:deno:source", value: "npm" },
];
const externalReferences = [];
if (resolvedUrl) {
properties.push({ name: "ResolvedUrl", value: resolvedUrl });
externalReferences.push({ type: "distribution", url: resolvedUrl });
}
const pkgObj = {
group: group || "",
name,
version,
purl: purlString,
"bom-ref": bomRef,
properties,
evidence: {
identity: {
field: "purl",
confidence: 1,
methods: [
{
technique: "manifest-analysis",
confidence: 1,
value: lockFile,
},
],
},
},
};
if (integrity) {
pkgObj._integrity = integrity;
}
if (externalReferences.length) {
pkgObj.externalReferences = externalReferences;
}
pkgObjectsByKey.set(entryKey, pkgObj);
if (!edgesByBomRef.has(bomRef)) {
edgesByBomRef.set(bomRef, new Set());
}
}
// Wire up nested npm -> npm edges using the `name@version` references.
for (const [entryKey, entry] of Object.entries(npmNested)) {
const fromRef = keyToBomRef.get(entryKey);
if (!fromRef || !entry?.dependencies) {
continue;
}
for (const depName of Object.keys(entry.dependencies)) {
const depTarget = entry.dependencies[depName];
// v2 maps each dependency name to a canonical `name@version` key.
const toRef = keyToBomRef.get(depTarget) || keyToBomRef.get(depName);
if (toRef) {
rememberEdge(fromRef, toRef);
}
}
}
// ---- remote (https://) imports ---------------------------------------
// Keyed by full URL; value is an integrity hash. Represented as
// `pkg:generic` with a download_url external reference.
const remoteMap = lockData.remote || {};
for (const [url, integrity] of Object.entries(remoteMap)) {
if (!url) {
continue;
}
let remoteName;
try {
remoteName = basename(new URL(url).pathname) || "remote";
} catch {
remoteName = "remote";
}
// Strip any query fragment from the stored name; the URL itself is kept
// whole only in the external reference (no userinfo is expected here).
const cleanName = remoteName.split("?")[0].split("#")[0] || "remote";
const integritySafe =
typeof integrity === "string" ? integrity : `${integrity}`;
const shortHash = integritySafe.slice(0, 12);
const qualifiers = { download_url: url };
const purlString = new PackageURL(
"generic",
null,
cleanName,
shortHash,
qualifiers,
null,
).toString();
const bomRef = decodeURIComponent(purlString);
// Only add each remote URL once (a bom-ref collision means we already did).
if (keyToBomRef.has(`remote:${url}`)) {
continue;
}
keyToBomRef.set(`remote:${url}`, bomRef);
const properties = [
{ name: "SrcFile", value: lockFile },
{ name: "cdx:deno:source", value: "https" },
];
const pkgObj = {
name: cleanName,
version: shortHash,
purl: purlString,
"bom-ref": bomRef,
externalReferences: [{ type: "distribution", url }],
properties,
evidence: {
identity: {
field: "purl",
confidence: 0.7,
methods: [
{
technique: "manifest-analysis",
confidence: 0.7,
value: lockFile,
},
],
},
},
};
pkgObjectsByKey.set(`remote:${url}`, pkgObj);
if (!edgesByBomRef.has(bomRef)) {
edgesByBomRef.set(bomRef, new Set());
}
}
// ---- root dependencies (workspace) -----------------------------------
// The workspace.dependencies array (v5) or npm.specifiers / npm-flat-specs
// lists the packages the project directly imports. Link the parent component
// to these so the root of the dependency graph is meaningful.
const workspaceDeps = lockData.workspace?.dependencies;
if (Array.isArray(workspaceDeps)) {
for (const depRef of workspaceDeps) {
const ref = resolveRootDep(depRef, specifiers, keyToBomRef);
if (ref) {
rootRefs.add(ref);
}
}
} else {
// Fall back to the specifiers maps for lockfiles without an explicit
// workspace block.
// v5 top-level specifiers: keys are `jsr:`/`npm:` requirements, values are
// the pinned version. v2 `npm.specifiers`: keys are `name@range`, values
// are the canonical `name@version` package key used in `npm.packages`.
for (const [req, resolved] of Object.entries(specifiers)) {
if (req.startsWith("jsr:")) {
const ref = resolveJsrRef(req, specifiers);
if (ref) {
rootRefs.add(ref);
}
} else if (req.startsWith("npm:")) {
const { group, name } = parseScopedDescriptor(req.slice(4));
const ref = keyToBomRef.get(
`${group ? `${group}/` : ""}${name}@${resolved}`,
);
if (ref) {
rootRefs.add(ref);
}
}
}
const npmSpecifiers = npmFlat?.specifiers || {};
for (const resolved of Object.values(npmSpecifiers)) {
// v2 specifier values are already the package key (e.g.
// `@cyclonedx/cdxgen@9.0.1`).
const ref = keyToBomRef.get(resolved);
if (ref) {
rootRefs.add(ref);
}
}
}
// ---- emit -------------------------------------------------------------
pkgList = [...pkgObjectsByKey.values()];
for (const [ref, deps] of edgesByBomRef) {
dependenciesList.push({ ref, dependsOn: [...deps].sort() });
}
// Parent component -> direct dependencies. Mirror the bun emitter by
// appending an entry for the parent so the application root is wired into
// the dependency graph.
const parentRef = options.parentComponent?.["bom-ref"];
if (parentRef) {
dependenciesList.push({
ref: parentRef,
dependsOn: [...rootRefs].sort(),
});
}
// Offline enrichment first: mine license/description/repository from a
// project's node_modules and the global deno npm cache. This needs no
// network and runs even without --fetch-license.
mineInstalledMetadata(
pkgList,
options.projectRoot || dirname(lockFile),
JSR_NPM_REGISTRY_HOST,
);
if (
shouldFetchLicense() ||
shouldFetchVCS() ||
process.env.FETCH_LICENSE === "true"
) {
if (DEBUG_MODE) {
console.log(
`About to fetch registry metadata for ${pkgList.length} packages in parseDenoLock`,
);
}
// getNpmMetadata mutates each component in place, so the split lists share
// the same objects as pkgList. jsr packages live on jsr's npm mirror
// (npm.jsr.io), NOT registry.npmjs.org, so they must be resolved against a
// different registry or every lookup 404s.
const jsrPkgs = pkgList.filter((p) => p.group === JSR_NPM_SCOPE);
const npmPkgs = pkgList.filter((p) => p.group !== JSR_NPM_SCOPE);
if (npmPkgs.length) {
await getNpmMetadata(npmPkgs, defaultRegistry);
}
if (jsrPkgs.length) {
// The npm mirror provides the description + resolved tarball
// (distribution externalReference) but no license; jsr's own API is the
// authoritative source for the license, so query it afterwards.
await getNpmMetadata(jsrPkgs, JSR_NPM_REGISTRY);
await getJsrMetadata(jsrPkgs);
}
}
return { pkgList, dependenciesList };
}
/**
* Resolve a top-level workspace dependency reference from a v5 lockfile to a
* concrete bom-ref.
*
* @param {string} depRef Reference such as `jsr:@std/assert@1` or
* `npm:chalk@^5.3.0`.
* @param {Object} specifiers The v5 `specifiers` map (req -> pinned version).
* @param {Map<string, string>} keyToBomRef Concrete-key -> bom-ref lookup.
* @returns {string|undefined} Resolved bom-ref.
*/
function resolveRootDep(depRef, specifiers, keyToBomRef) {
if (!depRef) {
return undefined;
}
const pinned = specifiers?.[depRef];
if (depRef.startsWith("jsr:")) {
const { group, name } = parseScopedDescriptor(depRef.slice(4));
if (pinned) {
return keyToBomRef.get(`${group ? `${group}/` : ""}${name}@${pinned}`);
}
} else if (depRef.startsWith("npm:")) {
const { group, name } = parseScopedDescriptor(depRef.slice(4));
if (pinned) {
return keyToBomRef.get(`${group ? `${group}/` : ""}${name}@${pinned}`);
}
}
return undefined;
}