Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, + / −).
Expand Down
7 changes: 6 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,12 @@ Every component also carries a [purl](https://github.com/package-url/purl-spec)
(`pkg:npm/<name>@<version>`, 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
Expand Down
43 changes: 43 additions & 0 deletions src/engine/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
31 changes: 23 additions & 8 deletions src/engine/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -241,19 +242,33 @@ function sqlBool(v: boolean | null | undefined): string {
return v == null ? 'NULL' : v ? '1' : '0';
}

function libraryComponent(d: DepRecord): Record<string, unknown> {
const version = d.installed ?? d.declared;
const purl = purlFor(d.ecosystem, d.name, isConcreteVersion(version) ? version : UNKNOWN_VERSION);
const component: Record<string, unknown> = {
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 }] });
}
Expand Down
180 changes: 180 additions & 0 deletions src/engine/purl.ts
Original file line number Diff line number Diff line change
@@ -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.`;
}
21 changes: 21 additions & 0 deletions src/reporting/commands/evidence/evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
12 changes: 11 additions & 1 deletion src/reporting/commands/evidence/push-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 } : {}),
};
}
Expand Down
Loading
Loading