@secureport/core 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/index.d.ts +9 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/issue.d.ts +1 -1
  6. package/dist/issue.d.ts.map +1 -1
  7. package/dist/reconcile.d.ts +46 -1
  8. package/dist/reconcile.d.ts.map +1 -1
  9. package/dist/reconcile.js +34 -1
  10. package/dist/reconcile.js.map +1 -1
  11. package/dist/report/anchors.d.ts +39 -0
  12. package/dist/report/anchors.d.ts.map +1 -0
  13. package/dist/report/anchors.js +73 -0
  14. package/dist/report/anchors.js.map +1 -0
  15. package/dist/report/csv.d.ts +41 -0
  16. package/dist/report/csv.d.ts.map +1 -0
  17. package/dist/report/csv.js +152 -0
  18. package/dist/report/csv.js.map +1 -0
  19. package/dist/report/html.d.ts.map +1 -1
  20. package/dist/report/html.js +126 -22
  21. package/dist/report/html.js.map +1 -1
  22. package/dist/report/json.d.ts +15 -0
  23. package/dist/report/json.d.ts.map +1 -1
  24. package/dist/report/json.js +1 -0
  25. package/dist/report/json.js.map +1 -1
  26. package/dist/report/markdown.d.ts +24 -2
  27. package/dist/report/markdown.d.ts.map +1 -1
  28. package/dist/report/markdown.js +133 -31
  29. package/dist/report/markdown.js.map +1 -1
  30. package/dist/report/model.d.ts +288 -2
  31. package/dist/report/model.d.ts.map +1 -1
  32. package/dist/report/model.js +153 -21
  33. package/dist/report/model.js.map +1 -1
  34. package/dist/report/sarif.d.ts +60 -0
  35. package/dist/report/sarif.d.ts.map +1 -0
  36. package/dist/report/sarif.js +125 -0
  37. package/dist/report/sarif.js.map +1 -0
  38. package/dist/report/stream.d.ts +108 -0
  39. package/dist/report/stream.d.ts.map +1 -0
  40. package/dist/report/stream.js +534 -0
  41. package/dist/report/stream.js.map +1 -0
  42. package/package.json +4 -2
  43. package/src/index.ts +11 -2
  44. package/src/issue.ts +1 -0
  45. package/src/reconcile.ts +86 -2
  46. package/src/report/anchors.ts +96 -0
  47. package/src/report/csv.ts +180 -0
  48. package/src/report/html.ts +146 -26
  49. package/src/report/json.ts +17 -0
  50. package/src/report/markdown.ts +146 -30
  51. package/src/report/model.ts +433 -21
  52. package/src/report/sarif.ts +166 -0
  53. package/src/report/stream.ts +702 -0
@@ -1,7 +1,11 @@
1
+ import type { Finding } from '../finding.js';
2
+ import { markdownHeadings } from './anchors.js';
1
3
  import { SEVERITY_ORDER } from '../severity.js';
2
4
  import type { Snapshot, SnapshotIssue } from '../snapshot.js';
3
5
  import {
4
6
  buildReportModel,
7
+ type EvidenceVerbosity,
8
+ VERDICT_LABELS,
5
9
  type ReportModel,
6
10
  type ReportOptions,
7
11
  type RetestVerdict,
@@ -12,8 +16,14 @@ export function formatDate(date: Date): string {
12
16
  return date.toISOString().slice(0, 10);
13
17
  }
14
18
 
15
- /** Escapes the characters that would break out of a Markdown table cell. */
16
- function cell(value: string): string {
19
+ /**
20
+ * Escapes the characters that would break out of a Markdown table cell.
21
+ *
22
+ * Exported: the streaming renderer formats a row the moment it sees it, and
23
+ * needs the exact same escaping — a second copy is a copy free to miss the
24
+ * next character somebody discovers breaks a table.
25
+ */
26
+ export function cell(value: string): string {
17
27
  return value.replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
18
28
  }
19
29
 
@@ -50,8 +60,14 @@ function changeSummary(model: ReportModel): string[] {
50
60
  ];
51
61
  }
52
62
 
53
- /** One issue, in full. The Penetration Test presentation. */
54
- function issueDetail(entry: SnapshotIssue): string[] {
63
+ /**
64
+ * One issue, in full. The Penetration Test presentation.
65
+ *
66
+ * Exported: it is already a pure per-issue function needing nothing but the
67
+ * one entry, which is exactly what the streaming renderer can offer it a row
68
+ * at a time — the same reason `verdictFor` is exported from `model.ts`.
69
+ */
70
+ export function issueDetail(entry: SnapshotIssue, verbosity: EvidenceVerbosity): string[] {
55
71
  const { issue } = entry;
56
72
  const lines: string[] = [
57
73
  `#### ${issue.title}`,
@@ -81,39 +97,75 @@ function issueDetail(entry: SnapshotIssue): string[] {
81
97
  lines.push('**Recommendation.** ' + first.recommendation, '');
82
98
  }
83
99
 
84
- if (entry.findings.length > 0) {
85
- // The count, not the engine. Which scanner produced a finding is an
86
- // implementation detail of the assessment, and naming the stack in a
87
- // customer-facing document gives away more than it explains.
100
+ // The count or the detail, never the engine. Which scanner produced a
101
+ // finding is an implementation detail of the assessment, and naming the
102
+ // stack in a customer-facing document gives away more than it explains —
103
+ // it has leaked into rendered output twice, so no branch below reads
104
+ // `sourceEngine` or `sourceRuleId`.
105
+ if (entry.findings.length > 0 && verbosity === 'summary') {
88
106
  lines.push(
89
107
  `_Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded._`,
90
108
  '',
91
109
  );
92
- const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
93
- if (references.length > 0) {
94
- lines.push(...references.map((r) => `- ${r}`), '');
95
- }
110
+ } else if (entry.findings.length > 0 && verbosity === 'full') {
111
+ lines.push('**Evidence**', '');
112
+ for (const finding of entry.findings) lines.push(`- ${evidenceLine(finding)}`);
113
+ lines.push('');
114
+ }
115
+
116
+ // Outside the verbosity branch: an advisory is external reading, not
117
+ // evidence, so `none` still lists it.
118
+ const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
119
+ if (references.length > 0) {
120
+ lines.push(...references.map((r) => `- ${r}`), '');
96
121
  }
97
122
  return lines;
98
123
  }
99
124
 
100
- /** One issue, as a table row. The Vulnerability Assessment presentation. */
101
- function issueRow(entry: SnapshotIssue): string {
125
+ /**
126
+ * One supporting finding, in a sentence.
127
+ *
128
+ * Every field here is something that was *found*. Nothing identifies what
129
+ * found it.
130
+ */
131
+ function evidenceLine(finding: Finding): string {
132
+ const where =
133
+ finding.parameter === undefined
134
+ ? `\`${cell(finding.location)}\``
135
+ : `\`${cell(finding.location)}\` (\`${cell(finding.parameter)}\`)`;
136
+
137
+ const detail: string[] = [`${finding.detectedSeverity}, seen ${formatDate(finding.createdAt)}`];
138
+ if (finding.cve !== undefined) detail.push(cell(finding.cve));
139
+ if (finding.cvssScore !== undefined) {
140
+ detail.push(
141
+ finding.cvssVector === undefined
142
+ ? `CVSS ${finding.cvssScore}`
143
+ : `CVSS ${finding.cvssScore} \`${cell(finding.cvssVector)}\``,
144
+ );
145
+ }
146
+ if (finding.confidence !== undefined) {
147
+ detail.push(`confidence ${Math.round(finding.confidence * 100)}%`);
148
+ }
149
+ const captures = finding.evidenceUri ?? [];
150
+ if (captures.length > 0) {
151
+ detail.push(`${captures.length} capture${captures.length === 1 ? '' : 's'} stored`);
152
+ }
153
+
154
+ return `${where} — ${detail.join('; ')}`;
155
+ }
156
+
157
+ /**
158
+ * One issue, as a table row. The Vulnerability Assessment presentation.
159
+ *
160
+ * Exported for the same reason {@link issueDetail} is.
161
+ */
162
+ export function issueRow(entry: SnapshotIssue): string {
102
163
  const { issue } = entry;
103
164
  const location =
104
165
  issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
105
166
  return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
106
167
  }
107
168
 
108
- /** How a verdict is printed. The dashes are an implementation detail. */
109
- const VERDICT_LABELS: Readonly<Record<RetestVerdict, string>> = Object.freeze({
110
- fixed: 'fixed',
111
- still_present: 'still present',
112
- returned: 'returned',
113
- not_retested: 'not retested',
114
- new: 'new since',
115
- });
116
-
117
169
  /** The Retest body: the difference between two runs, and nothing else. */
118
170
  function retestBody(model: ReportModel): string[] {
119
171
  // `buildReportModel` refuses to build a retest without one.
@@ -299,7 +351,7 @@ function attestationBody(model: ReportModel): string[] {
299
351
  return [
300
352
  '## Statement',
301
353
  '',
302
- `${model.preparedBy ?? 'Secureport'} carried out security testing of **${snapshot.target.name}**`,
354
+ `${model.attestor} carried out security testing of **${snapshot.target.name}**`,
303
355
  `(${snapshot.target.url}) on ${formatDate(snapshot.run.createdAt)}.`,
304
356
  '',
305
357
  model.basis.statement,
@@ -324,6 +376,37 @@ function attestationBody(model: ReportModel): string[] {
324
376
  ];
325
377
  }
326
378
 
379
+ /**
380
+ * The contents list, inserted above the first section.
381
+ *
382
+ * Computed from the finished document rather than from a list kept alongside
383
+ * it, for the reason `anchors.ts` explains: a contents list assembled
384
+ * separately from the headings it points at is one that can point at nothing,
385
+ * and the failure is silent.
386
+ *
387
+ * Markdown headings carry no explicit id, so the links rely on the anchor
388
+ * every common Markdown renderer derives from the heading text. `slug` matches
389
+ * that convention, which is the only way this can work at all.
390
+ */
391
+ function withContents(lines: readonly string[], model: ReportModel): string {
392
+ if (!model.tableOfContents) return lines.join('\n');
393
+
394
+ // Before inserting, so the contents list does not list itself.
395
+ const toc = markdownHeadings(lines);
396
+ if (toc.length === 0) return lines.join('\n');
397
+
398
+ const block = [
399
+ '## Contents',
400
+ '',
401
+ ...toc.map((entry) => `${entry.level === 3 ? ' ' : ''}- [${entry.text}](#${entry.id})`),
402
+ '',
403
+ ];
404
+
405
+ const firstSection = lines.findIndex((line) => /^##\s/u.test(line));
406
+ const at = firstSection < 0 ? lines.length : firstSection;
407
+ return [...lines.slice(0, at), ...block, ...lines.slice(at)].join('\n');
408
+ }
409
+
327
410
  /**
328
411
  * Renders a snapshot as Markdown.
329
412
  *
@@ -347,9 +430,24 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
347
430
  const { target } = snapshot;
348
431
 
349
432
  const lines: string[] = [
433
+ // Markdown has no pages and no rotation, so the stamp the HTML puts across
434
+ // every page becomes a line at the top. Same statement, in the only form
435
+ // this format has for it.
436
+ ...(model.branding?.watermark === undefined ? [] : [`**${model.branding.watermark}**`, '']),
350
437
  `# ${model.title}`,
351
438
  '',
352
439
  `**${target.name}** — ${target.url}`,
440
+ // Markdown has no pages, so the cover is a block under the title rather
441
+ // than a page of its own. The same facts, in the only form this format
442
+ // has for them.
443
+ ...(model.coverPage
444
+ ? [
445
+ '',
446
+ model.attestor,
447
+ ...(model.branding?.companyDetails ?? []),
448
+ ...(model.preparedFor === undefined ? [] : [`Prepared for ${model.preparedFor}`]),
449
+ ].map((line, i) => (i === 0 ? line : `_${line}_`))
450
+ : []),
353
451
  '',
354
452
  `| | |`,
355
453
  `| --- | --- |`,
@@ -366,7 +464,7 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
366
464
  // reader stops trusting.
367
465
  if (model.kind === 'attest') {
368
466
  lines.push(...attestationBody(model), ...suppressedAppendix(snapshot), '');
369
- return lines.join('\n');
467
+ return withContents(lines, model);
370
468
  }
371
469
 
372
470
  lines.push('## Basis of testing', '', model.basis.statement, '');
@@ -385,21 +483,33 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
385
483
 
386
484
  if (model.kind === 'exec') {
387
485
  lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
388
- return lines.join('\n');
486
+ return withContents(lines, model);
389
487
  }
390
488
 
391
489
  if (model.kind === 'retest') {
392
490
  lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
393
- return lines.join('\n');
491
+ return withContents(lines, model);
394
492
  }
395
493
 
396
494
  if (model.sections.length === 0) {
397
- lines.push('## Findings', '', '_No issues were found._', '');
495
+ // "No issues were found" is false when a floor is what emptied the
496
+ // section, and it is the most dangerous sentence in the document to get
497
+ // wrong — a reader would take it as a clean result.
498
+ lines.push(
499
+ '## Findings',
500
+ '',
501
+ model.omitted === undefined
502
+ ? '_No issues were found._'
503
+ : '_No issues at or above the reporting threshold were found._',
504
+ '',
505
+ );
398
506
  } else if (model.kind === 'pen') {
399
507
  lines.push('## Findings', '');
400
508
  for (const section of model.sections) {
401
509
  lines.push(`### ${section.severity} (${section.issues.length})`, '');
402
- for (const entry of section.issues) lines.push(...issueDetail(entry));
510
+ for (const entry of section.issues) {
511
+ lines.push(...issueDetail(entry, model.evidenceVerbosity));
512
+ }
403
513
  }
404
514
  } else {
405
515
  lines.push(
@@ -414,6 +524,12 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
414
524
  lines.push('');
415
525
  }
416
526
 
527
+ // Printed here, beside the section it is about, rather than left to the
528
+ // limitations list — which only the Attestation Letter renders (B109). A
529
+ // floor that removed issues from this section and said so nowhere the reader
530
+ // will look is the quiet form of the over-claim §7 exists to stop.
531
+ if (model.omitted !== undefined) lines.push(`_${model.omitted.statement}_`, '');
532
+
417
533
  if (model.resolved.length > 0) {
418
534
  lines.push(
419
535
  '## Resolved since the last run',
@@ -431,5 +547,5 @@ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): stri
431
547
  }
432
548
 
433
549
  lines.push(...suppressedAppendix(snapshot), '');
434
- return lines.join('\n');
550
+ return withContents(lines, model);
435
551
  }