@secureport/core 1.0.0 → 2.2.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 (51) hide show
  1. package/README.md +2 -1
  2. package/dist/finding.d.ts +9 -8
  3. package/dist/finding.d.ts.map +1 -1
  4. package/dist/fingerprint.d.ts +12 -5
  5. package/dist/fingerprint.d.ts.map +1 -1
  6. package/dist/fingerprint.js +74 -8
  7. package/dist/fingerprint.js.map +1 -1
  8. package/dist/import/nessus.d.ts +9 -6
  9. package/dist/import/nessus.d.ts.map +1 -1
  10. package/dist/import/nessus.js +9 -6
  11. package/dist/import/nessus.js.map +1 -1
  12. package/dist/index.d.ts +6 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/issue.d.ts +1 -1
  17. package/dist/issue.d.ts.map +1 -1
  18. package/dist/reconcile.d.ts +71 -1
  19. package/dist/reconcile.d.ts.map +1 -1
  20. package/dist/reconcile.js +64 -4
  21. package/dist/reconcile.js.map +1 -1
  22. package/dist/refingerprint.d.ts +116 -0
  23. package/dist/refingerprint.d.ts.map +1 -0
  24. package/dist/refingerprint.js +190 -0
  25. package/dist/refingerprint.js.map +1 -0
  26. package/dist/report/anchors.d.ts +39 -0
  27. package/dist/report/anchors.d.ts.map +1 -0
  28. package/dist/report/anchors.js +73 -0
  29. package/dist/report/anchors.js.map +1 -0
  30. package/dist/report/html.d.ts.map +1 -1
  31. package/dist/report/html.js +126 -22
  32. package/dist/report/html.js.map +1 -1
  33. package/dist/report/markdown.d.ts.map +1 -1
  34. package/dist/report/markdown.js +112 -26
  35. package/dist/report/markdown.js.map +1 -1
  36. package/dist/report/model.d.ts +227 -1
  37. package/dist/report/model.d.ts.map +1 -1
  38. package/dist/report/model.js +87 -8
  39. package/dist/report/model.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/finding.ts +9 -8
  42. package/src/fingerprint.ts +76 -10
  43. package/src/import/nessus.ts +9 -6
  44. package/src/index.ts +14 -2
  45. package/src/issue.ts +1 -0
  46. package/src/reconcile.ts +121 -5
  47. package/src/refingerprint.ts +282 -0
  48. package/src/report/anchors.ts +96 -0
  49. package/src/report/html.ts +146 -26
  50. package/src/report/markdown.ts +125 -25
  51. package/src/report/model.ts +344 -7
@@ -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. */
@@ -288,7 +543,11 @@ function verdictFor(entry: SnapshotIssue): RetestVerdict {
288
543
  }
289
544
 
290
545
  /** Works out what a report cannot establish, from the run rather than a template. */
291
- function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[] {
546
+ function describeLimitations(
547
+ snapshot: Snapshot,
548
+ basis: TestingBasis,
549
+ omitted: OmittedIssues | undefined,
550
+ ): string[] {
292
551
  const limitations: string[] = [];
293
552
 
294
553
  if (!basis.includesManual) {
@@ -320,6 +579,11 @@ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[]
320
579
  'suppressed and excluded from the counts above. They are listed in full in the appendix.',
321
580
  );
322
581
  }
582
+ // Immediately after the suppression sentence and before the compliance one,
583
+ // because it answers the same question a reader is entitled to ask: what am
584
+ // I not being shown? A floor that removed pages silently would be the
585
+ // over-claim §7 exists to stop, in a quieter form than the appendix one.
586
+ if (omitted !== undefined) limitations.push(omitted.statement);
323
587
  limitations.push(
324
588
  'This document does not certify compliance with any standard, framework or regulation.',
325
589
  );
@@ -327,6 +591,12 @@ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[]
327
591
  return limitations;
328
592
  }
329
593
 
594
+ /**
595
+ * Hex triplets only. See {@link Branding.primaryColour} for why this is a
596
+ * refusal rather than a sanitisation.
597
+ */
598
+ const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/iu;
599
+
330
600
  const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
331
601
  pen: 'Penetration Test Report',
332
602
  vap: 'Vulnerability Assessment Report',
@@ -399,6 +669,16 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
399
669
  const bySeverity = new Map<Severity, SnapshotIssue[]>();
400
670
  const resolved: SnapshotIssue[] = [];
401
671
  let breached = 0;
672
+ let omittedBelowFloor = 0;
673
+
674
+ // `SEVERITY_ORDER` is most-severe-first, so "at or above the floor" is a
675
+ // *lower* index. Same trap `describeRetest` records above, from the other
676
+ // direction: `severityRank` counts the opposite way and using it here would
677
+ // floor out everything except the advisories.
678
+ const floorAt =
679
+ options.severityFloor === undefined
680
+ ? SEVERITY_ORDER.length
681
+ : SEVERITY_ORDER.indexOf(options.severityFloor);
402
682
 
403
683
  for (const entry of snapshot.issues) {
404
684
  if (entry.issue.status === 'resolved') {
@@ -407,14 +687,22 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
407
687
  }
408
688
 
409
689
  const severity = entry.issue.effectiveSeverity;
410
- const group = bySeverity.get(severity);
411
- if (group) group.push(entry);
412
- else bySeverity.set(severity, [entry]);
413
690
 
691
+ // Counted before the floor is consulted, deliberately: the floor decides
692
+ // what is *listed*, never what is *counted*.
414
693
  if (entry.issue.status === 'open' || entry.issue.status === 'regressed') {
415
694
  outstanding[severity]++;
416
695
  if (entry.slaStatus === 'breached') breached++;
417
696
  }
697
+
698
+ if (SEVERITY_ORDER.indexOf(severity) > floorAt) {
699
+ omittedBelowFloor++;
700
+ continue;
701
+ }
702
+
703
+ const group = bySeverity.get(severity);
704
+ if (group) group.push(entry);
705
+ else bySeverity.set(severity, [entry]);
418
706
  }
419
707
  resolved.sort((a, b) => b.issue.lastSeen.getTime() - a.issue.lastSeen.getTime());
420
708
 
@@ -430,6 +718,20 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
430
718
  });
431
719
  }
432
720
 
721
+ const floor = options.severityFloor;
722
+ const omitted: OmittedIssues | undefined =
723
+ omittedBelowFloor === 0 || floor === undefined
724
+ ? undefined
725
+ : {
726
+ count: omittedBelowFloor,
727
+ floor,
728
+ statement:
729
+ `${omittedBelowFloor} issue${omittedBelowFloor === 1 ? '' : 's'} below ${floor} severity ` +
730
+ `${omittedBelowFloor === 1 ? 'is' : 'are'} not listed individually in this document. ` +
731
+ `${omittedBelowFloor === 1 ? 'It remains' : 'They remain'} open and ` +
732
+ `${omittedBelowFloor === 1 ? 'is' : 'are'} included in every count above.`,
733
+ };
734
+
433
735
  const outstandingTotal = SEVERITY_ORDER.reduce((sum, s) => sum + outstanding[s], 0);
434
736
  const basis = describeBasis(snapshot);
435
737
  const retest = describeRetest(snapshot);
@@ -442,6 +744,35 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
442
744
  );
443
745
  }
444
746
 
747
+ 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
+ }
764
+
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
+ }
775
+
445
776
  return {
446
777
  kind: options.kind,
447
778
  title: options.title ?? DEFAULT_TITLES[options.kind],
@@ -449,10 +780,16 @@ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): Re
449
780
  generatedAt: options.now,
450
781
  ...(options.preparedBy === undefined ? {} : { preparedBy: options.preparedBy }),
451
782
  ...(options.preparedFor === undefined ? {} : { preparedFor: options.preparedFor }),
783
+ attestor: attestor ?? 'Secureport',
784
+ coverPage: options.coverPage === true,
785
+ tableOfContents: options.tableOfContents === true,
786
+ ...(branding === undefined ? {} : { branding }),
452
787
  basis,
453
- limitations: describeLimitations(snapshot, basis),
788
+ evidenceVerbosity: options.evidenceVerbosity ?? 'summary',
789
+ limitations: describeLimitations(snapshot, basis, omitted),
454
790
  sections,
455
- resolved,
791
+ resolved: options.includeResolved === false ? [] : resolved,
792
+ ...(omitted === undefined ? {} : { omitted }),
456
793
  outstanding,
457
794
  outstandingTotal,
458
795
  exposureScore: snapshot.run.exposureScore,