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
4 changes: 4 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ vg report [--in <file>] [--format md|text|json]
| `--in` | `.vibgrate/scan_result.json` | Input artifact file |
| `--format` | `text` | Output format: `md`, `text`, or `json` |

Text output prints a `fix available:` hint on any finding whose artifact already includes a fixed version or remediation. Findings without that metadata omit the hint.

---


Expand Down Expand Up @@ -1135,6 +1137,8 @@ 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.

The default text report (`vg scan`, and `vg report --format text`) prints a `fix available:` hint under a finding when the artifact already carries a fixed version or a remediation string. Findings without that metadata omit the hint. The line is not a lookup: it only restates data the scan already recorded.

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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ One scan gives you:

`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.

Text output from `vg scan` and `vg report` adds a `fix available:` line when that fixing version, or another remediation already stored on the finding, is present. If the scan has no fix metadata, the line is left out.

```bash
vg scan --vulns # drift score + known vulnerabilities
vg scan --full # drift + vulnerabilities + a banned-dependency report
Expand Down
4 changes: 3 additions & 1 deletion src/commands/why.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { lineProvenance, sessionTitle, TRAILER, turnsTouching } from '../review/
import { lookupProvenance, repoIdentity, type CloudLookup } from '../review/provenance-cloud.js';
import { cloudDsn } from '../review/doc-comments.js';
import { buildVersionTimelines, findPackageAnyEcosystem, gitHistoryAvailable } from '../core-open/index.js';
import { fixAvailableHint } from '../core-open/formatters/fix-available.js';
import { readScanArtifact } from '../mcp/vuln-data.js';
import { applyGlobalOptions, readGlobal } from '../cli-options.js';
import { rootOf } from './util.js';
Expand Down Expand Up @@ -76,7 +77,8 @@ export function registerWhy(program: Command): void {
const cve = adv.aliases.find((a) => a.startsWith('CVE-'));
const idLabel = cve && cve !== adv.id ? `${adv.id} (${cve})` : adv.id;
const cvss = adv.cvss != null ? ` cvss ${adv.cvss}` : '';
const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available';
const hint = fixAvailableHint({ fixedVersions: adv.fixedVersions });
const fixed = hint ? ` — ${hint}` : '';
info(` ${severityTag(adv.severity)} ${idLabel}${c.dim(cvss)}${c.dim(fixed)}`);
if (adv.introduced) {
const exposure = adv.exposureDays != null ? `, ${adv.exposureDays}d exposed` : '';
Expand Down
178 changes: 178 additions & 0 deletions src/core-open/formatters/fix-available.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { readFileSync } from 'node:fs';
import { stripVTControlCharacters } from 'node:util';
import { describe, expect, it } from 'vitest';
import { formatText as formatScanText } from './text.js';
import { formatText as formatReportText } from '../../reporting/formatters/text.js';
import { fixAvailableHint, presentFinding } from './fix-available.js';
import { generateVulnerabilityFindings } from '../scanners/vulnerability-scanner.js';
import type { ScanArtifact, VulnerabilityScanResult } from '../types.js';

const fixture = JSON.parse(
readFileSync(new URL('../../../test/fixtures/fix-available-findings.json', import.meta.url), 'utf8'),
) as {
withFix: ScanArtifact['findings'][number];
withoutFix: ScanArtifact['findings'][number];
remediationOnly: ScanArtifact['findings'][number];
};

function artifact(findings: ScanArtifact['findings']): ScanArtifact {
return {
schemaVersion: '1.0',
timestamp: '2026-02-16T00:00:00.000Z',
vibgrateVersion: '0.0.0-test',
rootPath: '/fixture',
projects: [],
drift: {
score: 10,
riskLevel: 'low',
components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 },
measured: ['runtime', 'framework', 'dependency', 'eol'],
},
findings,
};
}

describe('fixAvailableHint', () => {
it('names published fixed versions in payload order and drops duplicates', () => {
expect(fixAvailableHint({ fixedVersions: ['4.17.21', '4.17.21', '5.0.0'] })).toBe(
'fix available: 4.17.21, 5.0.0',
);
expect(fixAvailableHint({ fixed_versions: ['1.2.3-beta.1', '2.0.0'] })).toBe(
'fix available: 1.2.3-beta.1, 2.0.0',
);
});

it('uses a remediation string when no version is known', () => {
expect(fixAvailableHint({ fixedVersions: [], remediation: 'replace with node:util' })).toBe(
'fix available: replace with node:util',
);
expect(fixAvailableHint({ fix: 'upgrade the lockfile' })).toBe('fix available: upgrade the lockfile');
});

it('prefers versions over a remediation string', () => {
expect(fixAvailableHint({ fixedVersions: ['9.0.0'], remediation: 'upgrade' })).toBe('fix available: 9.0.0');
});

it('returns null when the fix is unknown', () => {
expect(fixAvailableHint(undefined)).toBeNull();
expect(fixAvailableHint({})).toBeNull();
expect(fixAvailableHint({ fixedVersions: [] })).toBeNull();
expect(fixAvailableHint({ fixedVersions: ['', ' '] })).toBeNull();
expect(fixAvailableHint({ remediation: 'no fix available' })).toBeNull();
expect(fixAvailableHint({ remediation: 'none' })).toBeNull();
expect(fixAvailableHint({ fix: 'unknown' })).toBeNull();
expect(fixAvailableHint({ fixAvailable: false })).toBeNull();
expect(fixAvailableHint({ fixAvailable: true })).toBeNull();
});

it('is deterministic for the same details', () => {
const details = { patchedVersions: ['2.0.0', '1.5.0'], fixedVersion: '2.0.0' };
expect(fixAvailableHint(details)).toBe(fixAvailableHint(details));
expect(fixAvailableHint(details)).toBe('fix available: 2.0.0, 1.5.0');
});
});

describe('presentFinding', () => {
it('replaces a baked-in fixed-in clause with the structured hint', () => {
const presented = presentFinding(fixture.withFix);
expect(presented.fixHint).toBe('fix available: 4.17.21');
expect(presented.message).toBe(
'lodash@4.17.20: GHSA-35jh-r3h4-6jhm (CVE-2021-23337) (high 7.2) — introduced by Ada Lovelace in abc1234 (12d exposed)',
);
expect(presented.message).not.toMatch(/no fix/i);
expect(presented.message).not.toMatch(/fixed in/i);
});

it('drops an invented no-fix claim and omits the hint', () => {
const presented = presentFinding(fixture.withoutFix);
expect(presented.fixHint).toBeNull();
expect(presented.message).toBe(
'minimist@1.2.5: GHSA-vh95-rmgr-6w4m (moderate 5.6) — introduced by Grace Hopper in def5678 (3d exposed)',
);
expect(presented.message).not.toMatch(/no fix/i);
expect(`${presented.message} ${presented.fixHint ?? ''}`).not.toMatch(/fix available/i);
});

it('keeps a prerelease version intact when stripping the prose clause', () => {
const presented = presentFinding({
message: 'left-pad@1.0.0: GHSA-x (low) — fixed in 1.2.3-beta.1 — introduced by Ada in abc',
details: { fixedVersions: ['1.2.3-beta.1'] },
});
expect(presented.fixHint).toBe('fix available: 1.2.3-beta.1');
expect(presented.message).toBe('left-pad@1.0.0: GHSA-x (low) — introduced by Ada in abc');
});

it('surfaces remediation text and still drops a no-fix claim', () => {
const presented = presentFinding(fixture.remediationOnly);
expect(presented.fixHint).toBe('fix available: replace with the built-in node:util debug API');
expect(presented.message).toBe('debug@2.6.9: GHSA-example (low)');
expect(presented.message).not.toMatch(/no fix/i);
});
});

describe('human scan and report text', () => {
const sample = artifact([fixture.withFix, fixture.withoutFix, fixture.remediationOnly]);

it.each([
['scan', formatScanText],
['report', formatReportText as (artifact: ScanArtifact) => string],
])('%s text shows a fix hint only when the payload has one', (_label, format) => {
const once = stripVTControlCharacters(format(sample));
const twice = stripVTControlCharacters(format(sample));
expect(twice).toBe(once);

expect(once).toContain('fix available: 4.17.21');
expect(once).toContain('fix available: replace with the built-in node:util debug API');
expect(once).toContain('lodash@4.17.20: GHSA-35jh-r3h4-6jhm (CVE-2021-23337) (high 7.2) — introduced by Ada Lovelace in abc1234 (12d exposed)');
expect(once).toContain('minimist@1.2.5: GHSA-vh95-rmgr-6w4m (moderate 5.6) — introduced by Grace Hopper in def5678 (3d exposed)');
expect(once).not.toMatch(/no fix/i);
expect(once).not.toMatch(/fixed in/i);
expect(once.match(/fix available:/g)).toHaveLength(2);
});
});

describe('generateVulnerabilityFindings', () => {
const base = {
id: 'GHSA-1',
aliases: ['CVE-2024-1'],
summary: null,
severity: 'high' as const,
cvss: 7.5,
cvssVector: null,
published: null,
withdrawn: null,
references: [],
};

function result(fixedVersions: string[]): VulnerabilityScanResult {
return {
source: 'manifest',
totalAdvisories: 1,
severityCounts: { low: 0, moderate: 0, high: 1, critical: 0, unknown: 0 },
packages: [
{
ecosystem: 'npm',
package: 'lodash',
version: '4.17.20',
advisories: [{ ...base, fixedVersions }],
},
],
};
}

it('keeps a fixed version in the message and in details', () => {
const [finding] = generateVulnerabilityFindings(result(['4.17.21']));
expect(finding.message).toContain('fixed in 4.17.21');
expect(finding.message).not.toMatch(/no fix/i);
expect(finding.details?.fixedVersions).toEqual(['4.17.21']);
expect(presentFinding(finding).fixHint).toBe('fix available: 4.17.21');
});

it('omits any fix claim when no fixed version is published', () => {
const [finding] = generateVulnerabilityFindings(result([]));
expect(finding.message).toBe('lodash@4.17.20: GHSA-1 (CVE-2024-1) (high 7.5)');
expect(finding.message).not.toMatch(/no fix/i);
expect(finding.message).not.toMatch(/fix available/i);
expect(presentFinding(finding).fixHint).toBeNull();
});
});
99 changes: 99 additions & 0 deletions src/core-open/formatters/fix-available.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Human "fix available" hints for `vg scan` / `vg report` text.
*
* The hint is derived only from fix or remediation metadata already on the
* finding. When that metadata is absent, callers omit the hint — they must
* not invent a "no fix" claim.
*/

/** A finding message plus the optional hint to print under it. */
export interface FindingPresentation {
message: string;
/** `fix available: …`, or null when the payload has no known fix. */
fixHint: string | null;
}

const VERSION_LIST_KEYS = ['fixedVersions', 'fixed_versions', 'patchedVersions', 'patched_versions'] as const;
const VERSION_KEYS = ['fixedVersion', 'fixed_version', 'patchedVersion', 'patched_version'] as const;
const TEXT_KEYS = ['remediation', 'fix'] as const;

/** Dash that starts a clause in a finding message (em, en, or a spaced hyphen). */
const CLAUSE_DASH = String.raw`(?:—|–|\s+-\s+)`;

/** Baked-in "no fix" claim. Stripped from human text; never a real signal. */
const NO_FIX_CLAUSE = new RegExp(String.raw`\s*${CLAUSE_DASH}\s*no fix(?: available)?\b`, 'gi');

/**
* Prose "fixed in <versions>" clause. Dropped from human text only when a
* structured version hint replaces it, so the version is not printed twice.
* Stops at the next clause (for example attribution) and keeps hyphens that
* belong to a version (`1.2.3-beta.1`).
*/
const FIXED_IN_CLAUSE = new RegExp(
String.raw`\s*${CLAUSE_DASH}\s*fixed in\s+.*?(?=\s*${CLAUSE_DASH}|$)`,
'gi',
);

const UNKNOWN_TEXT = /^(?:no fix(?: available)?|none|unknown|n\/a|null|undefined|-)$/i;

function pushVersion(out: string[], value: unknown): void {
if (typeof value !== 'string') return;
const trimmed = value.trim();
if (!trimmed || UNKNOWN_TEXT.test(trimmed) || out.includes(trimmed)) return;
out.push(trimmed);
}

function collectVersions(details: Record<string, unknown>): string[] {
const out: string[] = [];
for (const key of VERSION_LIST_KEYS) {
const value = details[key];
if (Array.isArray(value)) {
for (const item of value) pushVersion(out, item);
} else {
pushVersion(out, value);
}
}
for (const key of VERSION_KEYS) pushVersion(out, details[key]);
return out;
}

function remediationText(details: Record<string, unknown>): string | null {
for (const key of TEXT_KEYS) {
const value = details[key];
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed || UNKNOWN_TEXT.test(trimmed)) continue;
return trimmed;
}
return null;
}

/**
* `fix available: …` when `details` already names a fixed version or a
* remediation. Null when the fix is unknown — including an empty list, a
* false flag, or a placeholder such as "none".
*
* Version order is the payload's order (first list key, then single-version
* keys), with duplicates removed. Identical details therefore render the
* same hint.
*/
export function fixAvailableHint(details: Record<string, unknown> | undefined): string | null {
if (!details) return null;
const versions = collectVersions(details);
if (versions.length > 0) return `fix available: ${versions.join(', ')}`;
const text = remediationText(details);
if (text) return `fix available: ${text}`;
return null;
}

/** Human message plus hint. Does not mutate the finding. */
export function presentFinding(finding: { message: string; details?: Record<string, unknown> }): FindingPresentation {
const versions = finding.details ? collectVersions(finding.details) : [];
const fixHint = fixAvailableHint(finding.details);
let message = finding.message.replace(NO_FIX_CLAUSE, '');
// A version hint replaces the prose "fixed in …" clause. A remediation
// string does not, because that clause may be the only copy of the version.
if (versions.length > 0) message = message.replace(FIXED_IN_CLAUSE, '');
message = message.replace(/[ \t]{2,}/g, ' ').trim();
return { message, fixHint };
}
5 changes: 4 additions & 1 deletion src/core-open/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// and re-run the vendor script. Apache-2.0.
import chalk from 'chalk';
import type { ScanArtifact, BillingSummary, ExtendedScanResults, InventoryItem, ServiceDependencyItem, ArchitectureResult, SecurityFinding, SecuritySection } from '../types.js';
import { presentFinding } from './fix-available.js';
import { driftBar } from '../ui/bar.js';
import { titleBox, panelBox } from '../ui/box.js';

Expand Down Expand Up @@ -116,7 +117,9 @@ export function formatText(artifact: ScanArtifact, opts: FormatTextOptions = {})
lines.push(chalk.bold.underline(` Findings`) + chalk.dim(` (${summary})`));
for (const f of artifact.findings) {
const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
lines.push(` ${icon} ${f.message}`);
const presented = presentFinding(f);
lines.push(` ${icon} ${presented.message}`);
if (presented.fixHint) lines.push(chalk.dim(` ${presented.fixHint}`));
lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`));
}
lines.push('');
Expand Down
5 changes: 4 additions & 1 deletion src/core-open/scanners/vulnerability-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,10 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult):
for (const adv of pkg.advisories) {
const cve = adv.aliases.find((a) => a.startsWith('CVE-'));
const idLabel = cve && cve !== adv.id ? `${adv.id} (${cve})` : adv.id;
const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : ' — no fix available';
// Keep a published fixed version in the message for JSON/SARIF readers.
// Omit the clause when none is known — never claim "no fix". Human text
// prints `fix available:` from `details` instead of this prose.
const fixed = adv.fixedVersions.length ? ` — fixed in ${adv.fixedVersions.join(', ')}` : '';
const cvssLabel = adv.cvss != null ? ` ${adv.cvss}` : '';
const attribution =
adv.introduced != null
Expand Down
5 changes: 4 additions & 1 deletion src/reporting/formatters/text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import type { ScanArtifact, ExtendedScanResults, InventoryItem, ServiceDependencyItem, ArchitectureResult } from '../types.js';
import { presentFinding } from '../../core-open/formatters/fix-available.js';
import { VERSION } from '../version.js';
import { driftBar } from '../../core-open/ui/bar.js';
import { titleBox } from '../../core-open/ui/box.js';
Expand Down Expand Up @@ -87,7 +88,9 @@ export function formatText(artifact: ScanArtifact): string {
lines.push(chalk.bold.underline(` Findings`) + chalk.dim(` (${summary})`));
for (const f of artifact.findings) {
const icon = f.level === 'error' ? chalk.red('✖') : f.level === 'warning' ? chalk.yellow('⚠') : chalk.blue('ℹ');
lines.push(` ${icon} ${f.message}`);
const presented = presentFinding(f);
lines.push(` ${icon} ${presented.message}`);
if (presented.fixHint) lines.push(chalk.dim(` ${presented.fixHint}`));
lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`));
}
lines.push('');
Expand Down
Loading
Loading