From 4871143e43f242ddc4049d7accad3f3caea45200 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 21:58:41 +0000 Subject: [PATCH] fix(report): show fix-available hints in scan and report text Human vg scan and vg report text now prints "fix available:" when a finding already carries a fixed version or remediation. The hint is omitted when that metadata is absent, and the output no longer claims "no fix". Fixes #209 Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- DOCS.md | 4 + README.md | 2 + src/commands/why.ts | 4 +- .../formatters/fix-available.test.ts | 178 ++++++++++++++++++ src/core-open/formatters/fix-available.ts | 99 ++++++++++ src/core-open/formatters/text.ts | 5 +- .../scanners/vulnerability-scanner.ts | 5 +- src/reporting/formatters/text.ts | 5 +- test/fixtures/fix-available-findings.json | 43 +++++ 9 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 src/core-open/formatters/fix-available.test.ts create mode 100644 src/core-open/formatters/fix-available.ts create mode 100644 test/fixtures/fix-available-findings.json diff --git a/DOCS.md b/DOCS.md index 6ad775e..608107e 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 prints a `fix available:` hint on any finding whose artifact already includes a fixed version or remediation. Findings without that metadata omit the hint. + --- @@ -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. diff --git a/README.md b/README.md index 3346e40..fa5025b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/commands/why.ts b/src/commands/why.ts index a66a91b..4c62887 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-available.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-available.test.ts b/src/core-open/formatters/fix-available.test.ts new file mode 100644 index 0000000..ce12fc3 --- /dev/null +++ b/src/core-open/formatters/fix-available.test.ts @@ -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(); + }); +}); diff --git a/src/core-open/formatters/fix-available.ts b/src/core-open/formatters/fix-available.ts new file mode 100644 index 0000000..945d27e --- /dev/null +++ b/src/core-open/formatters/fix-available.ts @@ -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 " 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[] { + 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 | 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 | 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 }): 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 }; +} diff --git a/src/core-open/formatters/text.ts b/src/core-open/formatters/text.ts index 9f1ed28..c1d7c20 100644 --- a/src/core-open/formatters/text.ts +++ b/src/core-open/formatters/text.ts @@ -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'; @@ -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(''); diff --git a/src/core-open/scanners/vulnerability-scanner.ts b/src/core-open/scanners/vulnerability-scanner.ts index 854f705..1185911 100644 --- a/src/core-open/scanners/vulnerability-scanner.ts +++ b/src/core-open/scanners/vulnerability-scanner.ts @@ -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 diff --git a/src/reporting/formatters/text.ts b/src/reporting/formatters/text.ts index 5275e34..9a7c5ad 100644 --- a/src/reporting/formatters/text.ts +++ b/src/reporting/formatters/text.ts @@ -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'; @@ -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(''); diff --git a/test/fixtures/fix-available-findings.json b/test/fixtures/fix-available-findings.json new file mode 100644 index 0000000..049ab41 --- /dev/null +++ b/test/fixtures/fix-available-findings.json @@ -0,0 +1,43 @@ +{ + "withFix": { + "ruleId": "vibgrate/vulnerability", + "level": "error", + "message": "lodash@4.17.20: GHSA-35jh-r3h4-6jhm (CVE-2021-23337) (high 7.2) — fixed in 4.17.21 — introduced by Ada Lovelace in abc1234 (12d exposed)", + "location": "lodash", + "details": { + "ecosystem": "npm", + "package": "lodash", + "installedVersion": "4.17.20", + "advisoryId": "GHSA-35jh-r3h4-6jhm", + "severity": "high", + "fixedVersions": ["4.17.21"] + } + }, + "withoutFix": { + "ruleId": "vibgrate/vulnerability", + "level": "warning", + "message": "minimist@1.2.5: GHSA-vh95-rmgr-6w4m (moderate 5.6) — no fix available — introduced by Grace Hopper in def5678 (3d exposed)", + "location": "minimist", + "details": { + "ecosystem": "npm", + "package": "minimist", + "installedVersion": "1.2.5", + "advisoryId": "GHSA-vh95-rmgr-6w4m", + "severity": "moderate", + "fixedVersions": [] + } + }, + "remediationOnly": { + "ruleId": "vibgrate/vulnerability", + "level": "note", + "message": "debug@2.6.9: GHSA-example (low) — no fix available", + "location": "debug", + "details": { + "package": "debug", + "installedVersion": "2.6.9", + "advisoryId": "GHSA-example", + "fixedVersions": [], + "remediation": "replace with the built-in node:util debug API" + } + } +}