@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
@@ -1,4 +1,4 @@
1
- import type { RunSummary } from '../run.js';
1
+ import type { RunKind, RunSummary } from '../run.js';
2
2
  import type { Severity } from '../severity.js';
3
3
  import { SEVERITY_ORDER } from '../severity.js';
4
4
  import type { Snapshot, SnapshotIssue } from '../snapshot.js';
@@ -43,8 +43,185 @@ export interface ReportOptions {
43
43
  * with the copy somebody was sent.
44
44
  */
45
45
  readonly now: Date;
46
+
47
+ /**
48
+ * Keep issues below this severity out of the detail sections.
49
+ *
50
+ * **The counts do not move, and that asymmetry is the whole design.**
51
+ * {@link ReportModel.outstanding}, the exposure score and the breach count
52
+ * describe the target, not the document, so a floor that changed them would
53
+ * let somebody produce a report saying "3 outstanding" about a target with
54
+ * forty. What the floor removes is pages, not facts: the header still sums
55
+ * everything, and {@link ReportModel.limitations} gains a sentence naming
56
+ * how many issues were left out.
57
+ *
58
+ * That is the same shape the suppressed appendix already uses — excluded
59
+ * from the sections, stated in the limitations — because it answers the
60
+ * same question, which is what a reader is not being shown.
61
+ *
62
+ * Omitted entirely by default: every issue is listed.
63
+ */
64
+ readonly severityFloor?: Severity;
65
+
66
+ /**
67
+ * Whether to list the issues this run resolved. Defaults to `true`.
68
+ *
69
+ * Off is a legitimate choice for a document that only needs to state what is
70
+ * outstanding, and unlike {@link ReportOptions.severityFloor} it needs no disclosure —
71
+ * omitting evidence of remediation understates the good news rather than the
72
+ * bad, so a reader cannot be misled about risk by its absence.
73
+ *
74
+ * It does not touch {@link ReportModel.retest}: a retest's `fixed` verdict is
75
+ * the document's entire point, and a caller asking for less detail about
76
+ * resolved issues is not asking for a retest that cannot do its job.
77
+ */
78
+ readonly includeResolved?: boolean;
79
+
80
+ /**
81
+ * How much of each issue's evidence to print. Defaults to `'summary'`.
82
+ *
83
+ * - `'none'` — no evidence at all. The issue, its severity, its location and
84
+ * what to do about it. External references are still listed: an advisory is
85
+ * reading, not evidence.
86
+ * - `'summary'` — how many findings support the issue, and nothing about any
87
+ * one of them. What every report printed before this option existed.
88
+ * - `'full'` — each supporting finding in its own right: where it was seen,
89
+ * when, at what severity, with its CVSS and CVE where the engine supplied
90
+ * them, and pointers to any stored capture.
91
+ *
92
+ * **No verbosity names an engine, and that is not negotiable.** Which scanner
93
+ * produced a finding is an implementation detail of the assessment, and
94
+ * naming the stack in a customer-facing document gives away more than it
95
+ * explains — `00-DOMAIN.md` §7 and {@link TestingBasis.engines}. The engine
96
+ * name has leaked into rendered output twice already, so `'full'` prints
97
+ * what was found and never who found it.
98
+ */
99
+ readonly evidenceVerbosity?: EvidenceVerbosity;
100
+
101
+ /** Whose document this is. See {@link Branding}. */
102
+ readonly branding?: Branding;
103
+
104
+ /**
105
+ * Open the report with a cover page. Defaults to `false`.
106
+ *
107
+ * **On the options rather than on {@link Branding}, and the split is
108
+ * deliberate.** Branding is org-level configuration, stored once and the
109
+ * same for every document; whether a particular render wants a cover is a
110
+ * per-report layout decision — a PDF for a client does, a Markdown export
111
+ * piped into a terminal does not.
112
+ *
113
+ * Markdown has no pages, so there it is a block at the top rather than a
114
+ * page of its own. The statement is the same; the format has one form for
115
+ * it.
116
+ */
117
+ readonly coverPage?: boolean;
118
+
119
+ /**
120
+ * Open the body with a contents list, and give every section an anchor.
121
+ * Defaults to `false`.
122
+ *
123
+ * **The anchors are what a PDF needs, not the list.** Chromium derives a
124
+ * document outline from the heading structure, which is how a reader
125
+ * navigates a sixty-page report in a viewer's sidebar; the printed contents
126
+ * list is for whoever has it on paper. Both come from the same scan of the
127
+ * rendered headings, so a list entry cannot point at an anchor that is not
128
+ * there.
129
+ *
130
+ * Individual issues (`<h4>`) are left out on purpose: a report with sixty
131
+ * findings would have a contents list longer than its summary.
132
+ */
133
+ readonly tableOfContents?: boolean;
134
+ }
135
+
136
+ /**
137
+ * Whose document this is — the marks on it, and the name on it.
138
+ *
139
+ * Per `00-TIER-MATRIX.md`: free-tier reports are watermarked, branding is
140
+ * `[branding]`, and white-label is Enterprise only. **None of that is enforced
141
+ * here.** This package has no idea what anyone is paying, and a pure function
142
+ * that consulted an entitlement would be the wrong place to find out; the
143
+ * caller passes what the caller is entitled to.
144
+ */
145
+ export interface Branding {
146
+ /**
147
+ * The name that appears where Secureport's otherwise would — on an
148
+ * Attestation Letter, as the party who carried out the testing.
149
+ *
150
+ * {@link ReportOptions.preparedBy} wins over it when both are given, because
151
+ * it is the more specific statement: branding is who owns the document,
152
+ * `preparedBy` is who did the work, and they are not always the same party.
153
+ */
154
+ readonly companyName?: string;
155
+
156
+ /**
157
+ * Accent colour for headings and rules, as a hex triplet — `#0a7`,
158
+ * `#00aa77`, or `#00aa77ff`.
159
+ *
160
+ * **Hex only, and the restriction is a security boundary rather than
161
+ * fussiness.** This value is interpolated into the document's `<style>`
162
+ * block, which is a context the HTML renderer's escaping does not protect:
163
+ * it escapes text nodes, and a colour of `red</style><script>…` would close
164
+ * the element and run. Anything that does not match the pattern is refused by
165
+ * {@link buildReportModel} rather than sanitised, because silently altering
166
+ * somebody's brand colour is its own kind of wrong.
167
+ */
168
+ readonly primaryColour?: string;
169
+
170
+ /**
171
+ * Remove Secureport's own marks from the document entirely.
172
+ *
173
+ * An Attestation Letter with this set and no name to put in Secureport's
174
+ * place is refused: a formal statement that testing was carried out has to
175
+ * say who carried it out, and an unattributed one is worth nothing to the
176
+ * auditor it exists for.
177
+ */
178
+ readonly whiteLabel?: boolean;
179
+
180
+ /**
181
+ * Text printed across every page — what the free tier stamps on a report.
182
+ *
183
+ * Rendered as an element rather than through CSS `content`, so it goes
184
+ * through the same escaping as every other value from outside.
185
+ */
186
+ readonly watermark?: string;
187
+
188
+ /**
189
+ * The logo, as a `data:` URI — never a URL.
190
+ *
191
+ * **The restriction is what keeps a report a single file.** `renderHtml`
192
+ * emits a self-contained document with its stylesheet inlined and nothing
193
+ * linked, which is what lets P7 render it through Chromium with no network
194
+ * and get the same bytes in CI as on a laptop. One `<img src="https://…">`
195
+ * would trade that for a logo, and trade it silently: the report would look
196
+ * right on the machine that rendered it and lose its mark for a reader
197
+ * offline, or three months later when the URL stops resolving.
198
+ *
199
+ * Refused by {@link buildReportModel} rather than fetched, because a pure
200
+ * function that reached the network would stop being one.
201
+ *
202
+ * Rendered into an `<img>`, which is also why an SVG data URI is allowed: an
203
+ * image context does not execute script, where inlining the same markup into
204
+ * the document would.
205
+ */
206
+ readonly logo?: string;
207
+
208
+ /**
209
+ * Address, registration number, contact — whatever belongs under the name on
210
+ * a cover page, one line each.
211
+ *
212
+ * Only shown when {@link ReportOptions.coverPage} is set, because there is
213
+ * nowhere else in the document these belong.
214
+ */
215
+ readonly companyDetails?: readonly string[];
46
216
  }
47
217
 
218
+ /**
219
+ * How much of an issue's supporting evidence a report prints.
220
+ *
221
+ * See {@link ReportOptions.evidenceVerbosity} for what each level shows.
222
+ */
223
+ export type EvidenceVerbosity = 'none' | 'summary' | 'full';
224
+
48
225
  /**
49
226
  * How the findings in a report were actually produced.
50
227
  *
@@ -94,6 +271,24 @@ export interface TestingBasis {
94
271
  readonly statement: string;
95
272
  }
96
273
 
274
+ /**
275
+ * What a severity floor kept out of a report's detail sections.
276
+ *
277
+ * The issues are still open, still counted and still the target's problem;
278
+ * only the pages describing them are gone. That distinction is the reason this
279
+ * is reported at all rather than silently applied.
280
+ */
281
+ export interface OmittedIssues {
282
+ /** How many outstanding issues were left out. Never `0` — the field is absent instead. */
283
+ readonly count: number;
284
+
285
+ /** The floor that excluded them. */
286
+ readonly floor: Severity;
287
+
288
+ /** One sentence, ready to print, so no two renderers word it differently. */
289
+ readonly statement: string;
290
+ }
291
+
97
292
  /** A group of issues sharing a severity, most urgent first. */
98
293
  export interface ReportSection {
99
294
  /** The severity this section covers. */
@@ -123,9 +318,38 @@ export interface ReportModel {
123
318
  /** Who it is for, if stated. */
124
319
  readonly preparedFor?: string;
125
320
 
321
+ /**
322
+ * Who the document says carried out the testing.
323
+ *
324
+ * Resolved once here from {@link ReportOptions.preparedBy},
325
+ * {@link Branding.companyName} and the default, because both renderers used
326
+ * to write `preparedBy ?? 'Secureport'` themselves — two copies of a default
327
+ * that white-labelling has to change in both places or not at all.
328
+ */
329
+ readonly attestor: string;
330
+
331
+ /** Whose document this is, as given. Absent when nothing was branded. */
332
+ readonly branding?: Branding;
333
+
334
+ /** Whether to open with a cover, resolved from {@link ReportOptions.coverPage}. */
335
+ readonly coverPage: boolean;
336
+
337
+ /** Whether to print a contents list, resolved from {@link ReportOptions.tableOfContents}. */
338
+ readonly tableOfContents: boolean;
339
+
126
340
  /** How the findings were produced. */
127
341
  readonly basis: TestingBasis;
128
342
 
343
+ /**
344
+ * How much of each issue's evidence to print, resolved from
345
+ * {@link ReportOptions.evidenceVerbosity}.
346
+ *
347
+ * On the model rather than read from the options by each renderer, so the
348
+ * Markdown and the HTML cannot disagree about how much of an issue they are
349
+ * showing — the same reason every number here is derived once.
350
+ */
351
+ readonly evidenceVerbosity: EvidenceVerbosity;
352
+
129
353
  /**
130
354
  * Outstanding issues grouped by severity, most urgent first, empties dropped.
131
355
  *
@@ -140,10 +364,23 @@ export interface ReportModel {
140
364
  *
141
365
  * Kept and shown rather than dropped: "what you fixed" is the story the
142
366
  * product exists to tell, and a report that silently omits it throws away its
143
- * best evidence.
367
+ * best evidence. Empty when {@link ReportOptions.includeResolved} is `false`,
368
+ * which is the caller saying so deliberately.
144
369
  */
145
370
  readonly resolved: readonly SnapshotIssue[];
146
371
 
372
+ /**
373
+ * What {@link ReportOptions.severityFloor} kept out of {@link ReportModel.sections}.
374
+ * Absent when no floor was set, or when one was set and removed nothing.
375
+ *
376
+ * Carried as data and not only as prose, because `00-DOMAIN.md` §7 says what
377
+ * a report does not establish travels as data too — so a consumer rendering
378
+ * its own view cannot drop the disclosure simply by not printing a sentence.
379
+ * `statement` is that sentence, derived once here so the renderers and
380
+ * {@link ReportModel.limitations} cannot word it three different ways.
381
+ */
382
+ readonly omitted?: OmittedIssues;
383
+
147
384
  /** Open and regressed issues, by severity. What the reader owes work on. */
148
385
  readonly outstanding: Readonly<Record<Severity, number>>;
149
386
 
@@ -195,6 +432,24 @@ export interface ReportModel {
195
432
  */
196
433
  export type RetestVerdict = 'fixed' | 'still_present' | 'returned' | 'not_retested' | 'new';
197
434
 
435
+ /**
436
+ * How each verdict is printed.
437
+ *
438
+ * Exported because it is printed, and a printed vocabulary is a contract —
439
+ * `00-DOMAIN.md` §7 says so of the verdicts themselves. It was defined
440
+ * identically in both renderers, which PDF and DOCX would have made four
441
+ * copies of, each one a chance for `not_retested` to be worded differently in
442
+ * the format somebody actually reads. The underscores are an implementation
443
+ * detail of the union and should never reach a page.
444
+ */
445
+ export const VERDICT_LABELS: Readonly<Record<RetestVerdict, string>> = Object.freeze({
446
+ fixed: 'fixed',
447
+ still_present: 'still present',
448
+ returned: 'returned',
449
+ not_retested: 'not retested',
450
+ new: 'new since',
451
+ });
452
+
198
453
  /** One issue, with what the retest established about it. */
199
454
  export interface RetestEntry {
200
455
  /** The issue as the snapshot sees it. */
@@ -279,16 +534,33 @@ function describeRetest(snapshot: Snapshot): RetestOutcome | undefined {
279
534
  * an issue left open because the run never covered it looks identical to one
280
535
  * left open because the run found it again, and telling a reader those are the
281
536
  * same thing is the failure this report exists to avoid.
537
+ *
538
+ * Exported: it is per-issue and needs nothing but the one entry, which is
539
+ * exactly what the streaming machine renderers can offer it a row at a time.
282
540
  */
283
- function verdictFor(entry: SnapshotIssue): RetestVerdict {
541
+ export function verdictFor(entry: SnapshotIssue): RetestVerdict {
284
542
  if (entry.change === 'resolved') return 'fixed';
285
543
  if (entry.change === 'regressed') return 'returned';
286
544
  if (entry.change === 'new') return 'new';
287
545
  return entry.findings.length > 0 ? 'still_present' : 'not_retested';
288
546
  }
289
547
 
290
- /** Works out what a report cannot establish, from the run rather than a template. */
291
- function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[] {
548
+ /**
549
+ * Works out what a report cannot establish, from the run rather than a
550
+ * template.
551
+ *
552
+ * Takes primitives rather than a {@link Snapshot} so the streaming machine
553
+ * renderers can produce the same wording from a fold over a cursor, without
554
+ * needing the eagerly-materialised issue array this package's PDF/HTML path
555
+ * builds.
556
+ */
557
+ export function describeLimitations(
558
+ coveragePaths: readonly string[],
559
+ runCreatedAt: Date,
560
+ basis: TestingBasis,
561
+ suppressedCount: number,
562
+ omitted: OmittedIssues | undefined,
563
+ ): string[] {
292
564
  const limitations: string[] = [];
293
565
 
294
566
  if (!basis.includesManual) {
@@ -302,11 +574,11 @@ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[]
302
574
  'upon as audit evidence.',
303
575
  );
304
576
  limitations.push(
305
- `Testing covered only ${snapshot.run.coverage.paths.join(', ')}. Anything outside that ` +
577
+ `Testing covered only ${coveragePaths.join(', ')}. Anything outside that ` +
306
578
  'was not examined, and its absence from this document is not evidence that it is sound.',
307
579
  );
308
580
  limitations.push(
309
- `This reflects the state of the target as of ${snapshot.run.createdAt
581
+ `This reflects the state of the target as of ${runCreatedAt
310
582
  .toISOString()
311
583
  .slice(0, 10)}. It says nothing about the target before or after that date.`,
312
584
  );
@@ -314,12 +586,17 @@ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[]
314
586
  'Automated testing cannot establish the absence of a vulnerability. A clean result means ' +
315
587
  'nothing was detected, not that nothing is there.',
316
588
  );
317
- if (snapshot.suppressed.length > 0) {
589
+ if (suppressedCount > 0) {
318
590
  limitations.push(
319
- `${snapshot.suppressed.length} finding${snapshot.suppressed.length === 1 ? ' has' : 's have'} been ` +
591
+ `${suppressedCount} finding${suppressedCount === 1 ? ' has' : 's have'} been ` +
320
592
  'suppressed and excluded from the counts above. They are listed in full in the appendix.',
321
593
  );
322
594
  }
595
+ // Immediately after the suppression sentence and before the compliance one,
596
+ // because it answers the same question a reader is entitled to ask: what am
597
+ // I not being shown? A floor that removed pages silently would be the
598
+ // over-claim §7 exists to stop, in a quieter form than the appendix one.
599
+ if (omitted !== undefined) limitations.push(omitted.statement);
323
600
  limitations.push(
324
601
  'This document does not certify compliance with any standard, framework or regulation.',
325
602
  );
@@ -327,7 +604,80 @@ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[]
327
604
  return limitations;
328
605
  }
329
606
 
330
- const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
607
+ /**
608
+ * Hex triplets only. See {@link Branding.primaryColour} for why this is a
609
+ * refusal rather than a sanitisation.
610
+ */
611
+ const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
612
+
613
+ /**
614
+ * Why this branding cannot be rendered, or `undefined` if it can.
615
+ *
616
+ * **Exported so the rules have one home rather than two.** {@link buildReportModel}
617
+ * enforces these at render time, which is the last possible moment — by then the
618
+ * value has been stored, and the caller who set it is long gone. A hosted
619
+ * service wants to refuse it at the boundary instead, with a `400` naming the
620
+ * field. That needs the same two rules in two places, and a colour pattern
621
+ * copied into an API schema is a copy free to drift from the one that actually
622
+ * protects the stylesheet.
623
+ *
624
+ * So both callers ask this. The messages are identical wherever the value is
625
+ * rejected, because there is only one place that composes them.
626
+ *
627
+ * The white-label rule is deliberately **not** here: it depends on the report
628
+ * kind and on `preparedBy`, so it is a property of the options rather than of
629
+ * the branding, and only the renderer can decide it.
630
+ */
631
+ export function brandingProblem(branding: Branding | undefined): string | undefined {
632
+ const colour = branding?.primaryColour;
633
+ if (colour !== undefined && !HEX_COLOUR.test(colour)) {
634
+ return (
635
+ `primaryColour must be a hex triplet such as #0a7 or #00aa77, not ${JSON.stringify(colour)}: ` +
636
+ 'it is interpolated into the document stylesheet, where anything else could end the element'
637
+ );
638
+ }
639
+
640
+ const logo = branding?.logo;
641
+ if (logo !== undefined && !logo.startsWith('data:image/')) {
642
+ return (
643
+ `logo must be a data: URI, not ${JSON.stringify(logo.slice(0, 40))}: ` +
644
+ 'a linked image would make the report depend on a network at render time, ' +
645
+ 'which is the guarantee that lets it be rendered to PDF reproducibly'
646
+ );
647
+ }
648
+
649
+ return undefined;
650
+ }
651
+
652
+ /**
653
+ * Who the document says carried out the testing, resolved once so the PDF/HTML
654
+ * path and the streaming machine path cannot word it two different ways.
655
+ *
656
+ * @throws TypeError A white-labelled attestation naming nobody: a formal
657
+ * statement that testing was carried out has to say who carried it out.
658
+ */
659
+ export function resolveAttestor(
660
+ options: Pick<ReportOptions, 'kind' | 'preparedBy' | 'branding'>,
661
+ ): string {
662
+ const attestor = options.preparedBy ?? options.branding?.companyName;
663
+ // An attestation is a statement that a named party carried out testing. With
664
+ // Secureport's name removed and nothing put in its place there is no such
665
+ // party, and the document would assert something on nobody's behalf.
666
+ if (
667
+ attestor === undefined &&
668
+ options.branding?.whiteLabel === true &&
669
+ options.kind === 'attest'
670
+ ) {
671
+ throw new TypeError(
672
+ 'a white-labelled attestation must name who carried out the testing: ' +
673
+ 'set branding.companyName or preparedBy',
674
+ );
675
+ }
676
+ return attestor ?? 'Secureport';
677
+ }
678
+
679
+ /** The title printed when {@link ReportOptions.title} is not given. */
680
+ export const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
331
681
  pen: 'Penetration Test Report',
332
682
  vap: 'Vulnerability Assessment Report',
333
683
  exec: 'Executive Summary',
@@ -335,11 +685,19 @@ const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
335
685
  retest: 'Retest Report',
336
686
  });
337
687
 
338
- /** Works out how the findings were produced, from the run rather than a claim. */
339
- function describeBasis(snapshot: Snapshot): TestingBasis {
340
- const kind = snapshot.run.kind;
341
- const engines = snapshot.run.engines;
342
- const includesManual = snapshot.issues.some((i) => i.issue.origin === 'manual');
688
+ /**
689
+ * Works out how the findings were produced, from the run rather than a claim.
690
+ *
691
+ * Takes `kind`/`engines`/`includesManual` as primitives, the same reason
692
+ * {@link describeLimitations} does: the streaming machine renderers know
693
+ * `includesManual` only once a fold over the issue cursor finishes, and have
694
+ * no `Snapshot` to read `run.kind`/`run.engines` from.
695
+ */
696
+ export function describeBasis(
697
+ kind: RunKind,
698
+ engines: readonly string[],
699
+ includesManual: boolean,
700
+ ): TestingBasis {
343
701
  const automated = kind === 'scan';
344
702
  const uploaded = kind === 'upload';
345
703
 
@@ -399,6 +757,16 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
399
757
  const bySeverity = new Map<Severity, SnapshotIssue[]>();
400
758
  const resolved: SnapshotIssue[] = [];
401
759
  let breached = 0;
760
+ let omittedBelowFloor = 0;
761
+
762
+ // `SEVERITY_ORDER` is most-severe-first, so "at or above the floor" is a
763
+ // *lower* index. Same trap `describeRetest` records above, from the other
764
+ // direction: `severityRank` counts the opposite way and using it here would
765
+ // floor out everything except the advisories.
766
+ const floorAt =
767
+ options.severityFloor === undefined
768
+ ? SEVERITY_ORDER.length
769
+ : SEVERITY_ORDER.indexOf(options.severityFloor);
402
770
 
403
771
  for (const entry of snapshot.issues) {
404
772
  if (entry.issue.status === 'resolved') {
@@ -407,14 +775,22 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
407
775
  }
408
776
 
409
777
  const severity = entry.issue.effectiveSeverity;
410
- const group = bySeverity.get(severity);
411
- if (group) group.push(entry);
412
- else bySeverity.set(severity, [entry]);
413
778
 
779
+ // Counted before the floor is consulted, deliberately: the floor decides
780
+ // what is *listed*, never what is *counted*.
414
781
  if (entry.issue.status === 'open' || entry.issue.status === 'regressed') {
415
782
  outstanding[severity]++;
416
783
  if (entry.slaStatus === 'breached') breached++;
417
784
  }
785
+
786
+ if (SEVERITY_ORDER.indexOf(severity) > floorAt) {
787
+ omittedBelowFloor++;
788
+ continue;
789
+ }
790
+
791
+ const group = bySeverity.get(severity);
792
+ if (group) group.push(entry);
793
+ else bySeverity.set(severity, [entry]);
418
794
  }
419
795
  resolved.sort((a, b) => b.issue.lastSeen.getTime() - a.issue.lastSeen.getTime());
420
796
 
@@ -430,8 +806,26 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
430
806
  });
431
807
  }
432
808
 
809
+ const floor = options.severityFloor;
810
+ const omitted: OmittedIssues | undefined =
811
+ omittedBelowFloor === 0 || floor === undefined
812
+ ? undefined
813
+ : {
814
+ count: omittedBelowFloor,
815
+ floor,
816
+ statement:
817
+ `${omittedBelowFloor} issue${omittedBelowFloor === 1 ? '' : 's'} below ${floor} severity ` +
818
+ `${omittedBelowFloor === 1 ? 'is' : 'are'} not listed individually in this document. ` +
819
+ `${omittedBelowFloor === 1 ? 'It remains' : 'They remain'} open and ` +
820
+ `${omittedBelowFloor === 1 ? 'is' : 'are'} included in every count above.`,
821
+ };
822
+
433
823
  const outstandingTotal = SEVERITY_ORDER.reduce((sum, s) => sum + outstanding[s], 0);
434
- const basis = describeBasis(snapshot);
824
+ const basis = describeBasis(
825
+ snapshot.run.kind,
826
+ snapshot.run.engines,
827
+ snapshot.issues.some((i) => i.issue.origin === 'manual'),
828
+ );
435
829
  const retest = describeRetest(snapshot);
436
830
 
437
831
  // Rendering a retest of nothing is the over-claim §7 warns about: the
@@ -442,6 +836,12 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
442
836
  );
443
837
  }
444
838
 
839
+ const branding = options.branding;
840
+ const problem = brandingProblem(branding);
841
+ if (problem !== undefined) throw new TypeError(problem);
842
+
843
+ const attestor = resolveAttestor(options);
844
+
445
845
  return {
446
846
  kind: options.kind,
447
847
  title: options.title ?? DEFAULT_TITLES[options.kind],
@@ -449,10 +849,22 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
449
849
  generatedAt: options.now,
450
850
  ...(options.preparedBy === undefined ? {} : { preparedBy: options.preparedBy }),
451
851
  ...(options.preparedFor === undefined ? {} : { preparedFor: options.preparedFor }),
852
+ attestor,
853
+ coverPage: options.coverPage === true,
854
+ tableOfContents: options.tableOfContents === true,
855
+ ...(branding === undefined ? {} : { branding }),
452
856
  basis,
453
- limitations: describeLimitations(snapshot, basis),
857
+ evidenceVerbosity: options.evidenceVerbosity ?? 'summary',
858
+ limitations: describeLimitations(
859
+ snapshot.run.coverage.paths,
860
+ snapshot.run.createdAt,
861
+ basis,
862
+ snapshot.suppressed.length,
863
+ omitted,
864
+ ),
454
865
  sections,
455
- resolved,
866
+ resolved: options.includeResolved === false ? [] : resolved,
867
+ ...(omitted === undefined ? {} : { omitted }),
456
868
  outstanding,
457
869
  outstandingTotal,
458
870
  exposureScore: snapshot.run.exposureScore,