Overview
vg scan --vulns adds known-vulnerability detection to a scan. It matches every installed dependency against the public OSV database and reports each advisory with its severity, CVSS score, and the version that fixes it.
Opt in: vulnerability detection is off by default — add --vulns, or run vg scan --full for drift, vulnerabilities, infrastructure misconfiguration findings (--iac), and a banned-dependency report together.
Infrastructure Misconfiguration (--iac)
vg scan --iac (also on with --full) evaluates Terraform, Kubernetes, Helm and Dockerfile facts against the Architecture module's iac-cis-v1 pack: public S3 buckets, security groups open to the internet, unencrypted volumes, privileged or root containers, host networking, wildcard RBAC, missing resource limits, Dockerfiles that run as root, and secret-shaped keys with literal values in ENV. Each finding carries a content-addressed id (the same tree gives the same id tomorrow), an OWASP Top 10:2025 category, a CWE and, where one applies, a CIS control, and lands in the text, JSON and SARIF output next to the drift findings.
vg scan --iac
vg scan --full --format sarif --out vibgrate.sarif --fail-on iac-finding
--fail-on iac-finding exits 2 on a high or critical finding; --fail-on iac-finding=medium lowers the bar, and a comma list combines gates (--fail-on error,iac-finding). The rules run locally inside the Architecture module; no source leaves the machine. Findings are evidence, not a certification, and the pack does not replace Checkov, Trivy or Snyk IaC — it covers a small, gold-gated rule set and joins each finding to the code map. A rule that cannot read an attribute (a variable, a dynamic block) abstains rather than reporting the resource as safe.
Usage
vg scan --vulns
vg scan --full
How Detection Works
Vibgrate reads the installed version of each dependency from your lockfile and queries OSV for advisories affecting that exact version. Each finding carries the advisory's own id. CVE and other aliases are recorded with it. The finding also carries a qualitative severity derived from the CVSS v3 vector, the affected package and version, and the fixing version when one is published.
Critical and high findings are emitted as errors and moderate as warnings, so --fail-on gates apply to vulnerabilities just as they do to drift.
JSON exploitability fields
vg scan --format json (and .vibgrate/scan_result.json) records advisories under extended.vulnerabilities.packages[].advisories. Match the package on ecosystem, package, and version. That is the same identity vg sbom writes as a package URL. The scan file has no CPE field. When the advisory data used for the scan already carries exploitability, each advisory may also include:
| Field | Type | Meaning |
|---|---|---|
epss | number, 0–1 | FIRST EPSS probability of exploitation within 30 days |
epssPercentile | number, 0–1 | EPSS percentile for that probability |
kev | boolean | Whether that data marks the CVE in the CISA Known Exploited Vulnerabilities catalog |
A missing value is omitted. It is never written as 0 or false to mean "unknown". A present 0 is a real EPSS score. Text and SARIF findings do not add these fields.
Offline and --package-manifest scans read the fields from the local bundle only — they do not contact an EPSS or KEV service. An online scan copies them from the OSV document when that document already includes them, and does not make a separate request. A package-manifest vulns entry accepts the same optional epss, epssPercentile, and kev fields.
Offline / Air-Gapped
vg scan has no separate advisory-file flag. Offline advisories are vulns entries in a package-version manifest. The file is JSON, or a ZIP whose root contains package-versions.json, manifest.json, or index.json (a ZIP is refused when it is over 256 MiB, declares more than 256 MiB uncompressed, or declares more than 10,000 entries; VG_MAX_ARCHIVE_BYTES and VG_MAX_ARCHIVE_ENTRIES raise those limits, and 0 disables that one limit). Each ecosystem (npm, nuget, pypi, maven, rubygems, swift, go, cargo, composer, pub, hex, docker, helm, terraform) is an object keyed by package name. NuGet names match case-insensitively. An entry's latest and versions feed drift. vulns entries need an id. ranges match from introduced up to, and not including, fixed.
vg scan --vulns --offline --package-manifest ./package-versions.json
{
"npm": {
"left-pad": {
"latest": "1.3.0",
"versions": ["1.3.0"],
"vulns": [
{
"id": "GHSA-example",
"severity": "high",
"ranges": [{ "introduced": "0", "fixed": "1.3.1" }]
}
]
}
}
}
A missing file prints error: Package manifest not found: <path>. Pass a readable JSON or ZIP package-version manifest to --package-manifest. A file that is not a package-version manifest prints error: Package manifest is not usable: <path>. Expected a JSON object of package versions, or a ZIP containing package-versions.json, manifest.json, or index.json. The exit code is 1. Stdout is empty, and no scan artifact is written.
--offline does not contact package registries, does not query OSV, and does not upload, including when --push is set or VIBGRATE_DSN is present. It does not call an EPSS or KEV service.
The same tree and the same manifest produce the same findings order and the same advisory ids. JSON lists every finding by path (location), then rule id, then advisory id, then package URL when the finding carries an ecosystem and a package name. Alias lists on a finding are sorted. The text report prints errors, then warnings, then notes, and within a level it uses that same path order. SARIF keeps drift findings first, then vulnerability results by ecosystem, package name, and installed version, and within a package by severity (critical, high, moderate, low, unknown) and advisory id. JSON records that id as details.advisoryId. Key order in the manifest file does not change the result.
Supported Ecosystems
Detection reads each project's lockfile for npm / pnpm / yarn, pip / poetry / pipenv, cargo, composer, bundler, pub, hex, NuGet, and Maven/Gradle. Go is matched from direct require lines in go.mod.
Go modules: pseudo-versions and +incompatible
Go modules pin an untagged commit as a pseudo-version (v0.0.0-20191109021931-daa7c04131f5, or v1.2.4-0.20210101120000-abcdefabcdef for a commit after tag v1.2.3). A major of 2 or higher whose module path has no /vN suffix is written with +incompatible (v2.0.0+incompatible). The local comparison below is what vg scan --vulns uses against a package-version manifest. --offline keeps that comparison on the machine: no module proxy, no OSV query, no registry token. Leave --offline off and the same recorded version is sent to the public OSV API, which applies OSV's own comparison.
Which version is compared
The scan reads direct require lines in go.mod.
- A concrete
vversion that is valid semver after the leadingvis removed is the recorded version.v1.2.3is recorded as1.2.3. - Build metadata is dropped.
v2.0.0+incompatibleis recorded as2.0.0. The major stays 2. - A pseudo-version keeps its pre-release suffix.
v0.0.0-20191109021931-daa7c04131f5is recorded as0.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.sumis 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.
Pseudo-versions and tagged releases
| Require line | Recorded | Compared as | introduced 1.0.0, fixed 1.2.4 |
|---|---|---|---|
v1.2.3 | 1.2.3 | 1.2.3 | matches |
v1.2.3-0.20210101120000-abcdefabcdef | 1.2.3-0.20210101120000-abcdefabcdef | 1.2.3 | matches |
v1.2.4-0.20210101120000-abcdefabcdef | 1.2.4-0.20210101120000-abcdefabcdef | 1.2.4 | does not match |
v0.0.0-20191109021931-daa7c04131f5 | 0.0.0-20191109021931-daa7c04131f5 | 0.0.0 | does not match |
In Go's own order, v1.2.4-0.<timestamp>-<commit> is a commit after v1.2.3 and before the v1.2.4 tag. Local matching compares it as 1.2.4, so an advisory fixed at 1.2.4 does not include it. That gap is real. A v0.0.0-<timestamp>-<commit> pseudo-version (no earlier tag for that major) compares as 0.0.0. It matches a range that starts at 0 and is fixed at 1.0.0. It does not match a range that starts at 1.0.0.
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.
+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.
replace and exclude
replace and exclude are not applied before the match.
- The module path and version on the
requireline are what get matched. Areplaceto a fork, another version, or a local directory does not change that target. Areplacewith norequireadds nothing. - An
excludeline does not add a module, and it does not drop a version that is also required.
The code graph records those same require paths, including lines marked // indirect, and skips replace and exclude. The graph does not store the version. Vulnerability matching still skips // indirect.
Check it locally
The two files are checked in at examples/go-pseudo-versions/. They use example.com module paths and example advisory ids. Copy them into an empty directory if you are reading this from an installed package.
go.mod:
module example.com/demo
go 1.22
require (
example.com/tagged v1.2.3
example.com/pseudo-next v1.2.4-0.20210101120000-abcdefabcdef
example.com/pseudo-base v0.0.0-20191109021931-daa7c04131f5
example.com/oldmajor v2.0.0+incompatible
example.com/replaced-mod v1.0.0
example.com/indirect-mod v1.2.3 // indirect
)
exclude example.com/excluded-mod v1.2.3
replace example.com/replaced-mod => ./local
replace example.com/fork-only => example.com/fork v1.2.3
package-versions.json:
{
"go": {
"example.com/tagged": {
"latest": "1.2.4",
"vulns": [
{
"id": "GHSA-example-tagged",
"severity": "high",
"ranges": [{ "introduced": "1.0.0", "fixed": "1.2.4" }]
}
]
},
"example.com/pseudo-next": {
"latest": "1.2.4",
"vulns": [
{
"id": "GHSA-example-pseudo-next",
"severity": "high",
"ranges": [{ "introduced": "1.0.0", "fixed": "1.2.4" }]
}
]
},
"example.com/pseudo-base": {
"latest": "1.0.0",
"vulns": [
{
"id": "GHSA-example-pseudo-base",
"severity": "moderate",
"ranges": [{ "introduced": "0", "fixed": "1.0.0" }]
},
{
"id": "GHSA-example-pseudo-explicit-v",
"severity": "low",
"versions": ["v0.0.0-20191109021931-daa7c04131f5"]
},
{
"id": "GHSA-example-pseudo-explicit-clean",
"severity": "low",
"versions": ["0.0.0-20191109021931-daa7c04131f5"]
}
]
},
"example.com/oldmajor": {
"latest": "2.1.0",
"vulns": [
{
"id": "GHSA-example-incompatible-range",
"severity": "high",
"ranges": [{ "introduced": "2.0.0", "fixed": "2.1.0" }]
},
{
"id": "GHSA-example-incompatible-v1",
"severity": "high",
"ranges": [{ "introduced": "1.0.0", "fixed": "1.9.0" }]
},
{
"id": "GHSA-example-incompatible-explicit",
"severity": "low",
"versions": ["v2.0.0+incompatible"]
}
]
},
"example.com/replaced-mod": {
"latest": "1.0.1",
"vulns": [
{
"id": "GHSA-example-replaced",
"severity": "moderate",
"ranges": [{ "introduced": "0", "fixed": "1.0.1" }]
}
]
},
"example.com/indirect-mod": {
"latest": "1.2.4",
"vulns": [
{
"id": "GHSA-example-indirect",
"severity": "high",
"ranges": [{ "introduced": "0", "fixed": "9.0.0" }]
}
]
},
"example.com/excluded-mod": {
"latest": "1.2.3",
"vulns": [
{
"id": "GHSA-example-excluded",
"severity": "high",
"ranges": [{ "introduced": "0" }]
}
]
}
}
}
From that directory:
vg scan --vulns --offline --package-manifest package-versions.json --format json --out go-vulns.json
findings[].details.advisoryId in go-vulns.json (the same ids are under extended.vulnerabilities.packages[].advisories):
| Module | Advisory id | Result |
|---|---|---|
example.com/tagged | GHSA-example-tagged | reported (1.2.3 is below 1.2.4) |
example.com/pseudo-next | GHSA-example-pseudo-next | absent (compared as 1.2.4) |
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-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) |
example.com/indirect-mod | GHSA-example-indirect | absent |
example.com/excluded-mod | GHSA-example-excluded | absent |
example.com/fork-only | — | absent (replace only, no require) |
timestamp and durationMs change between runs. The advisory ids and their order do not: ecosystem, then package name, then recorded version, then severity (critical, high, moderate, low, unknown), then advisory id. The scan also writes .vibgrate/scan_result.json beside the go.mod.
If the match looks wrong
A surprise match, or a missing one, is a bug report. Include a local repro:
- the
go.modrequireline - the advisory id and its
introduced/fixedbounds, or the explicitversionslist - the command above, run with
--offline, so the repro needs no network and no registry credential - the advisory ids from the JSON, and which row in the table you expected to differ
vg --version
Leave out tokens, .netrc contents, and private module-proxy URLs.
Exposure Attribution
In a git repository, each finding is attributed to the commit, author, and date that introduced the vulnerable version, with the days you have been exposed. Those windows roll up into CRA remediation metrics. See vg why.
In CI
vg scan --vulns --format sarif --out vibgrate.sarif --fail-on error
Upload the SARIF with the GitHub upload-sarif action to surface vulnerabilities in the Security tab.
Several ids on one advisory
One advisory often has its own id plus aliases (a GHSA, a CVE, another OSV id). SARIF writes one result per package and advisory. Aliases stay on that result. Two advisories stay two results when one lists the other as an alias, so a near-duplicate code-scanning alert is expected in that case. Match alerts on properties.advisoryId.
| Field | What it holds |
|---|---|
ruleId | vibgrate/vulnerability on every vulnerability result |
properties.advisoryId | The advisory's own id (the primary id) |
properties.aliases | The alias list, in the order the advisory supplied |
message.text | The advisory id. The first CVE- alias is added in parentheses when it is not already that id |
properties is a copy of the finding details. Drift findings come first. Vulnerability results then follow package order (ecosystem, name, version) and, within a package, severity from critical down to unknown, then advisory id. The same advisory set produces the same SARIF.
On GitHub, the maintained vibgrate/cli Action does the scan, the PR gate, and the SARIF upload in one step (the workflow needs permissions: security-events: write):
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: vibgrate/cli@v1
with:
vulns: true
fail-on: error
upload-sarif: true
category: vibgrate-vulns