diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..f064cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DOCS.md b/DOCS.md index 6ad775e..b501724 100644 --- a/DOCS.md +++ b/DOCS.md @@ -446,6 +446,8 @@ vg report [--in ] [--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. + --- @@ -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 diff --git a/src/commands/why.ts b/src/commands/why.ts index a66a91b..4f46499 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -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'; @@ -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` : ''; diff --git a/src/core-open/formatters/fix-hint.ts b/src/core-open/formatters/fix-hint.ts new file mode 100644 index 0000000..8e337ec --- /dev/null +++ b/src/core-open/formatters/fix-hint.ts @@ -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 | 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 | null; +}): { message: string; hint: string | null } { + return { + message: findingDisplayMessage(finding.message), + hint: fixAvailableHint(finding.details), + }; +} + +function readVersions(details: Record): string[] { + const raw = details.fixedVersions ?? details.fixedVersion; + const list = Array.isArray(raw) ? raw : raw != null ? [raw] : []; + const out: string[] = []; + const seen = new Set(); + 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; +} diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index 9f1ed28..31fc588 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -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, @@ -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(''); } diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index 854f705..0ce9091 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -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 @@ -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, diff --git a/src/reporting/formatters/fix-available.test.ts b/src/reporting/formatters/fix-available.test.ts new file mode 100644 index 0000000..3d99eec --- /dev/null +++ b/src/reporting/formatters/fix-available.test.ts @@ -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(); + }); +}); diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index 5275e34..bb95444 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -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[] = []; @@ -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(''); } diff --git a/test/fixtures/fix-available-scan.json b/test/fixtures/fix-available-scan.json new file mode 100644 index 0000000..bc88afd --- /dev/null +++ b/test/fixtures/fix-available-scan.json @@ -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" + } + } + ] +}