diff --git a/DOCS.md b/DOCS.md index 5b8fbe0f..694f71b3 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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: @@ -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 @@ -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 diff --git a/src/reporting/formatters/html.test.ts b/src/reporting/formatters/html.test.ts index c59b2244..98bd66a1 100644 --- a/src/reporting/formatters/html.test.ts +++ b/src/reporting/formatters/html.test.ts @@ -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(`', start); + if (start < 0 || end < 0) throw new Error('missing advisory footer'); + return html.slice(start, end + ''.length); +} + +function vulnResult( + source: 'osv' | 'manifest' | 'unreachable' | 'none', + totalAdvisories: number, +): NonNullable['vulnerabilities'] { + return { + source, + packages: [], + totalAdvisories, + severityCounts: { low: 0, moderate: 0, high: 0, critical: 0, unknown: 0 }, + }; +} + function sample(): ScanArtifact { return artifact({ drift: { @@ -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('

Not scanned

'); + expect(footer(html)).toContain('Advisory data: Not scanned'); + expect(footer(html)).not.toContain('id="advisory-count"'); }); it('renders real zeros when a vulnerability check finished with nothing to report', () => { @@ -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('
No packages to check
'); + expect(block(html, 'advisory-count')).toBe('
0
'); + expect(footer(html)).toContain('Advisory data: No packages to check, 0 advisories'); }); it('leaves an unfinished vulnerability check blank instead of zero', () => { @@ -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('

Not checked

'); + expect(footer(html)).toContain('Advisory data: Not checked'); + 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', () => { @@ -278,6 +307,54 @@ describe('formatHtmlReport', () => { expect(block(html, 'fix-available')).toBe('

Fix available: 0

'); }); + 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('Advisory data: OSV, 1 advisory'); + expect(block(osv, 'advisory-source')).toBe('
OSV
'); + expect(block(osv, 'advisory-count')).toBe('
1
'); + + const manifest = formatHtmlReport(artifact({ + extended: { vulnerabilities: vulnResult('manifest', 2) }, + })); + expect(block(manifest, 'advisory-source')).toBe('
Offline package-version manifest
'); + expect(block(manifest, 'advisory-count')).toBe('
2
'); + + const missingTotal = vulnResult('osv', 0); + delete (missingTotal as { totalAdvisories?: number }).totalAdvisories; + const omitted = formatHtmlReport(artifact({ extended: { vulnerabilities: missingTotal } })); + expect(footer(omitted)).toContain('Advisory data: OSV'); + 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 = ` ${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('

Not scanned

'); + expect(html).not.toContain(TOKEN); + expect(html).not.toContain(' { + const html = formatHtmlReport(sample()); + expect(block(html, 'advisory-source')).toBe('

Not recorded

'); + 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 = `

`; @@ -317,10 +394,12 @@ describe('formatHtmlReport', () => { expect(again).not.toMatch(/