diff --git a/DOCS.md b/DOCS.md index 326d5f7..3bdd637 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1703,6 +1703,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. @@ -2360,12 +2362,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 is dropped. `v2.0.0+incompatible` is recorded as `2.0.0`. The major stays 2. +- 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 @@ -2380,11 +2382,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 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 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. `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` @@ -2536,7 +2538,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 (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) | @@ -2934,7 +2936,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 0000000..5d8af8a --- /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 0000000..ac2bccc --- /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 0000000..8dc78cf --- /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 0000000..a6d2e84 --- /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 0000000..f1e8e43 --- /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 0000000..c5dd2dd --- /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 0000000..d8d9860 --- /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 0000000..b4bd4b7 --- /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 0000000..07357f3 --- /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 0000000..8e84283 --- /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 0000000..cc1b63c --- /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 0000000..c31ea28 --- /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 0000000..71d20f0 --- /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 0000000..8a9a966 --- /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 0000000..0321544 --- /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 0000000..de8536d --- /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 0000000..bc17eed --- /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 0000000..294a037 --- /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 0000000..d30a2c1 --- /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 0000000..26224db --- /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 0000000..c3040a6 --- /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/dart-scanner.ts b/src/core-open/scanners/dart-scanner.ts index ff6c429..c52fdb6 100644 --- a/src/core-open/scanners/dart-scanner.ts +++ b/src/core-open/scanners/dart-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -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 ? recordedSemver(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/go-scanner.ts b/src/core-open/scanners/go-scanner.ts index aad284b..83096c8 100644 --- a/src/core-open/scanners/go-scanner.ts +++ b/src/core-open/scanners/go-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -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 ? 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 4000c68..52f5390 100644 --- a/src/core-open/scanners/java-scanner.ts +++ b/src/core-open/scanners/java-scanner.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { XMLParser } from 'fast-xml-parser'; 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'; @@ -336,15 +337,18 @@ function parseGradleBuild(content: string, filePath: string): GradleData { * Convert a Maven version string to semver where possible. */ function mavenToSemver(ver: string): string | null { - 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/php-scanner.ts b/src/core-open/scanners/php-scanner.ts index 618e95c..f0ce0dc 100644 --- a/src/core-open/scanners/php-scanner.ts +++ b/src/core-open/scanners/php-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -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 ? recordedSemver(resolvedVersionStr) : null; const latestStable = meta.latestStableOverall; let majorsBehind: number | null = null; diff --git a/src/core-open/scanners/python-scanner.ts b/src/core-open/scanners/python-scanner.ts index e421fe4..ab17717 100644 --- a/src/core-open/scanners/python-scanner.ts +++ b/src/core-open/scanners/python-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -181,12 +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 { - 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 e5927bf..f35895f 100644 --- a/src/core-open/scanners/ruby-scanner.ts +++ b/src/core-open/scanners/ruby-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -197,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]!; @@ -208,14 +209,14 @@ function extractGemVersion(spec: string): string | null { * Convert a Ruby gem version string to semver. */ function rubyVersionToSemver(ver: string): string | null { - 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 1f11642..6a777f1 100644 --- a/src/core-open/scanners/rust-scanner.ts +++ b/src/core-open/scanners/rust-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -291,7 +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; - const resolvedVersion = (locked && semver.valid(locked)) ? locked : semver.valid(semver.coerce(dep.version)); + const resolvedVersion = locked && recordedSemver(locked) + ? locked + : 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-scanner.ts b/src/core-open/scanners/swift-scanner.ts index 54a1d3b..c827e83 100644 --- a/src/core-open/scanners/swift-scanner.ts +++ b/src/core-open/scanners/swift-scanner.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; 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'; @@ -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 ? 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 0000000..e20ce16 --- /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 0000000..5e1a878 --- /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/reporting/commands/sbom.test.ts b/src/reporting/commands/sbom.test.ts index 4fd6a59..4ae2f0f 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' }], diff --git a/src/reporting/commands/sbom.ts b/src/reporting/commands/sbom.ts index 890aa28..2eda478 100644 --- a/src/reporting/commands/sbom.ts +++ b/src/reporting/commands/sbom.ts @@ -571,11 +571,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` (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/go-pseudo-versions.test.ts b/test/go-pseudo-versions.test.ts index 71c091d..b958fa4 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 0000000..134678d --- /dev/null +++ b/test/semver-build-metadata.test.ts @@ -0,0 +1,121 @@ +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 { 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 type { ProjectScan, ScanArtifact } from '../src/reporting/types.js'; +import { collectLockfileGraph, toCycloneDx, toSpdx } from '../src/reporting/commands/sbom.js'; + +const BUILD = '1.2.3+build.7'; +const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../examples/semver-build-metadata'); + +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.0', + rootPath: 'semver-build-metadata', + drift: { + score: 0, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [], + projects, + }; +} + +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('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 }> }>; + }; + + 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 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'); + }); +});