From 2ef2f357c74152c0dcc71dc7426290a084f2d95d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 15:21:21 +0000 Subject: [PATCH] fix: keep components when a package URL cannot be encoded A name that cannot be a Package URL was percent-encoded into a purl-shaped string, or the purl was omitted with no signal. The component stays in the SBOM, the purl is left off, and the output records vibgrate:purlStatus=unavailable with a warning that names the package and ecosystem. Fixes #250 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 11 + DOCS.md | 7 +- src/engine/export.test.ts | 43 +++ src/engine/export.ts | 31 ++- src/engine/purl.ts | 180 ++++++++++++ .../commands/evidence/evidence.test.ts | 21 ++ .../commands/evidence/push-payload.ts | 12 +- src/reporting/commands/evidence/release.ts | 49 +++- src/reporting/commands/evidence/types.ts | 6 +- src/reporting/commands/sbom.test.ts | 178 +++++++++++- src/reporting/commands/sbom.ts | 262 +++++++++--------- 11 files changed, 661 insertions(+), 139 deletions(-) create mode 100644 src/engine/purl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..2ea9572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,17 @@ backward compatible. ### Fixed +- **A component whose name cannot be a Package URL is no longer dropped or given a made-up purl.** + A space, an empty path segment, or another character outside the purl name + alphabet used to be percent-encoded into a purl-shaped string, or the purl + was left off with no signal. `vg sbom export` (CycloneDX and SPDX) and + CycloneDX graph export keep the component, omit the purl (and the SPDX purl + `externalRef`), record `vibgrate:purlStatus=unavailable`, and include a + warning that names the package and ecosystem and says what to change. + `vg sbom export` also prints that warning. A component that already has a + valid purl is unchanged. A project type with no purl type is not reported as + npm. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..c174e4a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1054,7 +1054,12 @@ Every component also carries a [purl](https://github.com/package-url/purl-spec) (`pkg:npm/@`, scoped names as their own namespace segment) — as the CycloneDX `purl` field and `bom-ref`, and as the SPDX `externalRefs` PACKAGE-MANAGER reference — so a vulnerability scanner can match components without re-deriving an -identifier. When the lockfile format resolves real dependency edges (npm +identifier. When a package name cannot be encoded as a purl (a space, an empty +path segment, or another character outside the purl name alphabet), the component +is kept. The purl and the SPDX purl `externalRef` are omitted, the component gains +`vibgrate:purlStatus=unavailable` plus a `vibgrate:purlWarning` that names the +package and ecosystem, and `vg sbom export` prints that warning. A name that +encodes cleanly is unchanged. When the lockfile format resolves real dependency edges (npm `package-lock.json` v2/v3 today; pnpm and yarn report components without edges), the SBOM also carries the resolved dependency graph: CycloneDX's top-level `dependencies` array, or SPDX `DEPENDS_ON` relationships. Where edges aren't resolvable, that section diff --git a/src/engine/export.test.ts b/src/engine/export.test.ts index 28b814c..20e30a3 100644 --- a/src/engine/export.test.ts +++ b/src/engine/export.test.ts @@ -114,3 +114,46 @@ describe('sql export', () => { expect(a).toBe(b); }); }); + +describe('cyclonedx purl encoding', () => { + const base = ctx(makeGraph(false)); + + it('keeps a valid npm purl unchanged', () => { + const out = exportGraph('cyclonedx', { + ...base, + deps: [{ name: 'left', ecosystem: 'npm', declared: '^1', installed: '1.2.3' }], + }); + const bom = JSON.parse(out) as { components: Array<{ name: string; purl?: string; properties?: unknown }> }; + expect(bom.components[0]).toEqual({ type: 'library', name: 'left', version: '1.2.3', purl: 'pkg:npm/left@1.2.3' }); + }); + + it('keeps a component whose name cannot be a purl and does not invent one', () => { + const secretPath = '/var/lib/secret-workspace'; + const deps = [{ name: 'foo bar', ecosystem: 'npm' as const, declared: `file:${secretPath}/id_rsa`, installed: '1.0.0' }]; + const out = exportGraph('cyclonedx', { ...base, deps }); + expect(out).toBe(exportGraph('cyclonedx', { ...base, deps })); + const bom = JSON.parse(out) as { + components: Array<{ name: string; purl?: string; properties?: Array<{ name: string; value: string }> }>; + }; + expect(bom.components).toHaveLength(1); + expect(bom.components[0]?.name).toBe('foo bar'); + expect(bom.components[0]?.purl).toBeUndefined(); + expect(out).not.toContain('foo%20bar'); + expect(out).not.toContain('pkg:npm/foo'); + expect(out).not.toContain(secretPath); + const warning = bom.components[0]?.properties?.find((p) => p.name === 'vibgrate:purlWarning')?.value ?? ''; + expect(bom.components[0]?.properties?.find((p) => p.name === 'vibgrate:purlStatus')?.value).toBe('unavailable'); + expect(warning).toContain('npm'); + expect(warning).toContain('foo bar'); + expect(warning).toContain('regenerate the SBOM'); + }); + + it('encodes a non-npm package as its own purl instead of dropping the field', () => { + const out = exportGraph('cyclonedx', { + ...base, + deps: [{ name: 'Flask-SQLAlchemy', ecosystem: 'pypi', declared: '3.0.0', installed: '3.0.0' }], + }); + const bom = JSON.parse(out) as { components: Array<{ purl?: string }> }; + expect(bom.components[0]?.purl).toBe('pkg:pypi/flask-sqlalchemy@3.0.0'); + }); +}); diff --git a/src/engine/export.ts b/src/engine/export.ts index af05e47..e9e8c43 100644 --- a/src/engine/export.ts +++ b/src/engine/export.ts @@ -4,6 +4,7 @@ import { renderHtml } from './html.js'; import type { DepRecord } from './drift.js'; import type { LocalModel } from './models.js'; import type { VgGraph } from '../schema.js'; +import { isConcreteVersion, PURL_STATUS_UNAVAILABLE, purlFor, purlUnavailableMessage, UNKNOWN_VERSION } from './purl.js'; /** Above this node count, JSON export defaults to compact (no pretty-print). */ export const COMPACT_JSON_NODES = 5_000; @@ -241,19 +242,33 @@ function sqlBool(v: boolean | null | undefined): string { return v == null ? 'NULL' : v ? '1' : '0'; } +function libraryComponent(d: DepRecord): Record { + const version = d.installed ?? d.declared; + const purl = purlFor(d.ecosystem, d.name, isConcreteVersion(version) ? version : UNKNOWN_VERSION); + const component: Record = { + type: 'library', + name: d.name, + version, + }; + if (purl) { + component.purl = purl; + } else { + // Keep the component. A missing purl is a property, not a deleted row, + // and not an invented Package URL. + component.properties = [ + { name: 'vibgrate:purlStatus', value: PURL_STATUS_UNAVAILABLE }, + { name: 'vibgrate:purlWarning', value: purlUnavailableMessage(d.ecosystem, d.name) }, + ]; + } + return component; +} + function cyclonedx(ctx: ExportContext): string { // CycloneDX 1.6 JSON — dependencies as library components + local models as // machine-learning-model components (AI-BOM). Deterministic ordering; no // timestamps beyond the pinned generatedAt. const components: unknown[] = []; - for (const d of ctx.deps ?? []) { - components.push({ - type: 'library', - name: d.name, - version: d.installed ?? d.declared, - purl: d.ecosystem === 'npm' ? `pkg:npm/${d.name}@${d.installed ?? ''}` : undefined, - }); - } + for (const d of ctx.deps ?? []) components.push(libraryComponent(d)); for (const m of ctx.models ?? []) { components.push({ type: 'machine-learning-model', name: m.name, properties: [{ name: 'vg:runtime', value: m.runtime }] }); } diff --git a/src/engine/purl.ts b/src/engine/purl.ts new file mode 100644 index 0000000..d559b45 --- /dev/null +++ b/src/engine/purl.ts @@ -0,0 +1,180 @@ +import type { Ecosystem } from './drift.js'; + +/** + * Sentinel for "we know the package but not a concrete installed version". + * A purl's `@version` is a claim about what is installed, so this value omits + * the version instead of encoding a range or protocol spec as one. + */ +export const UNKNOWN_VERSION = 'unknown'; + +/** CycloneDX property / SPDX marker when a component cannot be encoded as a purl. */ +export const PURL_STATUS_UNAVAILABLE = 'unavailable'; + +/** + * True for something that names one real, installed version — false for a + * semver range (`^1.2.3`, `>=1.0.0`), a wildcard/dist-tag (`*`, `latest`), or + * a package-manager protocol spec (`workspace:*`, `npm:real-name@1.2.3`, + * `patch:pkg@…`, `file:../local`, a git/http(s) URL). + */ +export function isConcreteVersion(spec: string): boolean { + if (!spec || spec === '*' || spec === 'latest') return false; + if (/[\^~*<>|]/.test(spec)) return false; + if (/^(npm|workspace|patch|file|link|git|github|https?):/i.test(spec)) return false; + return true; +} + +/** purl type for each ecosystem this CLI can encode. There is no npm fallback. */ +const PURL_TYPE: { [K in Ecosystem]: string } = { + npm: 'npm', + pypi: 'pypi', + rust: 'cargo', + go: 'golang', + java: 'maven', + ruby: 'gem', + php: 'composer', + dotnet: 'nuget', + swift: 'swift', + dart: 'pub', +}; + +const KNOWN_PURL_TYPES = new Set(Object.values(PURL_TYPE)); + +/** Decoded purl segment: ASCII letters, digits, and the unreserved extras we emit. */ +const PLAIN_SEGMENT = /^[A-Za-z0-9._~+-]+$/; +/** npm scope segment, after percent-decoding `%40`. */ +const NPM_SCOPE_SEGMENT = /^@[A-Za-z0-9._~+-]+$/; + +/** PyPI purl names are normalized per PEP 503: lowercased, runs of `-_.` collapsed to one `-`. */ +function pypiPurlName(name: string): string { + return name.trim().toLowerCase().replace(/[-_.]+/g, '-'); +} + +/** + * The purl type/namespace/name portion, without a version. + * Returns null when this ecosystem has no purl type — callers must not + * substitute an npm purl. + */ +function purlPath(ecosystem: Ecosystem, name: string): string | null { + const type = PURL_TYPE[ecosystem]; + if (!type) return null; + switch (ecosystem) { + case 'npm': { + const scopeSlash = name.startsWith('@') ? name.indexOf('/') : -1; + if (scopeSlash > 0) { + return `pkg:npm/${encodeURIComponent(name.slice(0, scopeSlash))}/${encodeURIComponent(name.slice(scopeSlash + 1))}`; + } + return `pkg:npm/${encodeURIComponent(name)}`; + } + case 'pypi': + return `pkg:pypi/${encodeURIComponent(pypiPurlName(name))}`; + case 'rust': + return `pkg:cargo/${encodeURIComponent(name)}`; + case 'go': + return `pkg:golang/${name.split('/').map(encodeURIComponent).join('/')}`; + case 'java': { + const [group, artifact] = name.includes(':') ? name.split(':') : [undefined, name]; + return group + ? `pkg:maven/${encodeURIComponent(group)}/${encodeURIComponent(artifact)}` + : `pkg:maven/${encodeURIComponent(artifact)}`; + } + case 'ruby': + return `pkg:gem/${encodeURIComponent(name)}`; + case 'php': + return `pkg:composer/${name.split('/').map(encodeURIComponent).join('/')}`; + case 'dotnet': + return `pkg:nuget/${encodeURIComponent(name)}`; + case 'swift': + return `pkg:swift/${name.split('/').map(encodeURIComponent).join('/')}`; + case 'dart': + return `pkg:pub/${encodeURIComponent(name)}`; + default: + return null; + } +} + +function decodeSegment(segment: string): string | undefined { + if (!segment || /%(?![0-9A-Fa-f]{2})/.test(segment)) return undefined; + try { + return decodeURIComponent(segment); + } catch { + return undefined; + } +} + +function segmentAllowed(decoded: string, allowNpmScope: boolean): boolean { + if (decoded === '.' || decoded === '..') return false; + if (allowNpmScope && decoded.startsWith('@')) return NPM_SCOPE_SEGMENT.test(decoded); + return PLAIN_SEGMENT.test(decoded); +} + +/** + * A purl string this CLI is willing to emit: a known type, a non-empty name, + * no empty path segments, and a version that is either absent or one concrete + * token. Spaces and other characters outside the purl name alphabet fail here + * even when percent-encoding would still produce a purl-shaped string. + */ +export function isValidBuiltPurl(purl: string): boolean { + const match = /^pkg:([a-z0-9.+-]+)\/([^?#]*?)(?:@([^?#]*))?$/.exec(purl); + if (!match) return false; + const type = match[1]; + const path = match[2]; + const version = match[3]; + if (!type || !KNOWN_PURL_TYPES.has(type) || !path) return false; + const segments = path.split('/'); + if (segments.some((segment) => segment.length === 0)) return false; + for (let i = 0; i < segments.length; i++) { + const decoded = decodeSegment(segments[i]!); + if (decoded === undefined || !segmentAllowed(decoded, type === 'npm' && i === 0)) return false; + } + if (version === undefined) return true; + const decodedVersion = decodeSegment(version); + if (decodedVersion === undefined || decodedVersion === '.' || decodedVersion === '..') return false; + if (!PLAIN_SEGMENT.test(decodedVersion)) return false; + return isConcreteVersion(decodedVersion); +} + +/** + * [purl](https://github.com/package-url/purl-spec) for a dependency. + * `UNKNOWN_VERSION` omits `@version` (a bare `pkg:npm/axios` is valid purl + * syntax). Returns undefined when the name or version cannot be encoded — + * callers keep the component and must not invent a replacement purl. + */ +export function purlFor(ecosystem: Ecosystem, name: string, version: string): string | undefined { + const path = purlPath(ecosystem, name); + if (!path) return undefined; + const purl = version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; + return isValidBuiltPurl(purl) ? purl : undefined; +} + +/** + * [purl](https://github.com/package-url/purl-spec) for an npm package. + * A scope is its own namespace segment (`pkg:npm/%40scope/name@1.0.0`). + */ +export function npmPurl(name: string, version: string): string | undefined { + return purlFor('npm', name, version); +} + +/** Stable CycloneDX bom-ref for a component that has no purl. Not a purl. */ +export function unavailableBomRef(ecosystemLabel: string, packageName: string, version: string): string { + return `vibgrate:${ecosystemLabel}:${packageName}@${version}`; +} + +const MAX_PACKAGE_NAME_IN_WARNING = 200; + +/** Package name as it appears in a diagnostic: one line, bounded, no control characters. */ +function packageNameForWarning(name: string): string { + const collapsed = name.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/[ \t\f\v]+/g, ' ').trim(); + if (!collapsed) return '(empty name)'; + if (collapsed.length <= MAX_PACKAGE_NAME_IN_WARNING) return collapsed; + return `${collapsed.slice(0, MAX_PACKAGE_NAME_IN_WARNING)}...`; +} + +/** + * Actionable diagnostic for a component whose purl could not be encoded. + * Names the package and ecosystem only — no filesystem path, declared spec, + * or file contents. + */ +export function purlUnavailableMessage(ecosystemLabel: string, packageName: string): string { + const shown = packageNameForWarning(packageName); + return `Package URL unavailable for ${ecosystemLabel} package ${JSON.stringify(shown)}. The component is included without a purl. Use a non-empty package name with no spaces or empty path segments (letters, digits, and ._-~; an npm scope may start with @) and a concrete version when one is known, then regenerate the SBOM.`; +} diff --git a/src/reporting/commands/evidence/evidence.test.ts b/src/reporting/commands/evidence/evidence.test.ts index 75a8fcf..ec3ad0a 100644 --- a/src/reporting/commands/evidence/evidence.test.ts +++ b/src/reporting/commands/evidence/evidence.test.ts @@ -143,6 +143,27 @@ describe('release freezing', () => { const comps = componentsFromArtifact(artifact as never); expect(comps).toEqual([{ name: 'netty', version: '4.1.104', ecosystem: 'npm', purl: 'pkg:npm/netty@4.1.104' }]); }); + it('does not invent a purl when the package name cannot be encoded', () => { + const artifact = { + projects: [{ type: 'node', path: '/var/lib/secret-workspace', dependencies: [{ package: 'foo bar', resolvedVersion: '1.0.0', currentSpec: 'file:/var/lib/secret-workspace/id_rsa' }] }], + }; + const comps = componentsFromArtifact(artifact as never); + expect(comps).toHaveLength(1); + expect(comps[0]?.name).toBe('foo bar'); + expect(comps[0]?.purl).toBeUndefined(); + expect(comps[0]?.purlStatus).toBe('unavailable'); + expect(comps[0]?.purlWarning).toContain('npm'); + expect(comps[0]?.purlWarning).toContain('foo bar'); + expect(comps[0]?.purlWarning).not.toContain('/var/lib/secret-workspace'); + expect(comps[0]?.purlWarning).not.toContain('id_rsa'); + expect(JSON.stringify(comps)).not.toContain('foo%20bar'); + expect(JSON.stringify(comps)).not.toContain('pkg:'); + }); + it('encodes a non-npm scan dependency as its own purl', () => { + const artifact = { projects: [{ type: 'python', dependencies: [{ package: 'Flask-SQLAlchemy', resolvedVersion: '3.0.0', currentSpec: '3.0.0' }] }] }; + const comps = componentsFromArtifact(artifact as never); + expect(comps).toEqual([{ name: 'Flask-SQLAlchemy', version: '3.0.0', ecosystem: 'PyPI', purl: 'pkg:pypi/flask-sqlalchemy@3.0.0' }]); + }); it('extracts components from a CycloneDX SBOM', () => { const comps = componentsFromCycloneDx({ components: [{ name: 'netty', version: '4.1.104', purl: 'pkg:maven/io.netty/netty@4.1.104' }] }); expect(comps[0]).toMatchObject({ name: 'netty', version: '4.1.104', ecosystem: 'Maven' }); diff --git a/src/reporting/commands/evidence/push-payload.ts b/src/reporting/commands/evidence/push-payload.ts index 5c2e81c..4f52fb5 100644 --- a/src/reporting/commands/evidence/push-payload.ts +++ b/src/reporting/commands/evidence/push-payload.ts @@ -34,6 +34,9 @@ export interface PushComponent { version: string; ecosystem?: string; purl?: string; + /** Present when the component was kept but its purl could not be encoded. */ + purlStatus?: 'unavailable'; + purlWarning?: string; } export interface PushRelease { @@ -109,7 +112,14 @@ export function pushRelease(r: Release): PushRelease { distribution: r.distribution, frozenAt: r.frozenAt, componentCount: r.components.length, - components: r.components.map((c) => ({ name: c.name, version: c.version, ecosystem: c.ecosystem, purl: c.purl })), + components: r.components.map((c) => ({ + name: c.name, + version: c.version, + ecosystem: c.ecosystem, + ...(c.purl ? { purl: c.purl } : {}), + ...(c.purlStatus ? { purlStatus: c.purlStatus } : {}), + ...(c.purlWarning ? { purlWarning: c.purlWarning } : {}), + })), ...(r.build ? { build: r.build } : {}), }; } diff --git a/src/reporting/commands/evidence/release.ts b/src/reporting/commands/evidence/release.ts index a395892..16582b6 100644 --- a/src/reporting/commands/evidence/release.ts +++ b/src/reporting/commands/evidence/release.ts @@ -9,6 +9,8 @@ import { readJsonFile, pathExists } from '../../utils/fs.js'; import { CliError, ExitCode } from '../../../util/exit.js'; +import type { Ecosystem } from '../../../engine/drift.js'; +import { isConcreteVersion, purlFor, purlUnavailableMessage, UNKNOWN_VERSION } from '../../../engine/purl.js'; import type { ProjectType, ScanArtifact } from '../../types.js'; import type { FrozenComponent, Release, ReleaseBuild } from './types.js'; import { @@ -60,6 +62,39 @@ export function ecosystemForProjectType(type: ProjectType): string | undefined { } } +/** Project type → purl ecosystem. Types with no purl type return undefined (no invented npm purl). */ +function purlEcosystemForProjectType(type: ProjectType): Ecosystem | undefined { + switch (type) { + case 'node': + case 'typescript': + return 'npm'; + case 'python': + return 'pypi'; + case 'java': + case 'kotlin': + case 'scala': + case 'groovy': + return 'java'; + case 'dotnet': + case 'visual-basic': + return 'dotnet'; + case 'go': + return 'go'; + case 'rust': + return 'rust'; + case 'ruby': + return 'ruby'; + case 'php': + return 'php'; + case 'swift': + return 'swift'; + case 'dart': + return 'dart'; + default: + return undefined; + } +} + function ecosystemForPurl(purl: string): string | undefined { const m = /^pkg:([^/]+)\//.exec(purl); if (!m) return undefined; @@ -77,7 +112,19 @@ export function componentsFromArtifact(artifact: ScanArtifact): FrozenComponent[ const key = `${ecosystem ?? ''}|${dep.package}|${version}`; if (seen.has(key)) continue; seen.add(key); - out.push({ name: dep.package, version, ecosystem, purl: ecosystem === 'npm' ? `pkg:npm/${dep.package}@${version}` : undefined }); + const purlEco = purlEcosystemForProjectType(project.type); + const purl = purlEco ? purlFor(purlEco, dep.package, isConcreteVersion(version) ? version : UNKNOWN_VERSION) : undefined; + if (purl) { + out.push({ name: dep.package, version, ecosystem, purl }); + } else { + out.push({ + name: dep.package, + version, + ecosystem, + purlStatus: 'unavailable', + purlWarning: purlUnavailableMessage(ecosystem ?? project.type, dep.package), + }); + } } } return out.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version)); diff --git a/src/reporting/commands/evidence/types.ts b/src/reporting/commands/evidence/types.ts index ce7a1ce..f21c4ac 100644 --- a/src/reporting/commands/evidence/types.ts +++ b/src/reporting/commands/evidence/types.ts @@ -109,8 +109,12 @@ export interface FrozenComponent { name: string; /** The exact shipped/resolved version (never a range). */ version: string; - /** package-url, when derivable. */ + /** package-url, when derivable. Absent when the coordinate cannot be encoded as a purl. */ purl?: string; + /** Set when `purl` cannot be encoded. The component is kept. */ + purlStatus?: 'unavailable'; + /** Actionable reason `purl` is absent. Names the package and ecosystem only. */ + purlWarning?: string; /** Ecosystem, e.g. npm / PyPI / Maven — drives OSV matching. */ ecosystem?: string; } diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4625967..199616b 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { toCycloneDx, toSpdx, formatDeltaText, npmPurl, purlFor, collectLockfileGraph } from './sbom.js'; +import { toCycloneDx, toSpdx, formatDeltaText, npmPurl, purlFor, collectLockfileGraph, collectPurlWarnings, sbomCommand } from './sbom.js'; import type { ProjectScan, ScanArtifact } from '../types.js'; import type { LockfileGraph } from '../../engine/lockfile.js'; @@ -232,6 +232,182 @@ describe('sbom helpers', () => { expect(sbom.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); }); + /** + * Names that cannot be a purl name. A space percent-encodes to a purl-shaped + * string; an empty path segment encodes to `pkg:golang/github.com//sse@…`. + * Neither is a Package URL. + */ + const unencodableNames = ['foo bar', 'github.com//sse'] as const; + + it('keeps a component when its name cannot be encoded as a purl, without inventing one', () => { + const secretPath = '/var/lib/secret-workspace'; + const artifact = makeArtifact('5.3.0', 90); + artifact.rootPath = secretPath; + artifact.projects[0]!.dependencies[0]!.currentSpec = `file:${secretPath}/id_rsa`; + artifact.projects[0]!.dependencies.push({ + package: 'foo bar', + section: 'dependencies', + currentSpec: `file:${secretPath}/id_rsa`, + resolvedVersion: '1.0.0', + latestStable: null, + majorsBehind: null, + drift: 'unknown', + }); + + const sbom = toCycloneDx(artifact) as { + components: Array<{ + name: string; + purl?: string; + 'bom-ref': string; + properties: Array<{ name: string; value: string }>; + }>; + }; + expect(JSON.stringify(sbom)).toBe(JSON.stringify(toCycloneDx(artifact))); + + const chalk = sbom.components.find((c) => c.name === 'chalk'); + expect(chalk?.purl).toBe('pkg:npm/chalk@5.3.0'); + expect(chalk?.['bom-ref']).toBe('pkg:npm/chalk@5.3.0'); + expect(chalk?.properties.some((p) => p.name === 'vibgrate:purlStatus')).toBe(false); + + const broken = sbom.components.find((c) => c.name === 'foo bar'); + expect(broken).toBeDefined(); + expect(broken?.purl).toBeUndefined(); + expect(broken?.['bom-ref']).toBe('vibgrate:npm:foo bar@1.0.0'); + expect(broken?.['bom-ref'].startsWith('pkg:')).toBe(false); + const status = broken?.properties.find((p) => p.name === 'vibgrate:purlStatus')?.value; + const warning = broken?.properties.find((p) => p.name === 'vibgrate:purlWarning')?.value ?? ''; + expect(status).toBe('unavailable'); + expect(warning).toContain('npm'); + expect(warning).toContain('foo bar'); + expect(warning).toContain('regenerate the SBOM'); + expect(warning).not.toContain(secretPath); + expect(warning).not.toContain('id_rsa'); + expect(JSON.stringify(sbom)).not.toContain('foo%20bar'); + expect(JSON.stringify(sbom)).not.toContain('pkg:npm/foo'); + + const warnings = collectPurlWarnings( + // Same order the export command prints: direct dependencies, document order. + [ + { + project: 'app', + package: 'chalk', + version: '5.3.0', + currentSpec: '5.3.0', + drift: 'current', + majorsBehind: 0, + scope: 'direct', + ecosystem: 'npm', + ecosystemLabel: 'npm', + }, + { + project: 'app', + package: 'foo bar', + version: '1.0.0', + currentSpec: '1.0.0', + drift: 'unknown', + majorsBehind: null, + scope: 'direct', + ecosystem: 'npm', + ecosystemLabel: 'npm', + }, + ], + ); + expect(warnings).toEqual([warning]); + }); + + it('rejects an empty purl path segment and a non-ASCII name instead of percent-encoding them', () => { + expect(purlFor('npm', 'chalk', '5.3.0')).toBe('pkg:npm/chalk@5.3.0'); + expect(purlFor('go', 'github.com//sse', '1.2.3')).toBeUndefined(); + expect(purlFor('npm', 'café', '1.0.0')).toBeUndefined(); + expect(purlFor('not-a-registry' as never, 'left-pad', '1.0.0')).toBeUndefined(); + + for (const packageName of unencodableNames) { + const artifact = makeArtifact('1.2.3', 90); + artifact.projects[0]!.type = packageName.includes('//') ? 'go' : 'node'; + artifact.projects[0]!.dependencies[0]!.package = packageName; + artifact.projects[0]!.dependencies[0]!.resolvedVersion = '1.2.3'; + artifact.projects[0]!.dependencies[0]!.currentSpec = '1.2.3'; + const cyclonedx = JSON.stringify(toCycloneDx(artifact)); + const spdx = toSpdx(artifact) as { + packages: Array<{ name: string; externalRefs?: unknown; annotations: Array<{ comment: string }> }>; + }; + expect(cyclonedx).toBe(JSON.stringify(toCycloneDx(artifact))); + expect(cyclonedx).not.toContain('%20'); + expect(cyclonedx).not.toContain('%2F%2F'); + expect(cyclonedx).toContain('"vibgrate:purlStatus"'); + expect(cyclonedx).toContain(packageName); + const pkg = spdx.packages.find((p) => p.name === packageName); + expect(pkg).toBeDefined(); + expect(pkg?.externalRefs).toBeUndefined(); + expect(pkg?.annotations.some((a) => a.comment.includes('purlStatus=unavailable'))).toBe(true); + expect(pkg?.annotations.some((a) => a.comment.includes(packageName) && a.comment.includes('regenerate the SBOM'))).toBe(true); + expect(JSON.stringify(spdx)).not.toContain('pkg:'); + } + }); + + it('does not key the dependency graph with an invented purl when a name cannot be encoded', () => { + const artifact = makeArtifact('5.3.0', 90); + artifact.projects[0]!.dependencies.push({ + package: 'foo bar', + section: 'dependencies', + currentSpec: '1.0.0', + resolvedVersion: '1.0.0', + latestStable: null, + majorsBehind: null, + drift: 'unknown', + }); + const graph: LockfileGraph = { + components: [ + { package: 'chalk', version: '5.3.0' }, + { package: 'foo bar', version: '1.0.0' }, + ], + edges: new Map([['chalk@5.3.0', ['foo bar@1.0.0']]]), + rootDependsOn: ['chalk@5.3.0'], + }; + const sbom = toCycloneDx(artifact, graph) as { dependencies: Array<{ ref: string; dependsOn: string[] }> }; + expect(sbom.dependencies).toEqual([ + { ref: 'vibgrate-root', dependsOn: ['pkg:npm/chalk@5.3.0'] }, + { ref: 'pkg:npm/chalk@5.3.0', dependsOn: ['vibgrate:npm:foo bar@1.0.0'] }, + { ref: 'vibgrate:npm:foo bar@1.0.0', dependsOn: [] }, + ]); + expect(JSON.stringify(sbom)).not.toContain('foo%20bar'); + }); + + it('prints the same warning on export and leaves it out of the document path', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-sbom-purl-')); + const artifact = makeArtifact('1.0.0', 90); + artifact.projects[0]!.dependencies[0]!.package = 'foo bar'; + artifact.projects[0]!.dependencies[0]!.resolvedVersion = '1.0.0'; + artifact.projects[0]!.dependencies[0]!.currentSpec = '1.0.0'; + const inFile = path.join(dir, 'scan.json'); + fs.writeFileSync(inFile, JSON.stringify(artifact)); + const errors: string[] = []; + const logs: string[] = []; + const origLog = console.log; + const origError = console.error; + console.log = (msg?: unknown) => { + logs.push(String(msg)); + }; + console.error = (msg?: unknown) => { + errors.push(String(msg)); + }; + try { + await sbomCommand.parseAsync(['node', 'sbom', 'export', '--in', inFile, '--no-transitive']); + } finally { + console.log = origLog; + console.error = origError; + fs.rmSync(dir, { recursive: true, force: true }); + } + const stderr = errors.join('\n'); + expect(stderr).toContain('foo bar'); + expect(stderr).toContain('npm'); + expect(stderr).toContain('regenerate the SBOM'); + expect(stderr).not.toContain(dir); + const body = JSON.parse(logs.join('\n')) as { components: Array<{ purl?: string; properties: Array<{ name: string; value: string }> }> }; + expect(body.components[0]?.purl).toBeUndefined(); + expect(body.components[0]?.properties.find((p) => p.name === 'vibgrate:purlStatus')?.value).toBe('unavailable'); + }); + describe('collectLockfileGraph', () => { let root: string; beforeEach(() => { diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c560d2f..bcf05a4 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -5,8 +5,18 @@ import { pathExists, readJsonFile, writeTextFile } from '../utils/fs.js'; import type { DependencyRow, ProjectScan, ScanArtifact } from '../types.js'; import { fullDependencyGraph, type LockfileComponent, type LockfileGraph } from '../../engine/lockfile.js'; import type { Ecosystem } from '../../engine/drift.js'; +import { + isConcreteVersion, + PURL_STATUS_UNAVAILABLE, + purlFor, + purlUnavailableMessage, + unavailableBomRef, + UNKNOWN_VERSION, +} from '../../engine/purl.js'; import { vexCommand } from './vex.js'; +export { npmPurl, purlFor } from '../../engine/purl.js'; + type SbomFormat = 'cyclonedx' | 'spdx'; interface FlattenedDependency { @@ -18,13 +28,22 @@ interface FlattenedDependency { majorsBehind: number | null; /** 'direct' comes from a scanned manifest; 'transitive' is lockfile-only. */ scope: 'direct' | 'transitive'; - /** Which package registry this dependency resolves against — picks the purl scheme. */ - ecosystem: Ecosystem; + /** Which package registry this dependency resolves against — picks the purl scheme. Absent when this project type has no purl type. */ + ecosystem?: Ecosystem; + /** Ecosystem name used in diagnostics. A project type with no purl type keeps its own name here. */ + ecosystemLabel: string; } -/** `ProjectScan.type` → the purl-scheme ecosystem for its dependencies. */ -function projectEcosystem(type: ProjectScan['type']): Ecosystem { +/** + * `ProjectScan.type` → the purl-scheme ecosystem for its dependencies. + * Node and TypeScript are npm. A project type with no purl type returns + * undefined so the SBOM does not invent an npm purl for it. + */ +function projectEcosystem(type: ProjectScan['type']): Ecosystem | undefined { switch (type) { + case 'node': + case 'typescript': + return 'npm'; case 'python': return 'pypi'; case 'rust': @@ -34,19 +53,21 @@ function projectEcosystem(type: ProjectScan['type']): Ecosystem { case 'java': case 'kotlin': case 'scala': + case 'groovy': return 'java'; case 'ruby': return 'ruby'; case 'php': return 'php'; case 'dotnet': + case 'visual-basic': return 'dotnet'; case 'swift': return 'swift'; case 'dart': return 'dart'; default: - return 'npm'; + return undefined; } } @@ -82,91 +103,32 @@ export function deterministicUuid(seed: string): string { } /** - * Sentinel for "we know the package but not a concrete installed version" — - * same convention as `majorsBehind`'s `'unknown'` elsewhere in this file. - * Never emitted as a real version: `isConcreteVersion` below is what routes a - * dependency here instead of its raw declared spec. + * A resolved purl, or a stable non-purl identity plus a warning when the + * package cannot be encoded. The component is always kept. */ -const UNKNOWN_VERSION = 'unknown'; - -/** - * True for something that names one real, installed version — false for a - * semver range (`^1.2.3`, `>=1.0.0`), a wildcard/dist-tag (`*`, `latest`), or - * a package-manager protocol spec (`workspace:*`, `npm:real-name@1.2.3`, - * `patch:pkg@…`, `file:../local`, a git/http(s) URL). Only `resolvedVersion` - * or a lockfile hit should ever produce the latter; when neither exists the - * SBOM must say so honestly (`UNKNOWN_VERSION`) rather than put someone's - * *intent* ("whatever satisfies ^1.2.3") in the field a vulnerability scanner - * reads as "this exact version is installed" — that's not a smaller version - * of the truth, it's a different claim. - */ -function isConcreteVersion(spec: string): boolean { - if (!spec || spec === '*' || spec === 'latest') return false; - if (/[\^~*<>|]/.test(spec)) return false; - if (/^(npm|workspace|patch|file|link|git|github|https?):/i.test(spec)) return false; - return true; -} - -/** The purl type/namespace/name portion, without a version — shared by every ecosystem branch of `purlFor`. */ -function purlPath(ecosystem: Ecosystem, name: string): string { - switch (ecosystem) { - case 'npm': { - const scopeSlash = name.startsWith('@') ? name.indexOf('/') : -1; - if (scopeSlash > 0) { - return `pkg:npm/${encodeURIComponent(name.slice(0, scopeSlash))}/${encodeURIComponent(name.slice(scopeSlash + 1))}`; - } - return `pkg:npm/${encodeURIComponent(name)}`; - } - case 'pypi': - return `pkg:pypi/${encodeURIComponent(pypiPurlName(name))}`; - case 'rust': - return `pkg:cargo/${encodeURIComponent(name)}`; - case 'go': - return `pkg:golang/${name.split('/').map(encodeURIComponent).join('/')}`; - case 'java': { - const [group, artifact] = name.includes(':') ? name.split(':') : [undefined, name]; - return group ? `pkg:maven/${encodeURIComponent(group)}/${encodeURIComponent(artifact)}` : `pkg:maven/${encodeURIComponent(artifact)}`; - } - case 'ruby': - return `pkg:gem/${encodeURIComponent(name)}`; - case 'php': - return `pkg:composer/${name.split('/').map(encodeURIComponent).join('/')}`; - case 'dotnet': - return `pkg:nuget/${encodeURIComponent(name)}`; - case 'swift': - return `pkg:swift/${name.split('/').map(encodeURIComponent).join('/')}`; - case 'dart': - return `pkg:pub/${encodeURIComponent(name)}`; - default: - return purlPath('npm', name); - } +interface ResolvedPurl { + bomRef: string; + purl?: string; + warning?: string; } -/** - * [purl](https://github.com/package-url/purl-spec) for an npm package, - * scope handled as its own namespace segment per spec (`pkg:npm/%40scope/name@1.0.0`, - * not a single percent-encoded `%40scope%2Fname`). Used to key components and - * dependency-graph refs so a vulnerability scanner can match on purl directly. - */ -export function npmPurl(name: string, version: string): string { - return `${purlPath('npm', name)}@${encodeURIComponent(version)}`; -} - -/** PyPI purl names are normalized per PEP 503: lowercased, runs of `-_.` collapsed to one `-`. */ -function pypiPurlName(name: string): string { - return name.trim().toLowerCase().replace(/[-_.]+/g, '-'); +function resolvedPurl(dep: FlattenedDependency): ResolvedPurl { + const purl = dep.ecosystem ? purlFor(dep.ecosystem, dep.package, dep.version) : undefined; + if (purl) return { bomRef: purl, purl }; + return { + bomRef: unavailableBomRef(dep.ecosystemLabel, dep.package, dep.version), + warning: purlUnavailableMessage(dep.ecosystemLabel, dep.package), + }; } -/** - * [purl](https://github.com/package-url/purl-spec) for a dependency, keyed - * by ecosystem — see `purlPath` for the per-ecosystem type/namespace/name - * mapping. A purl's `@version` is a claim about what's actually installed, - * so `UNKNOWN_VERSION` omits it (a bare `pkg:npm/axios` is valid purl syntax) - * rather than encode a range or protocol spec as if it were one. - */ -export function purlFor(ecosystem: Ecosystem, name: string, version: string): string { - const path = purlPath(ecosystem, name); - return version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; +/** Warnings for components whose purl could not be encoded, in document order. */ +export function collectPurlWarnings(deps: FlattenedDependency[]): string[] { + const warnings: string[] = []; + for (const dep of deps) { + const warning = resolvedPurl(dep).warning; + if (warning) warnings.push(warning); + } + return warnings; } function splitDependencyKey(key: string): { name: string; version: string } { @@ -209,14 +171,29 @@ function cycloneDxDependencyGraph( graph: LockfileGraph | undefined, ): Array<{ ref: string; dependsOn: string[] }> | undefined { if (!graph?.edges) return undefined; - const purlOfKey = (key: string): string => { + const ecosystem = graph.ecosystem ?? 'npm'; + const refByKey = new Map(); + for (const dep of dependencies) refByKey.set(`${dep.package}@${dep.version}`, resolvedPurl(dep).bomRef); + const refOfKey = (key: string): string => { + const known = refByKey.get(key); + if (known) return known; const { name, version } = splitDependencyKey(key); - return npmPurl(name, version); + return resolvedPurl({ + project: '', + package: name, + version, + currentSpec: version, + drift: 'unknown', + majorsBehind: null, + scope: 'transitive', + ecosystem, + ecosystemLabel: ecosystem, + }).bomRef; }; - const nodes = [{ ref: ROOT_BOM_REF, dependsOn: uniqSorted(graph.rootDependsOn).map(purlOfKey) }]; + const nodes = [{ ref: ROOT_BOM_REF, dependsOn: uniqSorted(graph.rootDependsOn).map(refOfKey) }]; for (const dep of dependencies) { const key = `${dep.package}@${dep.version}`; - nodes.push({ ref: npmPurl(dep.package, dep.version), dependsOn: uniqSorted(graph.edges.get(key) ?? []).map(purlOfKey) }); + nodes.push({ ref: resolvedPurl(dep).bomRef, dependsOn: uniqSorted(graph.edges.get(key) ?? []).map(refOfKey) }); } return nodes; } @@ -265,6 +242,7 @@ export function flattenDependencies( const seen = new Set(); for (const project of artifact.projects) { const ecosystem = projectEcosystem(project.type); + const ecosystemLabel = ecosystem ?? project.type; for (const dep of project.dependencies) { // Go always pins an exact version in go.mod, but the scanner's // `resolvedVersion` runs it through `semver.clean` (for semver math @@ -298,6 +276,7 @@ export function flattenDependencies( majorsBehind: dep.majorsBehind, scope: 'direct', ecosystem, + ecosystemLabel, }); } } @@ -305,6 +284,7 @@ export function flattenDependencies( const key = `${dep.package}@${dep.version}`; if (seen.has(key)) continue; seen.add(key); + const ecosystem = lockfileEcosystem ?? 'npm'; rows.push({ project: artifact.rootPath, package: dep.package, @@ -313,7 +293,8 @@ export function flattenDependencies( drift: 'unknown', majorsBehind: null, scope: 'transitive', - ecosystem: lockfileEcosystem ?? 'npm', + ecosystem, + ecosystemLabel: ecosystem, }); } return rows; @@ -369,20 +350,29 @@ export function toCycloneDx(artifact: ScanArtifact, graph?: LockfileGraph): Reco name: artifact.rootPath, }, }, - components: dependencies.map((dep) => ({ - type: 'library', - 'bom-ref': purlFor(dep.ecosystem, dep.package, dep.version), - name: dep.package, - version: dep.version, - purl: purlFor(dep.ecosystem, dep.package, dep.version), - properties: [ - { name: 'vibgrate:project', value: dep.project }, - { name: 'vibgrate:currentSpec', value: dep.currentSpec }, - { name: 'vibgrate:drift', value: dep.drift }, - { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, - { name: 'vibgrate:scope', value: dep.scope }, - ], - })), + components: dependencies.map((dep) => { + const identity = resolvedPurl(dep); + return { + type: 'library', + 'bom-ref': identity.bomRef, + name: dep.package, + version: dep.version, + ...(identity.purl ? { purl: identity.purl } : {}), + properties: [ + { name: 'vibgrate:project', value: dep.project }, + { name: 'vibgrate:currentSpec', value: dep.currentSpec }, + { name: 'vibgrate:drift', value: dep.drift }, + { name: 'vibgrate:majorsBehind', value: String(dep.majorsBehind ?? 'unknown') }, + { name: 'vibgrate:scope', value: dep.scope }, + ...(identity.warning + ? [ + { name: 'vibgrate:purlStatus', value: PURL_STATUS_UNAVAILABLE }, + { name: 'vibgrate:purlWarning', value: identity.warning }, + ] + : []), + ], + }; + }), ...(dependencyGraph ? { dependencies: dependencyGraph } : {}), }; } @@ -400,28 +390,46 @@ export function toSpdx(artifact: ScanArtifact, graph?: LockfileGraph): Record ({ - name: dep.package, - SPDXID: `SPDXRef-Package-${i + 1}`, - versionInfo: dep.version, - downloadLocation: 'NOASSERTION', - filesAnalyzed: false, - externalRefs: [ - { - referenceCategory: 'PACKAGE-MANAGER', - referenceType: 'purl', - referenceLocator: purlFor(dep.ecosystem, dep.package, dep.version), - }, - ], - annotations: [ - { - annotationType: 'OTHER', - annotator: 'Tool: @vibgrate/cli', - annotationDate: artifact.timestamp, - comment: `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}`, - }, - ], - })), + packages: dependencies.map((dep, i) => { + const identity = resolvedPurl(dep); + const comment = `project=${dep.project}; drift=${dep.drift}; majorsBehind=${dep.majorsBehind ?? 'unknown'}; scope=${dep.scope}`; + return { + name: dep.package, + SPDXID: `SPDXRef-Package-${i + 1}`, + versionInfo: dep.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + ...(identity.purl + ? { + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: identity.purl, + }, + ], + } + : {}), + annotations: [ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: identity.warning ? `${comment}; purlStatus=${PURL_STATUS_UNAVAILABLE}` : comment, + }, + ...(identity.warning + ? [ + { + annotationType: 'OTHER', + annotator: 'Tool: @vibgrate/cli', + annotationDate: artifact.timestamp, + comment: identity.warning, + }, + ] + : []), + ], + }; + }), ...(relationships ? { relationships } : {}), }; } @@ -515,6 +523,8 @@ const exportCommand = new Command('export') // format supports it, the resolved dependency edges — so the SBOM // reflects real supply-chain exposure, not just direct dependencies. const lockfileGraph = opts.transitive ? collectLockfileGraph(artifact, path.resolve(opts.root)) : undefined; + const warnings = collectPurlWarnings(flattenDependencies(artifact, lockfileGraph?.components ?? [], lockfileGraph?.ecosystem)); + for (const warning of warnings) console.error(chalk.yellow('warning:') + ` ${warning}`); const sbom = format === 'cyclonedx' ? toCycloneDx(artifact, lockfileGraph) : toSpdx(artifact, lockfileGraph); const body = JSON.stringify(sbom, null, 2);