@secureport/core 2.1.0 → 2.3.0

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 (53) hide show
  1. package/dist/index.d.ts +9 -3
  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/issue.d.ts +1 -1
  6. package/dist/issue.d.ts.map +1 -1
  7. package/dist/reconcile.d.ts +46 -1
  8. package/dist/reconcile.d.ts.map +1 -1
  9. package/dist/reconcile.js +34 -1
  10. package/dist/reconcile.js.map +1 -1
  11. package/dist/report/anchors.d.ts +39 -0
  12. package/dist/report/anchors.d.ts.map +1 -0
  13. package/dist/report/anchors.js +73 -0
  14. package/dist/report/anchors.js.map +1 -0
  15. package/dist/report/csv.d.ts +41 -0
  16. package/dist/report/csv.d.ts.map +1 -0
  17. package/dist/report/csv.js +152 -0
  18. package/dist/report/csv.js.map +1 -0
  19. package/dist/report/html.d.ts.map +1 -1
  20. package/dist/report/html.js +126 -22
  21. package/dist/report/html.js.map +1 -1
  22. package/dist/report/json.d.ts +15 -0
  23. package/dist/report/json.d.ts.map +1 -1
  24. package/dist/report/json.js +1 -0
  25. package/dist/report/json.js.map +1 -1
  26. package/dist/report/markdown.d.ts +24 -2
  27. package/dist/report/markdown.d.ts.map +1 -1
  28. package/dist/report/markdown.js +133 -31
  29. package/dist/report/markdown.js.map +1 -1
  30. package/dist/report/model.d.ts +288 -2
  31. package/dist/report/model.d.ts.map +1 -1
  32. package/dist/report/model.js +153 -21
  33. package/dist/report/model.js.map +1 -1
  34. package/dist/report/sarif.d.ts +60 -0
  35. package/dist/report/sarif.d.ts.map +1 -0
  36. package/dist/report/sarif.js +125 -0
  37. package/dist/report/sarif.js.map +1 -0
  38. package/dist/report/stream.d.ts +108 -0
  39. package/dist/report/stream.d.ts.map +1 -0
  40. package/dist/report/stream.js +534 -0
  41. package/dist/report/stream.js.map +1 -0
  42. package/package.json +4 -2
  43. package/src/index.ts +11 -2
  44. package/src/issue.ts +1 -0
  45. package/src/reconcile.ts +86 -2
  46. package/src/report/anchors.ts +96 -0
  47. package/src/report/csv.ts +180 -0
  48. package/src/report/html.ts +146 -26
  49. package/src/report/json.ts +17 -0
  50. package/src/report/markdown.ts +146 -30
  51. package/src/report/model.ts +433 -21
  52. package/src/report/sarif.ts +166 -0
  53. package/src/report/stream.ts +702 -0
@@ -0,0 +1,702 @@
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 } 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 — into a closing
267
+ * `## Summary` section, written once both cursors are exhausted.
268
+ *
269
+ * **`sections` grouped by severity do not exist here**, for the reason given
270
+ * in {@link renderJsonStream}: `issues` and `suppressed` are trusted to
271
+ * already arrive in the order the document should show them. For a `pen`
272
+ * report that means a severity sub-heading appears whenever the incoming
273
+ * severity changes, without the issue count that heading carries in
274
+ * `renderMarkdown` — the count is not known until the section ends. A
275
+ * `retest` report's per-issue table has no severity floor applied to it,
276
+ * matching `renderMarkdown`: the whole point of that table is showing what
277
+ * happened to every issue carried into the retest, not a curated subset.
278
+ *
279
+ * **`attest` buffers its suppressed list; nothing else buffers issues.** An
280
+ * Attestation Letter states counts and names nobody's individual issue, so
281
+ * it folds the whole `issues` cursor for those counts without printing a
282
+ * row — but it also prints the suppressed appendix *after* that letter body,
283
+ * by which point the `suppressed` cursor would already be exhausted. Holding
284
+ * that one list (the accepted-risk register, ordinarily a minority of the
285
+ * total) is the one deliberate exception to "never hold more than a row".
286
+ *
287
+ * @param header - The run, its baseline, and the target. Never scales with
288
+ * issue count.
289
+ * @param issues - Every issue in scope, most urgent first: open/regressed
290
+ * before resolved, each group by severity then most-recently-seen first —
291
+ * the order the document lists them in.
292
+ * @param suppressed - The accepted-risk register, in the order the appendix
293
+ * should list it.
294
+ * @param options - Which report, attribution, and the render time.
295
+ * @returns Chunks of Markdown text; concatenated, they are one document.
296
+ */
297
+ export async function* renderMarkdownStream(
298
+ header: ReportHeader,
299
+ issues: AsyncIterable<SnapshotIssue>,
300
+ suppressed: AsyncIterable<SuppressedIssue>,
301
+ options: ReportOptions,
302
+ ): AsyncGenerator<string> {
303
+ const problem = brandingProblem(options.branding);
304
+ if (problem !== undefined) throw new TypeError(problem);
305
+ if (options.kind === 'retest' && header.baseline === undefined) {
306
+ throw new TypeError('a retest report needs a baseline: pass `baseline` on the header');
307
+ }
308
+
309
+ const attestor = resolveAttestor(options);
310
+ const { target } = header;
311
+ const floor = options.severityFloor;
312
+ const floorAt = floor === undefined ? SEVERITY_ORDER.length : SEVERITY_ORDER.indexOf(floor);
313
+ const state = newFoldState(header.baseline !== undefined);
314
+ const outstanding = outstandingFrom(header.run);
315
+
316
+ const preamble: string[] = [];
317
+ if (options.branding?.watermark !== undefined) {
318
+ preamble.push(`**${options.branding.watermark}**`, '');
319
+ }
320
+ preamble.push(
321
+ `# ${options.title ?? DEFAULT_TITLES[options.kind]}`,
322
+ '',
323
+ `**${target.name}** — ${target.url}`,
324
+ );
325
+ if (options.coverPage === true) {
326
+ preamble.push(
327
+ '',
328
+ ...[
329
+ attestor,
330
+ ...(options.branding?.companyDetails ?? []),
331
+ ...(options.preparedFor === undefined ? [] : [`Prepared for ${options.preparedFor}`]),
332
+ ].map((line, i) => (i === 0 ? line : `_${line}_`)),
333
+ );
334
+ }
335
+ preamble.push('', '| | |', '| --- | --- |', `| Generated | ${formatDate(options.now)} |`);
336
+ preamble.push(`| Run | ${header.run.runId} (${header.run.trigger}) |`);
337
+ if (options.preparedFor !== undefined)
338
+ preamble.push(`| Prepared for | ${cell(options.preparedFor)} |`);
339
+ if (options.preparedBy !== undefined)
340
+ preamble.push(`| Prepared by | ${cell(options.preparedBy)} |`);
341
+ preamble.push('');
342
+ yield preamble.join('\n') + '\n';
343
+
344
+ if (options.kind === 'attest') {
345
+ // No per-row content: an Attestation Letter names counts, not issues. The
346
+ // whole `issues` cursor is folded for `state` and never printed; the
347
+ // suppressed one is folded *and* buffered, since it is printed after the
348
+ // letter body, by which point it would otherwise be exhausted.
349
+ let totalFound = 0;
350
+ for await (const entry of issues) {
351
+ totalFound++;
352
+ if (entry.issue.origin === 'manual') state.includesManual = true;
353
+ // An Attestation Letter never lists issues individually regardless of
354
+ // the floor, but `buildReportModel` still folds `omitted` into
355
+ // `limitations` whenever one is set, kind notwithstanding — matched
356
+ // here rather than silently diverging.
357
+ if (
358
+ entry.issue.status !== 'resolved' &&
359
+ SEVERITY_ORDER.indexOf(entry.issue.effectiveSeverity) > floorAt
360
+ ) {
361
+ state.omittedBelowFloor++;
362
+ }
363
+ }
364
+ const suppressedRows: SuppressedIssue[] = [];
365
+ for await (const entry of suppressed) {
366
+ totalFound++;
367
+ state.suppressedCount++;
368
+ suppressedRows.push(entry);
369
+ }
370
+ const basis = describeBasis(header.run.kind, header.run.engines, state.includesManual);
371
+
372
+ const letter: string[] = [
373
+ '## Statement',
374
+ '',
375
+ `${attestor} carried out security testing of **${target.name}**`,
376
+ `(${target.url}) on ${formatDate(header.run.createdAt)}.`,
377
+ '',
378
+ basis.statement,
379
+ '',
380
+ `The testing identified ${totalFound} issue${totalFound === 1 ? '' : 's'} in total, of which`,
381
+ `${outstanding.total} ${outstanding.total === 1 ? 'remains' : 'remain'} outstanding at the date of this letter:`,
382
+ '',
383
+ '| Severity | Outstanding |',
384
+ '| --- | --- |',
385
+ ...SEVERITY_ORDER.map((sev) => `| ${sev} | ${outstanding.by[sev]} |`),
386
+ '',
387
+ '## What this letter does not establish',
388
+ '',
389
+ ...describeLimitations(
390
+ header.run.coverage.paths,
391
+ header.run.createdAt,
392
+ basis,
393
+ state.suppressedCount,
394
+ describeOmitted(state.omittedBelowFloor, floor),
395
+ ).map((l) => `- ${l}`),
396
+ '',
397
+ '## Scope tested',
398
+ '',
399
+ ...header.run.coverage.paths.map((p) => `- \`${cell(p)}\``),
400
+ '',
401
+ '## Suppressed findings',
402
+ '',
403
+ ];
404
+ yield letter.join('\n') + '\n';
405
+ yield suppressedTable(suppressedRows);
406
+ return;
407
+ }
408
+
409
+ yield [
410
+ '## Scope',
411
+ '',
412
+ `This report covers only what the run exercised: ${header.run.coverage.paths.map((p) => `\`${cell(p)}\``).join(', ')}.`,
413
+ 'Anything outside that was not tested and is not described here.',
414
+ '',
415
+ ].join('\n') + '\n';
416
+
417
+ const changeRows = SEVERITY_ORDER.map((severity) => {
418
+ const cells = [
419
+ severity,
420
+ header.run.new[severity],
421
+ header.run.stillOpen[severity],
422
+ header.run.regressed[severity],
423
+ header.run.resolved[severity],
424
+ header.run.ignored[severity],
425
+ ];
426
+ return `| ${cells.join(' | ')} |`;
427
+ });
428
+ yield [
429
+ '## What changed',
430
+ '',
431
+ header.baseline === undefined
432
+ ? '_First run against this target, so every issue is new._'
433
+ : `_Compared with the run of ${formatDate(header.baseline.createdAt)}._`,
434
+ '',
435
+ '| Severity | New | Still open | Regressed | Resolved | Suppressed |',
436
+ '| --- | --- | --- | --- | --- | --- |',
437
+ ...changeRows,
438
+ '',
439
+ ].join('\n') + '\n';
440
+
441
+ const notable: SnapshotIssue[] = [];
442
+ let openedFindings = false;
443
+ let lastSeverity: Severity | undefined;
444
+ let anyListed = false;
445
+ const resolvedRows: string[] = [];
446
+
447
+ for await (const entry of issues) {
448
+ if (entry.issue.origin === 'manual') state.includesManual = true;
449
+ const status = entry.issue.status;
450
+ if (status === 'open' || status === 'regressed') {
451
+ if (entry.slaStatus === 'breached') state.breached++;
452
+ }
453
+
454
+ let verdict: RetestVerdict | undefined;
455
+ if (state.retestCounts !== undefined) {
456
+ verdict = verdictFor(entry);
457
+ state.retestCounts[verdict]++;
458
+ }
459
+
460
+ if (options.kind === 'retest') {
461
+ // No severity floor and no `includeResolved` filter: every issue
462
+ // carried into the retest gets a row, matching `renderMarkdown`.
463
+ if (!openedFindings) {
464
+ yield [
465
+ '## Issue by issue',
466
+ '',
467
+ '| Issue | Severity | Verdict | Location | Open for |',
468
+ '| --- | --- | --- | --- | --- |',
469
+ ].join('\n') + '\n';
470
+ openedFindings = true;
471
+ }
472
+ const { issue } = entry;
473
+ const location =
474
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
475
+ yield `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[verdict as RetestVerdict]} | \`${cell(location)}\` | ${entry.daysOpen} days |\n`;
476
+ continue;
477
+ }
478
+
479
+ if (status === 'resolved') {
480
+ if (options.includeResolved === false) continue;
481
+ resolvedRows.push(
482
+ `| ${cell(entry.issue.title)} | ${entry.issue.effectiveSeverity} | \`${cell(entry.issue.location)}\` | ${entry.daysOpen} days |`,
483
+ );
484
+ continue;
485
+ }
486
+
487
+ if (SEVERITY_ORDER.indexOf(entry.issue.effectiveSeverity) > floorAt) {
488
+ state.omittedBelowFloor++;
489
+ continue;
490
+ }
491
+ anyListed = true;
492
+
493
+ if (options.kind === 'exec') {
494
+ if (['critical', 'high'].includes(entry.issue.effectiveSeverity) && notable.length < 10) {
495
+ notable.push(entry);
496
+ }
497
+ continue;
498
+ }
499
+
500
+ if (options.kind === 'pen') {
501
+ if (!openedFindings) {
502
+ yield '## Findings\n\n';
503
+ openedFindings = true;
504
+ }
505
+ if (entry.issue.effectiveSeverity !== lastSeverity) {
506
+ lastSeverity = entry.issue.effectiveSeverity;
507
+ yield `### ${lastSeverity}\n\n`;
508
+ }
509
+ yield issueDetail(entry, options.evidenceVerbosity ?? 'summary').join('\n') + '\n';
510
+ } else {
511
+ // `vap`, the tabulated presentation — the default for any kind not
512
+ // handled above.
513
+ if (!openedFindings) {
514
+ yield [
515
+ '## Findings',
516
+ '',
517
+ '| Issue | Severity | Location | Change | Days open | Remediation |',
518
+ '| --- | --- | --- | --- | --- | --- |',
519
+ ].join('\n') + '\n';
520
+ openedFindings = true;
521
+ }
522
+ yield issueRow(entry) + '\n';
523
+ }
524
+ }
525
+
526
+ if (options.kind === 'exec') {
527
+ yield executiveSummary(outstanding, state, notable).join('\n') + '\n';
528
+ } else if (options.kind !== 'retest') {
529
+ if (!anyListed) {
530
+ yield [
531
+ '## Findings',
532
+ '',
533
+ floor === undefined
534
+ ? '_No issues were found._'
535
+ : '_No issues at or above the reporting threshold were found._',
536
+ '',
537
+ ].join('\n') + '\n';
538
+ }
539
+ if (resolvedRows.length > 0) {
540
+ const n = resolvedRows.length;
541
+ yield [
542
+ '## Resolved since the last run',
543
+ '',
544
+ `${n} issue${n === 1 ? ' was' : 's were'} no longer detected by a run that covered ${n === 1 ? 'it' : 'them'}.`,
545
+ '',
546
+ '| Issue | Severity | Location | Open for |',
547
+ '| --- | --- | --- | --- |',
548
+ ...resolvedRows,
549
+ '',
550
+ ].join('\n') + '\n';
551
+ }
552
+ }
553
+
554
+ yield '## Suppressed findings\n\n';
555
+ yield suppressedTable(await drain(suppressed, state));
556
+
557
+ const basis = describeBasis(header.run.kind, header.run.engines, state.includesManual);
558
+ const summary: string[] = ['## Summary', '', '## Basis of testing', '', basis.statement, ''];
559
+ if (state.breached > 0) {
560
+ summary.push(
561
+ `**${state.breached} issue${state.breached === 1 ? '' : 's'} past ${state.breached === 1 ? 'its' : 'their'} remediation deadline.**`,
562
+ '',
563
+ );
564
+ }
565
+ const omitted = describeOmitted(state.omittedBelowFloor, floor);
566
+ if (omitted !== undefined) summary.push(omitted.statement, '');
567
+
568
+ if (options.kind === 'retest' && state.retestCounts !== undefined) {
569
+ const counts = state.retestCounts;
570
+ const carried = counts.fixed + counts.still_present + counts.returned + counts.not_retested;
571
+ const checkable = counts.fixed + counts.still_present + counts.returned;
572
+ const fixRate = checkable === 0 ? null : counts.fixed / checkable;
573
+ summary.push(
574
+ '## Retest verdict',
575
+ '',
576
+ carried === 0
577
+ ? 'No issues were carried into this retest.'
578
+ : `Of the ${carried} issue${carried === 1 ? '' : 's'} carried into this retest, ` +
579
+ `**${counts.fixed} ${counts.fixed === 1 ? 'is' : 'are'} confirmed fixed**, ` +
580
+ `${counts.still_present} ${counts.still_present === 1 ? 'is' : 'are'} still present, ` +
581
+ `${counts.returned} returned, and ${counts.not_retested} could not be retested.` +
582
+ (counts.new === 0
583
+ ? ''
584
+ : ` A further ${counts.new} ${counts.new === 1 ? 'issue was' : 'issues were'} found for the first time by this run.`),
585
+ '',
586
+ );
587
+ if (fixRate !== null) {
588
+ summary.push(
589
+ `${Math.round(fixRate * 100)}% of the issues this run could check are fixed.`,
590
+ '',
591
+ );
592
+ }
593
+ summary.push(
594
+ '| Verdict | Issues |',
595
+ '| --- | --- |',
596
+ ...(Object.keys(VERDICT_LABELS) as RetestVerdict[]).map(
597
+ (v) => `| ${VERDICT_LABELS[v]} | ${counts[v]} |`,
598
+ ),
599
+ '',
600
+ );
601
+ // **Its own heading, not just a sentence.** The array renderer gives this
602
+ // a `## Not retested` section because it is the part of a retest that
603
+ // stops a coverage gap reading as remediation; the first draft of this
604
+ // one kept the sentence and dropped the heading, which a test comparing
605
+ // the two formats caught.
606
+ if (counts.not_retested > 0) {
607
+ summary.push(
608
+ '## Not retested',
609
+ '',
610
+ `${counts.not_retested} issue${counts.not_retested === 1 ? '' : 's'} could not be retested by this run:`,
611
+ 'the run did not cover where they were found, or has not missed them often enough to',
612
+ 'call them resolved. **They are not fixed.** An issue nobody looked at is neither fixed',
613
+ 'nor unfixed, and it is excluded from the percentage above rather than counted either way.',
614
+ '',
615
+ );
616
+ }
617
+ }
618
+ yield summary.join('\n') + '\n';
619
+ }
620
+
621
+ /** Reads the rest of an `AsyncIterable<SuppressedIssue>`, folding as it goes. */
622
+ async function drain(
623
+ suppressed: AsyncIterable<SuppressedIssue>,
624
+ state: FoldState,
625
+ ): Promise<SuppressedIssue[]> {
626
+ const rows: SuppressedIssue[] = [];
627
+ for await (const entry of suppressed) {
628
+ state.suppressedCount++;
629
+ rows.push(entry);
630
+ }
631
+ return rows;
632
+ }
633
+
634
+ /** The suppressed-findings table. On by default, never optional (invariant 7). */
635
+ function suppressedTable(rows: readonly SuppressedIssue[]): string {
636
+ if (rows.length === 0) return '_None._\n';
637
+ return (
638
+ [
639
+ 'Accepted, out of scope or dismissed. Listed because an accepted risk that',
640
+ 'nobody can see is not an accepted risk.',
641
+ '',
642
+ '| Issue | Severity when suppressed | Reason | By | Expires | Justification |',
643
+ '| --- | --- | --- | --- | --- | --- |',
644
+ ...rows.map(
645
+ (s) =>
646
+ `| ${cell(s.issue.title)} | ${s.severityAtIgnore} | ${s.reason.replace('_', ' ')} | ${cell(s.ignoredBy)} | ${
647
+ s.expiresAt === undefined ? 'never' : formatDate(s.expiresAt)
648
+ } | ${cell(s.comment)} |`,
649
+ ),
650
+ ].join('\n') + '\n'
651
+ );
652
+ }
653
+
654
+ /** The Executive Summary body, once the fold and the top-10 buffer are complete. */
655
+ function executiveSummary(
656
+ outstanding: { by: Record<Severity, number>; total: number },
657
+ state: FoldState,
658
+ notable: readonly SnapshotIssue[],
659
+ ): string[] {
660
+ const headline =
661
+ outstanding.total === 0
662
+ ? 'Nothing is currently outstanding on this target.'
663
+ : `${outstanding.total} issue${outstanding.total === 1 ? '' : 's'} ${
664
+ outstanding.total === 1 ? 'is' : 'are'
665
+ } currently outstanding, of which ${outstanding.by.critical + outstanding.by.high} ` +
666
+ `${outstanding.by.critical + outstanding.by.high === 1 ? 'is' : 'are'} critical or high.`;
667
+
668
+ const lines = ['## Where things stand', '', headline, ''];
669
+ if (state.breached > 0) {
670
+ lines.push(
671
+ `**${state.breached} ${state.breached === 1 ? 'is' : 'are'} past the remediation deadline** ` +
672
+ 'set by the severity policy.',
673
+ '',
674
+ );
675
+ }
676
+ lines.push(
677
+ '## Outstanding by severity',
678
+ '',
679
+ '| Severity | Outstanding |',
680
+ '| --- | --- |',
681
+ ...SEVERITY_ORDER.filter((sev) => outstanding.by[sev] > 0).map(
682
+ (sev) => `| ${sev} | ${outstanding.by[sev]} |`,
683
+ ),
684
+ '',
685
+ );
686
+ if (outstanding.total === 0) lines.push('_Nothing outstanding._', '');
687
+
688
+ if (notable.length > 0) {
689
+ lines.push(
690
+ '## What needs attention first',
691
+ '',
692
+ ...notable.map(
693
+ (entry) =>
694
+ `- **${entry.issue.effectiveSeverity}** — ${entry.issue.title} (open ${entry.daysOpen} days)`,
695
+ ),
696
+ '',
697
+ '_Full detail, including evidence, is in the accompanying technical report._',
698
+ '',
699
+ );
700
+ }
701
+ return lines;
702
+ }