Skip to content
Merged
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 @@ -35,6 +35,17 @@ backward compatible.

### Fixed

- **SBOM component order no longer follows scan or filesystem order.**
`vg sbom export` and CycloneDX/SPDX graph export sort a component by its
Package URL when one is written, otherwise by package name, then by version.
Rows that share a Package URL and a version are ordered by name. CycloneDX
`components`, SPDX `packages`, and the package entries in CycloneDX
`dependencies` share that order, and `SPDXRef-Package-N` follows it. The
same artifact and the same lockfiles produce the same document on every run.
Reordering projects still changes which project's metadata is kept, and
therefore the document id, but it does not change component order or SPDX
IDs. (#286)

- **`vg report --format` rejects values other than `md`, `text`, and `json`.**
An unknown value, including `html`, exits `5` with a usage error that names
the value and lists the valid ones. Stdout is empty. `text` stays the
Expand Down
41 changes: 23 additions & 18 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,7 @@ Use this to treat SBOMs as operational intelligence instead of static compliance

`vg sbom export` writes one JSON document. `--format` accepts `cyclonedx` or `spdx`, compared without regard to case. The default is `cyclonedx`. Any other value prints `Invalid SBOM format. Use cyclonedx or spdx.` and the process exits 1.

Both formats are built from the same scan artifact and the same lockfiles under `--root`. The component list and its order are the same. Each specification then places those facts in its own fields.
Both formats are built from the same scan artifact and the same lockfiles under `--root`. The component list and its order are the same: Package URL when the component has one, otherwise the package name, then the version. Each specification then places those facts in its own fields.

```bash
vg scan --offline
Expand All @@ -1642,7 +1642,7 @@ vg sbom export --format spdx --out sbom.spdx.json
| Created | `metadata.timestamp` is the artifact's `timestamp` | `creationInfo.created` is that same timestamp |
| Scan root | `metadata.component` has `type` `application`, `bom-ref` `vibgrate-root`, and `name` set to the scan root. That component is not copied into `components` | There is no package for the scan root |
| Package identity | `components[].purl`. When a purl is written, `bom-ref` is that purl | `packages[].externalRefs[]` with `referenceCategory` `PACKAGE-MANAGER`, `referenceType` `purl`, and `referenceLocator` set to the purl. `SPDXID` is `SPDXRef-Package-N` |
| Dependency graph | `dependencies` is an array of `{ ref, dependsOn }`. `ref` is a `bom-ref`. The first entry is `vibgrate-root`. Every component is an entry. `dependsOn` lists child `bom-ref` values, or is `[]` when none were recorded for that component | `relationships` is an array of `{ spdxElementId, relatedSpdxElementId, relationshipType }`. `relationshipType` is `DEPENDS_ON`. Edges from the document use `SPDXRef-DOCUMENT`. A row is written only for a recorded edge |
| Dependency graph | `dependencies` is an array of `{ ref, dependsOn }`. `ref` is a `bom-ref`. The first entry is `vibgrate-root`. Every component is an entry after that, in the component order above. `dependsOn` lists child `bom-ref` values, or is `[]` when none were recorded for that component | `relationships` is an array of `{ spdxElementId, relatedSpdxElementId, relationshipType }`. `relationshipType` is `DEPENDS_ON`. Edges from the document use `SPDXRef-DOCUMENT`. A row is written only for a recorded edge. `SPDXID` values follow the component order above |
| Licenses | `licenses` is present when the declared license can be written, and absent when it cannot | `licenseDeclared` is set on every package. `licenseConcluded` is `NOASSERTION` on every package. `hasExtractedLicensingInfos` is present when a `LicenseRef-…` is used. Every package has `downloadLocation` `NOASSERTION` and `filesAnalyzed` `false` |

`dataLicense` `CC0-1.0` is the SPDX license for the document data. Package licenses stay on `licenseDeclared`.
Expand Down Expand Up @@ -1763,9 +1763,9 @@ Two other strings in the same file are easy to misread as package digests. `vcs.
| `uv.lock` | `hash` |
| `go.sum` | `h1:` |

**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): direct rows follow the scan artifact, then lockfile-only rows sorted by package name, then version, then ecosystem. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`.
**Several digests, one component.** A lockfile can list more than one digest for one package. The export still writes one component for that ecosystem, name, and version. It does not add a row per digest. `hashes` and `checksums` are omitted, so there is no digest array and no digest order to keep stable. Component order stays the order in [Several versions of one package](#several-versions-of-one-package): Package URL when the component has one, otherwise the package name, then the version. Digest text is not part of that sort, and it is not part of the document id. Exporting the same scan artifact again, after a lockfile edit that changes only those digest strings and leaves names, versions, and edges alone, writes the same JSON, including `serialNumber` and `documentNamespace`.

`go.sum` lists a module twice: `<module> <version> h1:…` and `<module> <version>/go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, sorted with the others by name and version. An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm.
`go.sum` lists a module twice: `<module> <version> h1:…` and `<module> <version>/go.mod h1:…`. The `/go.mod` line is not a second component. Both `h1:` values are dropped. A `uv.lock` package block can carry more than one `hash`. Those values are dropped, and the block stays one component, in the same order as the other rows. An npm `integrity` string and a pnpm `resolution.integrity` string are not read, including when the string names more than one algorithm.

```bash
vg scan --offline --no-graph --format json --out scan.json
Expand Down Expand Up @@ -1882,13 +1882,17 @@ absent, and so is the dependency graph. A consumer that filters to
`vibgrate:scope=direct` sees the same gap: other installed versions of that
package are still in the full document, marked `transitive`.

**Order.** Direct rows follow `projects` on the scan artifact, and within a
project they follow that project's `dependencies` array. The npm scanner sorts
each project's array by drift, then by package name, before it writes the
artifact. Lockfile-only rows are appended after the direct rows, sorted by
package name, then by version, then by ecosystem. That combined list is the order of CycloneDX
`components`, CycloneDX `dependencies`, and SPDX `packages`. `dependsOn` entries
and the names inside one lockfile edge are sorted on their own.
**Order.** CycloneDX `components`, SPDX `packages`, and the package entries in
CycloneDX `dependencies` share one order. A component with a Package URL sorts
by that purl. A component without one sorts by package name. Version is the
next key. When two components share a purl and a version, package name orders
them (`Flask` before `flask`). `dependencies` starts with `vibgrate-root`, then
those components. The order is the same on every run of the same artifact and
the same lockfiles. It is independent of project order in the scan artifact,
directory walk order, and lockfile map order. `dependsOn` entries and the names
inside one lockfile edge are sorted on their own. SPDX `SPDXID` values are
`SPDXRef-Package-N` for that order, so they stay put when only discovery order
changes. The document id stays a content-derived UUID.

**Same inputs, same document.** For one scan artifact and the lockfiles under
`--root`, `vg sbom export` writes the same JSON on every run, including the
Expand All @@ -1903,13 +1907,14 @@ are in [Package digests](#package-digests).

**Known limitations:**

- Direct-row order, and which project's metadata is kept for a shared
identity, follow the scan artifact. Reordering projects changes
`vibgrate:project` on that row, reassigns SPDX `SPDXID` values to match the
new positions, retargets SPDX `DEPENDS_ON` relationships (they point at
SPDX IDs), and changes the document serial number and namespace. CycloneDX `bom-ref` stays on the purl,
so a scanner that stored the purl still matches. `vibgrate:projects` stays
the sorted set of contributing projects.
- Which project's metadata is kept for a shared identity follows the scan
artifact. Reordering projects changes `vibgrate:project` on that row and
changes the document serial number and namespace, because the kept project
is part of the document id. Component order and SPDX `SPDXID` values stay
on the Package URL, or on the name and version when there is no purl, so
those identifiers do not move when only project order changes. CycloneDX
`bom-ref` stays on the purl, so a scanner that stored the purl still
matches. `vibgrate:projects` stays the sorted set of contributing projects.
- npm `package-lock.json` v2/v3 collapses two install paths of the same
`name@version` into one component. When those paths declare different
dependencies, the edge list is the path that appears last in the lockfile
Expand Down
14 changes: 7 additions & 7 deletions docs/sbom-dependency-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,16 @@ scope-fixture
└── dev-optional-pkg@1.2.3 lockfile flags dev, optional, and devOptional
```

Component order follows the scan's dependency array (drift, then package name) and then lockfile-only rows (package name, then version). With every row at drift `unknown`, the names sort alphabetically inside each group.
Component order is the Package URL, then the version. Every row in this tree has a purl, so the names sort alphabetically and then by version. CycloneDX `dependencies` still starts with `vibgrate-root`.

| Package | `vibgrate:scope` | CycloneDX component `scope` | Root `dependsOn` | SPDX annotation | SPDX relationship |
| --- | --- | --- | --- | --- | --- |
| `fsevents@2.3.3` | `direct` | omitted | yes (`pkg:npm/fsevents@2.3.3`) | `scope=direct` on `SPDXRef-Package-1` | `DEPENDS_ON` from `SPDXRef-DOCUMENT` |
| `left-pad@1.3.0` | `direct` | omitted | yes | `scope=direct` on `SPDXRef-Package-2` | `DEPENDS_ON` from `SPDXRef-DOCUMENT`. This package `DEPENDS_ON` `SPDXRef-Package-6` (`nested-prod`) |
| `typescript@5.4.5` | `direct` | omitted | no | `scope=direct` on `SPDXRef-Package-3` | No document relationship. This package `DEPENDS_ON` `SPDXRef-Package-5` (`nested-dev`) |
| `dev-optional-pkg@1.2.3` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-4` | none |
| `nested-dev@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-5` | target of `typescript` only |
| `nested-prod@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-6` | target of `left-pad` only |
| `dev-optional-pkg@1.2.3` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-1` | none |
| `fsevents@2.3.3` | `direct` | omitted | yes (`pkg:npm/fsevents@2.3.3`) | `scope=direct` on `SPDXRef-Package-2` | `DEPENDS_ON` from `SPDXRef-DOCUMENT` |
| `left-pad@1.3.0` | `direct` | omitted | yes | `scope=direct` on `SPDXRef-Package-3` | `DEPENDS_ON` from `SPDXRef-DOCUMENT`. This package `DEPENDS_ON` `SPDXRef-Package-5` (`nested-prod`) |
| `nested-dev@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-4` | target of `typescript` only |
| `nested-prod@1.0.0` | `transitive` | omitted | no | `scope=transitive` on `SPDXRef-Package-5` | target of `left-pad` only |
| `typescript@5.4.5` | `direct` | omitted | no | `scope=direct` on `SPDXRef-Package-6` | No document relationship. This package `DEPENDS_ON` `SPDXRef-Package-4` (`nested-dev`) |

A CycloneDX component for `typescript` has `type`, `bom-ref`, `name`, `version`, `purl`, and `properties`. The component `scope` member is absent. `properties` includes `vibgrate:scope` = `direct`. The root `dependencies` entry (`bom-ref` `vibgrate-root`) lists `fsevents` and `left-pad`. `typescript` has its own `dependsOn` entry pointing at `nested-dev`.

Expand Down
33 changes: 33 additions & 0 deletions src/engine/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,37 @@ describe('cyclonedx export purl', () => {
// Non-npm rows still carry no guessed npm purl.
expect(bom.components.find((c) => c.name === 'requests')?.purl).toBeUndefined();
});

it('two runs with reversed inputs emit components in purl, then name, then version order', () => {
const base = ctx(makeGraph(false));
const deps = [
{ name: 'zzz', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' },
{ name: 'foo bar', ecosystem: 'npm' as const, declared: '1.0.0', installed: '1.0.0' },
{ name: 'aaa', ecosystem: 'npm' as const, declared: '2.0.0', installed: '2.0.0' },
{ name: 'requests', ecosystem: 'pypi' as const, declared: '2.31.0', installed: '2.31.0' },
];
const models = [
{ runtime: 'ollama' as const, name: 'llama3:latest', path: '/x' },
{ runtime: 'ollama' as const, name: 'aaa-model', path: '/y' },
];
const first = exportGraph('cyclonedx', { ...base, deps, models });
const second = exportGraph('cyclonedx', {
...base,
deps: [...deps].reverse(),
models: [...models].reverse(),
});
expect(second).toBe(first);
const bom = JSON.parse(first) as { components: Array<{ name: string; purl?: string }> };
expect(bom.components.map((c) => c.name)).toEqual(['aaa-model', 'foo bar', 'llama3:latest', 'aaa', 'zzz', 'requests']);
expect(bom.components.find((c) => c.name === 'aaa')?.purl).toBe('pkg:npm/aaa@2.0.0');
expect(bom.components.find((c) => c.name === 'foo bar')?.purl).toBeUndefined();

const spdxFirst = exportGraph('spdx', { ...base, deps });
const spdxSecond = exportGraph('spdx', { ...base, deps: [...deps].reverse() });
expect(spdxSecond).toBe(spdxFirst);
const spdx = JSON.parse(spdxFirst) as { packages: Array<{ name: string; SPDXID: string }> };
expect(spdx.packages.map((p) => p.name)).toEqual(['foo bar', 'aaa', 'zzz', 'requests']);
expect(spdx.packages.find((p) => p.name === 'aaa')?.SPDXID).toBe('SPDXRef-Package-aaa');
expect(spdx.packages.find((p) => p.name === 'zzz')?.SPDXID).toBe('SPDXRef-Package-zzz');
});
});
41 changes: 32 additions & 9 deletions src/engine/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { serializeGraph, slimGraphForExport } from './serialize.js';
import { renderReport } from './report.js';
import { renderHtml } from './html.js';
import type { DepRecord } from './drift.js';
import { resolvePurl } from '../reporting/commands/sbom.js';
import { compareSbomOrder, resolvePurl } from '../reporting/commands/sbom.js';
import type { LocalModel } from './models.js';
import type { VgGraph } from '../schema.js';

Expand Down Expand Up @@ -242,11 +242,20 @@ function sqlBool(v: boolean | null | undefined): string {
return v == null ? 'NULL' : v ? '1' : '0';
}

interface SbomLibraryComponent {
type: string;
name: string;
version?: string;
purl?: string;
properties?: Array<{ name: string; value: string | null }>;
}

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
// machine-learning-model components (AI-BOM). Component order is the Package
// URL when one is emitted, otherwise the name, then the version. No
// timestamps beyond the pinned generatedAt.
const components: unknown[] = [];
const components: SbomLibraryComponent[] = [];
for (const d of ctx.deps ?? []) {
const version = d.installed ?? d.declared;
if (d.ecosystem !== 'npm') {
Expand All @@ -273,6 +282,7 @@ function cyclonedx(ctx: ExportContext): string {
for (const m of ctx.models ?? []) {
components.push({ type: 'machine-learning-model', name: m.name, properties: [{ name: 'vg:runtime', value: m.runtime }] });
}
components.sort((a, b) => compareSbomOrder({ purl: a.purl, name: a.name, version: a.version }, { purl: b.purl, name: b.name, version: b.version }));
const bom = {
bomFormat: 'CycloneDX',
specVersion: '1.6',
Expand All @@ -282,13 +292,26 @@ function cyclonedx(ctx: ExportContext): string {
return JSON.stringify(bom, null, 2) + '\n';
}

/** Purl this exporter actually writes for a dependency. Non-npm rows omit it. */
function emittedExportPurl(d: DepRecord): string | null {
if (d.ecosystem !== 'npm') return null;
return resolvePurl('npm', d.name, d.installed ?? '').purl;
}

function spdx(ctx: ExportContext): string {
const packages = (ctx.deps ?? []).map((d) => ({
SPDXID: `SPDXRef-Package-${cypherLabel(d.name)}`,
name: d.name,
versionInfo: d.installed ?? d.declared,
downloadLocation: 'NOASSERTION',
}));
const packages = [...(ctx.deps ?? [])]
.sort((a, b) =>
compareSbomOrder(
{ purl: emittedExportPurl(a), name: a.name, version: a.installed ?? a.declared },
{ purl: emittedExportPurl(b), name: b.name, version: b.installed ?? b.declared },
),
)
.map((d) => ({
SPDXID: `SPDXRef-Package-${cypherLabel(d.name)}`,
name: d.name,
versionInfo: d.installed ?? d.declared,
downloadLocation: 'NOASSERTION',
}));
const doc = {
spdxVersion: 'SPDX-2.3',
dataLicense: 'CC0-1.0',
Expand Down
Loading
Loading