import { randomUUID } from "node:crypto"; import { chalk } from "../utils/chalk.js"; import type { Finding, PackageRef } from "../types.js"; import type { ProjectMeta, SbomAdvisoryMetadata } from "./cyclonedx.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import { findSuggestedCommandForFinding } from "../remediation/fix-commands.js"; import { getRecommendedAction } from "./formatters.js"; import { purlForSbom } from "../utils/purl.js"; import type { DependencyEdge } from "./sbom-dependency-edges.js"; import { MAX_PATHS_PER_PACKAGE } from "../parsers/utils.js"; import { getCliVersion } from "../utils/version-info.js"; import { sbomTimestamp, writeSbomFile } from "./sbom-file.js"; import { packageInstanceKey, registryPackageName } from "../utils/package-identity.js"; /** * Emitted when dependency edges are reconstructed from PackageRef.paths * which were subject to the per-package path cap. */ export const SBOM_FALLBACK_CAP_WARNING = "⚠ SPDX SBOM: dependency relationships were rebuilt from recorded paths, which are capped at five per package, so some may be missing."; /** * Document-level comment added to the SPDX document when the fallback edge * builder was used and dependency paths were capped. */ export const SBOM_FALLBACK_CAP_DOCUMENT_NOTE = "Dependency relationships were rebuilt from recorded paths, which are capped at five per package, so some may be missing."; /** * SPDX's explicit marker for "not determined" or "no assertion made". Preferred over * omitting a field, because omission reads as "not considered". */ const NOASSERTION = "NOASSERTION"; export type SpdxExternalRef = { referenceCategory: "PACKAGE-MANAGER" | "SECURITY"; referenceType: string; referenceLocator: string; }; export type SpdxAnnotation = { annotationDate: string; annotationType: "OTHER"; annotator: string; comment: string; }; export type SpdxPackage = { SPDXID: string; name: string; downloadLocation: string; supplier: string; filesAnalyzed: boolean; versionInfo?: string; licenseDeclared: string; licenseConcluded: string; externalRefs: SpdxExternalRef[]; annotations?: SpdxAnnotation[]; }; export type SpdxOptions = { /** * Omit the vulnerability overlay, leaving a pure inventory. The package * section is then fully determined by the lockfile, which is what SBOM * diffing and attestation workflows need. Note that `created` and * `documentNamespace` still vary per run: both are required by the spec. */ inventoryOnly?: boolean; /** * Dependency edges resolved from the lockfile graph, keyed by `name@version`. * Preferred over reconstructing edges from `PackageRef.paths`, which are * capped at five per package and therefore lose routes on large trees. When * absent (e.g. an unreadable or unsupported lockfile) the path-derived * fallback is used instead. */ dependencyEdges?: DependencyEdge[]; /** * Whether loading the lockfile graph failed. When omitted, inferred as true * when `dependencyEdges` is empty or missing. */ graphFailed?: boolean; /** * Callback to receive warnings (such as the fallback 5-path cap warning) * instead of directly printing to console. */ onWarning?: (warning: string) => void; /** Explicit filename for the generated SPDX document (e.g. from --output). */ filename?: string; /** Advisory database metadata (offline status, sync timestamp, source URL). */ advisoryMetadata?: SbomAdvisoryMetadata | null; }; export type SpdxRelationship = { spdxElementId: string; relatedSpdxElement: string; relationshipType: string; }; export type SpdxCreationInfo = { created: string; creators: string[]; comment?: string; }; export type SpdxDocument = { spdxVersion: "SPDX-2.3"; SPDXID: "SPDXRef-DOCUMENT"; name: string; dataLicense: "CC0-1.0"; documentNamespace: string; comment?: string; creationInfo: SpdxCreationInfo; packages: SpdxPackage[]; relationships: SpdxRelationship[]; }; /** * SPDX requires a second-precision UTC timestamp (YYYY-MM-DDThh:mm:ssZ), not * the millisecond form `toISOString()` produces. */ function spdxTimestamp(): string { return new Date().toISOString().slice(0, 19) + "Z"; } /** * Mints an SPDXID for a package. * * The spec restricts identifiers to letters, digits, `.` and `-`, which rules * out the `@` and `/` in scoped npm names. Sanitising can make two distinct * packages collide (`@scope/pkg` and `scope-pkg` both reduce to `scope-pkg`), * so a counter disambiguates rather than silently emitting a duplicate ID that * would make external references ambiguous. */ function makeSpdxId(name: string, version: string, used: Map): string { const base = `SPDXRef-${`${name}-${version}`.replace(/[^A-Za-z0-9.-]/g, "-").replace(/^-+/, "")}`; const seen = used.get(base); if (seen === undefined) { used.set(base, 1); return base; } used.set(base, seen + 1); return `${base}-${seen + 1}`; } function findingPackageKey(pkg: PackageRef): string { return `${pkg.ecosystem}\0${packageInstanceKey(pkg)}`; } function groupFindingsByPackage(findings: Finding[]): Map { const byPackage = new Map(); for (const finding of findings) { // A supplemental advisory pass can produce an empty finding alongside a real // one for the same package. It has nothing to reference and no remediation, // and annotating it contradicts the real fix, so it is dropped here. if (finding.vulnerabilities.length === 0) continue; const key = findingPackageKey(finding.pkg); const existing = byPackage.get(key); if (existing) existing.push(finding); else byPackage.set(key, [finding]); } return byPackage; } /** * Summarises a package's findings for an SPDX annotation. * * Severity and runnable fix commands have no home in SPDX 2.3: the format has * no vulnerability model, and `referenceLocator` must be a URI, so a shell * command cannot go there. A free-text annotation is the spec-legal place for * both, and keeps the output actionable. */ function buildFindingAnnotation( findings: Finding[], plan: SuggestedFixCommandPlan | null, annotator: string, timestamp: string, ): SpdxAnnotation { // The advisory IDs are already enumerated as SECURITY externalRefs, so the // annotation carries only what SPDX has no field for: severity and the fix. const parts = findings.map(finding => { const fix = (plan ? findSuggestedCommandForFinding(plan, finding) : null) ?? getRecommendedAction(finding); return `severity=${finding.severity}; advisories=${finding.vulnerabilities.length}; fix=${fix}`; }); return { annotationDate: timestamp, annotationType: "OTHER", annotator, comment: parts.join(" | "), }; } /** * Builds the SPDX package entry describing the scanned project itself, plus the * DESCRIBES relationship tying the document to it. Returns null when the * project has no name to describe. */ function buildRootPackage( projectMeta: ProjectMeta, usedIds: Map, ): { pkg: SpdxPackage; relationship: SpdxRelationship } | null { if (!projectMeta?.name) return null; const rootId = makeSpdxId(projectMeta.name, projectMeta.version ?? "", usedIds); return { pkg: { SPDXID: rootId, name: projectMeta.name, downloadLocation: NOASSERTION, supplier: NOASSERTION, filesAnalyzed: false, ...(projectMeta.version ? { versionInfo: projectMeta.version } : {}), licenseDeclared: NOASSERTION, licenseConcluded: NOASSERTION, externalRefs: [], }, relationship: { spdxElementId: "SPDXRef-DOCUMENT", relatedSpdxElement: rootId, relationshipType: "DESCRIBES", }, }; } /** Advisory references for one package, with duplicate IDs collapsed. */ function buildAdvisoryRefs(pkgFindings: Finding[]): SpdxExternalRef[] { // Two advisory sources can report the same ID for one package; a duplicated // externalRef would overstate how many distinct advisories were found. const seen = new Set(); const refs: SpdxExternalRef[] = []; for (const finding of pkgFindings) { for (const vuln of finding.vulnerabilities) { if (seen.has(vuln.id)) continue; seen.add(vuln.id); refs.push({ referenceCategory: "SECURITY", referenceType: "advisory", referenceLocator: `https://osv.dev/vulnerability/${vuln.id}`, }); } } return refs; } function buildPackageEntry( pkg: PackageRef, pkgFindings: Finding[], usedIds: Map, plan: SuggestedFixCommandPlan | null, annotator: string, timestamp: string, purl: string, ): SpdxPackage { return { SPDXID: makeSpdxId(pkg.name, pkg.version, usedIds), name: registryPackageName(pkg), downloadLocation: pkg.resolvedUrl ?? NOASSERTION, // NTIA lists supplier as a minimum element. A lockfile does not record who // published a package, so we assert nothing rather than guess from the name. supplier: NOASSERTION, // We read lockfile metadata, never package contents. Omitting this would // default it to true and claim otherwise. filesAnalyzed: false, versionInfo: pkg.version, licenseDeclared: pkg.license ?? NOASSERTION, // We only ever observe what a package declares. Concluding a license is a // human legal judgement, so it stays NOASSERTION. licenseConcluded: NOASSERTION, externalRefs: [ { referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: purl, }, ...buildAdvisoryRefs(pkgFindings), ], ...(pkgFindings.length > 0 ? { annotations: [buildFindingAnnotation(pkgFindings, plan, annotator, timestamp)] } : {}), }; } /** A built package entry paired with the ref it came from, for graph building. */ type IndexedPackage = { ref: PackageRef; id: string }; /** * Indexes packages by their dependency path so a parent can be resolved by path * prefix rather than by name. * * Name lookup would be ambiguous whenever one package name is installed at two * versions, which is exactly the case a dependency graph most needs to get * right. A path identifies a single node in the tree, so it never collides. */ function indexPackagesByPath(entries: IndexedPackage[], rootId: string | null): Map { const index = new Map(); for (const { ref, id } of entries) { for (const path of ref.paths ?? []) { if (rootId && path.length > 0) { // The first path segment is the project itself, whatever it is labelled. index.set(JSON.stringify([path[0]]), rootId); } const key = JSON.stringify(path); if (!index.has(key)) index.set(key, id); } } return index; } /** * Derives DEPENDENCY_OF or DEV_DEPENDENCY_OF relationships from the dependency paths. * * NTIA lists dependency relationships among the minimum elements of an SBOM, so * a flat package list is not sufficient for the compliance use case SPDX serves. */ function buildDependencyEdges( entries: IndexedPackage[], index: Map, rootId: string | null, ): SpdxRelationship[] { const edges: SpdxRelationship[] = []; const seen = new Set(); for (const { ref, id } of entries) { for (const path of ref.paths ?? []) { if (path.length < 2) continue; const parentId = index.get(JSON.stringify(path.slice(0, -1))); // A self-edge would appear if a package somehow contained itself. if (!parentId || parentId === id) continue; const key = `${id}\u0000${parentId}`; if (seen.has(key)) continue; seen.add(key); const isDirectDev = Boolean(ref.dev) && rootId !== null && parentId === rootId; edges.push({ spdxElementId: id, relatedSpdxElement: parentId, relationshipType: isDirectDev ? "DEV_DEPENDENCY_OF" : "DEPENDENCY_OF", }); } } return edges; } /** * Maps edges already resolved from the lockfile graph onto SPDX ids. * * A null parent means the root project depends on the package directly, so it * anchors to the root element. An edge naming a package absent from the * document is dropped rather than emitted as a dangling reference. */ function buildEdgesFromResolved( resolved: DependencyEdge[], entries: IndexedPackage[], rootId: string | null, ): SpdxRelationship[] { const idByPackage = new Map(); const devByPackage = new Map(); for (const { ref, id } of entries) { const key = packageInstanceKey(ref); if (!idByPackage.has(key)) { idByPackage.set(key, id); devByPackage.set(key, ref.dev === true); } } const edges: SpdxRelationship[] = []; const seen = new Set(); for (const { child, parent } of resolved) { const childId = idByPackage.get(child); if (!childId) continue; const parentId = parent === null ? rootId : idByPackage.get(parent); if (!parentId || parentId === childId) continue; const key = `${childId} ${parentId}`; if (seen.has(key)) continue; seen.add(key); const isDirectDev = Boolean(devByPackage.get(child)) && (parent === null || (rootId !== null && parentId === rootId)); edges.push({ spdxElementId: childId, relatedSpdxElement: parentId, relationshipType: isDirectDev ? "DEV_DEPENDENCY_OF" : "DEPENDENCY_OF", }); } return edges; } export function buildSpdxDocument( allPackages: PackageRef[], findings: Finding[], projectMeta: ProjectMeta, version: string, plan: SuggestedFixCommandPlan | null = null, options: SpdxOptions = {}, ): SpdxDocument { const name = projectMeta?.name ?? "cve-lite-scan"; const annotator = `Tool: CVE Lite CLI-${version}`; const timestamp = spdxTimestamp(); const findingsByPackage = options.inventoryOnly ? new Map() : groupFindingsByPackage(findings); const usedIds = new Map(); const packages: SpdxPackage[] = []; const relationships: SpdxRelationship[] = []; const root = buildRootPackage(projectMeta, usedIds); if (root) { packages.push(root.pkg); relationships.push(root.relationship); } const indexed: IndexedPackage[] = []; for (const pkg of allPackages) { const purl = purlForSbom(pkg); if (!purl) continue; const pkgFindings = findingsByPackage.get(findingPackageKey(pkg)) ?? []; const entry = buildPackageEntry(pkg, pkgFindings, usedIds, plan, annotator, timestamp, purl); packages.push(entry); indexed.push({ ref: pkg, id: entry.SPDXID }); } // The graph describes the tree, not the findings, so it is inventory data and // stays even in inventory-only mode. // Checked by length, not truthiness: an empty array is truthy. const isFallback = !options.dependencyEdges || options.dependencyEdges.length === 0; const graphFailed = options.graphFailed ?? isFallback; const pathCapApplies = allPackages.some(pkg => (pkg.paths?.length ?? 0) >= MAX_PATHS_PER_PACKAGE); const shouldWarnFallback = isFallback && graphFailed && pathCapApplies; if (shouldWarnFallback) { options.onWarning?.(SBOM_FALLBACK_CAP_WARNING); } relationships.push(...(options.dependencyEdges?.length ? buildEdgesFromResolved(options.dependencyEdges, indexed, root?.pkg.SPDXID ?? null) : buildDependencyEdges(indexed, indexPackagesByPath(indexed, root?.pkg.SPDXID ?? null), root?.pkg.SPDXID ?? null))); const advisoryComment = options.advisoryMetadata ? formatSpdxAdvisoryComment(options.advisoryMetadata) : undefined; return { spdxVersion: "SPDX-2.3", SPDXID: "SPDXRef-DOCUMENT", name, dataLicense: "CC0-1.0", documentNamespace: `https://owasp.org/cve-lite-cli/spdxdocs/${encodeURIComponent(name)}-${randomUUID()}`, ...(shouldWarnFallback ? { comment: SBOM_FALLBACK_CAP_DOCUMENT_NOTE } : {}), creationInfo: { created: timestamp, creators: [annotator], ...(advisoryComment ? { comment: advisoryComment } : {}), }, packages, relationships, }; } function formatSpdxAdvisoryComment(metadata: SbomAdvisoryMetadata): string { const parts: string[] = [`offline=${metadata.offline ? "true" : "false"}`]; if (metadata.syncTimestamp) { parts.push(`syncTimestamp=${metadata.syncTimestamp}`); } if (metadata.sourceUrl) { parts.push(`sourceUrl=${metadata.sourceUrl}`); } return `Advisory database: ${parts.join("; ")}`; } export function writeSpdxReport( allPackages: PackageRef[], findings: Finding[], plan: SuggestedFixCommandPlan | null, projectMeta: ProjectMeta, options: SpdxOptions = {}, outputDir?: string, ): string { const filename = options.filename ?? `cve-lite-scan-${sbomTimestamp()}.spdx.json`; const effectiveOptions: SpdxOptions = { ...options, onWarning: (msg: string) => { if (options.onWarning) { options.onWarning(msg); } else { console.warn(chalk.yellow(msg)); } }, }; const doc = buildSpdxDocument(allPackages, findings, projectMeta, getCliVersion(), plan, effectiveOptions); return writeSbomFile(filename, doc, "SPDX SBOM", outputDir); }