diff --git a/DOCS.md b/DOCS.md index 92c8bdf2..2cc04ce2 100644 --- a/DOCS.md +++ b/DOCS.md @@ -501,6 +501,7 @@ 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. +- A **Fix available only** checkbox. It starts unchecked, so the tables show every finding. Checking it hides findings that do not name a fix version. The page file still contains every finding, and the checkbox does not call the network. - 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: @@ -597,7 +598,7 @@ When set: ##### 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. +`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`, a Fix available only checkbox, and links to the tables), then the full vulnerability, security, and other-finding tables. The checkbox starts unchecked. Checking it hides findings that do not name a fix version. Those rows stay in the page file, and the summary counts stay the full set. A check that did not run stays "Not scanned". A missing score is `n/a`. The same artifact always produces the same page. ##### Differences @@ -610,7 +611,7 @@ 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. +- HTML is the browser page: the summary above, then the vulnerability, security, and other-finding tables. A Fix available only checkbox starts unchecked and can hide findings that do not name a fix version. The page file still lists every finding. It does not print Markdown's recommended-standards section or the text inventory. ##### JSON diff --git a/src/reporting/formatters/html.test.ts b/src/reporting/formatters/html.test.ts index 96ab9deb..c59b2244 100644 --- a/src/reporting/formatters/html.test.ts +++ b/src/reporting/formatters/html.test.ts @@ -1,7 +1,7 @@ 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'; +import { ADVISORY_CORPUS_FOOTER_ID, FIX_AVAILABLE_ATTR, FIX_AVAILABLE_ONLY_ID, formatHtmlReport } from './html.js'; const TOKEN = ['ghp', '0123456789abcdefghijklmnopqrstuvwxyzA'].join('_'); @@ -226,7 +226,7 @@ describe('formatHtmlReport', () => { 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(html).not.toContain('

'); expect(section(html, 'vulnerabilities')).toContain('Not scanned'); expect(section(html, 'security')).toContain('Not scanned'); expect(section(html, 'findings')).toContain('None'); @@ -278,6 +278,31 @@ describe('formatHtmlReport', () => { expect(block(html, 'fix-available')).toBe('

Fix available: 0

'); }); + it('adds an unchecked fix-available-only toggle and still emits every row', () => { + const html = formatHtmlReport(sample()); + const control = `

`; + const rule = `body:has(#${FIX_AVAILABLE_ONLY_ID}:checked) tr[${FIX_AVAILABLE_ATTR}="no"] { display: none; }`; + + expect(html).toContain(control); + expect(html).toContain(rule); + expect(html).not.toMatch(/]*\schecked[\s=>]/); + expect(html.split(control)).toHaveLength(2); + + const vulns = section(html, 'vulnerabilities'); + const findings = section(html, 'findings'); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="yes"`); + expect(vulns).toContain(`${FIX_AVAILABLE_ATTR}="no"`); + expect(vulns).toContain('fix available (4.17.21)'); + expect(vulns).toContain('GHSA-high'); + expect(findings).toContain(`${FIX_AVAILABLE_ATTR}="yes"`); + expect(section(html, 'security')).toContain(`${FIX_AVAILABLE_ATTR}="no"`); + + const empty = formatHtmlReport(artifact()); + expect(empty).toContain(control); + expect(empty).toContain(rule); + expect(formatHtmlReport(sample())).toBe(html); + }); + it('is deterministic and does not embed a timestamp, a script, or external assets', () => { const first = sample(); const again = formatHtmlReport(first); @@ -293,7 +318,8 @@ describe('formatHtmlReport', () => { expect(again).not.toMatch(/]/i); expect(again).not.toMatch(/\ssrc\s*=/i); expect(again).not.toMatch(/
`); expect(again).toContain(``); expect(again.startsWith('')).toBe(true); }); diff --git a/src/reporting/formatters/html.ts b/src/reporting/formatters/html.ts index e1c98703..c93f73b0 100644 --- a/src/reporting/formatters/html.ts +++ b/src/reporting/formatters/html.ts @@ -11,16 +11,25 @@ import type { Finding, ScanArtifact } from '../types.js'; * * Stable ids: `#summary`, `#vulnerabilities`, `#security`, `#findings`. * - * Two extension points, unused today: - * - Rows carry {@link FIX_AVAILABLE_ATTR} so a client-side "fix available - * only" filter can be added without changing the row model. + * {@link FIX_AVAILABLE_ONLY_ID} is an unchecked checkbox. Checking it hides + * rows whose {@link FIX_AVAILABLE_ATTR} is `no`. The page file always contains + * every row, so the default (unfiltered) document stays a pure function of the + * artifact. The checkbox is presentation only: no network, no external file. + * + * One extension point, unused today: * - {@link advisoryCorpusFooter} emits an empty `#advisory-corpus` mount for * a later collapsible advisory-corpus metadata footer. */ -/** Row attribute a later "fix available only" filter can select on. */ +/** Row attribute the Fix available only checkbox selects on. `yes` or `no`. */ export const FIX_AVAILABLE_ATTR = 'data-fix-available'; +/** + * Checkbox id. The control is emitted unchecked, so the page opens showing + * every finding. + */ +export const FIX_AVAILABLE_ONLY_ID = 'fix-available-only'; + /** Element id reserved for a later advisory-corpus metadata footer. */ export const ADVISORY_CORPUS_FOOTER_ID = 'advisory-corpus'; @@ -75,6 +84,7 @@ export function formatHtmlReport(artifact: ScanArtifact): string { nav a { margin-right: 1rem; } a { color: inherit; } .absent { font-style: italic; } + body:has(#${FIX_AVAILABLE_ONLY_ID}:checked) tr[${FIX_AVAILABLE_ATTR}="no"] { display: none; } @@ -93,7 +103,7 @@ export function formatHtmlReport(artifact: ScanArtifact): string { ${renderCounts('vuln-counts', VULN_SEVERITIES, vulnCounts)}

Security

${renderCounts('security-counts', SECURITY_SEVERITIES, securityCounts)} -${fix === null ? '' : `

Fix available: ${fix}

\n`} +${fix === null ? '' : `

Fix available: ${fix}

\n`}${fixAvailableOnlyControl()}