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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -2380,11 +2382,11 @@ In Go's own order, `v1.2.4-0.<timestamp>-<commit>` 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`

Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 `<scope>test</scope>` 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

Expand Down
10 changes: 10 additions & 0 deletions examples/semver-build-metadata/dart/pubspec.lock
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions examples/semver-build-metadata/dart/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: demo
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
example_build_meta: 1.2.3+build.7
8 changes: 8 additions & 0 deletions examples/semver-build-metadata/dotnet/demo.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Example.BuildMeta" Version="1.2.3+build.7" />
</ItemGroup>
</Project>
5 changes: 5 additions & 0 deletions examples/semver-build-metadata/go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module example.com/demo

go 1.22

require example.com/with-build v1.2.3+build.7
2 changes: 2 additions & 0 deletions examples/semver-build-metadata/go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
3 changes: 3 additions & 0 deletions examples/semver-build-metadata/java-gradle/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies {
implementation "com.example:locked:1.+"
}
3 changes: 3 additions & 0 deletions examples/semver-build-metadata/java-gradle/gradle.lockfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Gradle dependency lock
com.example:locked:1.2.3+build.7=runtimeClasspath
empty=
12 changes: 12 additions & 0 deletions examples/semver-build-metadata/java/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<project>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>build-meta</artifactId>
<version>1.2.3+build.7</version>
</dependency>
</dependencies>
</project>
7 changes: 7 additions & 0 deletions examples/semver-build-metadata/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "build-meta-demo",
"version": "1.0.0",
"dependencies": {
"example-build-meta": "1.2.3+build.7"
}
}
7 changes: 7 additions & 0 deletions examples/semver-build-metadata/php/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "example/demo",
"require": {
"php": ">=8.2",
"example/build-meta": "v1.2.3+build.7"
}
}
8 changes: 8 additions & 0 deletions examples/semver-build-metadata/php/composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions examples/semver-build-metadata/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions examples/semver-build-metadata/python/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions examples/semver-build-metadata/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
example-build-meta==1.2.3+build.7
3 changes: 3 additions & 0 deletions examples/semver-build-metadata/ruby/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
source 'https://rubygems.org'

gem 'example-build-meta', '1.2.3+build.7'
13 changes: 13 additions & 0 deletions examples/semver-build-metadata/ruby/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions examples/semver-build-metadata/rust-declared/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "demo-declared"
version = "0.1.0"
edition = "2021"

[dependencies]
example-build-meta = "1.2.3+build.7"
7 changes: 7 additions & 0 deletions examples/semver-build-metadata/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions examples/semver-build-metadata/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "demo"
version = "0.1.0"
edition = "2021"

[dependencies]
example-build-meta = "1.2.3+build.7"
13 changes: 13 additions & 0 deletions examples/semver-build-metadata/swift/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions examples/semver-build-metadata/swift/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
)
3 changes: 2 additions & 1 deletion src/core-open/scanners/dart-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/core-open/scanners/go-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions src/core-open/scanners/java-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ──
Expand Down
3 changes: 2 additions & 1 deletion src/core-open/scanners/php-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions src/core-open/scanners/python-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ──
Expand Down
13 changes: 7 additions & 6 deletions src/core-open/scanners/ruby-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]!;
Expand All @@ -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);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/core-open/scanners/rust-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading