@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.
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/report/csv.d.ts +41 -0
- package/dist/report/csv.d.ts.map +1 -0
- package/dist/report/csv.js +158 -0
- package/dist/report/csv.js.map +1 -0
- package/dist/report/fonts.d.ts +53 -0
- package/dist/report/fonts.d.ts.map +1 -0
- package/dist/report/fonts.js +53 -0
- package/dist/report/fonts.js.map +1 -0
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +48 -13
- package/dist/report/html.js.map +1 -1
- package/dist/report/json.d.ts +15 -0
- package/dist/report/json.d.ts.map +1 -1
- package/dist/report/json.js +1 -0
- package/dist/report/json.js.map +1 -1
- package/dist/report/markdown.d.ts +33 -2
- package/dist/report/markdown.d.ts.map +1 -1
- package/dist/report/markdown.js +67 -16
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +108 -1
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +173 -36
- package/dist/report/model.js.map +1 -1
- package/dist/report/sarif.d.ts +60 -0
- package/dist/report/sarif.d.ts.map +1 -0
- package/dist/report/sarif.js +125 -0
- package/dist/report/sarif.js.map +1 -0
- package/dist/report/stream.d.ts +119 -0
- package/dist/report/stream.d.ts.map +1 -0
- package/dist/report/stream.js +546 -0
- package/dist/report/stream.js.map +1 -0
- package/package.json +5 -3
- package/src/index.ts +7 -1
- package/src/report/csv.ts +186 -0
- package/src/report/fonts.ts +55 -0
- package/src/report/html.ts +57 -14
- package/src/report/json.ts +17 -0
- package/src/report/markdown.ts +79 -15
- package/src/report/model.ts +242 -45
- package/src/report/sarif.ts +166 -0
- package/src/report/stream.ts +727 -0
package/src/report/markdown.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
-
/**
|
|
58
|
-
|
|
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
|
-
/**
|
|
146
|
-
|
|
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 |
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
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
|
}
|
package/src/report/model.ts
CHANGED
|
@@ -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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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 ${
|
|
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 ${
|
|
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 (
|
|
707
|
+
if (suppressedCount > 0) {
|
|
577
708
|
limitations.push(
|
|
578
|
-
`${
|
|
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
|
-
|
|
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
|
-
/**
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
749
|
-
if (
|
|
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
|
|
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
|
|
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(
|
|
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 }),
|