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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ backward compatible.

### Changed

- **`vg scan` and `vg report` text output shows when a fix is already known.**
A finding whose payload includes a fixed version (`fixedVersions` /
`fixedVersion`) or a `remediation` string is followed by `fix available: …`.
The hint is omitted when that metadata is absent — the report does not
claim "no fix", and it does not look anything up. `vg why` uses the same
rule for advisory lines.

- **`vg show chart` is now `vg show arch`.** The local interactive map of the
code graph takes the Architecture module's public name — it is the map that
paints roles, purposes and boundary-rule breaks from `graph.arch.json` when
Expand Down
4 changes: 3 additions & 1 deletion 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 follows each finding that already carries a fixed version or remediation with a `fix available: …` line. When that metadata is absent, the line is omitted — the report does not claim there is no fix.

---


Expand Down Expand Up @@ -2848,7 +2850,7 @@ The default output. A coloured, human-readable report showing:
- Overall drift score and risk level
- Score component breakdown with visual bars
- Per-project details: runtime lag, framework versions, dependency distribution
- Findings with severity icons
- Findings with severity icons. A finding that already includes a fixed version or remediation is followed by `fix available: …`. The hint is omitted when that metadata is absent.

### JSON Artifact

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-hint.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
64 changes: 64 additions & 0 deletions src/core-open/formatters/fix-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Human-readable "fix available" hints for scan and report text.
*
* Reads fix / remediation fields already present on a finding. Does not look
* anything up. When those fields are missing or empty the hint is null —
* callers omit the line rather than claiming there is no fix.
*/

const NO_FIX_SUFFIX = / — no fix available$/;

/** Drop a stored "no fix" claim from a finding message before human display. */
export function findingDisplayMessage(message: string): string {
return message.replace(NO_FIX_SUFFIX, '');
}

/**
* Concise hint when the finding payload already names a fixed version or a
* remediation. `null` when that metadata is absent — never "no fix".
*
* Version order follows the payload (already deterministic for a given scan).
*/
export function fixAvailableHint(details: Record<string, unknown> | undefined | null): string | null {
if (!details || typeof details !== 'object') return null;

const versions = readVersions(details);
if (versions.length > 0) return `fix available: ${versions.join(', ')}`;

const remediation = readRemediation(details.remediation);
if (remediation) return `fix available: ${remediation}`;

return null;
}

/** Message plus optional hint line for the default human text path. */
export function presentFinding(finding: {
message: string;
details?: Record<string, unknown> | null;
}): { message: string; hint: string | null } {
return {
message: findingDisplayMessage(finding.message),
hint: fixAvailableHint(finding.details),
};
}

function readVersions(details: Record<string, unknown>): string[] {
const raw = details.fixedVersions ?? details.fixedVersion;
const list = Array.isArray(raw) ? raw : raw != null ? [raw] : [];
const out: string[] = [];
const seen = new Set<string>();
for (const item of list) {
if (typeof item !== 'string') continue;
const version = item.trim();
if (!version || seen.has(version)) continue;
seen.add(version);
out.push(version);
}
return out;
}

function readRemediation(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const text = raw.trim().replace(/\s+/g, ' ');
return text.length > 0 ? text : null;
}
5 changes: 4 additions & 1 deletion src/core-open/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from 'chalk';
import type { ScanArtifact, BillingSummary, ExtendedScanResults, InventoryItem, ServiceDependencyItem, ArchitectureResult, SecurityFinding, SecuritySection } from '../types.js';
import { driftBar } from '../ui/bar.js';
import { titleBox, panelBox } from '../ui/box.js';
import { presentFinding } from './fix-hint.js';

/**
* Format a billable project-equivalent figure to at most 2 decimal places,
Expand Down Expand Up @@ -116,8 +117,10 @@ 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}`);
lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`));
if (presented.hint) lines.push(chalk.green(` ${presented.hint}`));
}
lines.push('');
}
Expand Down
5 changes: 3 additions & 2 deletions src/core-open/scanners/vulnerability-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ 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';
// Fix versions stay on `details.fixedVersions`. Human text prints
// "fix available: …" from that field and omits the line when it is empty.
const cvssLabel = adv.cvss != null ? ` ${adv.cvss}` : '';
const attribution =
adv.introduced != null
Expand All @@ -506,7 +507,7 @@ export function generateVulnerabilityFindings(result: VulnerabilityScanResult):
findings.push({
ruleId: VULN_RULE_ID,
level: levelForSeverity(adv.severity),
message: `${pkg.package}@${pkg.version}: ${idLabel} (${adv.severity}${cvssLabel})${fixed}${attribution}`,
message: `${pkg.package}@${pkg.version}: ${idLabel} (${adv.severity}${cvssLabel})${attribution}`,
location: pkg.package,
details: {
ecosystem: pkg.ecosystem,
Expand Down
140 changes: 140 additions & 0 deletions src/reporting/formatters/fix-available.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { formatText as formatScanText } from '../../core-open/formatters/text.js';
import { fixAvailableHint, presentFinding } from '../../core-open/formatters/fix-hint.js';
import { generateVulnerabilityFindings } from '../../core-open/scanners/vulnerability-scanner.js';
import type { VulnerabilityAdvisory, VulnerabilityScanResult } from '../../core-open/types.js';
import type { ScanArtifact } from '../types.js';
import { formatText as formatReportText } from './text.js';

const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '');

const FIXTURE_PATH = new URL('../../../test/fixtures/fix-available-scan.json', import.meta.url);

function loadFixture(): ScanArtifact {
return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as ScanArtifact;
}

function findingLines(text: string): string[] {
const lines = stripAnsi(text).split('\n');
const start = lines.findIndex((line) => line.includes('Findings'));
const end = lines.findIndex((line, i) => i > start && line.includes('╭'));
return lines
.slice(start, end === -1 ? undefined : end)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}

describe('fixAvailableHint', () => {
it('joins known fixed versions and drops blanks and duplicates', () => {
expect(fixAvailableHint({ fixedVersions: ['4.17.21', '', '4.17.21', ' 5.0.0 '] })).toBe(
'fix available: 4.17.21, 5.0.0',
);
});

it('reads a single fixedVersion string', () => {
expect(fixAvailableHint({ fixedVersion: '5.4.0' })).toBe('fix available: 5.4.0');
});

it('uses a remediation string when no version is present', () => {
expect(fixAvailableHint({ remediation: ' Upgrade the package. ' })).toBe('fix available: Upgrade the package.');
});

it('omits the hint when fix metadata is missing, empty, or blank', () => {
expect(fixAvailableHint(undefined)).toBeNull();
expect(fixAvailableHint(null)).toBeNull();
expect(fixAvailableHint({})).toBeNull();
expect(fixAvailableHint({ fixedVersions: [] })).toBeNull();
expect(fixAvailableHint({ fixedVersions: ['', ' '] })).toBeNull();
expect(fixAvailableHint({ fixedVersion: ' ' })).toBeNull();
expect(fixAvailableHint({ remediation: ' ' })).toBeNull();
expect(fixAvailableHint({ summary: 'no published fix' })).toBeNull();
});

it('does not invent a no-fix claim from an empty list', () => {
const presented = presentFinding({
message: 'minimist@1.2.5: GHSA-2 (moderate) — no fix available',
details: { fixedVersions: [] },
});
expect(presented.hint).toBeNull();
expect(presented.message).toBe('minimist@1.2.5: GHSA-2 (moderate)');
expect(presented.message).not.toMatch(/no fix/i);
});
});

describe('scan and report human text', () => {
const artifact = loadFixture();

it('prints fix-available hints from the fixture and omits them when unknown', () => {
const scanLines = findingLines(formatScanText(artifact as never));
const reportLines = findingLines(formatReportText(artifact));

const expected = [
'Findings (1 error, 2 warnings, 2 notes)',
'✖ lodash@4.17.20: GHSA-1 (CVE-2021-1) (critical 9.8)',
'vibgrate/vulnerability in lodash',
'fix available: 4.17.21, 5.0.0',
'⚠ minimist@1.2.5: GHSA-2 (moderate 5.3)',
'vibgrate/vulnerability in minimist',
'ℹ left-pad@1.3.0: GHSA-3 (low)',
'vibgrate/vulnerability in left-pad',
'fix available: Replace left-pad with a direct implementation.',
'⚠ Node.js runtime ">=20.0.0" is 2 major versions behind.',
'vibgrate/runtime-lag in /fixture',
'ℹ chalk is 1 major behind.',
'vibgrate/dependency-major-lag in /fixture',
'fix available: 5.4.0',
];

expect(scanLines).toEqual(expected);
expect(reportLines).toEqual(expected);
});

it('is deterministic for the same artifact', () => {
const once = stripAnsi(formatScanText(artifact as never));
const twice = stripAnsi(formatScanText(artifact as never));
expect(once).toBe(twice);
expect(stripAnsi(formatReportText(artifact))).toBe(stripAnsi(formatReportText(artifact)));
expect(once).not.toMatch(/no fix/i);
expect(stripAnsi(formatReportText(artifact))).not.toMatch(/no fix/i);
});
});

describe('generateVulnerabilityFindings', () => {
function advisory(fixedVersions: string[]): VulnerabilityAdvisory {
return {
id: 'GHSA-1',
aliases: [],
summary: null,
severity: 'high',
cvss: 7.2,
cvssVector: null,
fixedVersions,
published: null,
withdrawn: null,
references: [],
};
}

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

it('keeps fixed versions on the payload and does not write a no-fix claim', () => {
const withFix = generateVulnerabilityFindings(result(['4.17.21']));
expect(withFix[0].message).toBe('lodash@4.17.20: GHSA-1 (high 7.2)');
expect(withFix[0].message).not.toMatch(/no fix/i);
expect(withFix[0].details).toMatchObject({ fixedVersions: ['4.17.21'] });
expect(presentFinding(withFix[0]).hint).toBe('fix available: 4.17.21');

const unknown = generateVulnerabilityFindings(result([]));
expect(unknown[0].message).toBe('lodash@4.17.20: GHSA-1 (high 7.2)');
expect(unknown[0].message).not.toMatch(/no fix|fix available/i);
expect(presentFinding(unknown[0]).hint).toBeNull();
});
});
5 changes: 4 additions & 1 deletion src/reporting/formatters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ScanArtifact, ExtendedScanResults, InventoryItem, ServiceDependenc
import { VERSION } from '../version.js';
import { driftBar } from '../../core-open/ui/bar.js';
import { titleBox } from '../../core-open/ui/box.js';
import { presentFinding } from '../../core-open/formatters/fix-hint.js';

export function formatText(artifact: ScanArtifact): string {
const lines: string[] = [];
Expand Down Expand Up @@ -87,8 +88,10 @@ 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}`);
lines.push(chalk.dim(` ${f.ruleId} in ${f.location}`));
if (presented.hint) lines.push(chalk.green(` ${presented.hint}`));
}
lines.push('');
}
Expand Down
68 changes: 68 additions & 0 deletions test/fixtures/fix-available-scan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"schemaVersion": "1.0",
"timestamp": "2026-02-16T00:00:00.000Z",
"vibgrateVersion": "0.1.0",
"rootPath": "/fixture",
"projects": [],
"drift": {
"score": 40,
"riskLevel": "moderate",
"components": {
"runtimeScore": 40,
"frameworkScore": 40,
"dependencyScore": 40,
"eolScore": 40
}
},
"findings": [
{
"ruleId": "vibgrate/vulnerability",
"level": "error",
"message": "lodash@4.17.20: GHSA-1 (CVE-2021-1) (critical 9.8)",
"location": "lodash",
"details": {
"package": "lodash",
"installedVersion": "4.17.20",
"advisoryId": "GHSA-1",
"fixedVersions": ["4.17.21", "4.17.21", " 5.0.0 "]
}
},
{
"ruleId": "vibgrate/vulnerability",
"level": "warning",
"message": "minimist@1.2.5: GHSA-2 (moderate 5.3) — no fix available",
"location": "minimist",
"details": {
"package": "minimist",
"installedVersion": "1.2.5",
"advisoryId": "GHSA-2",
"fixedVersions": []
}
},
{
"ruleId": "vibgrate/vulnerability",
"level": "note",
"message": "left-pad@1.3.0: GHSA-3 (low)",
"location": "left-pad",
"details": {
"package": "left-pad",
"remediation": " Replace left-pad with\n a direct implementation. "
}
},
{
"ruleId": "vibgrate/runtime-lag",
"level": "warning",
"message": "Node.js runtime \">=20.0.0\" is 2 major versions behind.",
"location": "/fixture"
},
{
"ruleId": "vibgrate/dependency-major-lag",
"level": "note",
"message": "chalk is 1 major behind.",
"location": "/fixture",
"details": {
"fixedVersion": "5.4.0"
}
}
]
}
Loading