Skip to content
5 changes: 3 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ external files. The page opens with a summary:
- 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.
- A collapsed footer after the tables names the advisory source when the artifact records one: OSV, an offline package-version manifest, or no packages to check. The advisory count is included when it is present, and a finished check with nothing to report shows 0. A check that did not run says "Not scanned". A check that could not reach its source says "Not checked" and omits the count. Findings with no recorded source say "Not recorded".

The same artifact always produces the same page. Save it with a shell redirect:

Expand Down Expand Up @@ -599,7 +600,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`, 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.
`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`. A collapsed footer after the tables names the advisory source (OSV, an offline package-version manifest, or no packages to check) and the advisory count when that count is present. A missing source stays "Not scanned", "Not checked", or "Not recorded", and a missing count is left out rather than shown as 0. The same artifact always produces the same page.

##### Differences

Expand All @@ -612,7 +613,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. 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.
- HTML is the browser page: the summary above, then the vulnerability, security, and other-finding tables, then a collapsed advisory-data footer. 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. Text's closing line is the scan time and file counts. Markdown has no advisory-source line.

##### JSON

Expand Down
83 changes: 81 additions & 2 deletions src/reporting/formatters/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ function section(html: string, id: string): string {
return html.slice(start, end);
}

function footer(html: string): string {
const start = html.indexOf(`<footer id="${ADVISORY_CORPUS_FOOTER_ID}">`);
const end = html.indexOf('</footer>', start);
if (start < 0 || end < 0) throw new Error('missing advisory footer');
return html.slice(start, end + '</footer>'.length);
}

function vulnResult(
source: 'osv' | 'manifest' | 'unreachable' | 'none',
totalAdvisories: number,
): NonNullable<ScanArtifact['extended']>['vulnerabilities'] {
return {
source,
packages: [],
totalAdvisories,
severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 },
};
}

function sample(): ScanArtifact {
return artifact({
drift: {
Expand Down Expand Up @@ -230,6 +249,9 @@ describe('formatHtmlReport', () => {
expect(section(html, 'vulnerabilities')).toContain('Not scanned');
expect(section(html, 'security')).toContain('Not scanned');
expect(section(html, 'findings')).toContain('None');
expect(block(html, 'advisory-source')).toBe('<p id="advisory-source" class="absent">Not scanned</p>');
expect(footer(html)).toContain('<summary>Advisory data: Not scanned</summary>');
expect(footer(html)).not.toContain('id="advisory-count"');
});

it('renders real zeros when a vulnerability check finished with nothing to report', () => {
Expand All @@ -246,6 +268,9 @@ describe('formatHtmlReport', () => {
expect(block(html, 'vuln-counts')).toContain('data-evaluated="yes"');
expect(block(html, 'vuln-counts')).toContain('critical 0');
expect(section(html, 'vulnerabilities')).toContain('None');
expect(block(html, 'advisory-source')).toBe('<dd id="advisory-source">No packages to check</dd>');
expect(block(html, 'advisory-count')).toBe('<dd id="advisory-count">0</dd>');
expect(footer(html)).toContain('<summary>Advisory data: No packages to check, 0 advisories</summary>');
});

it('leaves an unfinished vulnerability check blank instead of zero', () => {
Expand All @@ -261,6 +286,10 @@ describe('formatHtmlReport', () => {
}));
expect(block(html, 'vuln-counts')).toContain('Not scanned');
expect(html).not.toContain('critical 0');
expect(block(html, 'advisory-source')).toBe('<p id="advisory-source" class="absent">Not checked</p>');
expect(footer(html)).toContain('<summary>Advisory data: Not checked</summary>');
expect(footer(html)).not.toContain('id="advisory-count"');
expect(footer(html)).not.toContain('0 advisories');
});

it('shows a zero fix rollup when the field is present and no version is listed', () => {
Expand All @@ -278,6 +307,54 @@ describe('formatHtmlReport', () => {
expect(block(html, 'fix-available')).toBe('<p id="fix-available">Fix available: 0</p>');
});

it('names a finished advisory source and keeps a missing total absent', () => {
const osv = formatHtmlReport(artifact({
findings: [{
ruleId: 'vibgrate/vulnerability',
level: 'error',
message: 'critical row',
location: 'left-pad',
details: { severity: 'critical', package: 'left-pad', advisoryId: 'GHSA-crit' },
}],
extended: { vulnerabilities: vulnResult('osv', 1) },
}));
expect(footer(osv)).toContain('<summary>Advisory data: OSV, 1 advisory</summary>');
expect(block(osv, 'advisory-source')).toBe('<dd id="advisory-source">OSV</dd>');
expect(block(osv, 'advisory-count')).toBe('<dd id="advisory-count">1</dd>');

const manifest = formatHtmlReport(artifact({
extended: { vulnerabilities: vulnResult('manifest', 2) },
}));
expect(block(manifest, 'advisory-source')).toBe('<dd id="advisory-source">Offline package-version manifest</dd>');
expect(block(manifest, 'advisory-count')).toBe('<dd id="advisory-count">2</dd>');

const missingTotal = vulnResult('osv', 0);
delete (missingTotal as { totalAdvisories?: number }).totalAdvisories;
const omitted = formatHtmlReport(artifact({ extended: { vulnerabilities: missingTotal } }));
expect(footer(omitted)).toContain('<summary>Advisory data: OSV</summary>');
expect(footer(omitted)).not.toContain('id="advisory-count"');
expect(footer(omitted)).not.toContain('0 advisories');
});

it('does not echo an unknown advisory source', () => {
const hostile = `<img src=x onerror=alert(1)> ${TOKEN}`;
const recorded = vulnResult('osv', 4);
(recorded as { source: string }).source = hostile;
const html = formatHtmlReport(artifact({ extended: { vulnerabilities: recorded } }));
expect(block(html, 'advisory-source')).toBe('<p id="advisory-source" class="absent">Not scanned</p>');
expect(html).not.toContain(TOKEN);
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror');
expect(footer(html)).not.toContain('4');
});

it('says the advisory source was not recorded when findings have no source', () => {
const html = formatHtmlReport(sample());
expect(block(html, 'advisory-source')).toBe('<p id="advisory-source" class="absent">Not recorded</p>');
expect(footer(html)).not.toContain('id="advisory-count"');
expect(html.indexOf('id="findings"')).toBeLessThan(html.indexOf(`id="${ADVISORY_CORPUS_FOOTER_ID}"`));
});

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>`;
Expand Down Expand Up @@ -317,10 +394,12 @@ describe('formatHtmlReport', () => {
expect(again).not.toMatch(/<script/i);
expect(again).not.toMatch(/<link[\s>]/i);
expect(again).not.toMatch(/\ssrc\s*=/i);
expect(again).not.toMatch(/<details/i);
expect(again.match(/<details/gi)).toEqual(['<details']);
expect(again).not.toMatch(/<details[^>]*\bopen\b/i);
expect(footer(again)).toContain(`<footer id="${ADVISORY_CORPUS_FOOTER_ID}">`);
expect(footer(again)).toContain('<summary>Advisory data: Not recorded</summary>');
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);
});
});
112 changes: 97 additions & 15 deletions src/reporting/formatters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import type { Finding, ScanArtifact } from '../types.js';
* Pure function of the artifact: stable sorts, no clock, no randomness, no
* network, no external assets. Artifact strings are redacted, then escaped.
*
* Stable ids: `#summary`, `#vulnerabilities`, `#security`, `#findings`.
* Stable ids: `#summary`, `#vulnerabilities`, `#security`, `#findings`,
* `#advisory-corpus`.
*
* {@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.
* Rows carry {@link FIX_AVAILABLE_ATTR}. {@link FIX_AVAILABLE_ONLY_ID} is an
* unchecked checkbox. Checking it hides rows whose attribute is `no`. The
* page file always contains every row, so the default 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.
* {@link advisoryCorpusFooter} fills `#advisory-corpus` with a collapsed
* footer. It names the advisory source recorded on the artifact, and it says
* "Not scanned", "Not checked", or "Not recorded" when that fact is absent.
* A missing advisory total is omitted rather than printed as zero.
*/

/** Row attribute the Fix available only checkbox selects on. `yes` or `no`. */
Expand All @@ -30,7 +33,7 @@ export const FIX_AVAILABLE_ATTR = 'data-fix-available';
*/
export const FIX_AVAILABLE_ONLY_ID = 'fix-available-only';

/** Element id reserved for a later advisory-corpus metadata footer. */
/** Element id of the collapsed advisory-data footer. */
export const ADVISORY_CORPUS_FOOTER_ID = 'advisory-corpus';

const VULN_RULE_ID = 'vibgrate/vulnerability';
Expand Down Expand Up @@ -85,6 +88,8 @@ export function formatHtmlReport(artifact: ScanArtifact): string {
a { color: inherit; }
.absent { font-style: italic; }
body:has(#${FIX_AVAILABLE_ONLY_ID}:checked) tr[${FIX_AVAILABLE_ATTR}="no"] { display: none; }
#${ADVISORY_CORPUS_FOOTER_ID} { margin-top: 2.5rem; font-size: 0.85rem; }
#${ADVISORY_CORPUS_FOOTER_ID} summary { cursor: pointer; }
</style>
</head>
<body>
Expand Down Expand Up @@ -122,12 +127,94 @@ ${securityEvaluated ? renderSecurityTable(securityRows) : '<p class="absent">Not
<h2>Findings</h2>
${renderFindingTable(otherRows)}
</section>
${advisoryCorpusFooter()}
${advisoryCorpusFooter(artifact)}
</body>
</html>
`;
}

const ADVISORY_SOURCES = ['osv', 'manifest', 'unreachable', 'none'] as const;
type AdvisorySourceName = (typeof ADVISORY_SOURCES)[number];

const ADVISORY_SOURCE_LABEL: Record<AdvisorySourceName, string> = {
osv: 'OSV',
manifest: 'Offline package-version manifest',
unreachable: 'Not checked',
none: 'No packages to check',
};

/**
* Collapsed footer. Closed by default so the finding tables stay first.
* Known sources are the only strings copied from the artifact. An unknown
* source value is not echoed.
*/
function advisoryCorpusFooter(artifact: ScanArtifact): string {
const view = advisoryCorpusView(artifact);
const summary = view.count === null
? `Advisory data: ${view.label}`
: `Advisory data: ${view.label}, ${advisoryCountPhrase(view.count)}`;
const body = view.absent
? `<p id="advisory-source" class="absent">${text(view.label)}</p>`
: advisoryCorpusList(view.label, view.count);
return `<footer id="${ADVISORY_CORPUS_FOOTER_ID}">
<details>
<summary>${text(summary)}</summary>
${body}
</details>
</footer>`;
}

function advisoryCorpusList(label: string, count: number | null): string {
const countRow = count === null
? ''
: `<dt>Advisories</dt><dd id="advisory-count">${count}</dd>\n`;
return `<dl>
<dt>Source</dt><dd id="advisory-source">${text(label)}</dd>
${countRow}</dl>`;
}

interface AdvisoryCorpusView {
label: string;
/** Null when the artifact does not record a finished total. Never a stand-in zero. */
count: number | null;
absent: boolean;
}

function advisoryCorpusView(artifact: ScanArtifact): AdvisoryCorpusView {
const recorded = artifact.extended?.vulnerabilities;
const source = advisorySourceName(recorded?.source);
if (source === 'unreachable') {
return { label: ADVISORY_SOURCE_LABEL.unreachable, count: null, absent: true };
}
if (source === 'osv' || source === 'manifest' || source === 'none') {
return {
label: ADVISORY_SOURCE_LABEL[source],
count: advisoryTotal(recorded?.totalAdvisories),
absent: false,
};
}
const findings = Array.isArray(artifact.findings) ? artifact.findings : [];
if (findings.some((finding) => finding.ruleId === VULN_RULE_ID)) {
return { label: 'Not recorded', count: null, absent: true };
}
return { label: 'Not scanned', count: null, absent: true };
}

function advisorySourceName(value: unknown): AdvisorySourceName | null {
if (typeof value !== 'string') return null;
return (ADVISORY_SOURCES as readonly string[]).includes(value) ? (value as AdvisorySourceName) : null;
}

/** A non-negative integer is a real total, including 0. Anything else is absent. */
function advisoryTotal(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) return null;
return value;
}

function advisoryCountPhrase(count: number): string {
return count === 1 ? '1 advisory' : `${count} advisories`;
}

/**
* Local checkbox. Unchecked in the file, so the serialized page is the
* unfiltered report. A browser hides `data-fix-available="no"` rows only
Expand All @@ -137,11 +224,6 @@ 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>`;
}

function vulnerabilityEvaluated(artifact: ScanArtifact, vulnFindings: Finding[]): boolean {
if (vulnFindings.length > 0) return true;
const source = artifact.extended?.vulnerabilities?.source;
Expand Down
Loading