@secureport/core 0.3.0 → 1.0.0-rc.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 (59) hide show
  1. package/README.md +116 -11
  2. package/dist/import/burp.d.ts +19 -0
  3. package/dist/import/burp.d.ts.map +1 -0
  4. package/dist/import/burp.js +114 -0
  5. package/dist/import/burp.js.map +1 -0
  6. package/dist/import/generic.d.ts +90 -0
  7. package/dist/import/generic.d.ts.map +1 -0
  8. package/dist/import/generic.js +159 -0
  9. package/dist/import/generic.js.map +1 -0
  10. package/dist/import/nessus.d.ts +32 -0
  11. package/dist/import/nessus.d.ts.map +1 -0
  12. package/dist/import/nessus.js +125 -0
  13. package/dist/import/nessus.js.map +1 -0
  14. package/dist/import/xml.d.ts +47 -0
  15. package/dist/import/xml.d.ts.map +1 -0
  16. package/dist/import/xml.js +157 -0
  17. package/dist/import/xml.js.map +1 -0
  18. package/dist/import/zap.d.ts +1 -1
  19. package/dist/import/zap.js +1 -1
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/reconcile.d.ts.map +1 -1
  25. package/dist/reconcile.js +35 -0
  26. package/dist/reconcile.js.map +1 -1
  27. package/dist/report/html.d.ts +21 -0
  28. package/dist/report/html.d.ts.map +1 -0
  29. package/dist/report/html.js +324 -0
  30. package/dist/report/html.js.map +1 -0
  31. package/dist/report/json.d.ts +81 -0
  32. package/dist/report/json.d.ts.map +1 -0
  33. package/dist/report/json.js +47 -0
  34. package/dist/report/json.js.map +1 -0
  35. package/dist/report/markdown.d.ts +24 -0
  36. package/dist/report/markdown.d.ts.map +1 -0
  37. package/dist/report/markdown.js +304 -0
  38. package/dist/report/markdown.js.map +1 -0
  39. package/dist/report/model.d.ts +215 -0
  40. package/dist/report/model.d.ts.map +1 -0
  41. package/dist/report/model.js +197 -0
  42. package/dist/report/model.js.map +1 -0
  43. package/dist/snapshot-builder.d.ts +8 -0
  44. package/dist/snapshot-builder.d.ts.map +1 -1
  45. package/dist/snapshot-builder.js +25 -1
  46. package/dist/snapshot-builder.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/import/burp.ts +126 -0
  49. package/src/import/generic.ts +258 -0
  50. package/src/import/nessus.ts +136 -0
  51. package/src/import/xml.ts +187 -0
  52. package/src/import/zap.ts +1 -1
  53. package/src/index.ts +19 -0
  54. package/src/reconcile.ts +37 -0
  55. package/src/report/html.ts +449 -0
  56. package/src/report/json.ts +134 -0
  57. package/src/report/markdown.ts +435 -0
  58. package/src/report/model.ts +462 -0
  59. package/src/snapshot-builder.ts +28 -2
@@ -0,0 +1,435 @@
1
+ import { SEVERITY_ORDER } from '../severity.js';
2
+ import type { Snapshot, SnapshotIssue } from '../snapshot.js';
3
+ import {
4
+ buildReportModel,
5
+ type ReportModel,
6
+ type ReportOptions,
7
+ type RetestVerdict,
8
+ } from './model.js';
9
+
10
+ /** A date, printed the same way everywhere, without a locale to disagree about. */
11
+ export function formatDate(date: Date): string {
12
+ return date.toISOString().slice(0, 10);
13
+ }
14
+
15
+ /** Escapes the characters that would break out of a Markdown table cell. */
16
+ function cell(value: string): string {
17
+ return value.replace(/\|/gu, '\\|').replace(/\n/gu, ' ');
18
+ }
19
+
20
+ /** The change summary every report opens with, per `00-DOMAIN.md` §7. */
21
+ function changeSummary(model: ReportModel): string[] {
22
+ const { run, baseline } = model.snapshot;
23
+ const rows = SEVERITY_ORDER.map((severity) => {
24
+ const cells = [
25
+ severity,
26
+ run.new[severity],
27
+ run.stillOpen[severity],
28
+ run.regressed[severity],
29
+ run.resolved[severity],
30
+ run.ignored[severity],
31
+ ];
32
+ return `| ${cells.join(' | ')} |`;
33
+ });
34
+
35
+ return [
36
+ '## What changed',
37
+ '',
38
+ baseline === undefined
39
+ ? '_First run against this target, so every issue is new._'
40
+ : `_Compared with the run of ${formatDate(baseline.createdAt)}._`,
41
+ '',
42
+ '| Severity | New | Still open | Regressed | Resolved | Suppressed |',
43
+ '| --- | --- | --- | --- | --- | --- |',
44
+ ...rows,
45
+ '',
46
+ `**${model.outstandingTotal} outstanding.** Exposure score ${model.exposureScore}.` +
47
+ (model.breached > 0
48
+ ? ` **${model.breached} past ${model.breached === 1 ? 'its' : 'their'} remediation deadline.**`
49
+ : ''),
50
+ ];
51
+ }
52
+
53
+ /** One issue, in full. The Penetration Test presentation. */
54
+ function issueDetail(entry: SnapshotIssue): string[] {
55
+ const { issue } = entry;
56
+ const lines: string[] = [
57
+ `#### ${issue.title}`,
58
+ '',
59
+ `| | |`,
60
+ `| --- | --- |`,
61
+ `| Severity | ${issue.effectiveSeverity}${
62
+ issue.effectiveSeverity === issue.detectedSeverity
63
+ ? ''
64
+ : ` (detected ${issue.detectedSeverity})`
65
+ } |`,
66
+ `| Status | ${issue.status} · ${entry.change.replace('_', ' ')} |`,
67
+ `| Location | \`${cell(issue.location)}\`${issue.parameter === undefined ? '' : ` (\`${cell(issue.parameter)}\`)`} |`,
68
+ `| First seen | ${formatDate(issue.firstSeen)} (${entry.daysOpen} days) |`,
69
+ `| Remediation | ${
70
+ issue.slaDueAt === undefined || issue.slaDueAt === null
71
+ ? 'no deadline'
72
+ : `${formatDate(issue.slaDueAt)} — ${entry.slaStatus.replace('_', ' ')}`
73
+ } |`,
74
+ ];
75
+ if (issue.cwe !== undefined) lines.push(`| Weakness | ${issue.cwe} |`);
76
+ lines.push('');
77
+
78
+ const first = entry.findings[0];
79
+ if (first?.description !== undefined) lines.push(first.description, '');
80
+ if (first?.recommendation !== undefined) {
81
+ lines.push('**Recommendation.** ' + first.recommendation, '');
82
+ }
83
+
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.
88
+ lines.push(
89
+ `_Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded._`,
90
+ '',
91
+ );
92
+ const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
93
+ if (references.length > 0) {
94
+ lines.push(...references.map((r) => `- ${r}`), '');
95
+ }
96
+ }
97
+ return lines;
98
+ }
99
+
100
+ /** One issue, as a table row. The Vulnerability Assessment presentation. */
101
+ function issueRow(entry: SnapshotIssue): string {
102
+ const { issue } = entry;
103
+ const location =
104
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
105
+ return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | \`${cell(location)}\` | ${entry.change.replace('_', ' ')} | ${entry.daysOpen} | ${entry.slaStatus.replace('_', ' ')} |`;
106
+ }
107
+
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
+ /** The Retest body: the difference between two runs, and nothing else. */
118
+ function retestBody(model: ReportModel): string[] {
119
+ // `buildReportModel` refuses to build a retest without one.
120
+ const retest = model.retest as NonNullable<ReportModel['retest']>;
121
+ const { counts } = retest;
122
+ const carried = counts.fixed + counts.still_present + counts.returned + counts.not_retested;
123
+
124
+ const lines = [
125
+ '## Retest verdict',
126
+ '',
127
+ `Retested against the run of ${formatDate(retest.baseline.createdAt)}.`,
128
+ '',
129
+ carried === 0
130
+ ? 'No issues were carried into this retest.'
131
+ : `Of the ${carried} issue${carried === 1 ? '' : 's'} carried into this retest, ` +
132
+ `**${counts.fixed} ${counts.fixed === 1 ? 'is' : 'are'} confirmed fixed**, ` +
133
+ `${counts.still_present} ${counts.still_present === 1 ? 'is' : 'are'} still present, ` +
134
+ `${counts.returned} returned, and ${counts.not_retested} could not be retested.` +
135
+ (counts.new === 0
136
+ ? ''
137
+ : ` A further ${counts.new} ${counts.new === 1 ? 'issue was' : 'issues were'} found for the first time by this run.`),
138
+ '',
139
+ ];
140
+
141
+ if (retest.fixRate !== null) {
142
+ lines.push(
143
+ `${Math.round(retest.fixRate * 100)}% of the issues this run could check are fixed.`,
144
+ '',
145
+ );
146
+ }
147
+
148
+ lines.push(
149
+ '| Verdict | Issues |',
150
+ '| --- | --- |',
151
+ ...(Object.keys(VERDICT_LABELS) as RetestVerdict[]).map(
152
+ (v) => `| ${VERDICT_LABELS[v]} | ${counts[v]} |`,
153
+ ),
154
+ '',
155
+ );
156
+
157
+ // The section that stops this document reporting a coverage gap as
158
+ // remediation. Stated before the issue table, not in a footnote after it.
159
+ if (counts.not_retested > 0) {
160
+ lines.push(
161
+ '## Not retested',
162
+ '',
163
+ `${counts.not_retested} issue${counts.not_retested === 1 ? '' : 's'} could not be retested by this run:`,
164
+ 'the run did not cover where they were found, or has not missed them often enough to',
165
+ 'call them resolved. **They are not fixed.** An issue nobody looked at is neither fixed',
166
+ 'nor unfixed, and it is excluded from the percentage above rather than counted either way.',
167
+ '',
168
+ );
169
+ }
170
+
171
+ if (retest.entries.length === 0) {
172
+ lines.push('## Issue by issue', '', '_No issues._', '');
173
+ return lines;
174
+ }
175
+
176
+ lines.push(
177
+ '## Issue by issue',
178
+ '',
179
+ '| Issue | Severity | Verdict | Location | Open for |',
180
+ '| --- | --- | --- | --- | --- |',
181
+ ...retest.entries.map((entry) => {
182
+ const { issue } = entry.issue;
183
+ const location =
184
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
185
+ return `| ${cell(issue.title)} | ${issue.effectiveSeverity} | ${VERDICT_LABELS[entry.verdict]} | \`${cell(location)}\` | ${entry.issue.daysOpen} days |`;
186
+ }),
187
+ '',
188
+ );
189
+ return lines;
190
+ }
191
+
192
+ /** The suppressed-findings appendix. On by default, never optional. */
193
+ function suppressedAppendix(snapshot: Snapshot): string[] {
194
+ if (snapshot.suppressed.length === 0) {
195
+ return ['## Suppressed findings', '', '_None._'];
196
+ }
197
+ return [
198
+ '## Suppressed findings',
199
+ '',
200
+ 'Accepted, out of scope or dismissed. Listed because an accepted risk that',
201
+ 'nobody can see is not an accepted risk.',
202
+ '',
203
+ '| Issue | Severity when suppressed | Reason | By | Expires | Justification |',
204
+ '| --- | --- | --- | --- | --- | --- |',
205
+ ...snapshot.suppressed.map(
206
+ (s) =>
207
+ `| ${cell(s.issue.title)} | ${s.severityAtIgnore} | ${s.reason.replace('_', ' ')} | ${cell(s.ignoredBy)} | ${
208
+ s.expiresAt === undefined ? 'never' : formatDate(s.expiresAt)
209
+ } | ${cell(s.comment)} |`,
210
+ ),
211
+ ];
212
+ }
213
+
214
+ /** The Executive Summary body: what changed, what it means, no technical detail. */
215
+ function executiveBody(model: ReportModel): string[] {
216
+ const resolvedCount = model.resolved.length;
217
+ const regressed = model.sections
218
+ .flatMap((s) => s.issues)
219
+ .filter((i) => i.change === 'regressed').length;
220
+
221
+ const headline =
222
+ model.outstandingTotal === 0
223
+ ? 'Nothing is currently outstanding on this target.'
224
+ : `${model.outstandingTotal} issue${model.outstandingTotal === 1 ? '' : 's'} ${
225
+ model.outstandingTotal === 1 ? 'is' : 'are'
226
+ } currently outstanding, of which ${model.outstanding.critical + model.outstanding.high} ` +
227
+ `${model.outstanding.critical + model.outstanding.high === 1 ? 'is' : 'are'} critical or high.`;
228
+
229
+ const lines = ['## Where things stand', '', headline, ''];
230
+
231
+ if (resolvedCount > 0) {
232
+ lines.push(
233
+ `${resolvedCount} issue${resolvedCount === 1 ? ' was' : 's were'} resolved since the last run.`,
234
+ '',
235
+ );
236
+ }
237
+ if (regressed > 0) {
238
+ lines.push(
239
+ `**${regressed} previously resolved issue${regressed === 1 ? ' has' : 's have'} returned.** ` +
240
+ 'A returning issue usually means a fix was reverted or incompletely applied.',
241
+ '',
242
+ );
243
+ }
244
+ if (model.breached > 0) {
245
+ lines.push(
246
+ `**${model.breached} ${model.breached === 1 ? 'is' : 'are'} past the remediation deadline** ` +
247
+ 'set by the severity policy.',
248
+ '',
249
+ );
250
+ }
251
+
252
+ lines.push(
253
+ '## Outstanding by severity',
254
+ '',
255
+ '| Severity | Outstanding |',
256
+ '| --- | --- |',
257
+ ...SEVERITY_ORDER.filter((sev) => model.outstanding[sev] > 0).map(
258
+ (sev) => `| ${sev} | ${model.outstanding[sev]} |`,
259
+ ),
260
+ '',
261
+ );
262
+ if (model.outstandingTotal === 0) lines.push('_Nothing outstanding._', '');
263
+
264
+ // Named, but not explained. An executive summary that reproduces the
265
+ // technical report is not a summary, and the reader who wants detail has the
266
+ // other three.
267
+ const notable = model.sections
268
+ .flatMap((s) => s.issues)
269
+ .filter((i) => ['critical', 'high'].includes(i.issue.effectiveSeverity))
270
+ .slice(0, 10);
271
+ if (notable.length > 0) {
272
+ lines.push(
273
+ '## What needs attention first',
274
+ '',
275
+ ...notable.map(
276
+ (entry) =>
277
+ `- **${entry.issue.effectiveSeverity}** — ${entry.issue.title} (open ${entry.daysOpen} days)`,
278
+ ),
279
+ '',
280
+ '_Full detail, including evidence, is in the accompanying technical report._',
281
+ '',
282
+ );
283
+ }
284
+
285
+ lines.push(
286
+ `Exposure score ${model.exposureScore}. This is a single number combining how many issues`,
287
+ 'are open, how severe they are and how long they have been open. It falls as issues are',
288
+ 'fixed and rises while they are not.',
289
+ '',
290
+ );
291
+ return lines;
292
+ }
293
+
294
+ /** The Attestation Letter body: a formal statement, with its limits stated. */
295
+ function attestationBody(model: ReportModel): string[] {
296
+ const { snapshot } = model;
297
+ const found = snapshot.issues.length + snapshot.suppressed.length;
298
+
299
+ return [
300
+ '## Statement',
301
+ '',
302
+ `${model.preparedBy ?? 'Secureport'} carried out security testing of **${snapshot.target.name}**`,
303
+ `(${snapshot.target.url}) on ${formatDate(snapshot.run.createdAt)}.`,
304
+ '',
305
+ model.basis.statement,
306
+ '',
307
+ `The testing identified ${found} issue${found === 1 ? '' : 's'} in total, of which`,
308
+ `${model.outstandingTotal} ${model.outstandingTotal === 1 ? 'remains' : 'remain'} outstanding at the date of this letter:`,
309
+ '',
310
+ '| Severity | Outstanding |',
311
+ '| --- | --- |',
312
+ ...SEVERITY_ORDER.map((sev) => `| ${sev} | ${model.outstanding[sev]} |`),
313
+ '',
314
+ // The most important section in the document, and deliberately not last:
315
+ // a reader who stops early must still have read it.
316
+ '## What this letter does not establish',
317
+ '',
318
+ ...model.limitations.map((l) => `- ${l}`),
319
+ '',
320
+ '## Scope tested',
321
+ '',
322
+ ...snapshot.run.coverage.paths.map((p) => `- \`${cell(p)}\``),
323
+ '',
324
+ ];
325
+ }
326
+
327
+ /**
328
+ * Renders a snapshot as Markdown.
329
+ *
330
+ * The Penetration Test report gives every issue in full with its evidence; the
331
+ * Vulnerability Assessment tabulates the same issues; the Executive Summary
332
+ * says what changed and what it means without technical detail; the Attestation
333
+ * Letter states formally that testing was carried out, and states what it does
334
+ * not establish as prominently as what it found. **They are presentations of one
335
+ * snapshot** — any difference in substance would be a bug.
336
+ *
337
+ * Deterministic: the same snapshot and the same `now` produce byte-identical
338
+ * output, which is what lets a report be compared with the copy somebody was
339
+ * sent, and what makes golden-file testing meaningful.
340
+ *
341
+ * @param snapshot - Issue state as of a run.
342
+ * @param options - Which report, attribution, and the render time.
343
+ * @returns The report as Markdown.
344
+ */
345
+ export function renderMarkdown(snapshot: Snapshot, options: ReportOptions): string {
346
+ const model = buildReportModel(snapshot, options);
347
+ const { target } = snapshot;
348
+
349
+ const lines: string[] = [
350
+ `# ${model.title}`,
351
+ '',
352
+ `**${target.name}** — ${target.url}`,
353
+ '',
354
+ `| | |`,
355
+ `| --- | --- |`,
356
+ `| Generated | ${formatDate(model.generatedAt)} |`,
357
+ `| Run | ${snapshot.run.runId} (${snapshot.run.trigger}) |`,
358
+ ];
359
+ if (model.preparedFor !== undefined) lines.push(`| Prepared for | ${cell(model.preparedFor)} |`);
360
+ if (model.preparedBy !== undefined) lines.push(`| Prepared by | ${cell(model.preparedBy)} |`);
361
+ lines.push('');
362
+
363
+ // The Attestation Letter carries the basis, the scope and the counts itself,
364
+ // in the order a letter reads. Adding the report furniture on top would state
365
+ // each of them twice, and a formal document that repeats itself is one a
366
+ // reader stops trusting.
367
+ if (model.kind === 'attest') {
368
+ lines.push(...attestationBody(model), ...suppressedAppendix(snapshot), '');
369
+ return lines.join('\n');
370
+ }
371
+
372
+ lines.push('## Basis of testing', '', model.basis.statement, '');
373
+
374
+ lines.push(
375
+ '## Scope',
376
+ '',
377
+ `This report covers only what the run exercised: ${snapshot.run.coverage.paths
378
+ .map((p) => `\`${cell(p)}\``)
379
+ .join(', ')}.`,
380
+ 'Anything outside that was not tested and is not described here.',
381
+ '',
382
+ );
383
+
384
+ lines.push(...changeSummary(model), '');
385
+
386
+ if (model.kind === 'exec') {
387
+ lines.push(...executiveBody(model), ...suppressedAppendix(snapshot), '');
388
+ return lines.join('\n');
389
+ }
390
+
391
+ if (model.kind === 'retest') {
392
+ lines.push(...retestBody(model), ...suppressedAppendix(snapshot), '');
393
+ return lines.join('\n');
394
+ }
395
+
396
+ if (model.sections.length === 0) {
397
+ lines.push('## Findings', '', '_No issues were found._', '');
398
+ } else if (model.kind === 'pen') {
399
+ lines.push('## Findings', '');
400
+ for (const section of model.sections) {
401
+ lines.push(`### ${section.severity} (${section.issues.length})`, '');
402
+ for (const entry of section.issues) lines.push(...issueDetail(entry));
403
+ }
404
+ } else {
405
+ lines.push(
406
+ '## Findings',
407
+ '',
408
+ '| Issue | Severity | Location | Change | Days open | Remediation |',
409
+ '| --- | --- | --- | --- | --- | --- |',
410
+ );
411
+ for (const section of model.sections) {
412
+ for (const entry of section.issues) lines.push(issueRow(entry));
413
+ }
414
+ lines.push('');
415
+ }
416
+
417
+ if (model.resolved.length > 0) {
418
+ lines.push(
419
+ '## Resolved since the last run',
420
+ '',
421
+ `${model.resolved.length} issue${model.resolved.length === 1 ? '' : 's'} no longer detected by a run that covered ${model.resolved.length === 1 ? 'it' : 'them'}.`,
422
+ '',
423
+ '| Issue | Severity | Location | Open for |',
424
+ '| --- | --- | --- | --- |',
425
+ ...model.resolved.map(
426
+ (entry) =>
427
+ `| ${cell(entry.issue.title)} | ${entry.issue.effectiveSeverity} | \`${cell(entry.issue.location)}\` | ${entry.daysOpen} days |`,
428
+ ),
429
+ '',
430
+ );
431
+ }
432
+
433
+ lines.push(...suppressedAppendix(snapshot), '');
434
+ return lines.join('\n');
435
+ }