From fe76a03cf2a4abcbcbe5aec52b9de8c12882931f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 22:08:25 +0000 Subject: [PATCH 1/2] feat(report): add an HTML findings report with a summary vg report --format html reads the local scan artifact and prints a self-contained page. The page leads with severity counts, links into the full tables, and a fix-available rollup when details.fixedVersions is present. A missing score stays n/a. text, md, and json are unchanged. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- CHANGELOG.md | 7 + DOCS.md | 40 +- src/reporting/commands/report.test.ts | 103 +++++ src/reporting/commands/report.ts | 21 +- src/reporting/formatters/html.test.ts | 375 ++++++++++++++++++ src/reporting/formatters/html.ts | 537 ++++++++++++++++++++++++++ src/reporting/types.ts | 2 +- 7 files changed, 1075 insertions(+), 10 deletions(-) create mode 100644 src/reporting/commands/report.test.ts create mode 100644 src/reporting/formatters/html.test.ts create mode 100644 src/reporting/formatters/html.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72af8f05..d0ede4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ backward compatible. ### Added +- **`vg report --format html` prints a self-contained findings page.** The page + opens with a compact summary — vulnerability and security counts by severity, + anchor links into the tables below, and a fix-available rollup when a finding + already carries `details.fixedVersions`. A missing score stays `n/a`, never + `0`. `text`, `md`, and `json` are unchanged. Any other `--format` is a usage + error (exit `5`). + - **`vg build --attest` and `vg build --verify` are documented.** The docs cover the DSSE in-toto statement written to `.vibgrate/attestation.intoto.jsonl`, the Ed25519 key diff --git a/DOCS.md b/DOCS.md index f31fd5c0..d39b9a5a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -18,6 +18,7 @@ For a quick overview, see the [README](./README.md). This document covers everyt - [vg fix](#vg-fix) - [vg init](#vg-init) - [vg report](#vg-report) + - [HTML report](#html-report) - [vg review](#vg-review) - [vg sbom](#vg-sbom) - [Choosing CycloneDX or SPDX](#choosing-cyclonedx-or-spdx) @@ -450,16 +451,43 @@ Creates: ### vg report -Generate a human-readable report from a scan artifact. +Render a report from a local scan artifact. `text`, `md`, and `json` are unchanged. `html` is a self-contained page generated on this machine: a compact summary, then the full finding tables. The page does not load anything from the network. ```bash -vg report [--in ] [--format md|text|json] +vg report [--in ] [--format md|text|json|html] +vg report --in .vibgrate/scan_result.json --format html ``` -| Flag | Default | Description | -| ---------- | ---------------------------- | -------------------------------------- | -| `--in` | `.vibgrate/scan_result.json` | Input artifact file | -| `--format` | `text` | Output format: `md`, `text`, or `json` | +| Flag | Default | Description | +| ---------- | ---------------------------- | --------------------------------------------------------------------------- | +| `--in` | `.vibgrate/scan_result.json` | Input artifact file | +| `--format` | `text` | `md`, `text`, `json`, or `html`. Any other value is a usage error (exit `5`) | + +An unknown value is rejected before the file is read. The process exits `5`, writes nothing to stdout, and prints one stderr line that names the value and lists the valid ones: + +```text +error: unknown --format "sarif" (expected md, text, json, html) +``` + +The comparison is exact. `HTML` is unknown. Omitting `--format` stays on `text`. + +#### HTML report + +`--format html` reads the artifact already on disk (the same file `text`, `md`, and `json` read) and prints one page: + +1. A summary (`#summary`) at the top. +2. The full tables: vulnerabilities (`#vulnerabilities`), security (`#security`), and the other findings (`#findings`). +3. An empty footer (`#report-footer`). + +The summary counts vulnerability findings by severity (`critical`, `high`, `moderate`, `low`, `unknown`). Security findings are counted on their own scale (`critical`, `high`, `medium`, `low`, `info`). A count greater than zero links to the first row of that severity. The section links stay in the summary when a table is empty, so the summary is navigation and the tables below stay complete. + +A severity that was not recorded on a finding is `absent`. That bucket is omitted when nothing is absent, and it is never shown as `0` or folded into `unknown`. A vulnerability scan that did not run is `not scanned`, not a row of zeros. An unreachable scan is `not checked`. A security section that is missing from the artifact is `not evaluated`. A scan that ran and found nothing shows measured zeros. + +CVSS and DriftScore follow the same rule. A missing score is `n/a`. A score that was computed as `0` stays `0`. + +When a vulnerability finding has `details.fixedVersions` with at least one version, the summary includes a fix-available count and that row is marked `data-fix-available="yes"`. An empty list or a missing field adds nothing, and the page does not say that no fix exists. + +The same artifact produces the same HTML. Rows are sorted by severity, then by the remaining columns. The page embeds no clock, no remote assets, and no script. Text from the artifact is escaped, and credential-shaped text is redacted. --- diff --git a/src/reporting/commands/report.test.ts b/src/reporting/commands/report.test.ts new file mode 100644 index 00000000..1eb8329c --- /dev/null +++ b/src/reporting/commands/report.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { reportCommand } from './report.js'; +import { ExitCode } from '../../util/exit.js'; + +const artifact = { + schemaVersion: '1.0', + timestamp: '2026-01-01T00:00:00.000Z', + vibgrateVersion: '0.0.0', + rootPath: '/fixture', + projects: [ + { + type: 'node', + path: 'app', + name: 'report-format-sentinel', + frameworks: [], + dependencies: [], + dependencyAgeBuckets: { current: 0, oneBehind: 0, twoPlusBehind: 0, unknown: 0 }, + }, + ], + drift: { + score: 1, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + findings: [], +}; + +const roots: string[] = []; + +function writeArtifact(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-report-format-')); + roots.push(root); + const file = path.join(root, 'scan_result.json'); + fs.writeFileSync(file, JSON.stringify(artifact)); + return file; +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('vg report --format', () => { + it('lists html in the help text', () => { + expect(reportCommand.helpInformation()).toContain('md|text|json|html'); + }); + + it('rejects an unknown format before reading the artifact', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`EXIT:${code ?? 0}`); + }) as never); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + for (const format of ['sarif', 'HTML', 'html ']) { + await expect( + reportCommand.parseAsync(['--format', format, '--in', '/no/such/scan_result.json'], { from: 'user' }), + ).rejects.toThrow(`unknown --format ${JSON.stringify(format)} (expected md, text, json, html)`); + } + + expect(logSpy).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('prints html, and leaves md and json on their existing paths', async () => { + const file = writeArtifact(); + const logs: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + logs.push(parts.map((part) => String(part)).join(' ')); + }); + + logs.length = 0; + await reportCommand.parseAsync(['--in', file, '--format', 'html'], { from: 'user' }); + expect(logs.join('\n')).toContain('
'); + + logs.length = 0; + await reportCommand.parseAsync(['--in', file, '--format', 'json'], { from: 'user' }); + expect(JSON.parse(logs.join('\n'))).toEqual(artifact); + + logs.length = 0; + await reportCommand.parseAsync(['--in', file, '--format', 'text'], { from: 'user' }); + expect(logs.join('\n')).toContain('Vibgrate Drift Report'); + expect(logs.join('\n')).toContain('report-format-sentinel'); + expect(logs.join('\n')).not.toContain(''); + }); + + it('uses the usage-error exit code for a bad format', async () => { + await expect( + reportCommand.parseAsync(['--format', 'sarif'], { from: 'user' }), + ).rejects.toMatchObject({ code: ExitCode.USAGE_ERROR }); + }); +}); diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index 25a40b80..9e68d812 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -1,16 +1,29 @@ import * as path from 'node:path'; import { Command } from 'commander'; import chalk from 'chalk'; +import { usageError } from '../../util/exit.js'; import { pathExists, readJsonFile } from '../utils/fs.js'; import { formatText } from '../formatters/text.js'; import { formatMarkdown } from '../formatters/markdown.js'; -import type { ScanArtifact } from '../types.js'; +import { formatHtml } from '../formatters/html.js'; +import type { ReportFormat, ScanArtifact } from '../types.js'; + +/** Accepted by `vg report --format`. `text` is the commander default. */ +const REPORT_FORMATS = ['md', 'text', 'json', 'html'] as const satisfies readonly ReportFormat[]; + +function assertReportFormat(format: string): asserts format is ReportFormat { + if ((REPORT_FORMATS as readonly string[]).includes(format)) return; + throw usageError(`unknown --format ${JSON.stringify(format)} (expected ${REPORT_FORMATS.join(', ')})`); +} export const reportCommand = new Command('report') .description('Generate a drift report from a scan artifact') .option('--in ', 'Input artifact file', '.vibgrate/scan_result.json') - .option('--format ', 'Output format (md|text|json)', 'text') + .option('--format ', `Output format (${REPORT_FORMATS.join('|')})`, 'text') .action(async (opts: { in: string; format: string }) => { + // Before the artifact is read, so an unknown value never renders as text. + assertReportFormat(opts.format); + const artifactPath = path.resolve(opts.in); if (!(await pathExists(artifactPath))) { @@ -29,8 +42,10 @@ export const reportCommand = new Command('report') console.log(JSON.stringify(artifact, null, 2)); break; case 'text': - default: console.log(formatText(artifact)); break; + case 'html': + console.log(formatHtml(artifact)); + break; } }); diff --git a/src/reporting/formatters/html.test.ts b/src/reporting/formatters/html.test.ts new file mode 100644 index 00000000..f5a18558 --- /dev/null +++ b/src/reporting/formatters/html.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it, vi } from 'vitest'; +import { formatHtml } from './html.js'; +import { VULN_RULE_ID } from '../../core-open/scanners/vulnerability-scanner.js'; +import { SECURITY_FINDING_SEVERITIES } from '../../security/types.js'; +import type { ScanArtifact } from '../types.js'; + +const VULN_RULE = 'vibgrate/vulnerability'; + +function artifact(overrides: Record = {}): ScanArtifact { + return { + schemaVersion: '1.0', + timestamp: '2026-02-16T00:00:00.000Z', + vibgrateVersion: '0.0.0-fixture', + rootPath: '/home/secret-user/proj', + vcs: { type: 'git', remoteUrl: 'https://user:ghp_abcdefghijklmnopqrstuvwxyz@github.com/acme/private.git' }, + projects: [], + drift: { + score: null, + riskLevel: null, + components: { runtimeScore: null, frameworkScore: null, dependencyScore: null, eolScore: null }, + }, + findings: [], + ...overrides, + } as ScanArtifact; +} + +function summaryOf(html: string): string { + const start = html.indexOf('
', start); + return html.slice(start, end + '
'.length); +} + +function sectionOf(html: string, id: string): string { + const start = html.indexOf(`
', start); + return html.slice(start, end); +} + +describe('formatHtml', () => { + const scanned = artifact({ + findings: [ + { + ruleId: VULN_RULE, + level: 'note', + message: 'gamma issue', + location: 'gamma', + details: { package: 'gamma', advisoryId: 'GHSA-g' }, + }, + { + ruleId: VULN_RULE, + level: 'error', + message: 'alpha issue', + location: 'alpha', + details: { + package: 'alpha', + advisoryId: 'GHSA-1', + severity: 'critical', + cvss: 9.8, + fixedVersions: ['1.2.3'], + }, + }, + { + ruleId: VULN_RULE, + level: 'warning', + message: 'beta issue — no fix available', + location: 'beta', + details: { + package: 'beta', + advisoryId: 'GHSA-2', + severity: 'high', + cvss: null, + fixedVersions: [], + }, + }, + { + ruleId: VULN_RULE, + level: 'note', + message: 'delta issue', + location: 'delta', + details: { + package: 'delta', + advisoryId: 'GHSA-d', + severity: 'unknown', + cvss: 0, + fixedVersions: ['9.9.9'], + }, + }, + { + ruleId: 'vibgrate/runtime-lag', + level: 'note', + message: 'runtime is current', + location: 'app', + }, + ], + extended: { + security: { + schema: 'vg.security.v1', + engine: 'test', + packs: { zeta: '1', alpha: '2' }, + facts: { received: 1, evaluated: 1, rejected: 0 }, + findings: [ + { + id: 'b'.repeat(32), + pack: 'iac-cis-v1', + packVersion: '1', + rule: 'disk-unencrypted', + severity: 'medium', + message: 'disk is open', + path: 'infra/disk.tf', + line: 4, + address: 'aws_ebs_volume.data', + }, + ], + }, + }, + }); + + const summary = `
+

Summary

+

DriftScore: n/a

+ +

2 with a fix available

+ +
`; + + it('locks the vulnerability rule id to the scanner', () => { + expect(VULN_RULE_ID).toBe(VULN_RULE); + }); + + it('opens with the summary block, severity counts, and anchors', () => { + const html = formatHtml(scanned); + expect(summaryOf(html)).toBe(summary); + expect(html.indexOf('id="summary"')).toBeLessThan(html.indexOf('id="vulnerabilities"')); + expect(html.indexOf('id="vulnerabilities"')).toBeLessThan(html.indexOf('id="security"')); + expect(html.indexOf('id="security"')).toBeLessThan(html.indexOf('id="findings"')); + expect(html.indexOf('id="findings"')).toBeLessThan(html.indexOf('id="report-footer"')); + expect(html).toContain('id="vuln-critical"'); + expect(html).toContain('id="vuln-high"'); + expect(html).toContain('id="vuln-unknown"'); + expect(html).toContain('id="vuln-absent"'); + expect(html).toContain('id="security-medium"'); + expect(html).toContain('
'); + + const vulns = sectionOf(html, 'vulnerabilities'); + expect(vulns).toContain('alpha issue'); + expect(vulns).toContain('9.8'); + expect(vulns).toContain('n/a'); + expect(vulns).toContain('0'); + expect(vulns).toContain('fix available (1.2.3)'); + expect(vulns).toContain('fix available (9.9.9)'); + expect(vulns).not.toMatch(/no fix/i); + expect(vulns.match(/data-fix-available="yes"/g)).toHaveLength(2); + + const findings = sectionOf(html, 'findings'); + expect(findings).toContain('runtime is current'); + expect(findings).not.toContain('alpha issue'); + + const security = sectionOf(html, 'security'); + expect(security).toContain('alpha@2, zeta@1'); + expect(security).toContain('iac-cis-v1/disk-unencrypted'); + expect(security).toContain('infra/disk.tf:4'); + expect(security.indexOf('medium')).toBeGreaterThan(-1); + expect(summaryOf(html)).not.toContain('moderate 1'); + for (const severity of SECURITY_FINDING_SEVERITIES) { + expect(summaryOf(html)).toContain(severity); + } + }); + + it('keeps an absent DriftScore and an absent CVSS off the zero mark', () => { + const html = formatHtml(scanned); + expect(html).toContain('DriftScore: n/a'); + expect(html).not.toContain('DriftScore: 0'); + expect(html).not.toContain('0/100'); + expect(sectionOf(html, 'vulnerabilities')).toContain('n/a'); + }); + + it('renders a measured zero DriftScore as 0', () => { + const html = formatHtml(artifact({ + drift: { + score: 0, + riskLevel: 'low', + components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, + }, + })); + expect(summaryOf(html)).toContain('DriftScore: 0/100'); + expect(summaryOf(html)).not.toContain('DriftScore: n/a'); + }); + + it('does not show zero severity counts when a scan did not run', () => { + const html = formatHtml(artifact()); + const summary = summaryOf(html); + expect(summary).toContain('Vulnerabilities: not scanned'); + expect(summary).toContain('Security: not evaluated'); + expect(summary).not.toContain('critical 0'); + expect(summary).not.toContain('absent'); + expect(summary).not.toContain('fix-available'); + expect(html).not.toMatch(/no fix/i); + expect(sectionOf(html, 'vulnerabilities')).toContain('Not scanned.'); + expect(sectionOf(html, 'security')).toContain('Not evaluated.'); + }); + + it('shows measured zeros after a clean vulnerability scan', () => { + const html = formatHtml(artifact({ + extended: { + vulnerabilities: { + source: 'osv', + packages: [], + totalAdvisories: 0, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }, + }, + })); + expect(summaryOf(html)).toContain( + 'Vulnerabilities: critical 0 · high 0 · moderate 0 · low 0 · unknown 0', + ); + expect(summaryOf(html)).not.toContain('absent'); + expect(sectionOf(html, 'vulnerabilities')).toContain('No vulnerability findings.'); + }); + + it('does not treat an unreachable vulnerability scan as a clean zero', () => { + const html = formatHtml(artifact({ + extended: { + vulnerabilities: { + source: 'unreachable', + packages: [], + totalAdvisories: 0, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }, + }, + })); + expect(summaryOf(html)).toContain('Vulnerabilities: not checked'); + expect(summaryOf(html)).not.toContain('critical 0'); + }); + + it('does not treat a scan with no packages as a clean zero', () => { + const html = formatHtml(artifact({ + extended: { + vulnerabilities: { + source: 'none', + packages: [], + totalAdvisories: 0, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }, + }, + })); + expect(summaryOf(html)).toContain('Vulnerabilities: no packages to check'); + expect(summaryOf(html)).not.toContain('critical 0'); + }); + + it('uses advisory severity when the finding does not record one', () => { + const html = formatHtml(artifact({ + findings: [ + { + ruleId: VULN_RULE, + level: 'error', + message: 'lodash issue', + location: 'lodash', + details: { package: 'lodash', advisoryId: 'GHSA-a', fixedVersions: ['4.17.21'] }, + }, + ], + extended: { + vulnerabilities: { + source: 'manifest', + packages: [ + { + ecosystem: 'npm', + package: 'lodash', + version: '4.17.20', + advisories: [{ id: 'GHSA-a', severity: 'high', cvss: 7.5 }], + }, + ], + totalAdvisories: 1, + severityCounts: { low: 0, moderate: 0, high: 1, critical: 0, unknown: 0 }, + }, + }, + })); + expect(summaryOf(html)).toContain('high 1'); + expect(summaryOf(html)).not.toContain('absent'); + expect(sectionOf(html, 'vulnerabilities')).toContain('7.5'); + expect(summaryOf(html)).toContain('1 with a fix available'); + }); + + it('counts an unrecognized severity as absent, not as unknown or zero', () => { + const html = formatHtml(artifact({ + findings: [ + { + ruleId: VULN_RULE, + level: 'note', + message: 'odd', + location: 'odd', + details: { package: 'odd', severity: 'severe', cvss: null }, + }, + ], + })); + expect(summaryOf(html)).toContain('unknown 0'); + expect(summaryOf(html)).toContain('absent 1'); + expect(summaryOf(html)).not.toContain('unknown 1'); + }); + + it('escapes markup and redacts credential-shaped text', () => { + const token = 'ghp_abcdefghijklmnopqrstuvwxyz'; + const html = formatHtml(artifact({ + findings: [ + { + ruleId: VULN_RULE, + level: 'error', + message: ` ${token}`, + location: 'p&q', + details: { + package: 'a', + advisoryId: 'x"y', + severity: 'low', + cvss: 1, + fixedVersions: ['1<2'], + }, + }, + ], + })); + expect(html).not.toContain(' { + const now = vi.spyOn(Date, 'now').mockReturnValueOnce(1).mockReturnValueOnce(2); + const random = vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.9); + try { + const first = formatHtml(scanned); + const second = formatHtml(scanned); + expect(second).toBe(first); + + const findings = [...(scanned.findings ?? [])].reverse(); + const extended = scanned.extended as { security?: { findings?: unknown[] } }; + const securityFindings = [...(extended.security?.findings ?? [])].reverse(); + const shuffled = artifact({ + findings, + extended: { + security: { + ...(extended.security as object), + findings: securityFindings, + packs: { zeta: '1', alpha: '2' }, + }, + }, + }); + expect(formatHtml(shuffled)).toBe(first); + } finally { + now.mockRestore(); + random.mockRestore(); + } + }); + + it('embeds no external assets', () => { + const html = formatHtml(artifact()); + expect(html.startsWith('')).toBe(true); + expect(html).not.toContain('; +} + +interface VulnRow { + severity: VulnBucket; + packageName: string; + advisory: string; + location: string; + cvssText: string; + fixText: string; + message: string; +} + +interface SecurityRow { + severity: SecurityBucket; + pack: string; + rule: string; + path: string; + line: number | null; + address: string; + message: string; + id: string; +} + +interface SecurityBlock { + packsLabel: string; + findings: SecurityRow[]; +} + +interface OtherRow { + level: 'error' | 'warning' | 'note'; + ruleId: string; + location: string; + message: string; +} + +type VulnState = + | { kind: 'not-scanned' } + | { kind: 'not-checked' } + | { kind: 'no-packages' } + | { kind: 'measured'; counts: Record; absent: number }; + +interface ReportModel { + version: string; + drift: string; + vulnState: VulnState; + vulnRows: VulnRow[]; + fixAvailable: number; + security: SecurityBlock | undefined; + securityCounts: Record; + securityAbsent: number; + otherRows: OtherRow[]; +} + +function cmp(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function esc(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function plain(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function shown(value: string): string { + return esc(redactForDisplay(value)); +} + +function rank(order: readonly string[], value: string): number { + const index = order.indexOf(value); + return index === -1 ? order.length : index; +} + +function asScore(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return value; +} + +/** A missing score is `n/a`. A computed 0 stays `0` — never the other way around. */ +function formatScore(score: number | null): string { + return score === null ? 'n/a' : String(score); +} + +function asVulnSeverity(value: unknown): VulnSeverity | null { + if (typeof value !== 'string') return null; + const label = value.trim().toLowerCase(); + return (VULN_SEVERITIES as readonly string[]).includes(label) ? (label as VulnSeverity) : null; +} + +function securitySeverity(value: unknown): SecurityBucket { + if (typeof value !== 'string') return 'absent'; + const label = value.trim().toLowerCase(); + return (SECURITY_SEVERITIES as readonly string[]).includes(label) ? (label as SecuritySeverity) : 'absent'; +} + +function detailString(details: Record | undefined, key: string): string { + const value = details?.[key]; + return typeof value === 'string' ? value : ''; +} + +function readVulns(artifact: ScanArtifact): VulnBlock | undefined { + const raw = artifact.extended && (artifact.extended as { vulnerabilities?: unknown }).vulnerabilities; + if (!isRecord(raw) || typeof raw.source !== 'string' || !Array.isArray(raw.packages)) return undefined; + const advisories = new Map(); + for (const item of raw.packages) { + if (!isRecord(item) || typeof item.package !== 'string' || !Array.isArray(item.advisories)) continue; + for (const advisory of item.advisories) { + if (!isRecord(advisory) || typeof advisory.id !== 'string') continue; + const key = `${item.package}\0${advisory.id}`; + if (!advisories.has(key)) advisories.set(key, advisory); + } + } + return { source: raw.source, advisories }; +} + +function readSecurity(artifact: ScanArtifact): SecurityBlock | undefined { + const raw = artifact.extended && (artifact.extended as { security?: unknown }).security; + if (!isRecord(raw) || !Array.isArray(raw.findings)) return undefined; + const findings: SecurityRow[] = []; + for (const item of raw.findings) { + if (!isRecord(item)) continue; + const line = typeof item.line === 'number' && Number.isFinite(item.line) ? item.line : null; + findings.push({ + severity: securitySeverity(item.severity), + pack: plain(item.pack), + rule: plain(item.rule), + path: plain(item.path), + line, + address: plain(item.address), + message: plain(item.message), + id: plain(item.id), + }); + } + return { packsLabel: packsLabel(raw.packs), findings }; +} + +function packsLabel(packs: unknown): string { + if (!isRecord(packs)) return ''; + return Object.keys(packs) + .filter((key) => typeof packs[key] === 'string') + .sort(cmp) + .map((key) => `${key}@${packs[key] as string}`) + .join(', '); +} + +function readFindings(artifact: ScanArtifact): Finding[] { + if (!Array.isArray(artifact.findings)) return []; + const out: Finding[] = []; + for (const item of artifact.findings) { + if (!isRecord(item) || typeof item.ruleId !== 'string' || typeof item.message !== 'string') continue; + const level = item.level === 'error' || item.level === 'warning' || item.level === 'note' ? item.level : 'note'; + const location = plain(item.location); + const details = isRecord(item.details) ? item.details : undefined; + out.push({ ruleId: item.ruleId, level, message: item.message, location, ...(details ? { details } : {}) }); + } + return out; +} + +function advisoryHit(block: VulnBlock | undefined, details: Record | undefined, location: string): AdvisoryHit | undefined { + if (!block) return undefined; + const id = detailString(details, 'advisoryId'); + if (!id) return undefined; + const packageName = detailString(details, 'package') || location; + return block.advisories.get(`${packageName}\0${id}`); +} + +function vulnSeverityOf(details: Record | undefined, hit: AdvisoryHit | undefined): VulnBucket { + if (details && Object.prototype.hasOwnProperty.call(details, 'severity')) { + return asVulnSeverity(details.severity) ?? 'absent'; + } + return asVulnSeverity(hit?.severity) ?? 'absent'; +} + +function cvssOf(details: Record | undefined, hit: AdvisoryHit | undefined): number | null { + if (details && Object.prototype.hasOwnProperty.call(details, 'cvss')) return asScore(details.cvss); + if (hit && Object.prototype.hasOwnProperty.call(hit, 'cvss')) return asScore(hit.cvss); + return null; +} + +function emptyVulnCounts(): Record { + return { critical: 0, high: 0, moderate: 0, low: 0, unknown: 0 }; +} + +function emptySecurityCounts(): Record { + return { critical: 0, high: 0, medium: 0, low: 0, info: 0 }; +} + +function vulnState(rows: VulnRow[], block: VulnBlock | undefined): VulnState { + const measured = rows.length > 0 || block?.source === 'osv' || block?.source === 'manifest'; + if (measured) { + const counts = emptyVulnCounts(); + let absent = 0; + for (const row of rows) { + if (row.severity === 'absent') absent += 1; + else counts[row.severity] += 1; + } + return { kind: 'measured', counts, absent }; + } + if (block?.source === 'unreachable') return { kind: 'not-checked' }; + if (block?.source === 'none') return { kind: 'no-packages' }; + return { kind: 'not-scanned' }; +} + +function driftLabel(artifact: ScanArtifact): string { + const score = artifact.drift?.score; + if (typeof score !== 'number' || !Number.isFinite(score)) return 'n/a'; + return `${score}/100`; +} + +function buildModel(artifact: ScanArtifact): ReportModel { + const block = readVulns(artifact); + const findings = readFindings(artifact); + const vulnRows: VulnRow[] = []; + const otherRows: OtherRow[] = []; + let fixAvailable = 0; + + for (const finding of findings) { + const human = humanFindingText(finding); + const message = redactForDisplay(human.message); + if (finding.ruleId !== VULN_RULE_ID) { + otherRows.push({ level: finding.level, ruleId: finding.ruleId, location: finding.location, message }); + continue; + } + const hit = advisoryHit(block, finding.details, finding.location); + const fixText = redactForDisplay(fixAvailableHint(finding.details?.fixedVersions)); + if (fixText) fixAvailable += 1; + const packageName = detailString(finding.details, 'package') || finding.location; + vulnRows.push({ + severity: vulnSeverityOf(finding.details, hit), + packageName, + advisory: detailString(finding.details, 'advisoryId'), + location: finding.location, + cvssText: formatScore(cvssOf(finding.details, hit)), + fixText, + message, + }); + } + + vulnRows.sort((a, b) => + rank(VULN_ORDER, a.severity) - rank(VULN_ORDER, b.severity) + || cmp(a.packageName, b.packageName) + || cmp(a.advisory, b.advisory) + || cmp(a.location, b.location) + || cmp(a.cvssText, b.cvssText) + || cmp(a.fixText, b.fixText) + || cmp(a.message, b.message), + ); + otherRows.sort((a, b) => + levelRank(a.level) - levelRank(b.level) + || cmp(a.ruleId, b.ruleId) + || cmp(a.location, b.location) + || cmp(a.message, b.message), + ); + + const security = readSecurity(artifact); + if (security) { + security.findings.sort((a, b) => + rank(SECURITY_ORDER, a.severity) - rank(SECURITY_ORDER, b.severity) + || cmp(a.path, b.path) + || (a.line ?? -1) - (b.line ?? -1) + || cmp(a.address, b.address) + || cmp(a.pack, b.pack) + || cmp(a.rule, b.rule) + || cmp(a.id, b.id) + || cmp(a.message, b.message), + ); + } + const securityCounts = emptySecurityCounts(); + let securityAbsent = 0; + for (const row of security?.findings ?? []) { + if (row.severity === 'absent') securityAbsent += 1; + else securityCounts[row.severity] += 1; + } + + const version = typeof artifact.vibgrateVersion === 'string' ? artifact.vibgrateVersion : ''; + return { + version, + drift: driftLabel(artifact), + vulnState: vulnState(vulnRows, block), + vulnRows, + fixAvailable, + security, + securityCounts, + securityAbsent, + otherRows, + }; +} + +function levelRank(level: OtherRow['level']): number { + if (level === 'error') return 0; + if (level === 'warning') return 1; + return 2; +} + +function countText(prefix: string, order: readonly string[], counts: Record, absent: number): string { + const parts = order.filter((severity) => severity !== 'absent').map((severity) => { + const count = counts[severity] ?? 0; + const label = `${severity} ${count}`; + return count > 0 ? `${label}` : label; + }); + if (absent > 0) parts.push(`absent ${absent}`); + return parts.join(' · '); +} + +function vulnSummary(state: VulnState): string { + const label = `Vulnerabilities`; + if (state.kind === 'not-scanned') return `${label}: not scanned`; + if (state.kind === 'not-checked') return `${label}: not checked`; + if (state.kind === 'no-packages') return `${label}: no packages to check`; + return `${label}: ${countText('vuln', VULN_ORDER, state.counts, state.absent)}`; +} + +function securitySummary(model: ReportModel): string { + const label = `Security`; + if (!model.security) return `${label}: not evaluated`; + return `${label}: ${countText('security', SECURITY_ORDER, model.securityCounts, model.securityAbsent)}`; +} + +function renderSummary(model: ReportModel): string { + const lines = [ + `
`, + '

Summary

', + `

DriftScore: ${model.drift}

`, + '
    ', + `
  • ${vulnSummary(model.vulnState)}
  • `, + `
  • ${securitySummary(model)}
  • `, + '
', + ]; + if (model.fixAvailable > 0) { + lines.push( + `

${model.fixAvailable} with a fix available

`, + ); + } + lines.push( + '', + '
', + ); + return lines.join('\n'); +} + +function openRow(id: string | undefined, fix: boolean): string { + const attrs: string[] = []; + if (id) attrs.push(`id="${id}"`); + if (fix) attrs.push('data-fix-available="yes"'); + return attrs.length > 0 ? `` : ''; +} + +function cells(values: string[], classes: readonly (string | undefined)[]): string { + return values.map((value, index) => { + const cls = classes[index]; + return cls ? `${value}` : `${value}`; + }).join(''); +} + +function table(headers: readonly string[], rows: readonly string[]): string { + const head = `${headers.map((header) => `${header}`).join('')}`; + return ['', '', head, '', '', ...rows, '', '
'].join('\n'); +} + +function renderVulnSection(model: ReportModel): string { + const body: string[] = [`
`, '

Vulnerabilities

']; + if (model.vulnState.kind === 'not-scanned') body.push('

Not scanned.

'); + else if (model.vulnState.kind === 'not-checked') body.push('

Not checked.

'); + else if (model.vulnState.kind === 'no-packages') body.push('

No packages to check.

'); + else if (model.vulnRows.length === 0) body.push('

No vulnerability findings.

'); + else { + const seen = new Set(); + const rows = model.vulnRows.map((row) => { + const id = seen.has(row.severity) ? undefined : `vuln-${row.severity}`; + seen.add(row.severity); + return `${openRow(id, row.fixText !== '')}${cells( + [ + shown(row.severity), + shown(row.packageName), + shown(row.advisory), + shown(row.cvssText), + shown(row.fixText), + shown(row.location), + shown(row.message), + ], + [undefined, undefined, undefined, 'cvss', 'fix', undefined, undefined], + )}`; + }); + body.push(table(['Severity', 'Package', 'Advisory', 'CVSS', 'Fix', 'Location', 'Message'], rows)); + } + body.push('
'); + return body.join('\n'); +} + +function securityWhere(row: SecurityRow): string { + return row.line === null ? row.path : `${row.path}:${row.line}`; +} + +function renderSecuritySection(model: ReportModel): string { + const body: string[] = [`
`, '

Security

']; + if (!model.security) { + body.push('

Not evaluated.

'); + } else { + if (model.security.packsLabel) body.push(`

${shown(model.security.packsLabel)}

`); + if (model.security.findings.length === 0) body.push('

No security findings.

'); + else { + const seen = new Set(); + const rows = model.security.findings.map((row) => { + const id = seen.has(row.severity) ? undefined : `security-${row.severity}`; + seen.add(row.severity); + const rule = row.pack && row.rule ? `${row.pack}/${row.rule}` : row.rule || row.pack; + return `${openRow(id, false)}${cells( + [shown(row.severity), shown(rule), shown(securityWhere(row)), shown(row.address), shown(row.message)], + [], + )}`; + }); + body.push(table(['Severity', 'Rule', 'Location', 'Address', 'Message'], rows)); + } + } + body.push('
'); + return body.join('\n'); +} + +function renderFindingsSection(model: ReportModel): string { + const body: string[] = [`
`, '

Findings

']; + if (model.otherRows.length === 0) body.push('

No other findings.

'); + else { + const rows = model.otherRows.map((row) => + `${openRow(undefined, false)}${cells( + [shown(row.level), shown(row.ruleId), shown(row.location), shown(row.message)], + [], + )}`, + ); + body.push(table(['Level', 'Rule', 'Location', 'Message'], rows)); + } + body.push('
'); + return body.join('\n'); +} + +function renderDocument(model: ReportModel): string { + const version = model.version ? ` ${shown(model.version)}` : ''; + return ` + + + + +vg · drift report + + + +

vg · drift report

+

vg${version}

+${renderSummary(model)} +${renderVulnSection(model)} +${renderSecuritySection(model)} +${renderFindingsSection(model)} +
+ + +`; +} + +/** Self-contained HTML report for a scan artifact. Deterministic and local. */ +export function formatHtml(artifact: ScanArtifact): string { + return renderDocument(buildModel(artifact)); +} diff --git a/src/reporting/types.ts b/src/reporting/types.ts index 5df43370..c2328827 100644 --- a/src/reporting/types.ts +++ b/src/reporting/types.ts @@ -42,7 +42,7 @@ export type ProjectType = export type OutputFormat = 'text' | 'json' | 'sarif' | 'md'; -export type ReportFormat = 'md' | 'text' | 'json'; +export type ReportFormat = 'md' | 'text' | 'json' | 'html'; // ── Package.json shape ── From 8ebfad8db1ede4a9e1882de9ec8457e529a67c9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 21:06:07 +0000 Subject: [PATCH 2/2] refactor(report): align html report with canonical formatter Accept html in vg report --format and render a self-contained page from the local scan artifact. Unknown values still exit 5 and list md, text, json, and html. Signed-off-by: Cursor Agent Co-authored-by: Crowers --- CHANGELOG.md | 7 - DOCS.md | 58 +- plugins/claude/vg/skills/vg/SKILL.md | 4 +- skills/vg/SKILL.md | 4 +- src/install/content.ts | 7 +- src/reporting/commands/report.test.ts | 36 +- src/reporting/commands/report.ts | 8 +- src/reporting/formatters/html.test.ts | 489 ++++++++--------- src/reporting/formatters/html.ts | 736 ++++++++++---------------- src/reporting/types.ts | 12 +- 10 files changed, 578 insertions(+), 783 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434cfaf9..ec0672cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,6 @@ backward compatible. ### Added -- **`vg report --format html` prints a self-contained findings page.** The page - opens with a compact summary — vulnerability and security counts by severity, - anchor links into the tables below, and a fix-available rollup when a finding - already carries `details.fixedVersions`. A missing score stays `n/a`, never - `0`. `text`, `md`, and `json` are unchanged. Any other `--format` is a usage - error (exit `5`). - - **`vg build --attest` and `vg build --verify` are documented.** The docs cover the DSSE in-toto statement written to `.vibgrate/attestation.intoto.jsonl`, the Ed25519 key diff --git a/DOCS.md b/DOCS.md index fbc09c5d..9a314a0e 100644 --- a/DOCS.md +++ b/DOCS.md @@ -459,46 +459,63 @@ Creates: ### vg report -Render a saved scan artifact. There is no HTML report. The formats are `md`, -`text`, and `json`. `text` is the default. `json` prints the artifact unchanged -and is the only stable machine-readable contract. `sarif` is not a `vg report` -format; write that with `vg scan --format sarif`. +Render a saved scan artifact. The formats are `md`, `text`, `json`, and `html`. +`text` is the default. `json` prints the artifact unchanged and is the only +stable machine-readable contract. `sarif` is not a `vg report` format; write +that with `vg scan --format sarif`. ```bash -vg report [--in ] [--format md|text|json] +vg report [--in ] [--format md|text|json|html] ``` -| Flag | Default | Description | -| ---------- | ---------------------------- | --------------------------------------------------------------------------- | -| `--in` | `.vibgrate/scan_result.json` | Input artifact file | -| `--format` | `text` | `md`, `text`, or `json`. Any other value is a usage error (exit `5`) | +| Flag | Default | Description | +| ---------- | ---------------------------- | -------------------------------------------------------------------------------- | +| `--in` | `.vibgrate/scan_result.json` | Input artifact file | +| `--format` | `text` | `md`, `text`, `json`, or `html`. Any other value is a usage error (exit `5`) | An unknown value is rejected before the file is read. The process exits `5`, writes nothing to stdout, and prints one stderr line that names the value and lists the valid ones: ```text -error: unknown --format "html" (expected md, text, json) +error: unknown --format "sarif" (expected md, text, json, html) ``` The comparison is exact. `HTML`, `sarif`, and `md ` are unknown. Omitting `--format` stays on `text`. +`md`, `text`, and `json` are unchanged. `html` writes one self-contained page +from that same artifact. It does not call the network and does not load +external files. The page opens with a summary: + +- DriftScore and the four component scores. A missing score is `n/a`. A measured `0` stays `0`. +- Counts by severity for vulnerability findings and for security findings. A check that did not run stays "Not scanned" rather than a row of zeros. A check that finished with nothing to report shows zeros. +- When a finding carries `details.fixedVersions`, how many of those findings name a fix. +- Links to the full tables under the summary: vulnerabilities, security findings, and the other findings. + +The same artifact always produces the same page. Save it with a shell redirect: + +```bash +vg report --format html > report.html +``` + #### Report format coverage `--format md` is `formatMarkdown` (`src/reporting/formatters/markdown.ts`). -`--format text` is `formatText` (`src/reporting/formatters/text.ts`). Both read -the same artifact. A section below is omitted when its guard is false. Lists -follow artifact order except where a sort is named. Colours in the text report -are terminal styling; under `NO_COLOR` the same words are plain. A null score -is `n/a` in both formats. A measured `0` stays `0`. +`--format text` is `formatText` (`src/reporting/formatters/text.ts`). +`--format html` is `formatHtmlReport` (`src/reporting/formatters/html.ts`). +All three read the same artifact. A section below is omitted when its guard is +false. Lists follow artifact order except where a sort is named. Colours in the +text report are terminal styling; under `NO_COLOR` the same words are plain. A +null score is `n/a` in the human formats. A measured `0` stays `0`. -The same artifact, three times: +The same artifact, four formats: ```bash vg report --in .vibgrate/scan_result.json --format md vg report --in .vibgrate/scan_result.json --format text vg report --in .vibgrate/scan_result.json --format json +vg report --in .vibgrate/scan_result.json --format html ``` ##### Markdown (`formatMarkdown`) @@ -566,7 +583,11 @@ When set: | Architecture Layers | `extended.architecture`. `archetype`, `archetypeConfidence`, `totalClassified`, `unclassified`. **Folders** — the first 12 of `folders`: `path`, `layer`, `confidence`, `fileCount`. **Coverage**, when set — `ratio`, `classified`, `unclassified`, and `bySource` counts sorted by count descending then source name. **Unclassified source** — `unclassifiedFiles.length` when that list is non-empty. **Unclassified by folder** — the first 8 of `unclassifiedFolders`: `count`, `path`. **Layers** with `fileCount` greater than 0: `layer`, `fileCount`, `driftScore`, `riskLevel`. **Boundary violations** — violations whose `rule` does not start with `graph-conflict:`, first 8: `fromFile`, `toFile`, `rule`. **Layer conflicts** — the `graph-conflict:` violations, first 8, same three fields. A capped violation list is marked when `violationsCapped` is set | | Solution Drift Summary | `solutions` is non-empty. `name`, `projectPaths.length`, and `drift.score` or `n/a` | -`relationshipDiagram` is not rendered. The text formatter leaves that diagram in the JSON artifact. +`relationshipDiagram` is not rendered. Markdown, text, and HTML leave that diagram in the JSON artifact. + +##### HTML (`formatHtmlReport`) + +`vg report --format html` writes one self-contained page from the same artifact. It does not call the network and does not load external files. The page opens with the summary listed above (DriftScore, severity counts, a fix-available count when a finding carries `details.fixedVersions`, and links to the tables), then the full vulnerability, security, and other-finding tables. A check that did not run stays "Not scanned". A missing score is `n/a`. The same artifact always produces the same page. ##### Differences @@ -579,10 +600,11 @@ When set: - DriftScore: Markdown puts the lower-is-better gloss in the opening cell. Text puts the score in **DriftScore Summary** without that gloss. - VCS: Markdown uses separate VCS, Branch, and Commit rows, and Commit requires `vcs.sha`. Text uses one line and can show `shortSha` without requiring `sha`. - Runtime wording: Markdown always says `major(s)`. Text says `major` or `majors`. +- HTML is the browser page: the summary above, then the vulnerability, security, and other-finding tables. It does not print Markdown's recommended-standards section or the text inventory. ##### JSON -`--format json` prints `JSON.stringify` of the loaded artifact with a two-space indent. Keys, values, and omissions are unchanged. Parse this when a script needs a field. Markdown and text drop fields, cap lists, and add derived lines; they are not a stable contract. +`--format json` prints `JSON.stringify` of the loaded artifact with a two-space indent. Keys, values, and omissions are unchanged. Parse this when a script needs a field. Markdown, text, and HTML drop fields, cap lists, and add derived lines; they are not a stable contract. --- diff --git a/plugins/claude/vg/skills/vg/SKILL.md b/plugins/claude/vg/skills/vg/SKILL.md index 029ee9e4..ada3b7d4 100644 --- a/plugins/claude/vg/skills/vg/SKILL.md +++ b/plugins/claude/vg/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -39,7 +39,7 @@ measured and the tools improved): - **Upgrade drift:** `vg drift` lists what is outdated across dependencies (offline; `--online` for currency). `vg scan` scores upgrade drift (`text`, `json`, `sarif`, or `md`). `vg report` renders a saved scan - artifact as `text` (the default), `md`, or `json`. There is no HTML report. + artifact as `text` (the default), `md`, `json`, or `html` (a self-contained page). - **Version-correct docs:** `vg lib ` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/skills/vg/SKILL.md b/skills/vg/SKILL.md index 4ad6401d..05fbb225 100644 --- a/skills/vg/SKILL.md +++ b/skills/vg/SKILL.md @@ -3,7 +3,7 @@ name: vg description: Query the local code graph (vg) for structure, impact, and navigation instead of grepping/reading many files. --- - + # vg — the code map @@ -39,7 +39,7 @@ measured and the tools improved): - **Upgrade drift:** `vg drift` lists what is outdated across dependencies (offline; `--online` for currency). `vg scan` scores upgrade drift (`text`, `json`, `sarif`, or `md`). `vg report` renders a saved scan - artifact as `text` (the default), `md`, or `json`. There is no HTML report. + artifact as `text` (the default), `md`, `json`, or `html` (a self-contained page). - **Version-correct docs:** `vg lib ` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/src/install/content.ts b/src/install/content.ts index 76e05e34..632a7d3a 100644 --- a/src/install/content.ts +++ b/src/install/content.ts @@ -22,9 +22,10 @@ export const NUDGE_END = ''; * v3 = library-docs workflow resolves by package name only, and an * `entityCount` of 0 means read `node_modules` rather than retry; * v4 = `vg report` formats are text (default), md, and json. There is no - * HTML report, and sarif belongs to `vg scan`. + * HTML report, and sarif belongs to `vg scan`; + * v5 = `vg report` also accepts `html` (a self-contained page). */ -export const INSTALL_CONTENT_VERSION = 4; +export const INSTALL_CONTENT_VERSION = 5; const VERSION_MARKER_RE = //; @@ -94,7 +95,7 @@ measured and the tools improved): - **Upgrade drift:** \`vg drift\` lists what is outdated across dependencies (offline; \`--online\` for currency). \`vg scan\` scores upgrade drift (\`text\`, \`json\`, \`sarif\`, or \`md\`). \`vg report\` renders a saved scan - artifact as \`text\` (the default), \`md\`, or \`json\`. There is no HTML report. + artifact as \`text\` (the default), \`md\`, \`json\`, or \`html\` (a self-contained page). - **Version-correct docs:** \`vg lib \` returns drift-annotated, version- specific usage docs for a library — inject these instead of guessing an API. diff --git a/src/reporting/commands/report.test.ts b/src/reporting/commands/report.test.ts index ec118114..2fb793b7 100644 --- a/src/reporting/commands/report.test.ts +++ b/src/reporting/commands/report.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { main } from '../../cli.js'; import { ExitCode } from '../../util/exit.js'; +import { REPORT_FORMATS, reportCommand } from './report.js'; const SENTINEL = 'report-format-sentinel'; @@ -91,14 +92,45 @@ async function runCli(args: string[]): Promise<{ code: number; stderr: string; s } describe('vg report --format', () => { + it('names html in help and in the accepted list', () => { + const option = reportCommand.options.find((item) => item.long === '--format'); + expect(option?.description).toBe('Output format (md|text|json|html)'); + expect(REPORT_FORMATS).toEqual(['md', 'text', 'json', 'html']); + }); + + it('renders html from the local artifact and leaves text, json, and md alone', async () => { + const file = writeArtifact(); + const html = await runCli(['report', '--in', file, '--format', 'html']); + expect(html.code).toBe(0); + expect(html.stdout).toContain(''); + expect(html.stdout).toContain('id="summary"'); + expect(html.stdout).toContain('id="drift-score">1'); + expect(html.stderr).toBe(''); + + const text = await runCli(['report', '--in', file]); + expect(text.code).toBe(0); + expect(text.stdout).toContain('Vibgrate Drift Report'); + expect(text.stdout).not.toContain(' { const file = writeArtifact(); - for (const format of ['html', 'HTML', 'sarif']) { + for (const format of ['HTML', 'sarif', 'html ']) { const result = await runCli(['report', '--in', file, '--format', format]); expect(result.code).toBe(ExitCode.USAGE_ERROR); expect(result.stdout).toBe(''); expect(result.stderr).toBe( - `error: unknown --format ${JSON.stringify(format)} (expected md, text, json)\n`, + `error: unknown --format ${JSON.stringify(format)} (expected md, text, json, html)\n`, ); expect(result.stderr).not.toContain(SENTINEL); } diff --git a/src/reporting/commands/report.ts b/src/reporting/commands/report.ts index 136175ab..bd984159 100644 --- a/src/reporting/commands/report.ts +++ b/src/reporting/commands/report.ts @@ -5,10 +5,11 @@ import { usageError } from '../../util/exit.js'; import { pathExists, readJsonFile } from '../utils/fs.js'; import { formatText } from '../formatters/text.js'; import { formatMarkdown } from '../formatters/markdown.js'; +import { formatHtmlReport } from '../formatters/html.js'; import type { ReportFormat, ScanArtifact } from '../types.js'; -/** Accepted by `vg report --format`. `text` is the commander default. */ -const REPORT_FORMATS = ['md', 'text', 'json'] as const satisfies readonly ReportFormat[]; +/** Formats `vg report` accepts. `text` is the default. */ +export const REPORT_FORMATS = ['md', 'text', 'json', 'html'] as const satisfies readonly ReportFormat[]; function assertReportFormat(format: string): asserts format is ReportFormat { if ((REPORT_FORMATS as readonly string[]).includes(format)) return; @@ -43,5 +44,8 @@ export const reportCommand = new Command('report') case 'text': console.log(formatText(artifact)); break; + case 'html': + console.log(formatHtmlReport(artifact)); + break; } }); diff --git a/src/reporting/formatters/html.test.ts b/src/reporting/formatters/html.test.ts index f5a18558..96ab9deb 100644 --- a/src/reporting/formatters/html.test.ts +++ b/src/reporting/formatters/html.test.ts @@ -1,375 +1,300 @@ -import { describe, expect, it, vi } from 'vitest'; -import { formatHtml } from './html.js'; -import { VULN_RULE_ID } from '../../core-open/scanners/vulnerability-scanner.js'; -import { SECURITY_FINDING_SEVERITIES } from '../../security/types.js'; +import { describe, expect, it } from 'vitest'; +import type { SecurityFinding } from '../../core-open/types.js'; import type { ScanArtifact } from '../types.js'; +import { ADVISORY_CORPUS_FOOTER_ID, FIX_AVAILABLE_ATTR, formatHtmlReport } from './html.js'; -const VULN_RULE = 'vibgrate/vulnerability'; +const TOKEN = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyzA'].join('_'); -function artifact(overrides: Record = {}): ScanArtifact { +function artifact(overrides: Partial = {}): ScanArtifact { return { schemaVersion: '1.0', timestamp: '2026-02-16T00:00:00.000Z', - vibgrateVersion: '0.0.0-fixture', - rootPath: '/home/secret-user/proj', - vcs: { type: 'git', remoteUrl: 'https://user:ghp_abcdefghijklmnopqrstuvwxyz@github.com/acme/private.git' }, + vibgrateVersion: '0.0.0', + rootPath: '/work', projects: [], + findings: [], drift: { score: null, riskLevel: null, - components: { runtimeScore: null, frameworkScore: null, dependencyScore: null, eolScore: null }, + components: { + runtimeScore: null, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, }, - findings: [], ...overrides, - } as ScanArtifact; + }; } -function summaryOf(html: string): string { - const start = html.indexOf('
', start); - return html.slice(start, end + '
'.length); +function security(overrides: Partial & Pick): SecurityFinding { + return { + pack: 'iac-cis-v1', + packVersion: '1', + rule: 'rule', + ...overrides, + }; +} + +function block(html: string, id: string): string { + const match = new RegExp(`<(p|ul|dd) id="${id}"[\\s\\S]*?`).exec(html); + if (!match) throw new Error(`missing #${id}`); + return match[0]; } -function sectionOf(html: string, id: string): string { - const start = html.indexOf(`
`); const end = html.indexOf('
', start); return html.slice(start, end); } -describe('formatHtml', () => { - const scanned = artifact({ +function sample(): ScanArtifact { + return artifact({ + drift: { + score: null, + riskLevel: null, + components: { + runtimeScore: 0, + frameworkScore: null, + dependencyScore: null, + eolScore: null, + }, + }, findings: [ { - ruleId: VULN_RULE, + ruleId: 'vibgrate/vulnerability', level: 'note', - message: 'gamma issue', - location: 'gamma', - details: { package: 'gamma', advisoryId: 'GHSA-g' }, + message: `low row token ${TOKEN}`, + location: 'left-pad', + details: { + severity: 'low', + package: 'left-pad', + advisoryId: 'GHSA-low', + cvss: 0, + }, }, { - ruleId: VULN_RULE, + ruleId: 'vibgrate/vulnerability', level: 'error', - message: 'alpha issue', - location: 'alpha', - details: { - package: 'alpha', - advisoryId: 'GHSA-1', - severity: 'critical', - cvss: 9.8, - fixedVersions: ['1.2.3'], - }, + message: ``, + location: 'left-pad', + details: { package: 'left-pad', advisoryId: 'GHSA-miss' }, }, { - ruleId: VULN_RULE, + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: 'critical row', + location: 'left-pad', + details: { severity: 'CRITICAL', package: 'left-pad', advisoryId: 'GHSA-crit', cvss: null }, + }, + { + ruleId: 'vibgrate/vulnerability', level: 'warning', - message: 'beta issue — no fix available', - location: 'beta', + message: 'moderate row', + location: 'left-pad', details: { - package: 'beta', - advisoryId: 'GHSA-2', - severity: 'high', - cvss: null, - fixedVersions: [], + severity: 'moderate', + package: 'left-pad', + advisoryId: 'GHSA-mod', + fixedVersions: ['4.17.21'], }, }, { - ruleId: VULN_RULE, - level: 'note', - message: 'delta issue', - location: 'delta', - details: { - package: 'delta', - advisoryId: 'GHSA-d', - severity: 'unknown', - cvss: 0, - fixedVersions: ['9.9.9'], - }, + ruleId: 'vibgrate/vulnerability', + level: 'error', + message: 'high row', + location: 'left-pad', + details: { severity: 'high', package: 'left-pad', advisoryId: 'GHSA-high', cvss: null, fixedVersions: [] }, }, { ruleId: 'vibgrate/runtime-lag', - level: 'note', - message: 'runtime is current', - location: 'app', + level: 'warning', + message: 'runtime is behind', + location: 'package.json', + details: { fixedVersions: ['9.9.9'] }, }, ], extended: { security: { schema: 'vg.security.v1', - engine: 'test', - packs: { zeta: '1', alpha: '2' }, - facts: { received: 1, evaluated: 1, rejected: 0 }, + engine: 'engine/1', + packs: { 'iac-cis-v1': '1' }, + facts: { received: 2, evaluated: 2, rejected: 0 }, findings: [ - { + security({ id: 'b'.repeat(32), - pack: 'iac-cis-v1', - packVersion: '1', - rule: 'disk-unencrypted', - severity: 'medium', - message: 'disk is open', - path: 'infra/disk.tf', - line: 4, - address: 'aws_ebs_volume.data', - }, + path: 'b.tf', + line: 2, + severity: 'low', + rule: 'second', + message: 'later path', + }), + security({ + id: 'a'.repeat(32), + path: 'a.tf', + line: 1, + severity: 'critical', + rule: 'first', + message: `says & "quoted"`, + }), ], }, }, }); +} - const summary = `
-

Summary

-

DriftScore: n/a

- -

2 with a fix available

- -
`; +describe('formatHtmlReport', () => { + it('renders a summary with severity counts, an absent score, a fix rollup, and section anchors', () => { + const html = formatHtmlReport(sample()); - it('locks the vulnerability rule id to the scanner', () => { - expect(VULN_RULE_ID).toBe(VULN_RULE); - }); + expect(block(html, 'drift-score')).toBe('
n/a
'); + expect(block(html, 'risk-level')).toBe('
n/a
'); + expect(block(html, 'score-runtime')).toBe('
0
'); + expect(block(html, 'score-framework')).toBe('
n/a
'); + expect(html).not.toContain('id="drift-score">0'); - it('opens with the summary block, severity counts, and anchors', () => { - const html = formatHtml(scanned); - expect(summaryOf(html)).toBe(summary); - expect(html.indexOf('id="summary"')).toBeLessThan(html.indexOf('id="vulnerabilities"')); - expect(html.indexOf('id="vulnerabilities"')).toBeLessThan(html.indexOf('id="security"')); - expect(html.indexOf('id="security"')).toBeLessThan(html.indexOf('id="findings"')); - expect(html.indexOf('id="findings"')).toBeLessThan(html.indexOf('id="report-footer"')); - expect(html).toContain('id="vuln-critical"'); - expect(html).toContain('id="vuln-high"'); - expect(html).toContain('id="vuln-unknown"'); - expect(html).toContain('id="vuln-absent"'); - expect(html).toContain('id="security-medium"'); - expect(html).toContain('
'); + const vulns = block(html, 'vuln-counts'); + expect(vulns).toContain('data-evaluated="yes"'); + expect(vulns).toContain('data-severity="critical">critical 1'); + expect(vulns).toContain('data-severity="high">high 1'); + expect(vulns).toContain('data-severity="moderate">moderate 1'); + expect(vulns).toContain('data-severity="low">low 1'); + expect(vulns).toContain('data-severity="unknown">unknown 0'); + expect(vulns).toContain('data-severity="unspecified">unspecified 1'); - const vulns = sectionOf(html, 'vulnerabilities'); - expect(vulns).toContain('alpha issue'); - expect(vulns).toContain('9.8'); - expect(vulns).toContain('n/a'); - expect(vulns).toContain('0'); - expect(vulns).toContain('fix available (1.2.3)'); - expect(vulns).toContain('fix available (9.9.9)'); - expect(vulns).not.toMatch(/no fix/i); - expect(vulns.match(/data-fix-available="yes"/g)).toHaveLength(2); + const securityCounts = block(html, 'security-counts'); + expect(securityCounts).toContain('data-severity="critical">critical 1'); + expect(securityCounts).toContain('data-severity="high">high 0'); + expect(securityCounts).toContain('data-severity="medium">medium 0'); + expect(securityCounts).toContain('data-severity="low">low 1'); + expect(securityCounts).toContain('data-severity="info">info 0'); - const findings = sectionOf(html, 'findings'); - expect(findings).toContain('runtime is current'); - expect(findings).not.toContain('alpha issue'); + expect(block(html, 'fix-available')).toBe('

Fix available: 2

'); - const security = sectionOf(html, 'security'); - expect(security).toContain('alpha@2, zeta@1'); - expect(security).toContain('iac-cis-v1/disk-unencrypted'); - expect(security).toContain('infra/disk.tf:4'); - expect(security.indexOf('medium')).toBeGreaterThan(-1); - expect(summaryOf(html)).not.toContain('moderate 1'); - for (const severity of SECURITY_FINDING_SEVERITIES) { - expect(summaryOf(html)).toContain(severity); - } + expect(html).toContain('href="#vulnerabilities"'); + expect(html).toContain('href="#security"'); + expect(html).toContain('href="#findings"'); + expect(html).toContain('id="summary"'); + expect(html).toContain('id="vulnerabilities"'); + expect(html).toContain('id="security"'); + expect(html).toContain('id="findings"'); }); - it('keeps an absent DriftScore and an absent CVSS off the zero mark', () => { - const html = formatHtml(scanned); - expect(html).toContain('DriftScore: n/a'); - expect(html).not.toContain('DriftScore: 0'); - expect(html).not.toContain('0/100'); - expect(sectionOf(html, 'vulnerabilities')).toContain('n/a'); - }); + it('keeps the full finding tables, in stable order, under the summary', () => { + const html = formatHtmlReport(sample()); + const vulns = section(html, 'vulnerabilities'); + const securitySection = section(html, 'security'); + const findings = section(html, 'findings'); - it('renders a measured zero DriftScore as 0', () => { - const html = formatHtml(artifact({ - drift: { - score: 0, - riskLevel: 'low', - components: { runtimeScore: 0, frameworkScore: 0, dependencyScore: 0, eolScore: 0 }, - }, - })); - expect(summaryOf(html)).toContain('DriftScore: 0/100'); - expect(summaryOf(html)).not.toContain('DriftScore: n/a'); + const crit = vulns.indexOf('GHSA-crit'); + const high = vulns.indexOf('GHSA-high'); + const mod = vulns.indexOf('GHSA-mod'); + const low = vulns.indexOf('GHSA-low'); + const miss = vulns.indexOf('GHSA-miss'); + expect(crit).toBeGreaterThan(-1); + expect(crit).toBeLessThan(high); + expect(high).toBeLessThan(mod); + expect(mod).toBeLessThan(low); + expect(low).toBeLessThan(miss); + + expect(vulns).toContain('criticalGHSA-critleft-padn/a'); + expect(vulns).toContain('lowGHSA-lowleft-pad0'); + expect(vulns).toContain('fix available (4.17.21)'); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="yes"`); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="no"`); + + expect(securitySection.indexOf('a.tf:1')).toBeLessThan(securitySection.indexOf('b.tf:2')); + expect(securitySection).toContain('iac-cis-v1/first'); + expect(findings).toContain('vibgrate/runtime-lag'); + expect(html).not.toContain('engine/1'); + expect(findings).not.toContain('GHSA-crit'); + expect(html.indexOf('id="summary"')).toBeLessThan(html.indexOf('id="vulnerabilities"')); }); - it('does not show zero severity counts when a scan did not run', () => { - const html = formatHtml(artifact()); - const summary = summaryOf(html); - expect(summary).toContain('Vulnerabilities: not scanned'); - expect(summary).toContain('Security: not evaluated'); - expect(summary).not.toContain('critical 0'); - expect(summary).not.toContain('absent'); - expect(summary).not.toContain('fix-available'); - expect(html).not.toMatch(/no fix/i); - expect(sectionOf(html, 'vulnerabilities')).toContain('Not scanned.'); - expect(sectionOf(html, 'security')).toContain('Not evaluated.'); + it('escapes markup and redacts credential-shaped text', () => { + const html = formatHtmlReport(sample()); + expect(html).toContain('<script>alert("x") & 'y'</script>'); + expect(html).toContain('says <b> & "quoted"'); + expect(html).not.toContain(' { - const html = formatHtml(artifact({ - extended: { - vulnerabilities: { - source: 'osv', - packages: [], - totalAdvisories: 0, - severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, - }, - }, - })); - expect(summaryOf(html)).toContain( - 'Vulnerabilities: critical 0 · high 0 · moderate 0 · low 0 · unknown 0', - ); - expect(summaryOf(html)).not.toContain('absent'); - expect(sectionOf(html, 'vulnerabilities')).toContain('No vulnerability findings.'); + it('does not invent a zero for a score or a check that did not run', () => { + const html = formatHtmlReport(artifact()); + expect(block(html, 'drift-score')).toBe('
n/a
'); + expect(block(html, 'vuln-counts')).toContain('Not scanned'); + expect(block(html, 'vuln-counts')).not.toContain('critical'); + expect(block(html, 'security-counts')).toContain('Not scanned'); + expect(html).not.toContain('id="fix-available"'); + expect(section(html, 'vulnerabilities')).toContain('Not scanned'); + expect(section(html, 'security')).toContain('Not scanned'); + expect(section(html, 'findings')).toContain('None'); }); - it('does not treat an unreachable vulnerability scan as a clean zero', () => { - const html = formatHtml(artifact({ + it('renders real zeros when a vulnerability check finished with nothing to report', () => { + const html = formatHtmlReport(artifact({ extended: { vulnerabilities: { - source: 'unreachable', + source: 'none', packages: [], totalAdvisories: 0, severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, }, }, })); - expect(summaryOf(html)).toContain('Vulnerabilities: not checked'); - expect(summaryOf(html)).not.toContain('critical 0'); + expect(block(html, 'vuln-counts')).toContain('data-evaluated="yes"'); + expect(block(html, 'vuln-counts')).toContain('critical 0'); + expect(section(html, 'vulnerabilities')).toContain('None'); }); - it('does not treat a scan with no packages as a clean zero', () => { - const html = formatHtml(artifact({ + it('leaves an unfinished vulnerability check blank instead of zero', () => { + const html = formatHtmlReport(artifact({ extended: { vulnerabilities: { - source: 'none', + source: 'unreachable', packages: [], totalAdvisories: 0, severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, }, }, })); - expect(summaryOf(html)).toContain('Vulnerabilities: no packages to check'); - expect(summaryOf(html)).not.toContain('critical 0'); + expect(block(html, 'vuln-counts')).toContain('Not scanned'); + expect(html).not.toContain('critical 0'); }); - it('uses advisory severity when the finding does not record one', () => { - const html = formatHtml(artifact({ + it('shows a zero fix rollup when the field is present and no version is listed', () => { + const html = formatHtmlReport(artifact({ findings: [ { - ruleId: VULN_RULE, - level: 'error', - message: 'lodash issue', - location: 'lodash', - details: { package: 'lodash', advisoryId: 'GHSA-a', fixedVersions: ['4.17.21'] }, + ruleId: 'vibgrate/runtime-lag', + level: 'warning', + message: 'behind', + location: 'package.json', + details: { fixedVersions: [] }, }, ], - extended: { - vulnerabilities: { - source: 'manifest', - packages: [ - { - ecosystem: 'npm', - package: 'lodash', - version: '4.17.20', - advisories: [{ id: 'GHSA-a', severity: 'high', cvss: 7.5 }], - }, - ], - totalAdvisories: 1, - severityCounts: { low: 0, moderate: 0, high: 1, critical: 0, unknown: 0 }, - }, - }, })); - expect(summaryOf(html)).toContain('high 1'); - expect(summaryOf(html)).not.toContain('absent'); - expect(sectionOf(html, 'vulnerabilities')).toContain('7.5'); - expect(summaryOf(html)).toContain('1 with a fix available'); + expect(block(html, 'fix-available')).toBe('

Fix available: 0

'); }); - it('counts an unrecognized severity as absent, not as unknown or zero', () => { - const html = formatHtml(artifact({ - findings: [ - { - ruleId: VULN_RULE, - level: 'note', - message: 'odd', - location: 'odd', - details: { package: 'odd', severity: 'severe', cvss: null }, - }, - ], - })); - expect(summaryOf(html)).toContain('unknown 0'); - expect(summaryOf(html)).toContain('absent 1'); - expect(summaryOf(html)).not.toContain('unknown 1'); - }); + it('is deterministic and does not embed a timestamp, a script, or external assets', () => { + const first = sample(); + const again = formatHtmlReport(first); + expect(formatHtmlReport(first)).toBe(again); - it('escapes markup and redacts credential-shaped text', () => { - const token = 'ghp_abcdefghijklmnopqrstuvwxyz'; - const html = formatHtml(artifact({ - findings: [ - { - ruleId: VULN_RULE, - level: 'error', - message: ` ${token}`, - location: 'p&q', - details: { - package: 'a', - advisoryId: 'x"y', - severity: 'low', - cvss: 1, - fixedVersions: ['1<2'], - }, - }, - ], - })); - expect(html).not.toContain(' { - const now = vi.spyOn(Date, 'now').mockReturnValueOnce(1).mockReturnValueOnce(2); - const random = vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.9); - try { - const first = formatHtml(scanned); - const second = formatHtml(scanned); - expect(second).toBe(first); + const shuffled = structuredClone(first); + shuffled.findings = [...first.findings].reverse(); + shuffled.extended!.security!.findings = [...first.extended!.security!.findings].reverse(); + expect(formatHtmlReport(shuffled)).toBe(again); - const findings = [...(scanned.findings ?? [])].reverse(); - const extended = scanned.extended as { security?: { findings?: unknown[] } }; - const securityFindings = [...(extended.security?.findings ?? [])].reverse(); - const shuffled = artifact({ - findings, - extended: { - security: { - ...(extended.security as object), - findings: securityFindings, - packs: { zeta: '1', alpha: '2' }, - }, - }, - }); - expect(formatHtml(shuffled)).toBe(first); - } finally { - now.mockRestore(); - random.mockRestore(); - } - }); - - it('embeds no external assets', () => { - const html = formatHtml(artifact()); - expect(html.startsWith('')).toBe(true); - expect(html).not.toContain(']/i); + expect(again).not.toMatch(/\ssrc\s*=/i); + expect(again).not.toMatch(/