Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
32 changes: 29 additions & 3 deletions src/reporting/formatters/html.test.ts
Original file line number Diff line number Diff line change
@@ -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('_');

Expand Down Expand Up @@ -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('<p id="fix-available">');
expect(section(html, 'vulnerabilities')).toContain('Not scanned');
expect(section(html, 'security')).toContain('Not scanned');
expect(section(html, 'findings')).toContain('None');
Expand Down Expand Up @@ -278,6 +278,31 @@ describe('formatHtmlReport', () => {
expect(block(html, 'fix-available')).toBe('<p id="fix-available">Fix available: 0</p>');
});

it('adds an unchecked fix-available-only toggle and still emits every row', () => {
const html = formatHtmlReport(sample());
const control = `<p id="fix-filter"><label><input id="${FIX_AVAILABLE_ONLY_ID}" type="checkbox"> Fix available only</label></p>`;
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(/<input[^>]*\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);
Expand All @@ -293,7 +318,8 @@ describe('formatHtmlReport', () => {
expect(again).not.toMatch(/<link[\s>]/i);
expect(again).not.toMatch(/\ssrc\s*=/i);
expect(again).not.toMatch(/<details/i);
expect(again).not.toMatch(/fix available only/i);
expect(again).toContain('Fix available only');
expect(again).toContain(`<input id="${FIX_AVAILABLE_ONLY_ID}" type="checkbox">`);
expect(again).toContain(`<div id="${ADVISORY_CORPUS_FOOTER_ID}" hidden></div>`);
expect(again.startsWith('<!doctype html>')).toBe(true);
});
Expand Down
29 changes: 24 additions & 5 deletions src/reporting/formatters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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; }
</style>
</head>
<body>
Expand All @@ -93,7 +103,7 @@ export function formatHtmlReport(artifact: ScanArtifact): string {
${renderCounts('vuln-counts', VULN_SEVERITIES, vulnCounts)}
<h3><a href="#security">Security</a></h3>
${renderCounts('security-counts', SECURITY_SEVERITIES, securityCounts)}
${fix === null ? '' : `<p id="fix-available">Fix available: ${fix}</p>\n`}
${fix === null ? '' : `<p id="fix-available">Fix available: ${fix}</p>\n`}${fixAvailableOnlyControl()}
<nav aria-label="Report sections">
<a href="#vulnerabilities">Vulnerabilities</a>
<a href="#security">Security</a>
Expand All @@ -118,6 +128,15 @@ ${advisoryCorpusFooter()}
`;
}

/**
* Local checkbox. Unchecked in the file, so the serialized page is the
* unfiltered report. A browser hides `data-fix-available="no"` rows only
* after the reader checks it.
*/
function fixAvailableOnlyControl(): string {
return `<p id="fix-filter"><label><input id="${FIX_AVAILABLE_ONLY_ID}" type="checkbox"> Fix available only</label></p>\n`;
}

/** Empty mount. A later change can fill this with a collapsible footer. */
function advisoryCorpusFooter(): string {
return `<div id="${ADVISORY_CORPUS_FOOTER_ID}" hidden></div>`;
Expand Down
Loading