From b53dafd1d153a3155fd4c37a3a0b7e70500e961b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 22:12:11 +0000 Subject: [PATCH 1/3] fix(sbom): keep SemVer build metadata in scan and SBOM JSON semver.clean and semver.valid drop the +build suffix before a version is recorded. Scan JSON and vg sbom now keep the canonical token (1.2.3+build.4, and Go 2.0.0+incompatible). Package URLs still percent-encode + as %2B. A version that cannot be percent-encoded keeps the component and omits the purl with an actionable warning. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- DOCS.md | 17 ++-- src/core-open/scanners/cargo-cache.ts | 7 +- src/core-open/scanners/composer-cache.ts | 7 +- src/core-open/scanners/dart-scanner.ts | 3 +- src/core-open/scanners/go-cache.ts | 7 +- src/core-open/scanners/go-scanner.ts | 3 +- src/core-open/scanners/java-scanner.ts | 6 ++ src/core-open/scanners/node-scanner.ts | 13 ++- src/core-open/scanners/php-scanner.ts | 3 +- src/core-open/scanners/pub-cache.ts | 7 +- src/core-open/scanners/python-scanner.ts | 5 ++ src/core-open/scanners/ruby-scanner.ts | 3 + src/core-open/scanners/rust-scanner.ts | 8 +- src/core-open/scanners/swift-cache.ts | 9 +- src/core-open/scanners/swift-scanner.ts | 3 +- src/core-open/utils/semver-build.test.ts | 26 ++++++ src/core-open/utils/semver-build.ts | 24 +++++ src/reporting/commands/sbom.test.ts | 45 ++++++++++ src/reporting/commands/sbom.ts | 98 +++++++++++++++----- test/go-pseudo-versions.test.ts | 4 +- test/semver-build-metadata.test.ts | 110 +++++++++++++++++++++++ 21 files changed, 355 insertions(+), 53 deletions(-) create mode 100644 src/core-open/utils/semver-build.test.ts create mode 100644 src/core-open/utils/semver-build.ts create mode 100644 test/semver-build-metadata.test.ts diff --git a/DOCS.md b/DOCS.md index b6b753ce..7225cf51 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1671,9 +1671,14 @@ 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 a package name cannot be a Package URL (a space, a non-ASCII +identifier. A resolved version with SemVer build metadata (`1.2.3+build.4`) +is written unchanged on the component `version` (CycloneDX) and `versionInfo` +(SPDX), and on scan JSON `resolvedVersion`. The purl version percent-encodes +`+` as `%2B` (`pkg:npm/name@1.2.3%2Bbuild.4`); a canonical purl does not contain +a raw `+`. When a package name cannot be a Package URL (a space, a non-ASCII character, an empty path segment, or a slash or colon that is not an npm scope -separator), that component stays in the document and +separator), or the version cannot be percent-encoded, that component stays in +the document and the purl is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable` and records the reason on `vibgrate:purlWarning`. SPDX omits the purl externalRef, records `purlStatus=unavailable` on the package annotation, and repeats the @@ -2327,7 +2332,7 @@ Go modules pin an untagged commit as a pseudo-version (`v0.0.0-20191109021931-da The scan reads direct `require` lines in `go.mod`. - A concrete `v` version that is valid semver after the leading `v` is removed is the recorded version. `v1.2.3` is recorded as `1.2.3`. -- Build metadata is dropped. `v2.0.0+incompatible` is recorded as `2.0.0`. The major stays 2. +- Build metadata stays on the recorded version. `v2.0.0+incompatible` is recorded as `2.0.0+incompatible`. The leading `v` is still removed. The major stays 2. Range checks drop the `+…` suffix and compare `2.0.0`. - A pseudo-version keeps its pre-release suffix. `v0.0.0-20191109021931-daa7c04131f5` is recorded as `0.0.0-20191109021931-daa7c04131f5`. - A bare module path, a range such as `>=1.4.0`, a line marked `// indirect`, and a token that is not a full semver (`v1.2`) are not matched against advisories. - `go.sum` is not the version source for this match. @@ -2347,11 +2352,11 @@ In Go's own order, `v1.2.4-0.-` is a commit after `v1.2.3` an An explicit list entry of `v0.0.0-20191109021931-daa7c04131f5` does not match the open finding. The entry `0.0.0-20191109021931-daa7c04131f5` does. The entry `0.0.0` does not: the list is exact, and the recorded string still has the pre-release suffix. -Exposure windows replay the `require` token as written in `go.mod`, including the leading `v` and a `+incompatible` suffix. Range checks reduce that token to the same `major.minor.patch` as the open finding. An explicit `versions` list matches the string it is given, so `v2.0.0+incompatible` can match a history replay and miss the open finding (`2.0.0`), or the other way around. +Exposure windows replay the `require` token as written in `go.mod`, including the leading `v` and a `+incompatible` suffix. Range checks reduce that token to the same `major.minor.patch` as the open finding. An explicit `versions` list matches the recorded string, so `v2.0.0+incompatible` can match a history replay and miss the open finding (`2.0.0+incompatible`), or the other way around. ##### `+incompatible` -The suffix is dropped before the comparison. `v2.0.0+incompatible` matches a range from `2.0.0` up to `2.1.0`. It does not match a range that only covers `1.x`. An explicit list entry of `v2.0.0+incompatible` does not match the open finding. The entry `2.0.0` does. +The suffix stays on the recorded version and is dropped before the range comparison. `v2.0.0+incompatible` matches a range from `2.0.0` up to `2.1.0`. It does not match a range that only covers `1.x`. An explicit list entry of `v2.0.0+incompatible` does not match the open finding. The entry `2.0.0` does not either. The entry `2.0.0+incompatible` does. ##### `replace` and `exclude` @@ -2503,7 +2508,7 @@ vg scan --vulns --offline --package-manifest package-versions.json --format json | `example.com/pseudo-base` | `GHSA-example-pseudo-base` | reported (compared as `0.0.0`) | | `example.com/pseudo-base` | `GHSA-example-pseudo-explicit-v` | absent (the list has the leading `v`) | | `example.com/pseudo-base` | `GHSA-example-pseudo-explicit-clean` | reported | -| `example.com/oldmajor` | `GHSA-example-incompatible-range` | reported (`2.0.0`) | +| `example.com/oldmajor` | `GHSA-example-incompatible-range` | reported (recorded `2.0.0+incompatible`, compared as `2.0.0`) | | `example.com/oldmajor` | `GHSA-example-incompatible-v1` | absent (that range is `1.x`) | | `example.com/oldmajor` | `GHSA-example-incompatible-explicit` | absent (the list is `v2.0.0+incompatible`) | | `example.com/replaced-mod` | `GHSA-example-replaced` | reported (the `require` version; the `replace` is ignored) | diff --git a/src/core-open/scanners/cargo-cache.ts b/src/core-open/scanners/cargo-cache.ts index 62fd938d..37b91335 100644 --- a/src/core-open/scanners/cargo-cache.ts +++ b/src/core-open/scanners/cargo-cache.ts @@ -2,6 +2,7 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -49,13 +50,13 @@ export class CargoCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -101,7 +102,7 @@ export class CargoCache { if (ver.yanked) continue; if (!ver.num) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver.num)) continue; - const sv = semver.valid(semver.clean(ver.num)); + const sv = semverKeepingBuild(ver.num); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/composer-cache.ts b/src/core-open/scanners/composer-cache.ts index 2e709997..0d72b5b2 100644 --- a/src/core-open/scanners/composer-cache.ts +++ b/src/core-open/scanners/composer-cache.ts @@ -2,6 +2,7 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -49,13 +50,13 @@ export class ComposerCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -103,7 +104,7 @@ export class ComposerCache { if (!version) continue; if (version === 'dev-master' || version.startsWith('dev-')) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(version)) continue; - const sv = semver.valid(semver.clean(version)); + const sv = semverKeepingBuild(version); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index ff6c4291..d0469c6d 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, notePubspecLockText, withLockfileNotes } from '../utils/lockfile-unknown.js'; @@ -352,7 +353,7 @@ async function scanOneDartProject( for (const { dep, meta } of resolved) { // Use resolved version if available const resolvedVersionStr = resolvedVersions.get(dep.name); - const resolvedVersion = resolvedVersionStr ? semver.valid(semver.clean(resolvedVersionStr)) : null; + const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/go-cache.ts b/src/core-open/scanners/go-cache.ts index a6078821..80f701eb 100644 --- a/src/core-open/scanners/go-cache.ts +++ b/src/core-open/scanners/go-cache.ts @@ -2,6 +2,7 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -49,13 +50,13 @@ export class GoCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -88,7 +89,7 @@ export class GoCache { for (const ver of versions) { if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver)) continue; if (/v\d+\.\d+\.\d+-\d{14}-[a-f0-9]+/.test(ver)) continue; - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index aad284bf..44e486b9 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { GoCache } from './go-cache.js'; @@ -297,7 +298,7 @@ async function scanOneGoProject( const resolved = await Promise.all(metaPromises); for (const { dep, meta } of resolved) { - const resolvedVersion = dep.version ? semver.valid(semver.clean(dep.version)) : null; + const resolvedVersion = dep.version ? semverKeepingBuild(dep.version) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 4000c683..96a6e168 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { MavenCache } from './maven-cache.js'; @@ -336,6 +337,11 @@ function parseGradleBuild(content: string, filePath: string): GradleData { * Convert a Maven version string to semver where possible. */ function mavenToSemver(ver: string): string | null { + // A real semver build suffix (`1.2.3+build.4`) is the version. The + // dot-split below would fold `+build` into a numeric part and `semver.valid` + // would then drop it. + const kept = semverKeepingBuild(ver); + if (kept?.includes('+')) return kept; let v = ver.trim(); if (!v || v.includes('$')) return null; // Maven pre-release markers — both hyphen (-alpha, -rc) and dot (.Beta1, .RC1) forms diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 1d110109..17a060d1 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -11,6 +11,7 @@ import { Semaphore } from '../utils/semaphore.js'; import { withTimeout } from '../utils/timeout.js'; import { NpmCache, isSemverSpec } from './npm-cache.js'; import { classifyNpmVersionLag } from './npm-latest.js'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { buildDependencyLicense, normalizeLicenseSourcePath } from '../licenses/dependency-license.js'; import { ageDaysBetween, daysToLibyears, aggregateLibyears } from '../scoring/libyear.js'; import { latestLts, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -379,7 +380,17 @@ async function scanOnePackageJson( !!lockedVersion && !!semver.valid(lockedVersion) && (!range || semver.satisfies(lockedVersion, range, { includePrerelease: true })); - const resolvedVersion = lockUsable ? lockedVersion : latestSatisfying; + let resolvedVersion = lockUsable ? lockedVersion : latestSatisfying; + // An exact manifest pin with build metadata (`1.2.3+build.4`) is a + // concrete version. `semver.validRange` drops the `+` suffix, so the + // registry match can come back as `1.2.3`. Keep the pin when nothing + // newer (and no lockfile) replaced it. + if (!lockUsable) { + const pinned = semverKeepingBuild(spec); + if (pinned?.includes('+') && (resolvedVersion == null || semver.eq(resolvedVersion, pinned))) { + resolvedVersion = pinned; + } + } const lag = classifyNpmVersionLag(resolvedVersion, meta.latestStableOverall); const latestStable = lag.reportedLatest; diff --git a/src/core-open/scanners/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index 618e95c7..c7294bcd 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { FileCache, readJsonFile } from '../utils/fs.js'; import { LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, noteComposerLock, withLockfileNotes } from '../utils/lockfile-unknown.js'; @@ -299,7 +300,7 @@ async function scanOnePhpProject( for (const { dep, meta } of resolved) { // Use resolved version if available const resolvedVersionStr = resolvedVersions.get(dep.name); - const resolvedVersion = resolvedVersionStr ? semver.valid(semver.clean(resolvedVersionStr)) : null; + const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/pub-cache.ts b/src/core-open/scanners/pub-cache.ts index 067627be..dfd9e380 100644 --- a/src/core-open/scanners/pub-cache.ts +++ b/src/core-open/scanners/pub-cache.ts @@ -2,6 +2,7 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -49,13 +50,13 @@ export class PubCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -96,7 +97,7 @@ export class PubCache { for (const ver of data.versions) { if (!ver.version) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver.version)) continue; - const sv = semver.valid(semver.clean(ver.version)); + const sv = semverKeepingBuild(ver.version); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index e421fe40..2dae4665 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadPythonLockIndex, type PythonLockIndex } from './python-lockfile.js'; @@ -181,6 +182,10 @@ function extractPinnedVersion(spec: string): string | null { * Convert a PEP 440 version to semver where possible (best-effort). */ function pep440ToSemver(ver: string): string | null { + // PEP 440 local versions and semver build metadata share the `+` suffix. + // Keep that token; the dot-split below would discard it. + const kept = semverKeepingBuild(ver); + if (kept?.includes('+')) return kept; let v = ver.replace(/^[vV]/, '').trim(); if (/(?:a\d|b\d|rc\d|alpha|beta|dev|post)/i.test(v)) return null; const parts = v.split('.'); diff --git a/src/core-open/scanners/ruby-scanner.ts b/src/core-open/scanners/ruby-scanner.ts index e5927bf8..e70a9588 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { RubyGemsCache } from './rubygems-cache.js'; @@ -208,6 +209,8 @@ function extractGemVersion(spec: string): string | null { * Convert a Ruby gem version string to semver. */ function rubyVersionToSemver(ver: string): string | null { + const kept = semverKeepingBuild(ver); + if (kept?.includes('+')) return kept; const v = ver.trim(); if (/(?:\.pre|\.rc|\.beta|\.alpha|\.dev)/i.test(v)) return null; diff --git a/src/core-open/scanners/rust-scanner.ts b/src/core-open/scanners/rust-scanner.ts index 1f11642f..6c04cf07 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; import { withTimeout } from '../utils/timeout.js'; import { CargoCache } from './cargo-cache.js'; @@ -291,7 +292,12 @@ async function scanOneRustProject( for (const { dep, meta } of resolved) { // Prefer the lockfile's exact version; otherwise coerce the declared spec (e.g. "1.0" → 1.0.0). const locked = lockIndex?.resolve(dep.name, dep.version) ?? null; - const resolvedVersion = (locked && semver.valid(locked)) ? locked : semver.valid(semver.coerce(dep.version)); + // The lockfile token is kept as written, including `+build`. A declared + // spec with build metadata must not fall through to `semver.coerce`, + // which returns only `major.minor.patch`. + const resolvedVersion = (locked && semver.valid(locked)) + ? locked + : (semverKeepingBuild(dep.version) ?? semver.valid(semver.coerce(dep.version))); const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/swift-cache.ts b/src/core-open/scanners/swift-cache.ts index f940f7ed..d80066a4 100644 --- a/src/core-open/scanners/swift-cache.ts +++ b/src/core-open/scanners/swift-cache.ts @@ -2,6 +2,7 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -49,13 +50,13 @@ export class SwiftCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semver.valid(semver.clean(ver)); + const sv = semverKeepingBuild(ver); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -106,7 +107,7 @@ export class SwiftCache { if (release.prerelease || release.draft) continue; const tag = release.tag_name; if (!tag) continue; - const sv = semver.valid(semver.clean(tag)); + const sv = semverKeepingBuild(tag); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); @@ -145,7 +146,7 @@ export class SwiftCache { const tagName = tag.name; if (!tagName) continue; if (/(?:alpha|beta|rc|pre|dev)/i.test(tagName)) continue; - const sv = semver.valid(semver.clean(tagName)); + const sv = semverKeepingBuild(tagName); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/swift-scanner.ts b/src/core-open/scanners/swift-scanner.ts index 54a1d3be..4a347107 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -3,6 +3,7 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; +import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; import { parseLockfileJson, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, notePackageResolved, withLockfileNotes } from '../utils/lockfile-unknown.js'; @@ -274,7 +275,7 @@ async function scanOneSwiftProject( // Use resolved version if available, otherwise use specified version const resolvedVersionStr = resolvedVersions.get(dep.name.toLowerCase()) ?? (dep.type === 'exact' ? dep.version : null); - const resolvedVersion = resolvedVersionStr ? semver.valid(semver.clean(resolvedVersionStr)) : null; + const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/utils/semver-build.test.ts b/src/core-open/utils/semver-build.test.ts new file mode 100644 index 00000000..d8dc070c --- /dev/null +++ b/src/core-open/utils/semver-build.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { semverKeepingBuild } from './semver-build.js'; + +describe('semverKeepingBuild', () => { + it('keeps build metadata that semver.clean would drop', () => { + expect(semverKeepingBuild('1.2.3+build.4')).toBe('1.2.3+build.4'); + expect(semverKeepingBuild('1.2.3-rc.1+build.4')).toBe('1.2.3-rc.1+build.4'); + expect(semverKeepingBuild('v2.0.0+incompatible')).toBe('2.0.0+incompatible'); + expect(semverKeepingBuild('=1.2.3+build.4')).toBe('1.2.3+build.4'); + }); + + it('matches semver.clean when there is no build metadata', () => { + expect(semverKeepingBuild('v1.2.3')).toBe('1.2.3'); + expect(semverKeepingBuild('=v1.2.3')).toBe('1.2.3'); + expect(semverKeepingBuild(' v1.2.3 ')).toBe('1.2.3'); + expect(semverKeepingBuild('v0.0.0-20191109021931-daa7c04131f5')).toBe( + '0.0.0-20191109021931-daa7c04131f5', + ); + }); + + it('returns null for a range or a partial token', () => { + expect(semverKeepingBuild('^1.2.3')).toBeNull(); + expect(semverKeepingBuild('1.2')).toBeNull(); + expect(semverKeepingBuild('')).toBeNull(); + }); +}); diff --git a/src/core-open/utils/semver-build.ts b/src/core-open/utils/semver-build.ts new file mode 100644 index 00000000..1e5dcb32 --- /dev/null +++ b/src/core-open/utils/semver-build.ts @@ -0,0 +1,24 @@ +import * as semver from 'semver'; + +/** + * Concrete semver for scan JSON and SBOMs, including build metadata. + * + * `semver.clean` / `semver.valid` return `major.minor.patch[-pre]` and drop + * the `+build` suffix. A lockfile token such as `1.2.3+build.4` is the + * version those documents record. Precedence is unchanged: build metadata + * does not participate in `semver.eq` / `semver.rcompare`. + * + * The leading `v` / `=` strip matches `semver.clean`, so `v1.2.3` is still + * `1.2.3` and `v2.0.0+incompatible` is `2.0.0+incompatible`. Returns null + * when `input` is not one semver (a range, a dist-tag, or a partial token). + */ +export function semverKeepingBuild(input: string): string | null { + const cleaned = semver.clean(input); + if (!cleaned) return null; + // `clean` already accepted this token. Re-parse the same prefix it strips + // so a leading `=` or `v` does not hide the build identifiers. + const normalized = input.trim().replace(/^[=v]+/, ''); + const parsed = semver.parse(normalized); + if (!parsed || parsed.build.length === 0) return cleaned; + return `${cleaned}+${parsed.build.join('.')}`; +} diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4fd6a599..38da1abe 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -274,6 +274,51 @@ describe('sbom helpers', () => { expect(sbom.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); }); + it('keeps SemVer build metadata in the version and percent-encodes + only in the purl', () => { + const version = '1.2.3+build.4'; + const cyclone = toCycloneDx(makeArtifact(version, 90)) as { + components: Array<{ name: string; version: string; purl: string; 'bom-ref': string }>; + }; + const again = JSON.stringify(toCycloneDx(makeArtifact(version, 90))); + expect(JSON.stringify(cyclone)).toBe(again); + const roundTrip = JSON.parse(again) as typeof cyclone; + expect(roundTrip.components[0]!.version).toBe(version); + expect(cyclone.components[0]!.version).toBe(version); + expect(cyclone.components[0]!.purl).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); + expect(cyclone.components[0]!['bom-ref']).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); + expect(again).not.toContain('"version":"1.2.3%2Bbuild.4"'); + expect(again).toContain('"version":"1.2.3+build.4"'); + + const spdx = toSpdx(makeArtifact(version, 90)) as { + packages: Array<{ versionInfo: string; externalRefs: Array<{ referenceLocator: string }> }>; + }; + expect(JSON.stringify(toSpdx(makeArtifact(version, 90)))).toBe(JSON.stringify(spdx)); + expect(spdx.packages[0]!.versionInfo).toBe(version); + expect(spdx.packages[0]!.externalRefs[0]!.referenceLocator).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); + expect(purlFor('go', 'example.com/oldmajor', 'v2.0.0+incompatible')).toBe( + 'pkg:golang/example.com/oldmajor@v2.0.0%2Bincompatible', + ); + }); + + it('keeps the component when a version cannot be percent-encoded', () => { + const version = '1.2.3+\uD800'; + expect(() => purlFor('npm', 'chalk', version)).not.toThrow(); + expect(purlFor('npm', 'chalk', version)).toBeNull(); + const warning = describeUnavailablePurl('npm', 'chalk', version); + expect(warning).toContain('cannot be percent-encoded'); + expect(warning).toContain('npm package "chalk"'); + expect(warning).not.toContain('/var/'); + + const cyclone = toCycloneDx(makeArtifact(version, 90)) as { + components: Array<{ version: string; purl?: string; properties: Array<{ name: string; value: string }> }>; + }; + expect(JSON.stringify(toCycloneDx(makeArtifact(version, 90)))).toBe(JSON.stringify(cyclone)); + expect(cyclone.components[0]!.version).toBe(version); + expect(cyclone.components[0]!.purl).toBeUndefined(); + expect(cyclone.components[0]!.properties.find((p) => p.name === 'vibgrate:purlStatus')?.value).toBe('unavailable'); + expect(cyclone.components[0]!.properties.find((p) => p.name === 'vibgrate:purlWarning')?.value).toBe(warning); + }); + /** * A name that cannot be a purl name. `encodeURIComponent` used to turn the * space into `pkg:npm/foo%20bar@1.0.0`, which is a purl-shaped string, and diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index 156a2c8c..c19e2180 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -158,36 +158,78 @@ const LICENSE_STATUS_UNREPRESENTABLE = 'unrepresentable'; /** CycloneDX property that carries the stable warning code beside a prose warning. */ const WARNING_CODE_PROPERTY = 'vibgrate:warningCode'; +/** + * Percent-encode one purl token. `encodeURIComponent` throws on a lone + * surrogate; that is "cannot encode", not a reason to drop the component. + * Returns null instead of throwing. + */ +function encodePurlToken(value: string): string | null { + try { + return encodeURIComponent(value); + } catch { + return null; + } +} + +function encodePurlSegments(segments: string[]): string | null { + const encoded: string[] = []; + for (const segment of segments) { + const token = encodePurlToken(segment); + if (token === null) return null; + encoded.push(token); + } + return encoded.join('/'); +} + /** The purl type/namespace/name portion, without a version — shared by every ecosystem branch of `purlFor`. */ function purlPath(ecosystem: Ecosystem, name: string): string | 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))}`; + const encoded = encodePurlSegments([name.slice(0, scopeSlash), name.slice(scopeSlash + 1)]); + return encoded === null ? null : `pkg:npm/${encoded}`; } - return `pkg:npm/${encodeURIComponent(name)}`; + const encoded = encodePurlToken(name); + return encoded === null ? null : `pkg:npm/${encoded}`; + } + case 'pypi': { + const encoded = encodePurlToken(pypiPurlName(name)); + return encoded === null ? null : `pkg:pypi/${encoded}`; + } + case 'rust': { + const encoded = encodePurlToken(name); + return encoded === null ? null : `pkg:cargo/${encoded}`; + } + case 'go': { + const encoded = encodePurlSegments(name.split('/')); + return encoded === null ? null : `pkg:golang/${encoded}`; } - 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)}`; + const encoded = encodePurlSegments(group ? [group, artifact] : [artifact]); + return encoded === null ? null : `pkg:maven/${encoded}`; + } + case 'ruby': { + const encoded = encodePurlToken(name); + return encoded === null ? null : `pkg:gem/${encoded}`; + } + case 'php': { + const encoded = encodePurlSegments(name.split('/')); + return encoded === null ? null : `pkg:composer/${encoded}`; + } + case 'dotnet': { + const encoded = encodePurlToken(name); + return encoded === null ? null : `pkg:nuget/${encoded}`; + } + case 'swift': { + const encoded = encodePurlSegments(name.split('/')); + return encoded === null ? null : `pkg:swift/${encoded}`; + } + case 'dart': { + const encoded = encodePurlToken(name); + return encoded === null ? null : `pkg:pub/${encoded}`; } - 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: // An ecosystem this function does not know is not npm. Falling through // to `pkg:npm/...` would report a registry the scan did not detect. @@ -219,14 +261,22 @@ function pypiPurlName(name: string): string { * * Returns null when the built string is not a Package URL: unknown type, * empty name or path segment, a space or other character that only survives - * as percent-encoding, or a version that is not one concrete token. Callers - * keep the component and mark the purl unavailable — they do not drop the - * row, and they do not emit the rejected string. + * as percent-encoding, a version that is not one concrete token, or a + * version that cannot be percent-encoded. Callers keep the component and + * mark the purl unavailable — they do not drop the row, and they do not + * emit the rejected string. + * + * The component `version` stays the canonical token (`1.2.3+build.4`). + * Only the purl version is percent-encoded. `+` is not unreserved, so a + * canonical purl writes it as `%2B` (`pkg:npm/foo@1.2.3%2Bbuild.4`). */ export function purlFor(ecosystem: Ecosystem, name: string, version: string): string | null { const path = purlPath(ecosystem, name); if (!path) return null; - const purl = version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; + if (version === UNKNOWN_VERSION) return isValidBuiltPurl(path) ? path : null; + const encodedVersion = encodePurlToken(version); + if (encodedVersion === null) return null; + const purl = `${path}@${encodedVersion}`; return isValidBuiltPurl(purl) ? purl : null; } @@ -286,6 +336,8 @@ export function describeUnavailablePurl(ecosystem: string, name: string, version because = 'the name contains whitespace or a non-ASCII character'; } else if (version !== UNKNOWN_VERSION && !isConcreteVersion(version)) { because = 'the version is not one concrete installed version'; + } else if (version !== UNKNOWN_VERSION && encodePurlToken(version) === null) { + return `Package URL unavailable for ${ecosystem} package "${name}": the version cannot be percent-encoded. The component is included with its recorded version and without a purl. Use a concrete version made of Unicode text that percent-encodes as UTF-8.`; } else { because = 'the coordinates cannot be encoded as a Package URL'; } diff --git a/test/go-pseudo-versions.test.ts b/test/go-pseudo-versions.test.ts index 71c091de..b958fa4f 100644 --- a/test/go-pseudo-versions.test.ts +++ b/test/go-pseudo-versions.test.ts @@ -38,7 +38,7 @@ describe('Go pseudo-versions and +incompatible (vg scan --vulns, offline)', () = const [project] = await scanGoProjects(fixtureDir, new GoCache(new Semaphore(1), undefined, true)); const resolved = Object.fromEntries(project.dependencies.map((d) => [d.package, d.resolvedVersion])); expect(resolved).toEqual({ - 'example.com/oldmajor': '2.0.0', + 'example.com/oldmajor': '2.0.0+incompatible', 'example.com/pseudo-base': '0.0.0-20191109021931-daa7c04131f5', 'example.com/pseudo-next': '1.2.4-0.20210101120000-abcdefabcdef', 'example.com/replaced-mod': '1.0.0', @@ -55,7 +55,7 @@ describe('Go pseudo-versions and +incompatible (vg scan --vulns, offline)', () = expect(ids).toEqual(REPORTED); for (const id of ABSENT) expect(ids).not.toContain(id); expect(result.packages.map((p) => p.version)).toEqual([ - '2.0.0', + '2.0.0+incompatible', '0.0.0-20191109021931-daa7c04131f5', '1.0.0', '1.2.3', diff --git a/test/semver-build-metadata.test.ts b/test/semver-build-metadata.test.ts new file mode 100644 index 00000000..38452f54 --- /dev/null +++ b/test/semver-build-metadata.test.ts @@ -0,0 +1,110 @@ +/** + * SemVer build metadata (`1.2.3+build.4`) round-trips through scan JSON and + * `vg sbom` JSON. The version field keeps the `+`. The purl percent-encodes it. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { NpmCache } from '../src/core-open/scanners/npm-cache.js'; +import { scanNodeProjects } from '../src/core-open/scanners/node-scanner.js'; +import { ComposerCache } from '../src/core-open/scanners/composer-cache.js'; +import { scanPhpProjects } from '../src/core-open/scanners/php-scanner.js'; +import { Semaphore } from '../src/core-open/utils/semaphore.js'; +import { toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; +import type { ScanArtifact } from '../src/reporting/types.js'; + +const VERSION = '1.2.3+build.4'; + +function artifactFrom(project: ScanArtifact['projects'][number], rootPath: string): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-02-19T00:00:00.000Z', + vibgrateVersion: '0.0.1', + rootPath, + drift: { + score: 0, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [], + projects: [project], + }; +} + +describe('SemVer build metadata in scan and sbom JSON', () => { + let root: string; + + afterEach(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('round-trips an npm lockfile version through scan JSON and both SBOM formats', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-semver-build-npm-')); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: 'plus-fixture', version: '1.0.0', dependencies: { 'left-pad': VERSION } }), + ); + fs.writeFileSync( + path.join(root, 'package-lock.json'), + JSON.stringify({ + name: 'plus-fixture', + lockfileVersion: 3, + packages: { + '': { name: 'plus-fixture', dependencies: { 'left-pad': VERSION } }, + 'node_modules/left-pad': { version: VERSION }, + }, + }), + ); + + const projects = await scanNodeProjects( + root, + new NpmCache(root, new Semaphore(1), { npm: { 'left-pad': { latest: '1.3.0', versions: [VERSION, '1.3.0'] } } }, true), + ); + expect(projects[0]!.dependencies[0]!.resolvedVersion).toBe(VERSION); + const scanJson = JSON.stringify({ projects }); + expect(JSON.parse(scanJson).projects[0].dependencies[0].resolvedVersion).toBe(VERSION); + expect(scanJson).toContain(VERSION); + expect(scanJson).not.toContain('1.2.3%2Bbuild.4'); + + const artifact = artifactFrom(projects[0]!, 'plus-fixture'); + const cyclone = toCycloneDx(artifact) as { components: Array<{ version: string; purl: string }> }; + const cycloneJson = JSON.stringify(cyclone); + expect(JSON.stringify(toCycloneDx(artifact))).toBe(cycloneJson); + expect(cyclone.components[0]!.version).toBe(VERSION); + expect(cyclone.components[0]!.purl).toBe('pkg:npm/left-pad@1.2.3%2Bbuild.4'); + expect(JSON.parse(cycloneJson).components[0].version).toBe(VERSION); + + const spdx = toSpdx(artifact) as { packages: Array<{ versionInfo: string; externalRefs: Array<{ referenceLocator: string }> }> }; + expect(spdx.packages[0]!.versionInfo).toBe(VERSION); + expect(spdx.packages[0]!.externalRefs[0]!.referenceLocator).toBe('pkg:npm/left-pad@1.2.3%2Bbuild.4'); + }); + + it('round-trips a composer.lock version that semver.clean used to strip', async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-semver-build-php-')); + fs.writeFileSync( + path.join(root, 'composer.json'), + JSON.stringify({ name: 'acme/app', require: { 'monolog/monolog': VERSION } }), + ); + fs.writeFileSync( + path.join(root, 'composer.lock'), + JSON.stringify({ + packages: [{ name: 'monolog/monolog', version: VERSION }], + 'packages-dev': [], + }), + ); + + const projects = await scanPhpProjects(root, new ComposerCache(new Semaphore(1), undefined, true)); + expect(projects).toHaveLength(1); + expect(projects[0]!.dependencies.map((d) => [d.package, d.resolvedVersion])).toEqual([['monolog/monolog', VERSION]]); + const scanJson = JSON.stringify({ projects }); + expect(JSON.parse(scanJson).projects[0].dependencies[0].resolvedVersion).toBe(VERSION); + expect(scanJson).not.toContain('%2B'); + + const artifact = artifactFrom(projects[0]!, 'acme/app'); + const cyclone = toCycloneDx(artifact) as { components: Array<{ version: string; purl: string }> }; + expect(JSON.stringify(toCycloneDx(artifact))).toBe(JSON.stringify(cyclone)); + expect(cyclone.components[0]!.version).toBe(VERSION); + expect(cyclone.components[0]!.purl).toBe('pkg:composer/monolog/monolog@1.2.3%2Bbuild.4'); + }); +}); From de618dbf042d5d541a367c9afae429a369bb53a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 22:12:25 +0000 Subject: [PATCH 2/3] fix(sbom): note that Go resolved versions keep build metadata Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- src/reporting/commands/sbom.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index c19e2180..43c43e4c 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -622,11 +622,11 @@ export function flattenDependencies( const guessed = projectEcosystemGuessed(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 - // elsewhere) and drops the `v` prefix go.sum's transitive entries keep - // — matching on `currentSpec` instead is what lets a direct Go - // dependency dedupe against its own go.sum-derived component instead - // of appearing as two, differently-versioned components. + // `resolvedVersion` drops the leading `v` go.sum's transitive entries + // keep (build metadata such as `+incompatible` stays). Matching on + // `currentSpec` instead is what lets a direct Go dependency dedupe + // against its own go.sum-derived component instead of appearing as + // two, differently-versioned components. const rawVersion = ecosystem === 'go' ? dep.currentSpec : (dep.resolvedVersion ?? dep.currentSpec); // A dependency with no lockfile/installed-tree resolution falls back // to its declared spec, which for npm/yarn/pnpm can be a semver range, From 8d5f5ca2d1e540f84aa0114711586da5a3ef2c1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 13:52:22 +0000 Subject: [PATCH 3/3] fix(sbom): keep SemVer build metadata in scan JSON and SBOM Record a lockfile or manifest version such as 1.2.3+build.7 with the + suffix. A Go v2.0.0+incompatible require is recorded as 2.0.0+incompatible in scan JSON and as v2.0.0+incompatible in the SBOM. Range checks still ignore the suffix. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 7 + DOCS.md | 23 +-- .../semver-build-metadata/dart/pubspec.lock | 10 + .../semver-build-metadata/dart/pubspec.yaml | 5 + .../semver-build-metadata/dotnet/demo.csproj | 8 + examples/semver-build-metadata/go/go.mod | 5 + examples/semver-build-metadata/go/go.sum | 2 + .../java-gradle/build.gradle | 3 + .../java-gradle/gradle.lockfile | 3 + examples/semver-build-metadata/java/pom.xml | 12 ++ examples/semver-build-metadata/package.json | 7 + .../semver-build-metadata/php/composer.json | 7 + .../semver-build-metadata/php/composer.lock | 8 + examples/semver-build-metadata/pnpm-lock.yaml | 13 ++ .../semver-build-metadata/python/poetry.lock | 3 + .../python/requirements.txt | 1 + examples/semver-build-metadata/ruby/Gemfile | 3 + .../semver-build-metadata/ruby/Gemfile.lock | 13 ++ .../rust-declared/Cargo.toml | 7 + .../semver-build-metadata/rust/Cargo.lock | 7 + .../semver-build-metadata/rust/Cargo.toml | 7 + .../swift/Package.resolved | 13 ++ .../semver-build-metadata/swift/Package.swift | 9 + src/core-open/scanners/cargo-cache.ts | 7 +- src/core-open/scanners/composer-cache.ts | 7 +- src/core-open/scanners/dart-scanner.ts | 4 +- src/core-open/scanners/go-cache.ts | 7 +- src/core-open/scanners/go-scanner.ts | 4 +- src/core-open/scanners/java-scanner.ts | 20 +- src/core-open/scanners/node-scanner.ts | 13 +- src/core-open/scanners/php-scanner.ts | 4 +- src/core-open/scanners/pub-cache.ts | 7 +- src/core-open/scanners/python-scanner.ts | 19 +- src/core-open/scanners/ruby-scanner.ts | 16 +- src/core-open/scanners/rust-scanner.ts | 9 +- src/core-open/scanners/swift-cache.ts | 9 +- src/core-open/scanners/swift-scanner.ts | 4 +- src/core-open/utils/recorded-semver.test.ts | 32 +++ src/core-open/utils/recorded-semver.ts | 25 +++ src/core-open/utils/semver-build.test.ts | 26 --- src/core-open/utils/semver-build.ts | 24 --- src/reporting/commands/sbom.test.ts | 49 +---- src/reporting/commands/sbom.ts | 108 +++-------- test/semver-build-metadata.test.ts | 183 ++++++++++-------- 44 files changed, 400 insertions(+), 353 deletions(-) create mode 100644 examples/semver-build-metadata/dart/pubspec.lock create mode 100644 examples/semver-build-metadata/dart/pubspec.yaml create mode 100644 examples/semver-build-metadata/dotnet/demo.csproj create mode 100644 examples/semver-build-metadata/go/go.mod create mode 100644 examples/semver-build-metadata/go/go.sum create mode 100644 examples/semver-build-metadata/java-gradle/build.gradle create mode 100644 examples/semver-build-metadata/java-gradle/gradle.lockfile create mode 100644 examples/semver-build-metadata/java/pom.xml create mode 100644 examples/semver-build-metadata/package.json create mode 100644 examples/semver-build-metadata/php/composer.json create mode 100644 examples/semver-build-metadata/php/composer.lock create mode 100644 examples/semver-build-metadata/pnpm-lock.yaml create mode 100644 examples/semver-build-metadata/python/poetry.lock create mode 100644 examples/semver-build-metadata/python/requirements.txt create mode 100644 examples/semver-build-metadata/ruby/Gemfile create mode 100644 examples/semver-build-metadata/ruby/Gemfile.lock create mode 100644 examples/semver-build-metadata/rust-declared/Cargo.toml create mode 100644 examples/semver-build-metadata/rust/Cargo.lock create mode 100644 examples/semver-build-metadata/rust/Cargo.toml create mode 100644 examples/semver-build-metadata/swift/Package.resolved create mode 100644 examples/semver-build-metadata/swift/Package.swift create mode 100644 src/core-open/utils/recorded-semver.test.ts create mode 100644 src/core-open/utils/recorded-semver.ts delete mode 100644 src/core-open/utils/semver-build.test.ts delete mode 100644 src/core-open/utils/semver-build.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 887e092f..1f6d04ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ backward compatible. ### Fixed +- **`vg scan --format json` and `vg sbom export` keep SemVer build metadata.** + A lockfile or manifest version such as `1.2.3+build.7` is recorded with + the `+` suffix. A Go `v2.0.0+incompatible` require is recorded as + `2.0.0+incompatible` in scan JSON (the leading `v` is still dropped) and + as `v2.0.0+incompatible` in the SBOM, matching `go.sum`. Range checks + still ignore the suffix. (#298) + - **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. diff --git a/DOCS.md b/DOCS.md index 7225cf51..70938df2 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1671,14 +1671,9 @@ 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. A resolved version with SemVer build metadata (`1.2.3+build.4`) -is written unchanged on the component `version` (CycloneDX) and `versionInfo` -(SPDX), and on scan JSON `resolvedVersion`. The purl version percent-encodes -`+` as `%2B` (`pkg:npm/name@1.2.3%2Bbuild.4`); a canonical purl does not contain -a raw `+`. When a package name cannot be a Package URL (a space, a non-ASCII +identifier. When a package name cannot be a Package URL (a space, a non-ASCII character, an empty path segment, or a slash or colon that is not an npm scope -separator), or the version cannot be percent-encoded, that component stays in -the document and +separator), that component stays in the document and the purl is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable` and records the reason on `vibgrate:purlWarning`. SPDX omits the purl externalRef, records `purlStatus=unavailable` on the package annotation, and repeats the @@ -1699,6 +1694,8 @@ Match a component on its [package URL](https://github.com/package-url/purl-spec) `projects[].type` is the project kind (`java`, `node`, `python`). The vulnerability block uses the advisory ecosystem (`maven`, `npm`, `pypi`). `vg sbom export` turns those coordinates into the purl type (`pkg:maven`, `pkg:npm`, `pkg:pypi`). +Build metadata stays on that recorded version. `1.2.3+build.7` is written as `1.2.3+build.7` in scan JSON (`resolvedVersion`) and as the SBOM component version. The package URL encodes `+` as `%2B` (`pkg:npm/example-build-meta@1.2.3%2Bbuild.7`). Range checks ignore the suffix. A Go scan drops a leading `v` and keeps the suffix, so `v1.2.3+build.7` is recorded as `1.2.3+build.7`. `vg sbom export` for Go uses the require token, which keeps the leading `v` and matches `go.sum`. The same shape is checked in at `examples/semver-build-metadata/`. + These documents have no CPE field. The CLI does not write one, including when a name has several segments (a Maven `group:artifact`, an npm scope, a Go module path). A missing CPE is the normal record. Leave it missing. If a file from another cataloger carries a `cpe` property, keep the purl as the identity you match. A CPE does not replace a purl, rank above it, or stand in for a purl that was omitted. When a name cannot be a package URL, the component stays in the SBOM and `purl` is omitted. CycloneDX sets `vibgrate:purlStatus` to `unavailable`. That row still has no CPE. @@ -2332,12 +2329,12 @@ Go modules pin an untagged commit as a pseudo-version (`v0.0.0-20191109021931-da The scan reads direct `require` lines in `go.mod`. - A concrete `v` version that is valid semver after the leading `v` is removed is the recorded version. `v1.2.3` is recorded as `1.2.3`. -- Build metadata stays on the recorded version. `v2.0.0+incompatible` is recorded as `2.0.0+incompatible`. The leading `v` is still removed. The major stays 2. Range checks drop the `+…` suffix and compare `2.0.0`. +- Build metadata stays. `v2.0.0+incompatible` is recorded as `2.0.0+incompatible`. The major stays 2. Range checks ignore the suffix. - A pseudo-version keeps its pre-release suffix. `v0.0.0-20191109021931-daa7c04131f5` is recorded as `0.0.0-20191109021931-daa7c04131f5`. - A bare module path, a range such as `>=1.4.0`, a line marked `// indirect`, and a token that is not a full semver (`v1.2`) are not matched against advisories. - `go.sum` is not the version source for this match. -A range is half-open: the version matches from `introduced` up to, and not including, `fixed`. `introduced` of `0` means from the beginning. Before that check, the recorded version is reduced to `major.minor.patch`. The pre-release suffix is not ordered against the tag. An explicit `versions` list matches the recorded string exactly, so the list entry still has the pre-release suffix and has no leading `v`. +A range is half-open: the version matches from `introduced` up to, and not including, `fixed`. `introduced` of `0` means from the beginning. Before that check, the recorded version is reduced to `major.minor.patch`. The pre-release suffix and any build metadata are not ordered against the tag. An explicit `versions` list matches the recorded string exactly, so the list entry still has the pre-release suffix and any build metadata, and has no leading `v`. ##### Pseudo-versions and tagged releases @@ -2352,11 +2349,11 @@ In Go's own order, `v1.2.4-0.-` is a commit after `v1.2.3` an An explicit list entry of `v0.0.0-20191109021931-daa7c04131f5` does not match the open finding. The entry `0.0.0-20191109021931-daa7c04131f5` does. The entry `0.0.0` does not: the list is exact, and the recorded string still has the pre-release suffix. -Exposure windows replay the `require` token as written in `go.mod`, including the leading `v` and a `+incompatible` suffix. Range checks reduce that token to the same `major.minor.patch` as the open finding. An explicit `versions` list matches the recorded string, so `v2.0.0+incompatible` can match a history replay and miss the open finding (`2.0.0+incompatible`), or the other way around. +Exposure windows replay the `require` token as written in `go.mod`, including the leading `v` and a `+incompatible` suffix. Range checks reduce that token to the same `major.minor.patch` as the open finding. An explicit `versions` list matches the string it is given, so `v2.0.0+incompatible` can match a history replay and miss the open finding (`2.0.0+incompatible`), or the other way around. ##### `+incompatible` -The suffix stays on the recorded version and is dropped before the range comparison. `v2.0.0+incompatible` matches a range from `2.0.0` up to `2.1.0`. It does not match a range that only covers `1.x`. An explicit list entry of `v2.0.0+incompatible` does not match the open finding. The entry `2.0.0` does not either. The entry `2.0.0+incompatible` does. +The suffix stays on the recorded version. `v2.0.0+incompatible` matches a range from `2.0.0` up to `2.1.0`, because the range check ignores the suffix. It does not match a range that only covers `1.x`. An explicit list entry of `v2.0.0+incompatible` does not match the open finding. The entry `2.0.0+incompatible` does. The entry `2.0.0` does not. ##### `replace` and `exclude` @@ -2508,7 +2505,7 @@ vg scan --vulns --offline --package-manifest package-versions.json --format json | `example.com/pseudo-base` | `GHSA-example-pseudo-base` | reported (compared as `0.0.0`) | | `example.com/pseudo-base` | `GHSA-example-pseudo-explicit-v` | absent (the list has the leading `v`) | | `example.com/pseudo-base` | `GHSA-example-pseudo-explicit-clean` | reported | -| `example.com/oldmajor` | `GHSA-example-incompatible-range` | reported (recorded `2.0.0+incompatible`, compared as `2.0.0`) | +| `example.com/oldmajor` | `GHSA-example-incompatible-range` | reported (compared as `2.0.0`; recorded `2.0.0+incompatible`) | | `example.com/oldmajor` | `GHSA-example-incompatible-v1` | absent (that range is `1.x`) | | `example.com/oldmajor` | `GHSA-example-incompatible-explicit` | absent (the list is `v2.0.0+incompatible`) | | `example.com/replaced-mod` | `GHSA-example-replaced` | reported (the `require` version; the `replace` is ignored) | @@ -2906,7 +2903,7 @@ A code-map `import` edge runs from the POM's `package` node to an `external` nod A scan row has `package` (`groupId:artifactId`), `section`, `currentSpec`, `resolvedVersion`, `latestStable`, `majorsBehind`, and `drift`. `section` is `dependencies` for every Java row, including `test` and `testImplementation`. Scope and the Gradle configuration name are not fields. A missing resolved version is `null`, and a missing major lag is `null`. -`resolvedVersion` is the declared version when that string converts to a semantic version, with a missing patch padded (`1.2` becomes `1.2.0`). `32.1.3-jre` is kept. `1.2.3-SNAPSHOT` and a Maven range such as `[1.0,2.0)` stay in `currentSpec` and set `resolvedVersion` to `null`. With `--offline`, `latestStable` is `null` and `drift` is `unknown`. A run that can reach Maven Central fills `latestStable` and `drift` from that registry. +`resolvedVersion` is the declared version when that string converts to a semantic version, with a missing patch padded (`1.2` becomes `1.2.0`). `32.1.3-jre` is kept. `1.2.3+build.7` stays `1.2.3+build.7`. `1.2.3-SNAPSHOT` and a Maven range such as `[1.0,2.0)` stay in `currentSpec` and set `resolvedVersion` to `null`. With `--offline`, `latestStable` is `null` and `drift` is `unknown`. A run that can reach Maven Central fills `latestStable` and `drift` from that registry. #### Maven diff --git a/examples/semver-build-metadata/dart/pubspec.lock b/examples/semver-build-metadata/dart/pubspec.lock new file mode 100644 index 00000000..5d8af8a8 --- /dev/null +++ b/examples/semver-build-metadata/dart/pubspec.lock @@ -0,0 +1,10 @@ +packages: + example_build_meta: + dependency: "direct main" + description: + name: example_build_meta + url: "https://pub.dev" + source: hosted + version: "1.2.3+build.7" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/examples/semver-build-metadata/dart/pubspec.yaml b/examples/semver-build-metadata/dart/pubspec.yaml new file mode 100644 index 00000000..ac2bcccd --- /dev/null +++ b/examples/semver-build-metadata/dart/pubspec.yaml @@ -0,0 +1,5 @@ +name: demo +environment: + sdk: ">=3.0.0 <4.0.0" +dependencies: + example_build_meta: 1.2.3+build.7 diff --git a/examples/semver-build-metadata/dotnet/demo.csproj b/examples/semver-build-metadata/dotnet/demo.csproj new file mode 100644 index 00000000..8dc78cf4 --- /dev/null +++ b/examples/semver-build-metadata/dotnet/demo.csproj @@ -0,0 +1,8 @@ + + + net8.0 + + + + + diff --git a/examples/semver-build-metadata/go/go.mod b/examples/semver-build-metadata/go/go.mod new file mode 100644 index 00000000..a6d2e841 --- /dev/null +++ b/examples/semver-build-metadata/go/go.mod @@ -0,0 +1,5 @@ +module example.com/demo + +go 1.22 + +require example.com/with-build v1.2.3+build.7 diff --git a/examples/semver-build-metadata/go/go.sum b/examples/semver-build-metadata/go/go.sum new file mode 100644 index 00000000..f1e8e43c --- /dev/null +++ b/examples/semver-build-metadata/go/go.sum @@ -0,0 +1,2 @@ +example.com/with-build v1.2.3+build.7 h1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa= +example.com/with-build v1.2.3+build.7/go.mod h1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb= diff --git a/examples/semver-build-metadata/java-gradle/build.gradle b/examples/semver-build-metadata/java-gradle/build.gradle new file mode 100644 index 00000000..c5dd2dd2 --- /dev/null +++ b/examples/semver-build-metadata/java-gradle/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation "com.example:locked:1.+" +} diff --git a/examples/semver-build-metadata/java-gradle/gradle.lockfile b/examples/semver-build-metadata/java-gradle/gradle.lockfile new file mode 100644 index 00000000..d8d9860a --- /dev/null +++ b/examples/semver-build-metadata/java-gradle/gradle.lockfile @@ -0,0 +1,3 @@ +# Gradle dependency lock +com.example:locked:1.2.3+build.7=runtimeClasspath +empty= diff --git a/examples/semver-build-metadata/java/pom.xml b/examples/semver-build-metadata/java/pom.xml new file mode 100644 index 00000000..b4bd4b7e --- /dev/null +++ b/examples/semver-build-metadata/java/pom.xml @@ -0,0 +1,12 @@ + + com.example + demo + 1.0.0 + + + com.example + build-meta + 1.2.3+build.7 + + + diff --git a/examples/semver-build-metadata/package.json b/examples/semver-build-metadata/package.json new file mode 100644 index 00000000..07357f30 --- /dev/null +++ b/examples/semver-build-metadata/package.json @@ -0,0 +1,7 @@ +{ + "name": "build-meta-demo", + "version": "1.0.0", + "dependencies": { + "example-build-meta": "1.2.3+build.7" + } +} diff --git a/examples/semver-build-metadata/php/composer.json b/examples/semver-build-metadata/php/composer.json new file mode 100644 index 00000000..8e842839 --- /dev/null +++ b/examples/semver-build-metadata/php/composer.json @@ -0,0 +1,7 @@ +{ + "name": "example/demo", + "require": { + "php": ">=8.2", + "example/build-meta": "v1.2.3+build.7" + } +} diff --git a/examples/semver-build-metadata/php/composer.lock b/examples/semver-build-metadata/php/composer.lock new file mode 100644 index 00000000..cc1b63c2 --- /dev/null +++ b/examples/semver-build-metadata/php/composer.lock @@ -0,0 +1,8 @@ +{ + "packages": [ + { + "name": "example/build-meta", + "version": "v1.2.3+build.7" + } + ] +} diff --git a/examples/semver-build-metadata/pnpm-lock.yaml b/examples/semver-build-metadata/pnpm-lock.yaml new file mode 100644 index 00000000..c31ea282 --- /dev/null +++ b/examples/semver-build-metadata/pnpm-lock.yaml @@ -0,0 +1,13 @@ +lockfileVersion: '9.0' + +importers: + .: + dependencies: + example-build-meta: + specifier: 1.2.3+build.7 + version: 1.2.3+build.7 + +packages: + example-build-meta@1.2.3+build.7: + resolution: + integrity: sha512-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/examples/semver-build-metadata/python/poetry.lock b/examples/semver-build-metadata/python/poetry.lock new file mode 100644 index 00000000..71d20f01 --- /dev/null +++ b/examples/semver-build-metadata/python/poetry.lock @@ -0,0 +1,3 @@ +[[package]] +name = "example-build-meta" +version = "1.2.3+build.7" diff --git a/examples/semver-build-metadata/python/requirements.txt b/examples/semver-build-metadata/python/requirements.txt new file mode 100644 index 00000000..8a9a966a --- /dev/null +++ b/examples/semver-build-metadata/python/requirements.txt @@ -0,0 +1 @@ +example-build-meta==1.2.3+build.7 diff --git a/examples/semver-build-metadata/ruby/Gemfile b/examples/semver-build-metadata/ruby/Gemfile new file mode 100644 index 00000000..03215445 --- /dev/null +++ b/examples/semver-build-metadata/ruby/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'example-build-meta', '1.2.3+build.7' diff --git a/examples/semver-build-metadata/ruby/Gemfile.lock b/examples/semver-build-metadata/ruby/Gemfile.lock new file mode 100644 index 00000000..de8536de --- /dev/null +++ b/examples/semver-build-metadata/ruby/Gemfile.lock @@ -0,0 +1,13 @@ +GEM + remote: https://rubygems.org/ + specs: + example-build-meta (1.2.3+build.7) + +PLATFORMS + ruby + +DEPENDENCIES + example-build-meta + +BUNDLED WITH + 2.5.0 diff --git a/examples/semver-build-metadata/rust-declared/Cargo.toml b/examples/semver-build-metadata/rust-declared/Cargo.toml new file mode 100644 index 00000000..bc17eed0 --- /dev/null +++ b/examples/semver-build-metadata/rust-declared/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "demo-declared" +version = "0.1.0" +edition = "2021" + +[dependencies] +example-build-meta = "1.2.3+build.7" diff --git a/examples/semver-build-metadata/rust/Cargo.lock b/examples/semver-build-metadata/rust/Cargo.lock new file mode 100644 index 00000000..294a0375 --- /dev/null +++ b/examples/semver-build-metadata/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "example-build-meta" +version = "1.2.3+build.7" diff --git a/examples/semver-build-metadata/rust/Cargo.toml b/examples/semver-build-metadata/rust/Cargo.toml new file mode 100644 index 00000000..d30a2c11 --- /dev/null +++ b/examples/semver-build-metadata/rust/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "demo" +version = "0.1.0" +edition = "2021" + +[dependencies] +example-build-meta = "1.2.3+build.7" diff --git a/examples/semver-build-metadata/swift/Package.resolved b/examples/semver-build-metadata/swift/Package.resolved new file mode 100644 index 00000000..26224db1 --- /dev/null +++ b/examples/semver-build-metadata/swift/Package.resolved @@ -0,0 +1,13 @@ +{ + "version": 2, + "pins": [ + { + "identity": "build-meta", + "kind": "remoteSourceControl", + "location": "https://github.com/example/build-meta.git", + "state": { + "version": "1.2.3+build.7" + } + } + ] +} diff --git a/examples/semver-build-metadata/swift/Package.swift b/examples/semver-build-metadata/swift/Package.swift new file mode 100644 index 00000000..c3040a65 --- /dev/null +++ b/examples/semver-build-metadata/swift/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "demo", + dependencies: [ + .package(url: "https://github.com/example/build-meta.git", exact: "1.2.3+build.7"), + ] +) diff --git a/src/core-open/scanners/cargo-cache.ts b/src/core-open/scanners/cargo-cache.ts index 37b91335..62fd938d 100644 --- a/src/core-open/scanners/cargo-cache.ts +++ b/src/core-open/scanners/cargo-cache.ts @@ -2,7 +2,6 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -50,13 +49,13 @@ export class CargoCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -102,7 +101,7 @@ export class CargoCache { if (ver.yanked) continue; if (!ver.num) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver.num)) continue; - const sv = semverKeepingBuild(ver.num); + const sv = semver.valid(semver.clean(ver.num)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/composer-cache.ts b/src/core-open/scanners/composer-cache.ts index 0d72b5b2..2e709997 100644 --- a/src/core-open/scanners/composer-cache.ts +++ b/src/core-open/scanners/composer-cache.ts @@ -2,7 +2,6 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -50,13 +49,13 @@ export class ComposerCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -104,7 +103,7 @@ export class ComposerCache { if (!version) continue; if (version === 'dev-master' || version.startsWith('dev-')) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(version)) continue; - const sv = semverKeepingBuild(version); + const sv = semver.valid(semver.clean(version)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index d0469c6d..c52fdb6e 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; +import { recordedSemver } from '../utils/recorded-semver.js'; import { assertLockfileText, LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, notePubspecLockText, withLockfileNotes } from '../utils/lockfile-unknown.js'; import { withTimeout } from '../utils/timeout.js'; @@ -353,7 +353,7 @@ async function scanOneDartProject( for (const { dep, meta } of resolved) { // Use resolved version if available const resolvedVersionStr = resolvedVersions.get(dep.name); - const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; + const resolvedVersion = resolvedVersionStr ? recordedSemver(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/go-cache.ts b/src/core-open/scanners/go-cache.ts index 80f701eb..a6078821 100644 --- a/src/core-open/scanners/go-cache.ts +++ b/src/core-open/scanners/go-cache.ts @@ -2,7 +2,6 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -50,13 +49,13 @@ export class GoCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -89,7 +88,7 @@ export class GoCache { for (const ver of versions) { if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver)) continue; if (/v\d+\.\d+\.\d+-\d{14}-[a-f0-9]+/.test(ver)) continue; - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index 44e486b9..83096c8f 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; +import { recordedSemver } from '../utils/recorded-semver.js'; import { withTimeout } from '../utils/timeout.js'; import { GoCache } from './go-cache.js'; import { latestStable, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -298,7 +298,7 @@ async function scanOneGoProject( const resolved = await Promise.all(metaPromises); for (const { dep, meta } of resolved) { - const resolvedVersion = dep.version ? semverKeepingBuild(dep.version) : null; + const resolvedVersion = dep.version ? recordedSemver(dep.version) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/java-scanner.ts b/src/core-open/scanners/java-scanner.ts index 96a6e168..52f53905 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -4,8 +4,8 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; +import { recordedSemver, splitBuildMetadata } from '../utils/recorded-semver.js'; import { withTimeout } from '../utils/timeout.js'; import { MavenCache } from './maven-cache.js'; import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; @@ -337,20 +337,18 @@ function parseGradleBuild(content: string, filePath: string): GradleData { * Convert a Maven version string to semver where possible. */ function mavenToSemver(ver: string): string | null { - // A real semver build suffix (`1.2.3+build.4`) is the version. The - // dot-split below would fold `+build` into a numeric part and `semver.valid` - // would then drop it. - const kept = semverKeepingBuild(ver); - if (kept?.includes('+')) return kept; - let v = ver.trim(); - if (!v || v.includes('$')) return null; + const trimmed = ver.trim(); + if (!trimmed || trimmed.includes('$')) return null; + // Peel `+build` before splitting on `.`, or a dotted build id (`+build.7`) is + // treated as extra numeric components and then dropped by `semver.valid`. + const { core, build } = splitBuildMetadata(trimmed); // Maven pre-release markers — both hyphen (-alpha, -rc) and dot (.Beta1, .RC1) forms - if (/(?:[-.](?:SNAPSHOT|alpha|beta|rc|M\d+|CR\d+))/i.test(v)) return null; - v = v.replace(/\.(?:RELEASE|Final|GA)$/i, ''); + if (/(?:[-.](?:SNAPSHOT|alpha|beta|rc|M\d+|CR\d+))/i.test(core)) return null; + let v = core.replace(/\.(?:RELEASE|Final|GA)$/i, ''); const parts = v.split('.'); while (parts.length < 3) parts.push('0'); v = parts.slice(0, 3).join('.'); - return semver.valid(v); + return recordedSemver(build ? `${v}${build}` : v); } // ── Main scanner ── diff --git a/src/core-open/scanners/node-scanner.ts b/src/core-open/scanners/node-scanner.ts index 17a060d1..1d110109 100644 --- a/src/core-open/scanners/node-scanner.ts +++ b/src/core-open/scanners/node-scanner.ts @@ -11,7 +11,6 @@ import { Semaphore } from '../utils/semaphore.js'; import { withTimeout } from '../utils/timeout.js'; import { NpmCache, isSemverSpec } from './npm-cache.js'; import { classifyNpmVersionLag } from './npm-latest.js'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { buildDependencyLicense, normalizeLicenseSourcePath } from '../licenses/dependency-license.js'; import { ageDaysBetween, daysToLibyears, aggregateLibyears } from '../scoring/libyear.js'; import { latestLts, runtimeEolStatus, extractCycle, eolDate } from '../runtimes/catalog.js'; @@ -380,17 +379,7 @@ async function scanOnePackageJson( !!lockedVersion && !!semver.valid(lockedVersion) && (!range || semver.satisfies(lockedVersion, range, { includePrerelease: true })); - let resolvedVersion = lockUsable ? lockedVersion : latestSatisfying; - // An exact manifest pin with build metadata (`1.2.3+build.4`) is a - // concrete version. `semver.validRange` drops the `+` suffix, so the - // registry match can come back as `1.2.3`. Keep the pin when nothing - // newer (and no lockfile) replaced it. - if (!lockUsable) { - const pinned = semverKeepingBuild(spec); - if (pinned?.includes('+') && (resolvedVersion == null || semver.eq(resolvedVersion, pinned))) { - resolvedVersion = pinned; - } - } + const resolvedVersion = lockUsable ? lockedVersion : latestSatisfying; const lag = classifyNpmVersionLag(resolvedVersion, meta.latestStableOverall); const latestStable = lag.reportedLatest; diff --git a/src/core-open/scanners/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index c7294bcd..f0ce0dc3 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { FileCache, readJsonFile } from '../utils/fs.js'; +import { recordedSemver } from '../utils/recorded-semver.js'; import { LockfileParseError, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, noteComposerLock, withLockfileNotes } from '../utils/lockfile-unknown.js'; import { withTimeout } from '../utils/timeout.js'; @@ -300,7 +300,7 @@ async function scanOnePhpProject( for (const { dep, meta } of resolved) { // Use resolved version if available const resolvedVersionStr = resolvedVersions.get(dep.name); - const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; + const resolvedVersion = resolvedVersionStr ? recordedSemver(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/pub-cache.ts b/src/core-open/scanners/pub-cache.ts index dfd9e380..067627be 100644 --- a/src/core-open/scanners/pub-cache.ts +++ b/src/core-open/scanners/pub-cache.ts @@ -2,7 +2,6 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -50,13 +49,13 @@ export class PubCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -97,7 +96,7 @@ export class PubCache { for (const ver of data.versions) { if (!ver.version) continue; if (/[+-](?:alpha|beta|rc|pre|dev)/i.test(ver.version)) continue; - const sv = semverKeepingBuild(ver.version); + const sv = semver.valid(semver.clean(ver.version)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index 2dae4665..ab177177 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; +import { recordedSemver, splitBuildMetadata } from '../utils/recorded-semver.js'; import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { loadPythonLockIndex, type PythonLockIndex } from './python-lockfile.js'; import type { LockfileIo } from './npm-lockfile.js'; @@ -182,16 +182,15 @@ function extractPinnedVersion(spec: string): string | null { * Convert a PEP 440 version to semver where possible (best-effort). */ function pep440ToSemver(ver: string): string | null { - // PEP 440 local versions and semver build metadata share the `+` suffix. - // Keep that token; the dot-split below would discard it. - const kept = semverKeepingBuild(ver); - if (kept?.includes('+')) return kept; - let v = ver.replace(/^[vV]/, '').trim(); - if (/(?:a\d|b\d|rc\d|alpha|beta|dev|post)/i.test(v)) return null; - const parts = v.split('.'); + const stripped = ver.replace(/^[vV]/, '').trim(); + // A `+local` suffix is build metadata, not a PEP 440 pre-release. Test the + // numeric core only, or `+post.1` / `+build.7` is rejected or truncated. + const { core, build } = splitBuildMetadata(stripped); + if (/(?:a\d|b\d|rc\d|alpha|beta|dev|post)/i.test(core)) return null; + const parts = core.split('.'); while (parts.length < 3) parts.push('0'); - v = parts.slice(0, 3).join('.'); - return semver.valid(v); + const v = parts.slice(0, 3).join('.'); + return recordedSemver(build ? `${v}${build}` : v); } // ── File parsers ── diff --git a/src/core-open/scanners/ruby-scanner.ts b/src/core-open/scanners/ruby-scanner.ts index e70a9588..f35895f8 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; +import { recordedSemver, splitBuildMetadata } from '../utils/recorded-semver.js'; import { withTimeout } from '../utils/timeout.js'; import { RubyGemsCache } from './rubygems-cache.js'; import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; @@ -198,8 +198,8 @@ function parseGemfileLine(line: string): { name: string; spec: string } | null { function extractGemVersion(spec: string): string | null { if (spec === '*') return null; - // Match version number after optional operator - const match = spec.match(/(?:~>|>=|>|=|<=|<)?\s*(\d+(?:\.\d+)*)/); + // Match version number after optional operator, including a `+build` suffix. + const match = spec.match(/(?:~>|>=|>|=|<=|<)?\s*(\d+(?:\.\d+)*(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)/); if (!match) return null; return match[1]!; @@ -209,16 +209,14 @@ function extractGemVersion(spec: string): string | null { * Convert a Ruby gem version string to semver. */ function rubyVersionToSemver(ver: string): string | null { - const kept = semverKeepingBuild(ver); - if (kept?.includes('+')) return kept; - const v = ver.trim(); - if (/(?:\.pre|\.rc|\.beta|\.alpha|\.dev)/i.test(v)) return null; + const { core, build } = splitBuildMetadata(ver.trim()); + if (/(?:\.pre|\.rc|\.beta|\.alpha|\.dev)/i.test(core)) return null; - const parts = v.split('.'); + const parts = core.split('.'); if (parts.length < 2) return null; while (parts.length < 3) parts.push('0'); const semverStr = parts.slice(0, 3).join('.'); - return semver.valid(semverStr); + return recordedSemver(build ? `${semverStr}${build}` : semverStr); } /** diff --git a/src/core-open/scanners/rust-scanner.ts b/src/core-open/scanners/rust-scanner.ts index 6c04cf07..6a777f16 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, readJsonFile, pathExists, FileCache } from '../utils/fs.js'; +import { recordedSemver } from '../utils/recorded-semver.js'; import { withTimeout } from '../utils/timeout.js'; import { CargoCache } from './cargo-cache.js'; import { rethrowLockfileParseError } from '../utils/lockfile-parse.js'; @@ -292,12 +292,9 @@ async function scanOneRustProject( for (const { dep, meta } of resolved) { // Prefer the lockfile's exact version; otherwise coerce the declared spec (e.g. "1.0" → 1.0.0). const locked = lockIndex?.resolve(dep.name, dep.version) ?? null; - // The lockfile token is kept as written, including `+build`. A declared - // spec with build metadata must not fall through to `semver.coerce`, - // which returns only `major.minor.patch`. - const resolvedVersion = (locked && semver.valid(locked)) + const resolvedVersion = locked && recordedSemver(locked) ? locked - : (semverKeepingBuild(dep.version) ?? semver.valid(semver.coerce(dep.version))); + : recordedSemver(dep.version) ?? semver.valid(semver.coerce(dep.version)); const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/swift-cache.ts b/src/core-open/scanners/swift-cache.ts index d80066a4..f940f7ed 100644 --- a/src/core-open/scanners/swift-cache.ts +++ b/src/core-open/scanners/swift-cache.ts @@ -2,7 +2,6 @@ // scripts/vendor-core-open.mjs. Do not edit here — change the source package // and re-run the vendor script. Apache-2.0. import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { Semaphore } from '../utils/semaphore.js'; import { getManifestEntry, type PackageVersionManifest } from '../package-version-manifest.js'; import { RegistryDiskCache, REGISTRY_FETCH_TIMEOUT_MS, REGISTRY_USER_AGENT, type RegistryCacheOptions } from '../utils/registry-disk-cache.js'; @@ -50,13 +49,13 @@ export class SwiftCache { if (manifestEntry) { const stableVersions: string[] = []; for (const ver of manifestEntry.versions ?? []) { - const sv = semverKeepingBuild(ver); + const sv = semver.valid(semver.clean(ver)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); const latestStableOverall = sorted[0] ?? null; return { - latest: manifestEntry.latest ? semverKeepingBuild(manifestEntry.latest) ?? latestStableOverall : latestStableOverall, + latest: manifestEntry.latest ? semver.valid(semver.clean(manifestEntry.latest)) ?? latestStableOverall : latestStableOverall, stableVersions, latestStableOverall, }; @@ -107,7 +106,7 @@ export class SwiftCache { if (release.prerelease || release.draft) continue; const tag = release.tag_name; if (!tag) continue; - const sv = semverKeepingBuild(tag); + const sv = semver.valid(semver.clean(tag)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); @@ -146,7 +145,7 @@ export class SwiftCache { const tagName = tag.name; if (!tagName) continue; if (/(?:alpha|beta|rc|pre|dev)/i.test(tagName)) continue; - const sv = semverKeepingBuild(tagName); + const sv = semver.valid(semver.clean(tagName)); if (sv) stableVersions.push(sv); } const sorted = [...stableVersions].sort(semver.rcompare); diff --git a/src/core-open/scanners/swift-scanner.ts b/src/core-open/scanners/swift-scanner.ts index 4a347107..c827e831 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -3,8 +3,8 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as semver from 'semver'; -import { semverKeepingBuild } from '../utils/semver-build.js'; import { readTextFile, FileCache } from '../utils/fs.js'; +import { recordedSemver } from '../utils/recorded-semver.js'; import { parseLockfileJson, rethrowLockfileParseError } from '../utils/lockfile-parse.js'; import { lockfileWarningPath, notePackageResolved, withLockfileNotes } from '../utils/lockfile-unknown.js'; import { withTimeout } from '../utils/timeout.js'; @@ -275,7 +275,7 @@ async function scanOneSwiftProject( // Use resolved version if available, otherwise use specified version const resolvedVersionStr = resolvedVersions.get(dep.name.toLowerCase()) ?? (dep.type === 'exact' ? dep.version : null); - const resolvedVersion = resolvedVersionStr ? semverKeepingBuild(resolvedVersionStr) : null; + const resolvedVersion = resolvedVersionStr ? recordedSemver(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/utils/recorded-semver.test.ts b/src/core-open/utils/recorded-semver.test.ts new file mode 100644 index 00000000..e20ce16c --- /dev/null +++ b/src/core-open/utils/recorded-semver.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { recordedSemver, splitBuildMetadata } from './recorded-semver.js'; + +describe('recordedSemver', () => { + it('keeps build metadata that semver.valid would drop', () => { + expect(recordedSemver('1.2.3+build.7')).toBe('1.2.3+build.7'); + expect(recordedSemver('v2.0.0+incompatible')).toBe('2.0.0+incompatible'); + expect(recordedSemver('1.2.3-rc.1+sha.abc')).toBe('1.2.3-rc.1+sha.abc'); + expect(recordedSemver('=v1.2.3+001')).toBe('1.2.3+001'); + }); + + it('still drops a leading v when there is no build suffix', () => { + expect(recordedSemver('v1.2.3')).toBe('1.2.3'); + expect(recordedSemver('v0.0.0-20191109021931-daa7c04131f5')).toBe('0.0.0-20191109021931-daa7c04131f5'); + }); + + it('returns null for a partial version or junk', () => { + expect(recordedSemver('v1.2')).toBeNull(); + expect(recordedSemver('not-a-version')).toBeNull(); + expect(recordedSemver(null)).toBeNull(); + expect(recordedSemver(undefined)).toBeNull(); + expect(recordedSemver(' ')).toBeNull(); + }); +}); + +describe('splitBuildMetadata', () => { + it('peels a dotted build id off the numeric core', () => { + expect(splitBuildMetadata('1.2.3+build.7')).toEqual({ core: '1.2.3', build: '+build.7' }); + expect(splitBuildMetadata('1.2.3')).toEqual({ core: '1.2.3', build: '' }); + expect(splitBuildMetadata('1.+')).toEqual({ core: '1.', build: '+' }); + }); +}); diff --git a/src/core-open/utils/recorded-semver.ts b/src/core-open/utils/recorded-semver.ts new file mode 100644 index 00000000..5e1a878d --- /dev/null +++ b/src/core-open/utils/recorded-semver.ts @@ -0,0 +1,25 @@ +import * as semver from 'semver'; + +/** + * Concrete SemVer from a lockfile or manifest, including build metadata. + * + * `semver.clean` and `semver.valid` return `SemVer#version`, which omits the + * `+` suffix. Precedence ignores that suffix, so drift and range checks stay + * the same, but scan JSON and `vg sbom` name the version that was written. + * A leading `v` or `=` is still removed. Returns null when `input` is not SemVer. + */ +export function recordedSemver(input: string | null | undefined): string | null { + if (input == null) return null; + const trimmed = input.trim().replace(/^[=v]+/, ''); + if (!trimmed) return null; + const parsed = semver.parse(trimmed); + if (!parsed) return null; + return parsed.build.length > 0 ? `${parsed.version}+${parsed.build.join('.')}` : parsed.version; +} + +/** Separate a SemVer build suffix so padding the numeric core cannot swallow it. */ +export function splitBuildMetadata(version: string): { core: string; build: string } { + const plus = version.indexOf('+'); + if (plus < 0) return { core: version, build: '' }; + return { core: version.slice(0, plus), build: version.slice(plus) }; +} diff --git a/src/core-open/utils/semver-build.test.ts b/src/core-open/utils/semver-build.test.ts deleted file mode 100644 index d8dc070c..00000000 --- a/src/core-open/utils/semver-build.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { semverKeepingBuild } from './semver-build.js'; - -describe('semverKeepingBuild', () => { - it('keeps build metadata that semver.clean would drop', () => { - expect(semverKeepingBuild('1.2.3+build.4')).toBe('1.2.3+build.4'); - expect(semverKeepingBuild('1.2.3-rc.1+build.4')).toBe('1.2.3-rc.1+build.4'); - expect(semverKeepingBuild('v2.0.0+incompatible')).toBe('2.0.0+incompatible'); - expect(semverKeepingBuild('=1.2.3+build.4')).toBe('1.2.3+build.4'); - }); - - it('matches semver.clean when there is no build metadata', () => { - expect(semverKeepingBuild('v1.2.3')).toBe('1.2.3'); - expect(semverKeepingBuild('=v1.2.3')).toBe('1.2.3'); - expect(semverKeepingBuild(' v1.2.3 ')).toBe('1.2.3'); - expect(semverKeepingBuild('v0.0.0-20191109021931-daa7c04131f5')).toBe( - '0.0.0-20191109021931-daa7c04131f5', - ); - }); - - it('returns null for a range or a partial token', () => { - expect(semverKeepingBuild('^1.2.3')).toBeNull(); - expect(semverKeepingBuild('1.2')).toBeNull(); - expect(semverKeepingBuild('')).toBeNull(); - }); -}); diff --git a/src/core-open/utils/semver-build.ts b/src/core-open/utils/semver-build.ts deleted file mode 100644 index 1e5dcb32..00000000 --- a/src/core-open/utils/semver-build.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as semver from 'semver'; - -/** - * Concrete semver for scan JSON and SBOMs, including build metadata. - * - * `semver.clean` / `semver.valid` return `major.minor.patch[-pre]` and drop - * the `+build` suffix. A lockfile token such as `1.2.3+build.4` is the - * version those documents record. Precedence is unchanged: build metadata - * does not participate in `semver.eq` / `semver.rcompare`. - * - * The leading `v` / `=` strip matches `semver.clean`, so `v1.2.3` is still - * `1.2.3` and `v2.0.0+incompatible` is `2.0.0+incompatible`. Returns null - * when `input` is not one semver (a range, a dist-tag, or a partial token). - */ -export function semverKeepingBuild(input: string): string | null { - const cleaned = semver.clean(input); - if (!cleaned) return null; - // `clean` already accepted this token. Re-parse the same prefix it strips - // so a leading `=` or `v` does not hide the build identifiers. - const normalized = input.trim().replace(/^[=v]+/, ''); - const parsed = semver.parse(normalized); - if (!parsed || parsed.build.length === 0) return cleaned; - return `${cleaned}+${parsed.build.join('.')}`; -} diff --git a/src/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 38da1abe..4ae2f0f5 100644 --- a/src/reporting/commands/sbom.test.ts +++ b/src/reporting/commands/sbom.test.ts @@ -236,8 +236,8 @@ describe('sbom helpers', () => { artifact.projects[0]!.type = 'go'; artifact.projects[0]!.dependencies[0]!.package = 'github.com/gin-contrib/sse'; artifact.projects[0]!.dependencies[0]!.currentSpec = 'v1.1.0'; - // The scanner's resolvedVersion runs go.mod's pinned version through - // semver.clean, which drops the leading `v` — go.sum keeps it. + // The scanner's resolvedVersion drops the leading `v` — go.sum keeps it. + // Build metadata, when present, stays on both strings. artifact.projects[0]!.dependencies[0]!.resolvedVersion = '1.1.0'; const graph: LockfileGraph = { components: [{ package: 'github.com/gin-contrib/sse', version: 'v1.1.0' }], @@ -274,51 +274,6 @@ describe('sbom helpers', () => { expect(sbom.components[0]!.purl).toBe('pkg:npm/chalk@5.3.0'); }); - it('keeps SemVer build metadata in the version and percent-encodes + only in the purl', () => { - const version = '1.2.3+build.4'; - const cyclone = toCycloneDx(makeArtifact(version, 90)) as { - components: Array<{ name: string; version: string; purl: string; 'bom-ref': string }>; - }; - const again = JSON.stringify(toCycloneDx(makeArtifact(version, 90))); - expect(JSON.stringify(cyclone)).toBe(again); - const roundTrip = JSON.parse(again) as typeof cyclone; - expect(roundTrip.components[0]!.version).toBe(version); - expect(cyclone.components[0]!.version).toBe(version); - expect(cyclone.components[0]!.purl).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); - expect(cyclone.components[0]!['bom-ref']).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); - expect(again).not.toContain('"version":"1.2.3%2Bbuild.4"'); - expect(again).toContain('"version":"1.2.3+build.4"'); - - const spdx = toSpdx(makeArtifact(version, 90)) as { - packages: Array<{ versionInfo: string; externalRefs: Array<{ referenceLocator: string }> }>; - }; - expect(JSON.stringify(toSpdx(makeArtifact(version, 90)))).toBe(JSON.stringify(spdx)); - expect(spdx.packages[0]!.versionInfo).toBe(version); - expect(spdx.packages[0]!.externalRefs[0]!.referenceLocator).toBe('pkg:npm/chalk@1.2.3%2Bbuild.4'); - expect(purlFor('go', 'example.com/oldmajor', 'v2.0.0+incompatible')).toBe( - 'pkg:golang/example.com/oldmajor@v2.0.0%2Bincompatible', - ); - }); - - it('keeps the component when a version cannot be percent-encoded', () => { - const version = '1.2.3+\uD800'; - expect(() => purlFor('npm', 'chalk', version)).not.toThrow(); - expect(purlFor('npm', 'chalk', version)).toBeNull(); - const warning = describeUnavailablePurl('npm', 'chalk', version); - expect(warning).toContain('cannot be percent-encoded'); - expect(warning).toContain('npm package "chalk"'); - expect(warning).not.toContain('/var/'); - - const cyclone = toCycloneDx(makeArtifact(version, 90)) as { - components: Array<{ version: string; purl?: string; properties: Array<{ name: string; value: string }> }>; - }; - expect(JSON.stringify(toCycloneDx(makeArtifact(version, 90)))).toBe(JSON.stringify(cyclone)); - expect(cyclone.components[0]!.version).toBe(version); - expect(cyclone.components[0]!.purl).toBeUndefined(); - expect(cyclone.components[0]!.properties.find((p) => p.name === 'vibgrate:purlStatus')?.value).toBe('unavailable'); - expect(cyclone.components[0]!.properties.find((p) => p.name === 'vibgrate:purlWarning')?.value).toBe(warning); - }); - /** * A name that cannot be a purl name. `encodeURIComponent` used to turn the * space into `pkg:npm/foo%20bar@1.0.0`, which is a purl-shaped string, and diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index 43c43e4c..da52da69 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -158,78 +158,36 @@ const LICENSE_STATUS_UNREPRESENTABLE = 'unrepresentable'; /** CycloneDX property that carries the stable warning code beside a prose warning. */ const WARNING_CODE_PROPERTY = 'vibgrate:warningCode'; -/** - * Percent-encode one purl token. `encodeURIComponent` throws on a lone - * surrogate; that is "cannot encode", not a reason to drop the component. - * Returns null instead of throwing. - */ -function encodePurlToken(value: string): string | null { - try { - return encodeURIComponent(value); - } catch { - return null; - } -} - -function encodePurlSegments(segments: string[]): string | null { - const encoded: string[] = []; - for (const segment of segments) { - const token = encodePurlToken(segment); - if (token === null) return null; - encoded.push(token); - } - return encoded.join('/'); -} - /** The purl type/namespace/name portion, without a version — shared by every ecosystem branch of `purlFor`. */ function purlPath(ecosystem: Ecosystem, name: string): string | null { switch (ecosystem) { case 'npm': { const scopeSlash = name.startsWith('@') ? name.indexOf('/') : -1; if (scopeSlash > 0) { - const encoded = encodePurlSegments([name.slice(0, scopeSlash), name.slice(scopeSlash + 1)]); - return encoded === null ? null : `pkg:npm/${encoded}`; + return `pkg:npm/${encodeURIComponent(name.slice(0, scopeSlash))}/${encodeURIComponent(name.slice(scopeSlash + 1))}`; } - const encoded = encodePurlToken(name); - return encoded === null ? null : `pkg:npm/${encoded}`; - } - case 'pypi': { - const encoded = encodePurlToken(pypiPurlName(name)); - return encoded === null ? null : `pkg:pypi/${encoded}`; - } - case 'rust': { - const encoded = encodePurlToken(name); - return encoded === null ? null : `pkg:cargo/${encoded}`; - } - case 'go': { - const encoded = encodePurlSegments(name.split('/')); - return encoded === null ? null : `pkg:golang/${encoded}`; + 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]; - const encoded = encodePurlSegments(group ? [group, artifact] : [artifact]); - return encoded === null ? null : `pkg:maven/${encoded}`; - } - case 'ruby': { - const encoded = encodePurlToken(name); - return encoded === null ? null : `pkg:gem/${encoded}`; - } - case 'php': { - const encoded = encodePurlSegments(name.split('/')); - return encoded === null ? null : `pkg:composer/${encoded}`; - } - case 'dotnet': { - const encoded = encodePurlToken(name); - return encoded === null ? null : `pkg:nuget/${encoded}`; - } - case 'swift': { - const encoded = encodePurlSegments(name.split('/')); - return encoded === null ? null : `pkg:swift/${encoded}`; - } - case 'dart': { - const encoded = encodePurlToken(name); - return encoded === null ? null : `pkg:pub/${encoded}`; + 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: // An ecosystem this function does not know is not npm. Falling through // to `pkg:npm/...` would report a registry the scan did not detect. @@ -261,22 +219,14 @@ function pypiPurlName(name: string): string { * * Returns null when the built string is not a Package URL: unknown type, * empty name or path segment, a space or other character that only survives - * as percent-encoding, a version that is not one concrete token, or a - * version that cannot be percent-encoded. Callers keep the component and - * mark the purl unavailable — they do not drop the row, and they do not - * emit the rejected string. - * - * The component `version` stays the canonical token (`1.2.3+build.4`). - * Only the purl version is percent-encoded. `+` is not unreserved, so a - * canonical purl writes it as `%2B` (`pkg:npm/foo@1.2.3%2Bbuild.4`). + * as percent-encoding, or a version that is not one concrete token. Callers + * keep the component and mark the purl unavailable — they do not drop the + * row, and they do not emit the rejected string. */ export function purlFor(ecosystem: Ecosystem, name: string, version: string): string | null { const path = purlPath(ecosystem, name); if (!path) return null; - if (version === UNKNOWN_VERSION) return isValidBuiltPurl(path) ? path : null; - const encodedVersion = encodePurlToken(version); - if (encodedVersion === null) return null; - const purl = `${path}@${encodedVersion}`; + const purl = version === UNKNOWN_VERSION ? path : `${path}@${encodeURIComponent(version)}`; return isValidBuiltPurl(purl) ? purl : null; } @@ -336,8 +286,6 @@ export function describeUnavailablePurl(ecosystem: string, name: string, version because = 'the name contains whitespace or a non-ASCII character'; } else if (version !== UNKNOWN_VERSION && !isConcreteVersion(version)) { because = 'the version is not one concrete installed version'; - } else if (version !== UNKNOWN_VERSION && encodePurlToken(version) === null) { - return `Package URL unavailable for ${ecosystem} package "${name}": the version cannot be percent-encoded. The component is included with its recorded version and without a purl. Use a concrete version made of Unicode text that percent-encodes as UTF-8.`; } else { because = 'the coordinates cannot be encoded as a Package URL'; } @@ -622,11 +570,11 @@ export function flattenDependencies( const guessed = projectEcosystemGuessed(project.type); for (const dep of project.dependencies) { // Go always pins an exact version in go.mod, but the scanner's - // `resolvedVersion` drops the leading `v` go.sum's transitive entries - // keep (build metadata such as `+incompatible` stays). Matching on - // `currentSpec` instead is what lets a direct Go dependency dedupe - // against its own go.sum-derived component instead of appearing as - // two, differently-versioned components. + // `resolvedVersion` drops the leading `v` (build metadata stays) while + // go.sum's transitive entries keep that `v` — matching on `currentSpec` + // instead is what lets a direct Go dependency dedupe against its own + // go.sum-derived component instead of appearing as two, + // differently-versioned components. const rawVersion = ecosystem === 'go' ? dep.currentSpec : (dep.resolvedVersion ?? dep.currentSpec); // A dependency with no lockfile/installed-tree resolution falls back // to its declared spec, which for npm/yarn/pnpm can be a semver range, diff --git a/test/semver-build-metadata.test.ts b/test/semver-build-metadata.test.ts index 38452f54..134678d8 100644 --- a/test/semver-build-metadata.test.ts +++ b/test/semver-build-metadata.test.ts @@ -1,110 +1,121 @@ -/** - * SemVer build metadata (`1.2.3+build.4`) round-trips through scan JSON and - * `vg sbom` JSON. The version field keeps the `+`. The purl percent-encodes it. - */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { NpmCache } from '../src/core-open/scanners/npm-cache.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { scanDartProjects } from '../src/core-open/scanners/dart-scanner.js'; +import { scanDotnetProjects } from '../src/core-open/scanners/dotnet-scanner.js'; +import { scanGoProjects } from '../src/core-open/scanners/go-scanner.js'; +import { scanJavaProjects } from '../src/core-open/scanners/java-scanner.js'; import { scanNodeProjects } from '../src/core-open/scanners/node-scanner.js'; -import { ComposerCache } from '../src/core-open/scanners/composer-cache.js'; import { scanPhpProjects } from '../src/core-open/scanners/php-scanner.js'; +import { scanPythonProjects } from '../src/core-open/scanners/python-scanner.js'; +import { scanRubyProjects } from '../src/core-open/scanners/ruby-scanner.js'; +import { scanRustProjects } from '../src/core-open/scanners/rust-scanner.js'; +import { scanSwiftProjects } from '../src/core-open/scanners/swift-scanner.js'; +import { CargoCache } from '../src/core-open/scanners/cargo-cache.js'; +import { ComposerCache } from '../src/core-open/scanners/composer-cache.js'; +import { GoCache } from '../src/core-open/scanners/go-cache.js'; +import { MavenCache } from '../src/core-open/scanners/maven-cache.js'; +import { NpmCache } from '../src/core-open/scanners/npm-cache.js'; +import { NuGetCache } from '../src/core-open/scanners/nuget-cache.js'; +import { PubCache } from '../src/core-open/scanners/pub-cache.js'; +import { PyPICache } from '../src/core-open/scanners/pypi-cache.js'; +import { RubyGemsCache } from '../src/core-open/scanners/rubygems-cache.js'; +import { SwiftCache } from '../src/core-open/scanners/swift-cache.js'; import { Semaphore } from '../src/core-open/utils/semaphore.js'; -import { toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; -import type { ScanArtifact } from '../src/reporting/types.js'; +import type { ProjectScan, ScanArtifact } from '../src/reporting/types.js'; +import { collectLockfileGraph, toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; -const VERSION = '1.2.3+build.4'; +const BUILD = '1.2.3+build.7'; +const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../examples/semver-build-metadata'); -function artifactFrom(project: ScanArtifact['projects'][number], rootPath: string): ScanArtifact { +function offline() { + return new Semaphore(1); +} + +async function scanFixture(): Promise { + const sem = offline(); + const [node, go, java, python, ruby, php, dart, swift, rust, dotnet] = await Promise.all([ + scanNodeProjects(fixtureDir, new NpmCache(fixtureDir, sem, undefined, true)), + scanGoProjects(fixtureDir, new GoCache(sem, undefined, true)), + scanJavaProjects(fixtureDir, new MavenCache(sem, undefined, true)), + scanPythonProjects(fixtureDir, new PyPICache(sem, undefined, true)), + scanRubyProjects(fixtureDir, new RubyGemsCache(sem, undefined, true)), + scanPhpProjects(fixtureDir, new ComposerCache(sem, undefined, true)), + scanDartProjects(fixtureDir, new PubCache(sem, undefined, true)), + scanSwiftProjects(fixtureDir, new SwiftCache(sem, undefined, true)), + scanRustProjects(fixtureDir, new CargoCache(sem, undefined, true)), + scanDotnetProjects(fixtureDir, new NuGetCache(sem, undefined, true)), + ]); + return [...node, ...go, ...java, ...python, ...ruby, ...php, ...dart, ...swift, ...rust, ...dotnet]; +} + +function resolved(projects: ProjectScan[], projectPath: string, pkg: string): string | null { + const project = projects.find((p) => p.path === projectPath); + const dep = project?.dependencies.find((d) => d.package === pkg); + return dep?.resolvedVersion ?? null; +} + +function artifact(projects: ProjectScan[]): ScanArtifact { return { schemaVersion: '1.0', timestamp: '2026-02-19T00:00:00.000Z', - vibgrateVersion: '0.0.1', - rootPath, + vibgrateVersion: '0.0.0', + rootPath: 'semver-build-metadata', drift: { score: 0, riskLevel: 'low', components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, }, findings: [], - projects: [project], + projects, }; } -describe('SemVer build metadata in scan and sbom JSON', () => { - let root: string; - - afterEach(() => { - if (root) fs.rmSync(root, { recursive: true, force: true }); +describe('SemVer build metadata in scan JSON and SBOMs', () => { + it('records +build from lockfiles and manifests', async () => { + const projects = await scanFixture(); + expect(resolved(projects, '.', 'example-build-meta')).toBe(BUILD); + expect(resolved(projects, 'go', 'example.com/with-build')).toBe(BUILD); + expect(projects.find((p) => p.path === 'go')?.dependencies[0]?.currentSpec).toBe(`v${BUILD}`); + expect(resolved(projects, 'java', 'com.example:build-meta')).toBe(BUILD); + expect(resolved(projects, 'java-gradle', 'com.example:locked')).toBe(BUILD); + expect(resolved(projects, 'python', 'example-build-meta')).toBe(BUILD); + expect(resolved(projects, 'ruby', 'example-build-meta')).toBe(BUILD); + expect(resolved(projects, 'php', 'example/build-meta')).toBe(BUILD); + expect(resolved(projects, 'dart', 'example_build_meta')).toBe(BUILD); + expect(resolved(projects, 'swift', 'build-meta')).toBe(BUILD); + expect(resolved(projects, 'rust', 'example-build-meta')).toBe(BUILD); + expect(resolved(projects, 'rust-declared', 'example-build-meta')).toBe(BUILD); + expect(resolved(projects, 'dotnet', 'Example.BuildMeta')).toBe(BUILD); + expect(JSON.stringify(projects)).toContain(BUILD); + expect(JSON.stringify(projects)).not.toContain('"resolvedVersion":"1.2.3"'); }); - it('round-trips an npm lockfile version through scan JSON and both SBOM formats', async () => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-semver-build-npm-')); - fs.writeFileSync( - path.join(root, 'package.json'), - JSON.stringify({ name: 'plus-fixture', version: '1.0.0', dependencies: { 'left-pad': VERSION } }), - ); - fs.writeFileSync( - path.join(root, 'package-lock.json'), - JSON.stringify({ - name: 'plus-fixture', - lockfileVersion: 3, - packages: { - '': { name: 'plus-fixture', dependencies: { 'left-pad': VERSION } }, - 'node_modules/left-pad': { version: VERSION }, - }, - }), - ); - - const projects = await scanNodeProjects( - root, - new NpmCache(root, new Semaphore(1), { npm: { 'left-pad': { latest: '1.3.0', versions: [VERSION, '1.3.0'] } } }, true), - ); - expect(projects[0]!.dependencies[0]!.resolvedVersion).toBe(VERSION); - const scanJson = JSON.stringify({ projects }); - expect(JSON.parse(scanJson).projects[0].dependencies[0].resolvedVersion).toBe(VERSION); - expect(scanJson).toContain(VERSION); - expect(scanJson).not.toContain('1.2.3%2Bbuild.4'); + it('keeps the suffix on CycloneDX and SPDX components, including the purl', async () => { + const projects = await scanFixture(); + const graph = collectLockfileGraph(artifact(projects), fixtureDir); + const cdx = toCycloneDx(artifact(projects), graph) as { + components: Array<{ name: string; version: string; purl?: string }>; + }; + const spdx = toSpdx(artifact(projects), graph) as { + packages: Array<{ versionInfo?: string; externalRefs?: Array<{ referenceLocator?: string }> }>; + }; - const artifact = artifactFrom(projects[0]!, 'plus-fixture'); - const cyclone = toCycloneDx(artifact) as { components: Array<{ version: string; purl: string }> }; - const cycloneJson = JSON.stringify(cyclone); - expect(JSON.stringify(toCycloneDx(artifact))).toBe(cycloneJson); - expect(cyclone.components[0]!.version).toBe(VERSION); - expect(cyclone.components[0]!.purl).toBe('pkg:npm/left-pad@1.2.3%2Bbuild.4'); - expect(JSON.parse(cycloneJson).components[0].version).toBe(VERSION); - - const spdx = toSpdx(artifact) as { packages: Array<{ versionInfo: string; externalRefs: Array<{ referenceLocator: string }> }> }; - expect(spdx.packages[0]!.versionInfo).toBe(VERSION); - expect(spdx.packages[0]!.externalRefs[0]!.referenceLocator).toBe('pkg:npm/left-pad@1.2.3%2Bbuild.4'); - }); - - it('round-trips a composer.lock version that semver.clean used to strip', async () => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-semver-build-php-')); - fs.writeFileSync( - path.join(root, 'composer.json'), - JSON.stringify({ name: 'acme/app', require: { 'monolog/monolog': VERSION } }), - ); - fs.writeFileSync( - path.join(root, 'composer.lock'), - JSON.stringify({ - packages: [{ name: 'monolog/monolog', version: VERSION }], - 'packages-dev': [], + expect(cdx.components.map((c) => c.version)).not.toContain('1.2.3'); + expect(cdx.components.some((c) => c.version.includes('+build.7'))).toBe(true); + expect(cdx.components.find((c) => c.purl === 'pkg:npm/example-build-meta@1.2.3%2Bbuild.7')).toBeTruthy(); + expect(cdx.components.filter((c) => c.name === 'example.com/with-build')).toEqual([ + expect.objectContaining({ + version: `v${BUILD}`, + purl: 'pkg:golang/example.com/with-build@v1.2.3%2Bbuild.7', }), - ); - - const projects = await scanPhpProjects(root, new ComposerCache(new Semaphore(1), undefined, true)); - expect(projects).toHaveLength(1); - expect(projects[0]!.dependencies.map((d) => [d.package, d.resolvedVersion])).toEqual([['monolog/monolog', VERSION]]); - const scanJson = JSON.stringify({ projects }); - expect(JSON.parse(scanJson).projects[0].dependencies[0].resolvedVersion).toBe(VERSION); - expect(scanJson).not.toContain('%2B'); + ]); - const artifact = artifactFrom(projects[0]!, 'acme/app'); - const cyclone = toCycloneDx(artifact) as { components: Array<{ version: string; purl: string }> }; - expect(JSON.stringify(toCycloneDx(artifact))).toBe(JSON.stringify(cyclone)); - expect(cyclone.components[0]!.version).toBe(VERSION); - expect(cyclone.components[0]!.purl).toBe('pkg:composer/monolog/monolog@1.2.3%2Bbuild.4'); + const spdxVersions = spdx.packages.map((p) => p.versionInfo); + expect(spdxVersions).not.toContain('1.2.3'); + expect(spdxVersions.some((v) => v?.includes('+build.7'))).toBe(true); + const locators = spdx.packages.flatMap((p) => p.externalRefs?.map((r) => r.referenceLocator) ?? []); + expect(locators).toContain('pkg:npm/example-build-meta@1.2.3%2Bbuild.7'); + expect(locators).toContain('pkg:golang/example.com/with-build@v1.2.3%2Bbuild.7'); }); });