@secureport/core 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/report/csv.d.ts +41 -0
  6. package/dist/report/csv.d.ts.map +1 -0
  7. package/dist/report/csv.js +158 -0
  8. package/dist/report/csv.js.map +1 -0
  9. package/dist/report/fonts.d.ts +53 -0
  10. package/dist/report/fonts.d.ts.map +1 -0
  11. package/dist/report/fonts.js +53 -0
  12. package/dist/report/fonts.js.map +1 -0
  13. package/dist/report/html.d.ts.map +1 -1
  14. package/dist/report/html.js +48 -13
  15. package/dist/report/html.js.map +1 -1
  16. package/dist/report/json.d.ts +15 -0
  17. package/dist/report/json.d.ts.map +1 -1
  18. package/dist/report/json.js +1 -0
  19. package/dist/report/json.js.map +1 -1
  20. package/dist/report/markdown.d.ts +33 -2
  21. package/dist/report/markdown.d.ts.map +1 -1
  22. package/dist/report/markdown.js +67 -16
  23. package/dist/report/markdown.js.map +1 -1
  24. package/dist/report/model.d.ts +108 -1
  25. package/dist/report/model.d.ts.map +1 -1
  26. package/dist/report/model.js +173 -36
  27. package/dist/report/model.js.map +1 -1
  28. package/dist/report/sarif.d.ts +60 -0
  29. package/dist/report/sarif.d.ts.map +1 -0
  30. package/dist/report/sarif.js +125 -0
  31. package/dist/report/sarif.js.map +1 -0
  32. package/dist/report/stream.d.ts +119 -0
  33. package/dist/report/stream.d.ts.map +1 -0
  34. package/dist/report/stream.js +546 -0
  35. package/dist/report/stream.js.map +1 -0
  36. package/package.json +5 -3
  37. package/src/index.ts +7 -1
  38. package/src/report/csv.ts +186 -0
  39. package/src/report/fonts.ts +55 -0
  40. package/src/report/html.ts +57 -14
  41. package/src/report/json.ts +17 -0
  42. package/src/report/markdown.ts +79 -15
  43. package/src/report/model.ts +242 -45
  44. package/src/report/sarif.ts +166 -0
  45. package/src/report/stream.ts +727 -0
@@ -0,0 +1,727 @@
1
+ import type { RunSummary } from '../run.js';
2
+ import { SEVERITY_ORDER } from '../severity.js';
3
+ import type { Severity } from '../severity.js';
4
+ import type { SnapshotIssue, SuppressedIssue, Target } from '../snapshot.js';
5
+ import { cell, formatDate, issueDetail, issueRow, timelineCell } from './markdown.js';
6
+ import {
7
+ brandingProblem,
8
+ describeBasis,
9
+ describeLimitations,
10
+ DEFAULT_TITLES,
11
+ resolveAttestor,
12
+ verdictFor,
13
+ VERDICT_LABELS,
14
+ type OmittedIssues,
15
+ type ReportOptions,
16
+ type RetestVerdict,
17
+ } from './model.js';
18
+
19
+ /**
20
+ * Everything a streaming machine-readable report needs that is not per-issue.
21
+ *
22
+ * **Deliberately not a {@link Snapshot}.** A `Snapshot`'s `issues` and
23
+ * `suppressed` are eagerly-materialised arrays — the right shape for PDF and
24
+ * HTML, which are queued, capped at 500 issues, and fully in memory before
25
+ * Chromium ever runs. The machine formats have no such cap, so their
26
+ * assembler must never hold every issue at once; a `ReportHeader` carries
27
+ * only the handful of fields that do not scale with issue count, and the
28
+ * issues themselves arrive as an `AsyncIterable`.
29
+ */
30
+ export interface ReportHeader {
31
+ /** The run this report describes. */
32
+ readonly run: RunSummary;
33
+
34
+ /** What to compare against, for a retest. Absent for a first run. */
35
+ readonly baseline?: RunSummary;
36
+
37
+ /** The system under test. */
38
+ readonly target: Target;
39
+ }
40
+
41
+ /** How many issues a severity floor kept out of the streamed `issues` array. */
42
+ interface FoldState {
43
+ includesManual: boolean;
44
+ breached: number;
45
+ omittedBelowFloor: number;
46
+ suppressedCount: number;
47
+ retestCounts?: Record<RetestVerdict, number>;
48
+ }
49
+
50
+ function newFoldState(hasBaseline: boolean): FoldState {
51
+ return {
52
+ includesManual: false,
53
+ breached: 0,
54
+ omittedBelowFloor: 0,
55
+ suppressedCount: 0,
56
+ ...(hasBaseline
57
+ ? { retestCounts: { fixed: 0, still_present: 0, returned: 0, not_retested: 0, new: 0 } }
58
+ : {}),
59
+ };
60
+ }
61
+
62
+ /** `"key":value`, both run through `JSON.stringify` so nothing is hand-escaped. */
63
+ function field(key: string, value: unknown): string {
64
+ return `${JSON.stringify(key)}:${JSON.stringify(value)}`;
65
+ }
66
+
67
+ /**
68
+ * Open+regressed issues by severity, and their total — read straight off
69
+ * `RunSummary`, needing no pass over the issues at all.
70
+ *
71
+ * `new[sev] + stillOpen[sev] + regressed[sev]` is exactly what
72
+ * `buildReportModel` recomputes by iterating every issue, because every
73
+ * non-suppressed, non-resolved issue is counted into exactly one of those
74
+ * three buckets by `reconcile()` — verified equal on the shared fixture
75
+ * before either streaming renderer relied on it (`report-stream.test.ts`).
76
+ */
77
+ function outstandingFrom(run: RunSummary): { by: Record<Severity, number>; total: number } {
78
+ const by: Record<Severity, number> = {
79
+ critical: run.new.critical + run.stillOpen.critical + run.regressed.critical,
80
+ high: run.new.high + run.stillOpen.high + run.regressed.high,
81
+ medium: run.new.medium + run.stillOpen.medium + run.regressed.medium,
82
+ low: run.new.low + run.stillOpen.low + run.regressed.low,
83
+ advisory: run.new.advisory + run.stillOpen.advisory + run.regressed.advisory,
84
+ };
85
+ return { by, total: SEVERITY_ORDER.reduce((sum, s) => sum + by[s], 0) };
86
+ }
87
+
88
+ /**
89
+ * What a severity floor kept out of a streamed document, from the count a
90
+ * fold over the issues accumulated — the same statement
91
+ * `buildReportModel` composes, but from a running total rather than a
92
+ * `filter().length`.
93
+ */
94
+ function describeOmitted(count: number, floor: Severity | undefined): OmittedIssues | undefined {
95
+ if (count === 0 || floor === undefined) return undefined;
96
+ return {
97
+ count,
98
+ floor,
99
+ statement:
100
+ `${count} issue${count === 1 ? '' : 's'} below ${floor} severity ` +
101
+ `${count === 1 ? 'is' : 'are'} not listed individually in this document. ` +
102
+ `${count === 1 ? 'It remains' : 'They remain'} open and ` +
103
+ `${count === 1 ? 'is' : 'are'} included in every count above.`,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Renders a report as JSON, from a header and a cursor over its issues,
109
+ * rather than from a fully-assembled {@link Snapshot}.
110
+ *
111
+ * **This is the machine path's whole reason to exist (B124).** `renderJson`
112
+ * holds every issue in one array before `JSON.stringify` walks it — measured
113
+ * at 239 MiB for 25,000 issues — so memory scales with the result and three
114
+ * concurrent exports exhaust a 512 MiB instance. This function instead
115
+ * yields the document a fragment at a time: everything that does not depend
116
+ * on having seen every issue is emitted immediately, the `issues` and
117
+ * `suppressed` arrays are written one element per row as the cursors
118
+ * produce them, and the handful of fields that need a full pass —
119
+ * `basis.includesManual`, `breached`, `omitted`, the retest counts — are
120
+ * accumulated in a fold whose state is a few counters, never the issues
121
+ * themselves, and written once both cursors are exhausted. Concatenating
122
+ * every yielded chunk produces one JSON document; nothing here buffers it
123
+ * as a string first.
124
+ *
125
+ * **`sections` grouped by severity and the sorted `resolved` list do not
126
+ * exist here.** Both require holding the whole array to sort; this function
127
+ * instead trusts that `issues` and `suppressed` already arrive in the order
128
+ * the document should show them; a cursor-based caller supplies that with a
129
+ * SQL `ORDER BY`, not with an in-memory sort. `includeResolved` and
130
+ * `severityFloor` still apply, filtering rows out of the stream rather than
131
+ * out of an array.
132
+ *
133
+ * @param header - The run, its baseline, and the target. Never scales with
134
+ * issue count.
135
+ * @param issues - Every issue in scope, most urgent first, in the order the
136
+ * document should list them.
137
+ * @param suppressed - The accepted-risk register, in the order the appendix
138
+ * should list it.
139
+ * @param options - Which report, attribution, and the render time.
140
+ * @returns Chunks of JSON text; concatenated, they are one document.
141
+ */
142
+ export async function* renderJsonStream(
143
+ header: ReportHeader,
144
+ issues: AsyncIterable<SnapshotIssue>,
145
+ suppressed: AsyncIterable<SuppressedIssue>,
146
+ options: ReportOptions,
147
+ ): AsyncGenerator<string> {
148
+ const problem = brandingProblem(options.branding);
149
+ if (problem !== undefined) throw new TypeError(problem);
150
+
151
+ // Rendering a retest of nothing is the over-claim `describeLimitations`'s
152
+ // caller in `model.ts` also refuses — the document's whole content is a
153
+ // comparison, and one run does not have one.
154
+ if (options.kind === 'retest' && header.baseline === undefined) {
155
+ throw new TypeError('a retest report needs a baseline: pass `baseline` on the header');
156
+ }
157
+
158
+ const attestor = resolveAttestor(options);
159
+ const floor = options.severityFloor;
160
+ const floorAt = floor === undefined ? SEVERITY_ORDER.length : SEVERITY_ORDER.indexOf(floor);
161
+ const state = newFoldState(header.baseline !== undefined);
162
+
163
+ yield '{';
164
+ yield field('reportVersion', 1);
165
+ yield ',' + field('kind', options.kind);
166
+ yield ',' + field('title', options.title ?? DEFAULT_TITLES[options.kind]);
167
+ yield ',' + field('generatedAt', options.now.toISOString());
168
+ if (options.preparedBy !== undefined) yield ',' + field('preparedBy', options.preparedBy);
169
+ if (options.preparedFor !== undefined) yield ',' + field('preparedFor', options.preparedFor);
170
+ yield ',' + field('attestor', attestor);
171
+ yield ',"target":' + JSON.stringify(header.target);
172
+ yield ',"run":' + JSON.stringify(header.run);
173
+ if (header.baseline !== undefined) yield ',"baseline":' + JSON.stringify(header.baseline);
174
+
175
+ yield ',"issues":[';
176
+ let firstIssue = true;
177
+ for await (const entry of issues) {
178
+ if (entry.issue.origin === 'manual') state.includesManual = true;
179
+
180
+ const status = entry.issue.status;
181
+ if (status === 'open' || status === 'regressed') {
182
+ if (entry.slaStatus === 'breached') state.breached++;
183
+ }
184
+
185
+ let verdict: RetestVerdict | undefined;
186
+ if (state.retestCounts !== undefined) {
187
+ verdict = verdictFor(entry);
188
+ state.retestCounts[verdict]++;
189
+ }
190
+
191
+ // The floor decides what is *listed*, never what is *counted* — it was
192
+ // consulted above this line for exactly that reason, and it never
193
+ // applies to a resolved issue, which is filtered by `includeResolved`
194
+ // instead.
195
+ if (status === 'resolved') {
196
+ if (options.includeResolved === false) continue;
197
+ } else if (SEVERITY_ORDER.indexOf(entry.issue.effectiveSeverity) > floorAt) {
198
+ state.omittedBelowFloor++;
199
+ continue;
200
+ }
201
+
202
+ const row = verdict === undefined ? entry : { ...entry, retestVerdict: verdict };
203
+ yield (firstIssue ? '' : ',') + JSON.stringify(row);
204
+ firstIssue = false;
205
+ }
206
+ yield ']';
207
+
208
+ yield ',"suppressed":[';
209
+ let firstSuppressed = true;
210
+ for await (const entry of suppressed) {
211
+ state.suppressedCount++;
212
+ yield (firstSuppressed ? '' : ',') + JSON.stringify(entry);
213
+ firstSuppressed = false;
214
+ }
215
+ yield ']';
216
+
217
+ const basis = describeBasis(header.run.kind, header.run.engines, state.includesManual);
218
+ const omitted = describeOmitted(state.omittedBelowFloor, floor);
219
+ const limitations = describeLimitations(
220
+ header.run.coverage.paths,
221
+ header.run.createdAt,
222
+ basis,
223
+ state.suppressedCount,
224
+ omitted,
225
+ );
226
+
227
+ yield ',"basis":' + JSON.stringify(basis);
228
+ yield ',"limitations":' + JSON.stringify(limitations);
229
+
230
+ const outstanding = outstandingFrom(header.run);
231
+ yield ',"outstanding":' + JSON.stringify({ ...outstanding.by, total: outstanding.total });
232
+ yield ',' + field('exposureScore', header.run.exposureScore);
233
+ yield ',' + field('breached', state.breached);
234
+ if (omitted !== undefined) yield ',"omitted":' + JSON.stringify(omitted);
235
+
236
+ if (state.retestCounts !== undefined) {
237
+ const checkable =
238
+ state.retestCounts.fixed + state.retestCounts.still_present + state.retestCounts.returned;
239
+ yield ',"retest":' +
240
+ JSON.stringify({
241
+ counts: state.retestCounts,
242
+ fixRate: checkable === 0 ? null : state.retestCounts.fixed / checkable,
243
+ });
244
+ }
245
+
246
+ yield '}';
247
+ }
248
+
249
+ /**
250
+ * Renders a report as Markdown, from a header and a cursor over its issues,
251
+ * rather than from a fully-assembled {@link Snapshot}. The streaming sibling
252
+ * of {@link renderJsonStream} — see it for the memory argument, which applies
253
+ * here identically: `renderMarkdown` holds every issue in one array before
254
+ * formatting any of it.
255
+ *
256
+ * **The document is reordered relative to `renderMarkdown`, and that is
257
+ * forced rather than stylistic.** The array-based renderer opens with "Basis
258
+ * of testing" and a "What changed" line naming how many issues are past
259
+ * their remediation deadline — both need to know something about *every*
260
+ * issue (`basis.includesManual`, `breached`) before the first line can be
261
+ * printed, which a forward-only stream cannot offer without buffering
262
+ * everything first. So this function prints what it already knows up front
263
+ * (title, scope, the New/Still open/Regressed/Resolved/Suppressed table,
264
+ * which all read straight off `RunSummary`) and moves everything that needs
265
+ * a full pass — the basis statement, the remediation-deadline count, what a
266
+ * severity floor omitted, the retest verdict counts, and what the report
267
+ * does not establish (B109) — into a closing `## Summary` section, written
268
+ * once both cursors are exhausted.
269
+ *
270
+ * **`sections` grouped by severity do not exist here**, for the reason given
271
+ * in {@link renderJsonStream}: `issues` and `suppressed` are trusted to
272
+ * already arrive in the order the document should show them. For a `pen`
273
+ * report that means a severity sub-heading appears whenever the incoming
274
+ * severity changes, without the issue count that heading carries in
275
+ * `renderMarkdown` — the count is not known until the section ends. A
276
+ * `retest` report's per-issue table has no severity floor applied to it,
277
+ * matching `renderMarkdown`: the whole point of that table is showing what
278
+ * happened to every issue carried into the retest, not a curated subset.
279
+ *
280
+ * **`attest` buffers its suppressed list; nothing else buffers issues.** An
281
+ * Attestation Letter states counts and names nobody's individual issue, so
282
+ * it folds the whole `issues` cursor for those counts without printing a
283
+ * row — but it also prints the suppressed appendix *after* that letter body,
284
+ * by which point the `suppressed` cursor would already be exhausted. Holding
285
+ * that one list (the accepted-risk register, ordinarily a minority of the
286
+ * total) is the one deliberate exception to "never hold more than a row".
287
+ *
288
+ * **"What this report does not establish" (B109) lands in the closing
289
+ * `## Summary` section, after the Suppressed findings table rather than
290
+ * before it as in `renderMarkdown`.** `describeLimitations` needs
291
+ * `state.suppressedCount`, complete only once the `suppressed` cursor is
292
+ * drained — and that drain, and the table it produces, happen earlier in
293
+ * this stream than the summary does. The array renderer has no such
294
+ * ordering constraint and keeps limitations immediately before the
295
+ * appendix; forcing the same document order here would mean buffering the
296
+ * suppressed rows a second time for no reason `renderJsonStream` needs to.
297
+ *
298
+ * @param header - The run, its baseline, and the target. Never scales with
299
+ * issue count.
300
+ * @param issues - Every issue in scope, most urgent first: open/regressed
301
+ * before resolved, each group by severity then most-recently-seen first —
302
+ * the order the document lists them in.
303
+ * @param suppressed - The accepted-risk register, in the order the appendix
304
+ * should list it.
305
+ * @param options - Which report, attribution, and the render time.
306
+ * @returns Chunks of Markdown text; concatenated, they are one document.
307
+ */
308
+ export async function* renderMarkdownStream(
309
+ header: ReportHeader,
310
+ issues: AsyncIterable<SnapshotIssue>,
311
+ suppressed: AsyncIterable<SuppressedIssue>,
312
+ options: ReportOptions,
313
+ ): AsyncGenerator<string> {
314
+ const problem = brandingProblem(options.branding);
315
+ if (problem !== undefined) throw new TypeError(problem);
316
+ if (options.kind === 'retest' && header.baseline === undefined) {
317
+ throw new TypeError('a retest report needs a baseline: pass `baseline` on the header');
318
+ }
319
+
320
+ const attestor = resolveAttestor(options);
321
+ const { target } = header;
322
+ const floor = options.severityFloor;
323
+ const floorAt = floor === undefined ? SEVERITY_ORDER.length : SEVERITY_ORDER.indexOf(floor);
324
+ const state = newFoldState(header.baseline !== undefined);
325
+ const outstanding = outstandingFrom(header.run);
326
+
327
+ const preamble: string[] = [];
328
+ if (options.branding?.watermark !== undefined) {
329
+ preamble.push(`**${options.branding.watermark}**`, '');
330
+ }
331
+ preamble.push(
332
+ `# ${options.title ?? DEFAULT_TITLES[options.kind]}`,
333
+ '',
334
+ `**${target.name}** — ${target.url}`,
335
+ );
336
+ if (options.coverPage === true) {
337
+ preamble.push(
338
+ '',
339
+ ...[
340
+ attestor,
341
+ ...(options.branding?.companyDetails ?? []),
342
+ ...(options.preparedFor === undefined ? [] : [`Prepared for ${options.preparedFor}`]),
343
+ ].map((line, i) => (i === 0 ? line : `_${line}_`)),
344
+ );
345
+ }
346
+ preamble.push('', '| | |', '| --- | --- |', `| Generated | ${formatDate(options.now)} |`);
347
+ preamble.push(`| Run | ${header.run.runId} (${header.run.trigger}) |`);
348
+ if (options.preparedFor !== undefined)
349
+ preamble.push(`| Prepared for | ${cell(options.preparedFor)} |`);
350
+ if (options.preparedBy !== undefined)
351
+ preamble.push(`| Prepared by | ${cell(options.preparedBy)} |`);
352
+ preamble.push('');
353
+ yield preamble.join('\n') + '\n';
354
+
355
+ if (options.kind === 'attest') {
356
+ // No per-row content: an Attestation Letter names counts, not issues. The
357
+ // whole `issues` cursor is folded for `state` and never printed; the
358
+ // suppressed one is folded *and* buffered, since it is printed after the
359
+ // letter body, by which point it would otherwise be exhausted.
360
+ let totalFound = 0;
361
+ for await (const entry of issues) {
362
+ totalFound++;
363
+ if (entry.issue.origin === 'manual') state.includesManual = true;
364
+ // An Attestation Letter never lists issues individually regardless of
365
+ // the floor, but `buildReportModel` still folds `omitted` into
366
+ // `limitations` whenever one is set, kind notwithstanding — matched
367
+ // here rather than silently diverging.
368
+ if (
369
+ entry.issue.status !== 'resolved' &&
370
+ SEVERITY_ORDER.indexOf(entry.issue.effectiveSeverity) > floorAt
371
+ ) {
372
+ state.omittedBelowFloor++;
373
+ }
374
+ }
375
+ const suppressedRows: SuppressedIssue[] = [];
376
+ for await (const entry of suppressed) {
377
+ totalFound++;
378
+ state.suppressedCount++;
379
+ suppressedRows.push(entry);
380
+ }
381
+ const basis = describeBasis(header.run.kind, header.run.engines, state.includesManual);
382
+
383
+ const letter: string[] = [
384
+ '## Statement',
385
+ '',
386
+ `${attestor} carried out security testing of **${target.name}**`,
387
+ `(${target.url}) on ${formatDate(header.run.createdAt)}.`,
388
+ '',
389
+ basis.statement,
390
+ '',
391
+ `The testing identified ${totalFound} issue${totalFound === 1 ? '' : 's'} in total, of which`,
392
+ `${outstanding.total} ${outstanding.total === 1 ? 'remains' : 'remain'} outstanding at the date of this letter:`,
393
+ '',
394
+ '| Severity | Outstanding |',
395
+ '| --- | --- |',
396
+ ...SEVERITY_ORDER.map((sev) => `| ${sev} | ${outstanding.by[sev]} |`),
397
+ '',
398
+ '## What this letter does not establish',
399
+ '',
400
+ ...describeLimitations(
401
+ header.run.coverage.paths,
402
+ header.run.createdAt,
403
+ basis,
404
+ state.suppressedCount,
405
+ describeOmitted(state.omittedBelowFloor, floor),
406
+ ).map((l) => `- ${l}`),
407
+ '',
408
+ '## Scope tested',
409
+ '',
410
+ ...header.run.coverage.paths.map((p) => `- \`${cell(p)}\``),
411
+ '',
412
+ '## Suppressed findings',
413
+ '',
414
+ ];
415
+ yield letter.join('\n') + '\n';
416
+ yield suppressedTable(suppressedRows);
417
+ return;
418
+ }
419
+
420
+ yield [
421
+ '## Scope',
422
+ '',
423
+ `This report covers only what the run exercised: ${header.run.coverage.paths.map((p) => `\`${cell(p)}\``).join(', ')}.`,
424
+ 'Anything outside that was not tested and is not described here.',
425
+ '',
426
+ ].join('\n') + '\n';
427
+
428
+ const changeRows = SEVERITY_ORDER.map((severity) => {
429
+ const cells = [
430
+ severity,
431
+ header.run.new[severity],
432
+ header.run.stillOpen[severity],
433
+ header.run.regressed[severity],
434
+ header.run.resolved[severity],
435
+ header.run.ignored[severity],
436
+ ];
437
+ return `| ${cells.join(' | ')} |`;
438
+ });
439
+ yield [
440
+ '## What changed',
441
+ '',
442
+ header.baseline === undefined
443
+ ? '_First run against this target, so every issue is new._'
444
+ : `_Compared with the run of ${formatDate(header.baseline.createdAt)}._`,
445
+ '',
446
+ '| Severity | New | Still open | Regressed | Resolved | Suppressed |',
447
+ '| --- | --- | --- | --- | --- | --- |',
448
+ ...changeRows,
449
+ '',
450
+ ].join('\n') + '\n';
451
+
452
+ const notable: SnapshotIssue[] = [];
453
+ let openedFindings = false;
454
+ let lastSeverity: Severity | undefined;
455
+ let anyListed = false;
456
+ const resolvedRows: string[] = [];
457
+
458
+ for await (const entry of issues) {
459
+ if (entry.issue.origin === 'manual') state.includesManual = true;
460
+ const status = entry.issue.status;
461
+ if (status === 'open' || status === 'regressed') {
462
+ if (entry.slaStatus === 'breached') state.breached++;
463
+ }
464
+
465
+ let verdict: RetestVerdict | undefined;
466
+ if (state.retestCounts !== undefined) {
467
+ verdict = verdictFor(entry);
468
+ state.retestCounts[verdict]++;
469
+ }
470
+
471
+ if (options.kind === 'retest') {
472
+ // No severity floor and no `includeResolved` filter: every issue
473
+ // carried into the retest gets a row, matching `renderMarkdown`.
474
+ if (!openedFindings) {
475
+ yield [
476
+ '## Issue by issue',
477
+ '',
478
+ '| Issue | Severity | Verdict | Location | Timeline |',
479
+ '| --- | --- | --- | --- | --- |',
480
+ ].join('\n') + '\n';
481
+ openedFindings = true;
482
+ }
483
+ const { issue } = entry;
484
+ const location =
485
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
486
+ yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${timelineCell(entry)} |\n`;
487
+ continue;
488
+ }
489
+
490
+ if (status === 'resolved') {
491
+ if (options.includeResolved === false) continue;
492
+ resolvedRows.push(
493
+ `| ${cell(entry.issue.title)} | ${entry.issue.effectiveSeverity} | \`${cell(entry.issue.location)}\` | ${entry.daysOpen} days |`,
494
+ );
495
+ continue;
496
+ }
497
+
498
+ if (SEVERITY_ORDER.indexOf(entry.issue.effectiveSeverity) > floorAt) {
499
+ state.omittedBelowFloor++;
500
+ continue;
501
+ }
502
+ anyListed = true;
503
+
504
+ if (options.kind === 'exec') {
505
+ if (['critical', 'high'].includes(entry.issue.effectiveSeverity) && notable.length < 10) {
506
+ notable.push(entry);
507
+ }
508
+ continue;
509
+ }
510
+
511
+ if (options.kind === 'pen') {
512
+ if (!openedFindings) {
513
+ yield '## Findings\n\n';
514
+ openedFindings = true;
515
+ }
516
+ if (entry.issue.effectiveSeverity !== lastSeverity) {
517
+ lastSeverity = entry.issue.effectiveSeverity;
518
+ yield `### ${lastSeverity}\n\n`;
519
+ }
520
+ yield issueDetail(entry, options.evidenceVerbosity ?? 'summary').join('\n') + '\n';
521
+ } else {
522
+ // `vap`, the tabulated presentation — the default for any kind not
523
+ // handled above.
524
+ if (!openedFindings) {
525
+ yield [
526
+ '## Findings',
527
+ '',
528
+ '| Issue | Severity | Location | Change | Days open | Remediation |',
529
+ '| --- | --- | --- | --- | --- | --- |',
530
+ ].join('\n') + '\n';
531
+ openedFindings = true;
532
+ }
533
+ yield issueRow(entry) + '\n';
534
+ }
535
+ }
536
+
537
+ if (options.kind === 'exec') {
538
+ yield executiveSummary(outstanding, state, notable).join('\n') + '\n';
539
+ } else if (options.kind !== 'retest') {
540
+ if (!anyListed) {
541
+ yield [
542
+ '## Findings',
543
+ '',
544
+ floor === undefined
545
+ ? '_No issues were found._'
546
+ : '_No issues at or above the reporting threshold were found._',
547
+ '',
548
+ ].join('\n') + '\n';
549
+ }
550
+ if (resolvedRows.length > 0) {
551
+ const n = resolvedRows.length;
552
+ yield [
553
+ '## Resolved since the last run',
554
+ '',
555
+ `${n} issue${n === 1 ? ' was' : 's were'} no longer detected by a run that covered ${n === 1 ? 'it' : 'them'}.`,
556
+ '',
557
+ '| Issue | Severity | Location | Open for |',
558
+ '| --- | --- | --- | --- |',
559
+ ...resolvedRows,
560
+ '',
561
+ ].join('\n') + '\n';
562
+ }
563
+ }
564
+
565
+ yield '## Suppressed findings\n\n';
566
+ yield suppressedTable(await drain(suppressed, state));
567
+
568
+ const basis = describeBasis(header.run.kind, header.run.engines, state.includesManual);
569
+ const summary: string[] = ['## Summary', '', '## Basis of testing', '', basis.statement, ''];
570
+ if (state.breached > 0) {
571
+ summary.push(
572
+ `**${state.breached} issue${state.breached === 1 ? '' : 's'} past ${state.breached === 1 ? 'its' : 'their'} remediation deadline.**`,
573
+ '',
574
+ );
575
+ }
576
+ const omitted = describeOmitted(state.omittedBelowFloor, floor);
577
+ if (omitted !== undefined) summary.push(omitted.statement, '');
578
+
579
+ if (options.kind === 'retest' && state.retestCounts !== undefined) {
580
+ const counts = state.retestCounts;
581
+ const carried = counts.fixed + counts.still_present + counts.returned + counts.not_retested;
582
+ const checkable = counts.fixed + counts.still_present + counts.returned;
583
+ const fixRate = checkable === 0 ? null : counts.fixed / checkable;
584
+ summary.push(
585
+ '## Retest verdict',
586
+ '',
587
+ carried === 0
588
+ ? 'No issues were carried into this retest.'
589
+ : `Of the ${carried} issue${carried === 1 ? '' : 's'} carried into this retest, ` +
590
+ `**${counts.fixed} ${counts.fixed === 1 ? 'is' : 'are'} confirmed fixed**, ` +
591
+ `${counts.still_present} ${counts.still_present === 1 ? 'is' : 'are'} still present, ` +
592
+ `${counts.returned} returned, and ${counts.not_retested} could not be retested.` +
593
+ (counts.new === 0
594
+ ? ''
595
+ : ` A further ${counts.new} ${counts.new === 1 ? 'issue was' : 'issues were'} found for the first time by this run.`),
596
+ '',
597
+ );
598
+ if (fixRate !== null) {
599
+ summary.push(
600
+ `${Math.round(fixRate * 100)}% of the issues this run could check are fixed.`,
601
+ '',
602
+ );
603
+ }
604
+ summary.push(
605
+ '| Verdict | Issues |',
606
+ '| --- | --- |',
607
+ ...(Object.keys(VERDICT_LABELS) as RetestVerdict[]).map(
608
+ (v) => `| ${VERDICT_LABELS[v]} | ${counts[v]} |`,
609
+ ),
610
+ '',
611
+ );
612
+ // **Its own heading, not just a sentence.** The array renderer gives this
613
+ // a `## Not retested` section because it is the part of a retest that
614
+ // stops a coverage gap reading as remediation; the first draft of this
615
+ // one kept the sentence and dropped the heading, which a test comparing
616
+ // the two formats caught.
617
+ if (counts.not_retested > 0) {
618
+ summary.push(
619
+ '## Not retested',
620
+ '',
621
+ `${counts.not_retested} issue${counts.not_retested === 1 ? '' : 's'} could not be retested by this run:`,
622
+ 'the run did not cover where they were found, or has not missed them often enough to',
623
+ 'call them resolved. **They are not fixed.** An issue nobody looked at is neither fixed',
624
+ 'nor unfixed, and it is excluded from the percentage above rather than counted either way.',
625
+ '',
626
+ );
627
+ }
628
+ }
629
+
630
+ summary.push(
631
+ '## What this report does not establish',
632
+ '',
633
+ ...describeLimitations(
634
+ header.run.coverage.paths,
635
+ header.run.createdAt,
636
+ basis,
637
+ state.suppressedCount,
638
+ omitted,
639
+ ).map((l) => `- ${l}`),
640
+ '',
641
+ );
642
+
643
+ yield summary.join('\n') + '\n';
644
+ }
645
+
646
+ /** Reads the rest of an `AsyncIterable<SuppressedIssue>`, folding as it goes. */
647
+ async function drain(
648
+ suppressed: AsyncIterable<SuppressedIssue>,
649
+ state: FoldState,
650
+ ): Promise<SuppressedIssue[]> {
651
+ const rows: SuppressedIssue[] = [];
652
+ for await (const entry of suppressed) {
653
+ state.suppressedCount++;
654
+ rows.push(entry);
655
+ }
656
+ return rows;
657
+ }
658
+
659
+ /** The suppressed-findings table. On by default, never optional (invariant 7). */
660
+ function suppressedTable(rows: readonly SuppressedIssue[]): string {
661
+ if (rows.length === 0) return '_None._\n';
662
+ return (
663
+ [
664
+ 'Accepted, out of scope or dismissed. Listed because an accepted risk that',
665
+ 'nobody can see is not an accepted risk.',
666
+ '',
667
+ '| Issue | Severity when suppressed | Reason | By | Expires | Justification |',
668
+ '| --- | --- | --- | --- | --- | --- |',
669
+ ...rows.map(
670
+ (s) =>
671
+ `| ${cell(s.issue.title)} | ${s.severityAtIgnore} | ${s.reason.replace('_', ' ')} | ${cell(s.ignoredBy)} | ${
672
+ s.expiresAt === undefined ? 'never' : formatDate(s.expiresAt)
673
+ } | ${cell(s.comment)} |`,
674
+ ),
675
+ ].join('\n') + '\n'
676
+ );
677
+ }
678
+
679
+ /** The Executive Summary body, once the fold and the top-10 buffer are complete. */
680
+ function executiveSummary(
681
+ outstanding: { by: Record<Severity, number>; total: number },
682
+ state: FoldState,
683
+ notable: readonly SnapshotIssue[],
684
+ ): string[] {
685
+ const headline =
686
+ outstanding.total === 0
687
+ ? 'Nothing is currently outstanding on this target.'
688
+ : `${outstanding.total} issue${outstanding.total === 1 ? '' : 's'} ${
689
+ outstanding.total === 1 ? 'is' : 'are'
690
+ } currently outstanding, of which ${outstanding.by.critical + outstanding.by.high} ` +
691
+ `${outstanding.by.critical + outstanding.by.high === 1 ? 'is' : 'are'} critical or high.`;
692
+
693
+ const lines = ['## Where things stand', '', headline, ''];
694
+ if (state.breached > 0) {
695
+ lines.push(
696
+ `**${state.breached} ${state.breached === 1 ? 'is' : 'are'} past the remediation deadline** ` +
697
+ 'set by the severity policy.',
698
+ '',
699
+ );
700
+ }
701
+ lines.push(
702
+ '## Outstanding by severity',
703
+ '',
704
+ '| Severity | Outstanding |',
705
+ '| --- | --- |',
706
+ ...SEVERITY_ORDER.filter((sev) => outstanding.by[sev] > 0).map(
707
+ (sev) => `| ${sev} | ${outstanding.by[sev]} |`,
708
+ ),
709
+ '',
710
+ );
711
+ if (outstanding.total === 0) lines.push('_Nothing outstanding._', '');
712
+
713
+ if (notable.length > 0) {
714
+ lines.push(
715
+ '## What needs attention first',
716
+ '',
717
+ ...notable.map(
718
+ (entry) =>
719
+ `- **${entry.issue.effectiveSeverity}** — ${entry.issue.title} (open ${entry.daysOpen} days)`,
720
+ ),
721
+ '',
722
+ '_Full detail, including evidence, is in the accompanying technical report._',
723
+ '',
724
+ );
725
+ }
726
+ return lines;
727
+ }