Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ backward compatible.

### Added

- **`vg scan --vulns` JSON includes EPSS and KEV when the advisory already has them.** Each advisory in `extended.vulnerabilities` now carries `epss` (0–1), `epssPercentile` (0–1), and `kev` when those signals are present on the OSV advisory body or in a `--package-manifest` bundle. A missing signal is `null`, and a real score of `0` is kept — absence is never written as `0`. The scan does not fetch EPSS, including in offline / `--package-manifest` mode. Text and SARIF findings are unchanged.

- **`vg sbom export` now reports the full resolved dependency tree, not just
direct manifest deps.** SBOM export previously flattened only the packages
a project's manifest scan sees — the names typed into `package.json` (or
Expand Down
10 changes: 10 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,16 @@ Expected results:

`vg scan --vulns` matches your installed dependencies against the public OSV database and records each known vulnerability — advisory id and CVE, severity, CVSS, and the fixing version — in the scan artifact, as findings, and in SARIF. Supply advisories in a `--package-manifest` bundle to run it offline.

When that advisory data already carries exploitability signals, each advisory object in `extended.vulnerabilities` (the `.vibgrate/scan_result.json` artifact and `--format json`) includes them:

| Field | JSON |
| --- | --- |
| `epss` | FIRST [EPSS](https://vibgrate.com/glossary/epss) probability of exploitation within 30 days, from 0 to 1. `null` when the source did not carry a score. A real score of `0` is kept; a missing score is never written as `0`. |
| `epssPercentile` | EPSS percentile, from 0 to 1, or `null`. |
| `kev` | `true` or `false` when the source recorded a [CISA KEV](https://vibgrate.com/glossary/kev) listing. `null` when it did not say — that is not the same as "not listed". |

The scan copies these from data it already has. On an OSV advisory that is the advisory body (`epss`, `epssPercentile` or `epss_percentile`, `kev` or `cisa_kev`, including a nested `exploitability` object). On a package-version manifest it is the same fields on each `vulns` entry. Offline and `--package-manifest` scans do not call out for EPSS. Text output and SARIF keep the existing severity, CVSS, and fix-version finding; they do not add these fields. Advisory order is unchanged: worst severity first, then id.

In a git repository the scan also attributes each finding: the commit, author, and date that introduced the vulnerable version, and how long you have been exposed. These exposure windows aggregate into remediation metrics framed around the [EU Cyber Resilience Act (CRA)](https://vibgrate.com/compliance/cra): open counts by severity, mean and maximum time exposed, and per-severity SLA breaches (defaults: critical 7 days, high 30, moderate 90, low 180). The metrics are descriptive — they show whether remediation keeps pace; they are not a compliance certification.

The scan also reconstructs **closed** exposure windows from history — a vulnerable version that was later bumped out of the affected range or removed from the lockfile entirely — and reports real remediation time (MTTR) from them: measured, not estimated. Offline, a package-version manifest extends this to advisories that are fully fixed today, so a dependency that is clean now but was once vulnerable still counts toward your remediation record.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ One scan gives you:

## Find known vulnerabilities and who introduced them

`vg scan --vulns` checks your installed dependencies against the public [OSV](https://vibgrate.com/glossary/osv) database and reports each known vulnerability with its severity, CVSS score, and the version that fixes it — as text, JSON, or SARIF. Add `--package-manifest` to run it fully offline from a local advisory bundle.
`vg scan --vulns` checks your installed dependencies against the public [OSV](https://vibgrate.com/glossary/osv) database and reports each known vulnerability with its severity, CVSS score, and the version that fixes it — as text, JSON, or SARIF. When the advisory or an offline `--package-manifest` bundle already includes an [EPSS](https://vibgrate.com/glossary/epss) score, percentile, or [CISA KEV](https://vibgrate.com/glossary/kev) flag, the JSON artifact records `epss`, `epssPercentile`, and `kev` (`null` when absent, never coerced to `0`). The scan does not fetch EPSS. Add `--package-manifest` to run it fully offline from a local advisory bundle.

```bash
vg scan --vulns # drift score + known vulnerabilities
Expand Down
22 changes: 22 additions & 0 deletions src/core-open/package-version-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ export interface ManifestAdvisory {
severity?: 'low' | 'moderate' | 'high' | 'critical' | 'unknown';
cvss?: number;
cvssVector?: string;
/**
* FIRST EPSS probability (0–1) when this bundle already carries one.
* Omit when unknown. A value of 0 is a real score — do not use it for "absent".
*/
epss?: number | null;
/** EPSS percentile (0–1), when the bundle carries one. Same absence rule as `epss`. */
epssPercentile?: number | null;
/**
* CISA Known Exploited Vulnerabilities flag, when the bundle recorded it.
* `true` or `false` are explicit; omit when unknown (that is not the same as `false`).
*/
kev?: boolean | null;
/**
* Same signals grouped under one object (`epss`, `epssPercentile` or `percentile`, `kev`).
* Flat fields above win when both are set.
*/
exploitability?: {
epss?: number | null;
epssPercentile?: number | null;
percentile?: number | null;
kev?: boolean | null;
};
/** Affected semver ranges as [introduced, fixed) pairs (either bound optional). */
ranges?: Array<{ introduced?: string; fixed?: string }>;
/** Explicit affected versions, as an alternative/complement to `ranges`. */
Expand Down
107 changes: 103 additions & 4 deletions src/core-open/scanners/vulnerability-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,88 @@ export function isVersionAffected(
return false;
}

/**
* A 0–1 exploitability score from an advisory source.
* Numbers and decimal strings in range are kept, including 0.
* Anything else (missing, blank, non-numeric, out of range) is null — never 0.
*/
export function unitIntervalOrNull(value: unknown): number | null {
let n: number;
if (typeof value === 'number') {
n = value;
} else if (typeof value === 'string') {
const trimmed = value.trim();
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(trimmed)) return null;
n = Number(trimmed);
} else {
return null;
}
if (!Number.isFinite(n) || n < 0 || n > 1) return null;
return n;
}

function booleanOrNull(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}

/** Fields an advisory body may use for EPSS / KEV. Read-only; nothing here is fetched. */
interface ExploitabilityCarrier {
epss?: unknown;
epssPercentile?: unknown;
epss_percentile?: unknown;
percentile?: unknown;
kev?: unknown;
cisaKev?: unknown;
cisa_kev?: unknown;
exploitability?: ExploitabilityCarrier;
}

/**
* Copy EPSS / KEV off advisory data the scan already holds.
* The first explicit value wins (flat fields before a nested `exploitability`
* object, earlier sources before later ones). Missing and invalid values stay
* null so a later source can still supply a real score, including 0.
*/
function readExploitability(...values: unknown[]): { epss: number | null; epssPercentile: number | null; kev: boolean | null } {
const ranked: Array<{ source: ExploitabilityCarrier; percentileAlias: boolean }> = [];
const push = (value: unknown, percentileAlias: boolean): void => {
if (!value || typeof value !== 'object') return;
const source = value as ExploitabilityCarrier;
ranked.push({ source, percentileAlias });
if (source.exploitability && typeof source.exploitability === 'object') {
ranked.push({ source: source.exploitability, percentileAlias: true });
}
};
for (const value of values) push(value, false);

let epss: number | null = null;
let epssPercentile: number | null = null;
let kev: boolean | null = null;
for (const { source, percentileAlias } of ranked) {
if (epss == null) {
const score = unitIntervalOrNull(source.epss);
if (score != null) epss = score;
}
if (epssPercentile == null) {
const candidates = [source.epssPercentile, source.epss_percentile];
if (percentileAlias) candidates.push(source.percentile);
for (const candidate of candidates) {
if (candidate === undefined) continue;
const score = unitIntervalOrNull(candidate);
if (score != null) {
epssPercentile = score;
break;
}
}
}
if (kev == null) {
const flag = booleanOrNull(source.kev ?? source.cisaKev ?? source.cisa_kev);
if (flag != null) kev = flag;
}
}
return { epss, epssPercentile, kev };
}

// ── OSV advisory parsing (pure) ──────────────────────────────────────────────

interface RawOsvSeverity {
Expand All @@ -184,10 +266,10 @@ interface RawOsvAffected {
package?: { ecosystem?: string; name?: string };
ranges?: Array<{ type?: string; events?: RawOsvRangeEvent[] }>;
versions?: string[];
ecosystem_specific?: { severity?: string };
database_specific?: { severity?: string };
ecosystem_specific?: { severity?: string } & ExploitabilityCarrier;
database_specific?: { severity?: string } & ExploitabilityCarrier;
}
interface RawOsvVuln {
interface RawOsvVuln extends ExploitabilityCarrier {
id?: string;
aliases?: string[];
summary?: string;
Expand All @@ -196,7 +278,7 @@ interface RawOsvVuln {
severity?: RawOsvSeverity[];
affected?: RawOsvAffected[];
references?: Array<{ url?: string }>;
database_specific?: { severity?: string };
database_specific?: { severity?: string } & ExploitabilityCarrier;
}

/** Parse a raw OSV advisory into our shape, scoped to a specific package name. */
Expand All @@ -212,6 +294,14 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab
matchingAffected.map((a) => a.ecosystem_specific?.severity ?? a.database_specific?.severity).find(Boolean) ??
null;
const severity: VulnSeverity = cvss != null ? severityFromCvss(cvss) : normalizeSeverityLabel(qualitative);
// EPSS/KEV live on the advisory body when a corpus already attached them.
// No separate EPSS request — offline scans never phone home for these.
const exploitability = readExploitability(
raw,
raw.database_specific,
...matchingAffected.map((affected) => affected.database_specific),
...matchingAffected.map((affected) => affected.ecosystem_specific),
);

const fixedVersions: string[] = [];
const affectedRanges: AffectedRange[] = [];
Expand Down Expand Up @@ -245,6 +335,9 @@ export function parseOsvAdvisory(raw: RawOsvVuln, packageName: string): Vulnerab
severity,
cvss,
cvssVector,
epss: exploitability.epss,
epssPercentile: exploitability.epssPercentile,
kev: exploitability.kev,
fixedVersions,
published: raw.published ?? null,
withdrawn: raw.withdrawn ?? null,
Expand All @@ -261,13 +354,17 @@ export function manifestAdvisoryToAdvisory(m: ManifestAdvisory): VulnerabilityAd
: cvss != null
? severityFromCvss(cvss)
: 'unknown';
const exploitability = readExploitability(m);
return {
id: m.id,
aliases: m.aliases ?? [],
summary: m.summary ?? null,
severity,
cvss,
cvssVector: m.cvssVector ?? null,
epss: exploitability.epss,
epssPercentile: exploitability.epssPercentile,
kev: exploitability.kev,
fixedVersions: (m.ranges ?? []).map((r) => r.fixed).filter((f): f is string => Boolean(f)),
published: m.published ?? null,
withdrawn: m.withdrawn ?? null,
Expand Down Expand Up @@ -408,6 +505,8 @@ export async function scanVulnerabilities(
if (targets.length === 0) return emptyResult();

// Offline / air-gapped: advisories come only from the manifest.
// EPSS and KEV are copied from those entries when present; this path does
// not fetch them.
if (opts.offline) {
return scanFromManifest(targets, opts.manifest);
}
Expand Down
17 changes: 17 additions & 0 deletions src/core-open/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,23 @@ export interface VulnerabilityAdvisory {
cvss: number | null;
/** Raw CVSS vector string, when the advisory carried one. */
cvssVector: string | null;
/**
* FIRST EPSS probability of exploitation in the wild within 30 days (0–1).
* `null` when the advisory source did not carry a score. Absence is never
* coerced to 0 — a stored 0 is a real score. Copied from data the scan
* already has (the advisory body or a `--package-manifest` bundle); the
* scan does not fetch EPSS.
*/
epss?: number | null;
/**
* EPSS percentile (0–1) when the source carried one. Same absence rule as `epss`.
*/
epssPercentile?: number | null;
/**
* CISA Known Exploited Vulnerabilities listing, when the source recorded it.
* `null` means the source did not say — not the same as `false`.
*/
kev?: boolean | null;
/** First fixed version per affected range (empty when no fix is published). */
fixedVersions: string[];
/** ISO-8601 publish date, when known. */
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { loadOrDiscoverFederation } from '../runtime/federation.js';
import { highConfidenceBridges } from '../runtime/bridge-edges.js';
import { repositoryIdFromRoot } from '../runtime/paths.js';
import { parseGraph } from '../engine/serialize.js';
import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories } from './vuln-data.js';
import { loadVulnerabilities, filterBySeverity, resolvePackageTarget, openFixableAdvisories, advisoryExploitability } from './vuln-data.js';
import { attributedInventory } from './attribution.js';
import { computeUpgradeImpact, getChangelogSignals, type VulnSeverity } from '../core-open/index.js';
import { discoverModels } from '../engine/models.js';
Expand Down Expand Up @@ -792,6 +792,7 @@ export const TOOLS: VgTool[] = [
cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null,
severity: a.severity,
cvss: a.cvss,
...advisoryExploitability(a),
exposureDays: a.exposureDays ?? null,
introduced: a.introduced ?? null,
fixedVersions: a.fixedVersions,
Expand All @@ -802,7 +803,7 @@ export const TOOLS: VgTool[] = [
},
{
name: 'list_vulnerabilities',
description: 'Known vulnerabilities from the last `vg scan --vulns`: id/CVE, severity, CVSS, fixed version.',
description: 'Known vulnerabilities from the last `vg scan --vulns`: id/CVE, severity, CVSS, EPSS and KEV when the scan recorded them (null if absent, never 0), fixed version.',
inputSchema: obj(
{ severity: { type: 'string', enum: ['low', 'moderate', 'high', 'critical'], description: 'minimum severity' } },
[],
Expand Down Expand Up @@ -831,6 +832,7 @@ export const TOOLS: VgTool[] = [
cve: a.aliases.find((x) => x.startsWith('CVE-')) ?? null,
severity: a.severity,
cvss: a.cvss,
...advisoryExploitability(a),
fixedVersions: a.fixedVersions,
summary: a.summary,
})),
Expand Down
16 changes: 16 additions & 0 deletions src/mcp/vuln-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ export function loadVulnerabilities(root: string): VulnerabilityScanResult | nul
return readScanArtifact(root)?.extended?.vulnerabilities ?? null;
}

/**
* EPSS / KEV for machine-readable vuln JSON.
* A missing key and an explicit null both mean "not supplied". A numeric 0 is kept.
*/
export function advisoryExploitability(advisory: {
epss?: number | null;
epssPercentile?: number | null;
kev?: boolean | null;
}): { epss: number | null; epssPercentile: number | null; kev: boolean | null } {
return {
epss: advisory.epss ?? null,
epssPercentile: advisory.epssPercentile ?? null,
kev: advisory.kev ?? null,
};
}

/** The drift target for a package: ecosystem + installed/latest versions. */
export interface PackageTarget {
ecosystem: VulnEcosystem | 'unknown';
Expand Down
35 changes: 30 additions & 5 deletions test/scan-offline-network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,17 @@ describe('offline scan network boundary', () => {
{
id: 'GHSA-offline-fixture',
summary: 'Fixture advisory for the offline path',
severity: 'moderate',
severity: 'high',
epss: 0.42,
epssPercentile: 0.91,
kev: true,
ranges: [{ introduced: '0', fixed: '1.3.1' }],
},
{
id: 'GHSA-offline-absent',
summary: 'Fixture advisory with no exploitability data',
severity: 'low',
epss: 0,
ranges: [{ introduced: '0', fixed: '1.3.1' }],
},
],
Expand Down Expand Up @@ -90,11 +100,26 @@ describe('offline scan network boundary', () => {
expect(fetchTripwire).not.toHaveBeenCalled();
expect(fs.existsSync(reportPath)).toBe(true);
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as {
findings: Array<{ ruleId?: string; message?: string }>;
extended?: { vulnerabilities?: { source?: string; totalAdvisories?: number } };
findings: Array<{ ruleId?: string; message?: string; details?: Record<string, unknown> }>;
extended?: {
vulnerabilities?: {
source?: string;
totalAdvisories?: number;
packages?: Array<{ advisories: Array<{ id: string; epss: number | null; epssPercentile: number | null; kev: boolean | null }> }>;
};
};
};
expect(report.extended?.vulnerabilities).toMatchObject({ source: 'manifest', totalAdvisories: 1 });
expect(report.findings.some((finding) => finding.message?.includes('GHSA-offline-fixture'))).toBe(true);
expect(report.extended?.vulnerabilities).toMatchObject({ source: 'manifest', totalAdvisories: 2 });
const advisories = report.extended?.vulnerabilities?.packages?.[0]?.advisories ?? [];
// Severity order, then id — EPSS does not reorder findings.
expect(advisories.map((advisory) => advisory.id)).toEqual(['GHSA-offline-fixture', 'GHSA-offline-absent']);
expect(advisories[0]).toMatchObject({ epss: 0.42, epssPercentile: 0.91, kev: true });
// A real EPSS of 0 is kept. The percentile and KEV flag were not supplied, so they stay null.
expect(advisories[1]).toMatchObject({ epss: 0, epssPercentile: null, kev: null });
const finding = report.findings.find((item) => item.message?.includes('GHSA-offline-fixture'));
expect(finding?.message).not.toMatch(/EPSS|epss/);
expect(finding?.details).not.toHaveProperty('epss');
expect(finding?.details).not.toHaveProperty('kev');
expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(true);
});
});
Loading
Loading