@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
@@ -5,6 +5,7 @@ import type { Snapshot, SnapshotIssue } from '../snapshot.js';
5
5
  import {
6
6
  buildReportModel,
7
7
  type EvidenceVerbosity,
8
+ timelineFor,
8
9
  VERDICT_LABELS,
9
10
  type ReportModel,
10
11
  type ReportOptions,
@@ -16,8 +17,14 @@ export function formatDate(date: Date): string {
16
17
  return date.toISOString().slice(0, 10);
17
18
  }
18
19
 
19
- /** Escapes the characters that would break out of a Markdown table cell. */
20
- function cell(value: string): string {
20
+ /**
21
+ * Escapes the characters that would break out of a Markdown table cell.
22
+ *
23
+ * Exported: the streaming renderer formats a row the moment it sees it, and
24
+ * needs the exact same escaping — a second copy is a copy free to miss the
25
+ * next character somebody discovers breaks a table.
26
+ */
27
+ export function cell(value: string): string {
21
28
  return value.replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
22
29
  }
23
30
 
@@ -54,8 +61,14 @@ function changeSummary(model: ReportModel): string[] {
54
61
  ];
55
62
  }
56
63
 
57
- /** One issue, in full. The Penetration Test presentation. */
58
- function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
64
+ /**
65
+ * One issue, in full. The Penetration Test presentation.
66
+ *
67
+ * Exported: it is already a pure per-issue function needing nothing but the
68
+ * one entry, which is exactly what the streaming renderer can offer it a row
69
+ * at a time — the same reason `verdictFor` is exported from `model.ts`.
70
+ */
71
+ export function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
59
72
  const { issue } = entry;
60
73
  const lines: string[] = [
61
74
  `#### ${issue.title}`,
@@ -142,14 +155,32 @@ function evidenceLine(finding: Finding): string {
142
155
  return `${where} — ${detail.join('; ')}`;
143
156
  }
144
157
 
145
- /** One issue, as a table row. The Vulnerability Assessment presentation. */
146
- function issueRow(entry: SnapshotIssue): string {
158
+ /**
159
+ * One issue, as a table row. The Vulnerability Assessment presentation.
160
+ *
161
+ * Exported for the same reason {@link issueDetail} is.
162
+ */
163
+ export function issueRow(entry: SnapshotIssue): string {
147
164
  const { issue } = entry;
148
165
  const location =
149
166
  issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
150
167
  return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
151
168
  }
152
169
 
170
+ /**
171
+ * An issue's remediation chronology, as a single table cell.
172
+ *
173
+ * Exported for the same reason {@link issueRow} is: the streaming renderer
174
+ * formats a row the moment it sees it, and needs the identical rendering of
175
+ * {@link timelineFor}'s events — a second join-and-escape is a second one
176
+ * free to drift.
177
+ */
178
+ export function timelineCell(entry: SnapshotIssue): string {
179
+ return timelineFor(entry)
180
+ .map((event) => cell(event.statement))
181
+ .join(' · ');
182
+ }
183
+
153
184
  /** The Retest body: the difference between two runs, and nothing else. */
154
185
  function retestBody(model: ReportModel): string[] {
155
186
  // `buildReportModel` refuses to build a retest without one.
@@ -212,13 +243,13 @@ function retestBody(model: ReportModel): string[] {
212
243
  lines.push(
213
244
  '## Issue by issue',
214
245
  '',
215
- '| Issue | Severity | Verdict | Location | Open for |',
246
+ '| Issue | Severity | Verdict | Location | Timeline |',
216
247
  '| --- | --- | --- | --- | --- |',
217
248
  ...retest.entries.map((entry) => {
218
249
  const { issue } = entry.issue;
219
250
  const location =
220
251
  issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
221
- return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${entry.issue.daysOpen} days |`;
252
+ return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${timelineCell(entry.issue)} |`;
222
253
  }),
223
254
  '',
224
255
  );
@@ -327,6 +358,27 @@ function executiveBody(model: ReportModel): string[] {
327
358
  return lines;
328
359
  }
329
360
 
361
+ /**
362
+ * "What this report does not establish" — every kind but the Attestation
363
+ * Letter, which states its limits inline as part of the formal statement
364
+ * itself (`attestationBody`) rather than as a separate section (B109).
365
+ *
366
+ * Placed immediately before the suppressed appendix in every caller, which is
367
+ * also where the streaming Markdown renderer (`stream.ts`) is forced to put
368
+ * it: `describeLimitations` needs the suppressed count and the omitted-below-
369
+ * floor count, both fold-dependent and only known once the issue cursor is
370
+ * drained. The array renderer has no such constraint but uses the same
371
+ * position, so the two cannot drift apart on placement.
372
+ */
373
+ function limitationsSection(model: ReportModel): string[] {
374
+ return [
375
+ '## What this report does not establish',
376
+ '',
377
+ ...model.limitations.map((l) => `- ${l}`),
378
+ '',
379
+ ];
380
+ }
381
+
330
382
  /** The Attestation Letter body: a formal statement, with its limits stated. */
331
383
  function attestationBody(model: ReportModel): string[] {
332
384
  const { snapshot } = model;
@@ -466,12 +518,22 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
466
518
  lines.push(...changeSummary(model), '');
467
519
 
468
520
  if (model.kind === 'exec') {
469
- lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
521
+ lines.push(
522
+ ...executiveBody(model),
523
+ ...limitationsSection(model),
524
+ ...suppressedAppendix(snapshot),
525
+ '',
526
+ );
470
527
  return withContents(lines, model);
471
528
  }
472
529
 
473
530
  if (model.kind === 'retest') {
474
- lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
531
+ lines.push(
532
+ ...retestBody(model),
533
+ ...limitationsSection(model),
534
+ ...suppressedAppendix(snapshot),
535
+ '',
536
+ );
475
537
  return withContents(lines, model);
476
538
  }
477
539
 
@@ -508,10 +570,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
508
570
  lines.push('');
509
571
  }
510
572
 
511
- // Printed here, beside the section it is about, rather than left to the
512
- // limitations list — which only the Attestation Letter renders (B109). A
513
- // floor that removed issues from this section and said so nowhere the reader
514
- // will look is the quiet form of the over-claim §7 exists to stop.
573
+ // Printed here too, beside the section it is about, even though it also
574
+ // appears in `limitationsSection` below (B109). A reader looking at a short
575
+ // Findings section learns why it is short at the point of confusion rather
576
+ // than having to find the register at the end; the register entry is what
577
+ // keeps the disclosure surviving there once the reader has moved past this
578
+ // section. Two honest statements, not a duplicate to trim.
515
579
  if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
516
580
 
517
581
  if (model.resolved.length > 0) {
@@ -530,6 +594,6 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
530
594
  );
531
595
  }
532
596
 
533
- lines.push(...suppressedAppendix(snapshot), '');
597
+ lines.push(...limitationsSection(model), ...suppressedAppendix(snapshot), '');
534
598
  return withContents(lines, model);
535
599
  }
@@ -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';
@@ -277,6 +277,10 @@ export interface TestingBasis {
277
277
  * The issues are still open, still counted and still the target's problem;
278
278
  * only the pages describing them are gone. That distinction is the reason this
279
279
  * is reported at all rather than silently applied.
280
+ *
281
+ * **Never set for a `retest` report.** Its own per-issue table lists every
282
+ * carried issue regardless of the floor (7.9a) — a floor changes nothing a
283
+ * reader sees, so there is nothing to disclose.
280
284
  */
281
285
  export interface OmittedIssues {
282
286
  /** How many outstanding issues were left out. Never `0` — the field is absent instead. */
@@ -534,18 +538,145 @@ function describeRetest(snapshot: Snapshot): RetestOutcome | undefined {
534
538
  * an issue left open because the run never covered it looks identical to one
535
539
  * left open because the run found it again, and telling a reader those are the
536
540
  * same thing is the failure this report exists to avoid.
541
+ *
542
+ * Exported: it is per-issue and needs nothing but the one entry, which is
543
+ * exactly what the streaming machine renderers can offer it a row at a time.
537
544
  */
538
- function verdictFor(entry: SnapshotIssue): RetestVerdict {
545
+ export function verdictFor(entry: SnapshotIssue): RetestVerdict {
539
546
  if (entry.change === 'resolved') return 'fixed';
540
547
  if (entry.change === 'regressed') return 'returned';
541
548
  if (entry.change === 'new') return 'new';
542
549
  return entry.findings.length > 0 ? 'still_present' : 'not_retested';
543
550
  }
544
551
 
545
- /** Works out what a report cannot establish, from the run rather than a template. */
546
- function describeLimitations(
547
- snapshot: Snapshot,
552
+ // What kind of moment a `TimelineEvent` records. Four of the five are things
553
+ // that already happened, in the order they happened. `deadline` is not: it is
554
+ // a standing obligation that can be in the future, and `timelineFor` never
555
+ // sorts it in among the other four — see that function's own doc comment for
556
+ // why. Not exported: nothing outside this module names the union, only the
557
+ // `kind` field it types.
558
+ type TimelineEventKind = 'opened' | 'resolved' | 'returned' | 'last_seen' | 'deadline';
559
+
560
+ /** One moment in an issue's remediation history, worded ready to print. */
561
+ export interface TimelineEvent {
562
+ /** What kind of moment this is. */
563
+ readonly kind: TimelineEventKind;
564
+
565
+ /** When it happened, or — for `deadline` alone — when it is due. */
566
+ readonly at: Date;
567
+
568
+ /**
569
+ * One sentence, ready to print, so no two renderers word it differently.
570
+ *
571
+ * `deadline`'s wording follows {@link SnapshotIssue.slaStatus}, never a
572
+ * second comparison of `at` against `now` — `slaStatus` is already the
573
+ * derived answer to "is this overdue", and a fresh comparison here would be
574
+ * a second one free to disagree.
575
+ */
576
+ readonly statement: string;
577
+ }
578
+
579
+ /**
580
+ * An issue's remediation chronology, derived only from dates already on
581
+ * {@link Issue} — no `issue_events` read, no migration.
582
+ *
583
+ * Exported for the same reason {@link verdictFor} is: it is per-issue and
584
+ * needs nothing but the one entry, which is exactly what the streaming
585
+ * Markdown renderer can offer it a row at a time.
586
+ *
587
+ * **`deadline` is appended last, unconditionally — never sorted in among the
588
+ * other four.** Those four are things that already happened; a remediation
589
+ * deadline is a standing obligation, not an event, and it can be in the past
590
+ * (breached) or the future (within the window). Sorting it by `at` would let
591
+ * a breached deadline land in the middle of the chronology, worded as though
592
+ * it were a fact that occurred between two sightings — this function's
593
+ * version of the mistake `not_retested` exists to stop: reporting something
594
+ * that has not happened as though it had.
595
+ *
596
+ * **No `deadline` event for a resolved issue**, even one whose `slaDueAt` has
597
+ * passed. A deadline is a claim about outstanding remediation; printing
598
+ * "overdue since …" beside a `fixed` verdict would report finished work as
599
+ * work still owed — the same dishonesty from the other direction. None
600
+ * either when {@link Issue.slaDueAt} is `null` (an `advisory` under
601
+ * {@link DEFAULT_SLA_POLICY}), since there is no deadline to state.
602
+ */
603
+ export function timelineFor(entry: SnapshotIssue): readonly TimelineEvent[] {
604
+ const { issue } = entry;
605
+ const events: TimelineEvent[] = [
606
+ {
607
+ kind: 'opened',
608
+ at: issue.firstSeen,
609
+ statement: `opened ${formatIsoDate(issue.firstSeen)} (${entry.daysOpen} days)`,
610
+ },
611
+ ];
612
+
613
+ if (issue.resolvedAt !== undefined) {
614
+ events.push({
615
+ kind: 'resolved',
616
+ at: issue.resolvedAt,
617
+ statement: `resolved ${formatIsoDate(issue.resolvedAt)}`,
618
+ });
619
+ }
620
+ if (issue.reopenedAt !== undefined) {
621
+ events.push({
622
+ kind: 'returned',
623
+ at: issue.reopenedAt,
624
+ statement: `returned ${formatIsoDate(issue.reopenedAt)}`,
625
+ });
626
+ }
627
+
628
+ // Deduplicated by exact timestamp: `reconcile()` sets `firstSeen` and
629
+ // `lastSeen` to the same instant when it creates an issue, and an issue
630
+ // that regressed this run has `reopenedAt === lastSeen` — without this
631
+ // check the same moment would print twice under two different names.
632
+ if (!events.some((e) => e.at.getTime() === issue.lastSeen.getTime())) {
633
+ events.push({
634
+ kind: 'last_seen',
635
+ at: issue.lastSeen,
636
+ statement: `last seen ${formatIsoDate(issue.lastSeen)}`,
637
+ });
638
+ }
639
+
640
+ // Sorted, not pushed in field-declaration order: `lastSeen` is the last
641
+ // time the issue was *detected*, which for a resolved issue predates
642
+ // `resolvedAt` — the date reconciliation stopped seeing it and started
643
+ // counting misses, not the date it stopped existing. Pushing in the order
644
+ // above would print "resolved … last seen …" backwards.
645
+ events.sort((a, b) => a.at.getTime() - b.at.getTime());
646
+
647
+ if (issue.slaDueAt !== null && issue.status !== 'resolved') {
648
+ const due = formatIsoDate(issue.slaDueAt);
649
+ const statement =
650
+ entry.slaStatus === 'breached'
651
+ ? `overdue since ${due}`
652
+ : entry.slaStatus === 'due_soon'
653
+ ? `due ${due} (due soon)`
654
+ : `due ${due}`;
655
+ events.push({ kind: 'deadline', at: issue.slaDueAt, statement });
656
+ }
657
+
658
+ return events;
659
+ }
660
+
661
+ /** A date, printed the same way everywhere in this module. */
662
+ function formatIsoDate(date: Date): string {
663
+ return date.toISOString().slice(0, 10);
664
+ }
665
+
666
+ /**
667
+ * Works out what a report cannot establish, from the run rather than a
668
+ * template.
669
+ *
670
+ * Takes primitives rather than a {@link Snapshot} so the streaming machine
671
+ * renderers can produce the same wording from a fold over a cursor, without
672
+ * needing the eagerly-materialised issue array this package's PDF/HTML path
673
+ * builds.
674
+ */
675
+ export function describeLimitations(
676
+ coveragePaths: readonly string[],
677
+ runCreatedAt: Date,
548
678
  basis: TestingBasis,
679
+ suppressedCount: number,
549
680
  omitted: OmittedIssues | undefined,
550
681
  ): string[] {
551
682
  const limitations: string[] = [];
@@ -561,11 +692,11 @@ function describeLimitations(
561
692
  'upon as audit evidence.',
562
693
  );
563
694
  limitations.push(
564
- `Testing covered only ${snapshot.run.coverage.paths.join(', ')}. Anything outside that ` +
695
+ `Testing covered only ${coveragePaths.join(', ')}. Anything outside that ` +
565
696
  'was not examined, and its absence from this document is not evidence that it is sound.',
566
697
  );
567
698
  limitations.push(
568
- `This reflects the state of the target as of ${snapshot.run.createdAt
699
+ `This reflects the state of the target as of ${runCreatedAt
569
700
  .toISOString()
570
701
  .slice(0, 10)}. It says nothing about the target before or after that date.`,
571
702
  );
@@ -573,9 +704,9 @@ function describeLimitations(
573
704
  'Automated testing cannot establish the absence of a vulnerability. A clean result means ' +
574
705
  'nothing was detected, not that nothing is there.',
575
706
  );
576
- if (snapshot.suppressed.length > 0) {
707
+ if (suppressedCount > 0) {
577
708
  limitations.push(
578
- `${snapshot.suppressed.length} finding${snapshot.suppressed.length === 1 ? ' has' : 's have'} been ` +
709
+ `${suppressedCount} finding${suppressedCount === 1 ? ' has' : 's have'} been ` +
579
710
  'suppressed and excluded from the counts above. They are listed in full in the appendix.',
580
711
  );
581
712
  }
@@ -597,7 +728,74 @@ function describeLimitations(
597
728
  */
598
729
  const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
599
730
 
600
- const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
731
+ /**
732
+ * Why this branding cannot be rendered, or `undefined` if it can.
733
+ *
734
+ * **Exported so the rules have one home rather than two.** {@link buildReportModel}
735
+ * enforces these at render time, which is the last possible moment — by then the
736
+ * value has been stored, and the caller who set it is long gone. A hosted
737
+ * service wants to refuse it at the boundary instead, with a `400` naming the
738
+ * field. That needs the same two rules in two places, and a colour pattern
739
+ * copied into an API schema is a copy free to drift from the one that actually
740
+ * protects the stylesheet.
741
+ *
742
+ * So both callers ask this. The messages are identical wherever the value is
743
+ * rejected, because there is only one place that composes them.
744
+ *
745
+ * The white-label rule is deliberately **not** here: it depends on the report
746
+ * kind and on `preparedBy`, so it is a property of the options rather than of
747
+ * the branding, and only the renderer can decide it.
748
+ */
749
+ export function brandingProblem(branding: Branding | undefined): string | undefined {
750
+ const colour = branding?.primaryColour;
751
+ if (colour !== undefined && !HEX_COLOUR.test(colour)) {
752
+ return (
753
+ `primaryColour must be a hex triplet such as #0a7 or #00aa77, not ${JSON.stringify(colour)}: ` +
754
+ 'it is interpolated into the document stylesheet, where anything else could end the element'
755
+ );
756
+ }
757
+
758
+ const logo = branding?.logo;
759
+ if (logo !== undefined && !logo.startsWith('data:image/')) {
760
+ return (
761
+ `logo must be a data: URI, not ${JSON.stringify(logo.slice(0, 40))}: ` +
762
+ 'a linked image would make the report depend on a network at render time, ' +
763
+ 'which is the guarantee that lets it be rendered to PDF reproducibly'
764
+ );
765
+ }
766
+
767
+ return undefined;
768
+ }
769
+
770
+ /**
771
+ * Who the document says carried out the testing, resolved once so the PDF/HTML
772
+ * path and the streaming machine path cannot word it two different ways.
773
+ *
774
+ * @throws TypeError A white-labelled attestation naming nobody: a formal
775
+ * statement that testing was carried out has to say who carried it out.
776
+ */
777
+ export function resolveAttestor(
778
+ options: Pick<ReportOptions, 'kind' | 'preparedBy' | 'branding'>,
779
+ ): string {
780
+ const attestor = options.preparedBy ?? options.branding?.companyName;
781
+ // An attestation is a statement that a named party carried out testing. With
782
+ // Secureport's name removed and nothing put in its place there is no such
783
+ // party, and the document would assert something on nobody's behalf.
784
+ if (
785
+ attestor === undefined &&
786
+ options.branding?.whiteLabel === true &&
787
+ options.kind === 'attest'
788
+ ) {
789
+ throw new TypeError(
790
+ 'a white-labelled attestation must name who carried out the testing: ' +
791
+ 'set branding.companyName or preparedBy',
792
+ );
793
+ }
794
+ return attestor ?? 'Secureport';
795
+ }
796
+
797
+ /** The title printed when {@link ReportOptions.title} is not given. */
798
+ export const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
601
799
  pen: 'Penetration Test Report',
602
800
  vap: 'Vulnerability Assessment Report',
603
801
  exec: 'Executive Summary',
@@ -605,11 +803,19 @@ const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
605
803
  retest: 'Retest Report',
606
804
  });
607
805
 
608
- /** Works out how the findings were produced, from the run rather than a claim. */
609
- function describeBasis(snapshot: Snapshot): TestingBasis {
610
- const kind = snapshot.run.kind;
611
- const engines = snapshot.run.engines;
612
- const includesManual = snapshot.issues.some((i) => i.issue.origin === 'manual');
806
+ /**
807
+ * Works out how the findings were produced, from the run rather than a claim.
808
+ *
809
+ * Takes `kind`/`engines`/`includesManual` as primitives, the same reason
810
+ * {@link describeLimitations} does: the streaming machine renderers know
811
+ * `includesManual` only once a fold over the issue cursor finishes, and have
812
+ * no `Snapshot` to read `run.kind`/`run.engines` from.
813
+ */
814
+ export function describeBasis(
815
+ kind: RunKind,
816
+ engines: readonly string[],
817
+ includesManual: boolean,
818
+ ): TestingBasis {
613
819
  const automated = kind === 'scan';
614
820
  const uploaded = kind === 'upload';
615
821
 
@@ -696,7 +902,11 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
696
902
  }
697
903
 
698
904
  if (SEVERITY_ORDER.indexOf(severity) > floorAt) {
699
- omittedBelowFloor++;
905
+ // B208: `retest`'s own table lists every carried issue regardless of
906
+ // the floor (7.9a) — nothing is actually left out of the document for
907
+ // this kind, so counting it here would make `limitations` state an
908
+ // omission the very next section contradicts.
909
+ if (options.kind !== 'retest') omittedBelowFloor++;
700
910
  continue;
701
911
  }
702
912
 
@@ -733,7 +943,11 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
733
943
  };
734
944
 
735
945
  const outstandingTotal = SEVERITY_ORDER.reduce((sum, s) => sum + outstanding[s], 0);
736
- const basis = describeBasis(snapshot);
946
+ const basis = describeBasis(
947
+ snapshot.run.kind,
948
+ snapshot.run.engines,
949
+ snapshot.issues.some((i) => i.issue.origin === 'manual'),
950
+ );
737
951
  const retest = describeRetest(snapshot);
738
952
 
739
953
  // Rendering a retest of nothing is the over-claim §7 warns about: the
@@ -745,33 +959,10 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
745
959
  }
746
960
 
747
961
  const branding = options.branding;
748
- const colour = branding?.primaryColour;
749
- if (colour !== undefined && !HEX_COLOUR.test(colour)) {
750
- throw new TypeError(
751
- `primaryColour must be a hex triplet such as #0a7 or #00aa77, not ${JSON.stringify(colour)}: ` +
752
- 'it is interpolated into the document stylesheet, where anything else could end the element',
753
- );
754
- }
755
-
756
- const logo = branding?.logo;
757
- if (logo !== undefined && !logo.startsWith('data:image/')) {
758
- throw new TypeError(
759
- `logo must be a data: URI, not ${JSON.stringify(logo.slice(0, 40))}: ` +
760
- 'a linked image would make the report depend on a network at render time, ' +
761
- 'which is the guarantee that lets it be rendered to PDF reproducibly',
762
- );
763
- }
962
+ const problem = brandingProblem(branding);
963
+ if (problem !== undefined) throw new TypeError(problem);
764
964
 
765
- const attestor = options.preparedBy ?? branding?.companyName;
766
- // An attestation is a statement that a named party carried out testing. With
767
- // Secureport's name removed and nothing put in its place there is no such
768
- // party, and the document would assert something on nobody's behalf.
769
- if (attestor === undefined && branding?.whiteLabel === true && options.kind === 'attest') {
770
- throw new TypeError(
771
- 'a white-labelled attestation must name who carried out the testing: ' +
772
- 'set branding.companyName or preparedBy',
773
- );
774
- }
965
+ const attestor = resolveAttestor(options);
775
966
 
776
967
  return {
777
968
  kind: options.kind,
@@ -780,13 +971,19 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
780
971
  generatedAt: options.now,
781
972
  ...(options.preparedBy === undefined ? {} : { preparedBy: options.preparedBy }),
782
973
  ...(options.preparedFor === undefined ? {} : { preparedFor: options.preparedFor }),
783
- attestor: attestor ?? 'Secureport',
974
+ attestor,
784
975
  coverPage: options.coverPage === true,
785
976
  tableOfContents: options.tableOfContents === true,
786
977
  ...(branding === undefined ? {} : { branding }),
787
978
  basis,
788
979
  evidenceVerbosity: options.evidenceVerbosity ?? 'summary',
789
- limitations: describeLimitations(snapshot, basis, omitted),
980
+ limitations: describeLimitations(
981
+ snapshot.run.coverage.paths,
982
+ snapshot.run.createdAt,
983
+ basis,
984
+ snapshot.suppressed.length,
985
+ omitted,
986
+ ),
790
987
  sections,
791
988
  resolved: options.includeResolved === false ? [] : resolved,
792
989
  ...(omitted === undefined ? {} : { omitted }),