@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.
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/issue.d.ts +1 -1
- package/dist/issue.d.ts.map +1 -1
- package/dist/reconcile.d.ts +46 -1
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/reconcile.js +34 -1
- package/dist/reconcile.js.map +1 -1
- package/dist/report/anchors.d.ts +39 -0
- package/dist/report/anchors.d.ts.map +1 -0
- package/dist/report/anchors.js +73 -0
- package/dist/report/anchors.js.map +1 -0
- package/dist/report/csv.d.ts +41 -0
- package/dist/report/csv.d.ts.map +1 -0
- package/dist/report/csv.js +152 -0
- package/dist/report/csv.js.map +1 -0
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +126 -22
- 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 +24 -2
- package/dist/report/markdown.d.ts.map +1 -1
- package/dist/report/markdown.js +133 -31
- package/dist/report/markdown.js.map +1 -1
- package/dist/report/model.d.ts +288 -2
- package/dist/report/model.d.ts.map +1 -1
- package/dist/report/model.js +153 -21
- 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 +108 -0
- package/dist/report/stream.d.ts.map +1 -0
- package/dist/report/stream.js +534 -0
- package/dist/report/stream.js.map +1 -0
- package/package.json +4 -2
- package/src/index.ts +11 -2
- package/src/issue.ts +1 -0
- package/src/reconcile.ts +86 -2
- package/src/report/anchors.ts +96 -0
- package/src/report/csv.ts +180 -0
- package/src/report/html.ts +146 -26
- package/src/report/json.ts +17 -0
- package/src/report/markdown.ts +146 -30
- package/src/report/model.ts +433 -21
- package/src/report/sarif.ts +166 -0
- package/src/report/stream.ts +702 -0
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';
|
|
@@ -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
|
-
/**
|
|
291
|
-
|
|
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 ${
|
|
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 ${
|
|
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 (
|
|
589
|
+
if (suppressedCount > 0) {
|
|
318
590
|
limitations.push(
|
|
319
|
-
`${
|
|
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
|
-
|
|
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
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|