@secureport/core 0.2.1 → 0.4.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 (100) hide show
  1. package/README.md +146 -33
  2. package/dist/coverage.d.ts +21 -0
  3. package/dist/coverage.d.ts.map +1 -0
  4. package/dist/coverage.js +65 -0
  5. package/dist/coverage.js.map +1 -0
  6. package/dist/finding.d.ts +89 -0
  7. package/dist/finding.d.ts.map +1 -0
  8. package/dist/finding.js +2 -0
  9. package/dist/finding.js.map +1 -0
  10. package/dist/fingerprint.d.ts +185 -0
  11. package/dist/fingerprint.d.ts.map +1 -0
  12. package/dist/fingerprint.js +247 -0
  13. package/dist/fingerprint.js.map +1 -0
  14. package/dist/import/burp.d.ts +19 -0
  15. package/dist/import/burp.d.ts.map +1 -0
  16. package/dist/import/burp.js +114 -0
  17. package/dist/import/burp.js.map +1 -0
  18. package/dist/import/generic.d.ts +90 -0
  19. package/dist/import/generic.d.ts.map +1 -0
  20. package/dist/import/generic.js +159 -0
  21. package/dist/import/generic.js.map +1 -0
  22. package/dist/import/nessus.d.ts +32 -0
  23. package/dist/import/nessus.d.ts.map +1 -0
  24. package/dist/import/nessus.js +125 -0
  25. package/dist/import/nessus.js.map +1 -0
  26. package/dist/import/nuclei.d.ts +39 -0
  27. package/dist/import/nuclei.d.ts.map +1 -0
  28. package/dist/import/nuclei.js +115 -0
  29. package/dist/import/nuclei.js.map +1 -0
  30. package/dist/import/xml.d.ts +47 -0
  31. package/dist/import/xml.d.ts.map +1 -0
  32. package/dist/import/xml.js +157 -0
  33. package/dist/import/xml.js.map +1 -0
  34. package/dist/import/zap.d.ts +26 -0
  35. package/dist/import/zap.d.ts.map +1 -0
  36. package/dist/import/zap.js +119 -0
  37. package/dist/import/zap.js.map +1 -0
  38. package/dist/index.d.ts +41 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +29 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/issue.d.ts +190 -11
  43. package/dist/issue.d.ts.map +1 -1
  44. package/dist/reconcile.d.ts +115 -10
  45. package/dist/reconcile.d.ts.map +1 -1
  46. package/dist/reconcile.js +306 -12
  47. package/dist/reconcile.js.map +1 -1
  48. package/dist/report/html.d.ts +21 -0
  49. package/dist/report/html.d.ts.map +1 -0
  50. package/dist/report/html.js +324 -0
  51. package/dist/report/html.js.map +1 -0
  52. package/dist/report/json.d.ts +81 -0
  53. package/dist/report/json.d.ts.map +1 -0
  54. package/dist/report/json.js +47 -0
  55. package/dist/report/json.js.map +1 -0
  56. package/dist/report/markdown.d.ts +24 -0
  57. package/dist/report/markdown.d.ts.map +1 -0
  58. package/dist/report/markdown.js +304 -0
  59. package/dist/report/markdown.js.map +1 -0
  60. package/dist/report/model.d.ts +215 -0
  61. package/dist/report/model.d.ts.map +1 -0
  62. package/dist/report/model.js +197 -0
  63. package/dist/report/model.js.map +1 -0
  64. package/dist/run.d.ts +134 -0
  65. package/dist/run.d.ts.map +1 -0
  66. package/dist/run.js +2 -0
  67. package/dist/run.js.map +1 -0
  68. package/dist/severity.d.ts +191 -0
  69. package/dist/severity.d.ts.map +1 -0
  70. package/dist/severity.js +171 -0
  71. package/dist/severity.js.map +1 -0
  72. package/dist/snapshot-builder.d.ts +78 -0
  73. package/dist/snapshot-builder.d.ts.map +1 -0
  74. package/dist/snapshot-builder.js +172 -0
  75. package/dist/snapshot-builder.js.map +1 -0
  76. package/dist/snapshot.d.ts +125 -0
  77. package/dist/snapshot.d.ts.map +1 -0
  78. package/dist/snapshot.js +2 -0
  79. package/dist/snapshot.js.map +1 -0
  80. package/package.json +5 -4
  81. package/src/coverage.ts +65 -0
  82. package/src/finding.ts +112 -0
  83. package/src/fingerprint.ts +315 -0
  84. package/src/import/burp.ts +126 -0
  85. package/src/import/generic.ts +258 -0
  86. package/src/import/nessus.ts +136 -0
  87. package/src/import/nuclei.ts +173 -0
  88. package/src/import/xml.ts +187 -0
  89. package/src/import/zap.ts +161 -0
  90. package/src/index.ts +75 -2
  91. package/src/issue.ts +244 -11
  92. package/src/reconcile.ts +421 -17
  93. package/src/report/html.ts +449 -0
  94. package/src/report/json.ts +134 -0
  95. package/src/report/markdown.ts +435 -0
  96. package/src/report/model.ts +462 -0
  97. package/src/run.ts +163 -0
  98. package/src/severity.ts +250 -0
  99. package/src/snapshot-builder.ts +225 -0
  100. package/src/snapshot.ts +146 -0
@@ -0,0 +1,449 @@
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
+ import { formatDate } from './markdown.js';
10
+
11
+ /** Escapes text for HTML. Every value from a snapshot goes through this. */
12
+ function esc(value: string): string {
13
+ return value
14
+ .replace(/&/gu, '&')
15
+ .replace(/</gu, '&lt;')
16
+ .replace(/>/gu, '&gt;')
17
+ .replace(/"/gu, '&quot;');
18
+ }
19
+
20
+ /**
21
+ * The stylesheet, inlined.
22
+ *
23
+ * **Inlined rather than linked because a report is a single file that gets
24
+ * emailed.** A stylesheet fetched at render time would make the document depend
25
+ * on a network the reader may not have, and on a URL that must then outlive the
26
+ * report.
27
+ *
28
+ * The print rules are here rather than in P7 because P7 renders this through
29
+ * Chromium: a page that only looks right on screen becomes a PDF that only
30
+ * looks right on screen. Severity colours are chosen to survive greyscale — the
31
+ * label carries the meaning, the colour only reinforces it.
32
+ */
33
+ const STYLES = `
34
+ :root {
35
+ --ink: #14181f; --muted: #5b6472; --rule: #d9dee6; --bg: #ffffff;
36
+ --critical: #7f1d1d; --high: #9a3412; --medium: #854d0e; --low: #1e40af; --advisory: #475569;
37
+ }
38
+ * { box-sizing: border-box; }
39
+ body {
40
+ margin: 0; padding: 2.5rem; background: var(--bg); color: var(--ink);
41
+ font: 15px/1.6 ui-serif, Georgia, "Times New Roman", serif;
42
+ max-width: 60rem; margin-inline: auto;
43
+ }
44
+ h1 { font-size: 1.9rem; margin: 0 0 .25rem; letter-spacing: -0.01em; }
45
+ h2 { font-size: 1.25rem; margin: 2.5rem 0 .75rem; padding-bottom: .3rem; border-bottom: 1px solid var(--rule); }
46
+ h3 { font-size: 1.05rem; margin: 1.75rem 0 .5rem; text-transform: capitalize; }
47
+ h4 { font-size: 1rem; margin: 1.25rem 0 .4rem; }
48
+ p, li { margin: .5rem 0; }
49
+ .lede { color: var(--muted); margin: 0 0 1.5rem; }
50
+ table { border-collapse: collapse; width: 100%; margin: .75rem 0; font-size: .92em; }
51
+ th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--rule); vertical-align: top; }
52
+ th { font-weight: 600; color: var(--muted); font-size: .82em; text-transform: uppercase; letter-spacing: .04em; }
53
+ td code, code { font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
54
+ .meta td:first-child { color: var(--muted); width: 12rem; }
55
+ .sev { font-weight: 600; }
56
+ .sev-critical { color: var(--critical); }
57
+ .sev-high { color: var(--high); }
58
+ .sev-medium { color: var(--medium); }
59
+ .sev-low { color: var(--low); }
60
+ .sev-advisory { color: var(--advisory); }
61
+ .breached { color: var(--critical); font-weight: 600; }
62
+ .issue { break-inside: avoid; page-break-inside: avoid; margin-bottom: 1.5rem; }
63
+ .none { color: var(--muted); font-style: italic; }
64
+ footer { margin-top: 3rem; padding-top: .75rem; border-top: 1px solid var(--rule); color: var(--muted); font-size: .85em; }
65
+
66
+ @media print {
67
+ body { padding: 0; font-size: 11pt; max-width: none; }
68
+ h2 { break-after: avoid; page-break-after: avoid; }
69
+ h3, h4 { break-after: avoid; page-break-after: avoid; }
70
+ table { break-inside: auto; }
71
+ tr { break-inside: avoid; page-break-inside: avoid; }
72
+ a { color: inherit; text-decoration: none; }
73
+ /* A printed report is read on paper: a bare URL is unusable, so print it. */
74
+ .refs a::after { content: " (" attr(href) ")"; font-size: .85em; color: var(--muted); }
75
+ }
76
+ @page { margin: 18mm 16mm; }
77
+ `.trim();
78
+
79
+ const sevClass = (s: string) => `sev sev-${s}`;
80
+
81
+ /** The change summary, as a table. */
82
+ function changeSummary(model: ReportModel): string {
83
+ const { run, baseline } = model.snapshot;
84
+ const rows = SEVERITY_ORDER.map(
85
+ (s) =>
86
+ `<tr><td class="${sevClass(s)}">${s}</td><td>${run.new[s]}</td><td>${run.stillOpen[s]}</td>` +
87
+ `<td>${run.regressed[s]}</td><td>${run.resolved[s]}</td><td>${run.ignored[s]}</td></tr>`,
88
+ ).join('');
89
+
90
+ return `<h2>What changed</h2>
91
+ <p class="lede">${
92
+ baseline === undefined
93
+ ? 'First run against this target, so every issue is new.'
94
+ : `Compared with the run of ${formatDate(baseline.createdAt)}.`
95
+ }</p>
96
+ <table><thead><tr><th>Severity</th><th>New</th><th>Still open</th><th>Regressed</th><th>Resolved</th><th>Suppressed</th></tr></thead><tbody>${rows}</tbody></table>
97
+ <p><strong>${model.outstandingTotal} outstanding.</strong> Exposure score ${model.exposureScore}.${
98
+ model.breached > 0
99
+ ? ` <span class="breached">${model.breached} past ${model.breached === 1 ? 'its' : 'their'} remediation deadline.</span>`
100
+ : ''
101
+ }</p>`;
102
+ }
103
+
104
+ /** One issue in full. */
105
+ function issueDetail(entry: SnapshotIssue): string {
106
+ const { issue } = entry;
107
+ const first = entry.findings[0];
108
+ const references = [...new Set(entry.findings.flatMap((f) => f.references ?? []))];
109
+
110
+ return `<div class="issue">
111
+ <h4>${esc(issue.title)}</h4>
112
+ <table class="meta"><tbody>
113
+ <tr><td>Severity</td><td class="${sevClass(issue.effectiveSeverity)}">${issue.effectiveSeverity}${
114
+ issue.effectiveSeverity === issue.detectedSeverity
115
+ ? ''
116
+ : ` <span class="lede">(detected ${issue.detectedSeverity})</span>`
117
+ }</td></tr>
118
+ <tr><td>Status</td><td>${issue.status} · ${entry.change.replace('_', ' ')}</td></tr>
119
+ <tr><td>Location</td><td><code>${esc(issue.location)}</code>${
120
+ issue.parameter === undefined ? '' : ` <code>${esc(issue.parameter)}</code>`
121
+ }</td></tr>
122
+ <tr><td>First seen</td><td>${formatDate(issue.firstSeen)} (${entry.daysOpen} days)</td></tr>
123
+ <tr><td>Remediation</td><td${entry.slaStatus === 'breached' ? ' class="breached"' : ''}>${
124
+ issue.slaDueAt === undefined || issue.slaDueAt === null
125
+ ? 'no deadline'
126
+ : `${formatDate(issue.slaDueAt)} — ${entry.slaStatus.replace('_', ' ')}`
127
+ }</td></tr>
128
+ ${issue.cwe === undefined ? '' : `<tr><td>Weakness</td><td>${esc(issue.cwe)}</td></tr>`}
129
+ </tbody></table>
130
+ ${first?.description === undefined ? '' : `<p>${esc(first.description)}</p>`}
131
+ ${first?.recommendation === undefined ? '' : `<p><strong>Recommendation.</strong> ${esc(first.recommendation)}</p>`}
132
+ ${
133
+ entry.findings.length === 0
134
+ ? ''
135
+ : `<p class="lede">Evidence: ${entry.findings.length} finding${entry.findings.length === 1 ? '' : 's'} recorded.</p>`
136
+ }
137
+ ${
138
+ references.length === 0
139
+ ? ''
140
+ : `<ul class="refs">${references.map((r) => `<li><a href="${esc(r)}">${esc(r)}</a></li>`).join('')}</ul>`
141
+ }
142
+ </div>`;
143
+ }
144
+
145
+ /** How a verdict is printed. The dashes are an implementation detail. */
146
+ const VERDICT_LABELS: Readonly<Record<RetestVerdict, string>> = Object.freeze({
147
+ fixed: 'fixed',
148
+ still_present: 'still present',
149
+ returned: 'returned',
150
+ not_retested: 'not retested',
151
+ new: 'new since',
152
+ });
153
+
154
+ /** The Retest body: the difference between two runs, and nothing else. */
155
+ function retestBody(model: ReportModel): string {
156
+ // `buildReportModel` refuses to build a retest without one.
157
+ const retest = model.retest as NonNullable<ReportModel['retest']>;
158
+ const { counts } = retest;
159
+ const carried = counts.fixed + counts.still_present + counts.returned + counts.not_retested;
160
+
161
+ const verdicts = (Object.keys(VERDICT_LABELS) as RetestVerdict[])
162
+ .map((v) => `<tr><td>${VERDICT_LABELS[v]}</td><td>${counts[v]}</td></tr>`)
163
+ .join('');
164
+
165
+ const rows = retest.entries
166
+ .map((entry) => {
167
+ const { issue } = entry.issue;
168
+ const location =
169
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
170
+ return `<tr><td>${esc(issue.title)}</td><td class="${sevClass(issue.effectiveSeverity)}">${
171
+ issue.effectiveSeverity
172
+ }</td><td>${VERDICT_LABELS[entry.verdict]}</td><td><code>${esc(location)}</code></td><td>${
173
+ entry.issue.daysOpen
174
+ } days</td></tr>`;
175
+ })
176
+ .join('');
177
+
178
+ return `<h2>Retest verdict</h2>
179
+ <p class="lede">Retested against the run of ${formatDate(retest.baseline.createdAt)}.</p>
180
+ <p>${
181
+ carried === 0
182
+ ? 'No issues were carried into this retest.'
183
+ : `Of the ${carried} issue${carried === 1 ? '' : 's'} carried into this retest, <strong>${
184
+ counts.fixed
185
+ } ${counts.fixed === 1 ? 'is' : 'are'} confirmed fixed</strong>, ${counts.still_present} ${
186
+ counts.still_present === 1 ? 'is' : 'are'
187
+ } still present, ${counts.returned} returned, and ${
188
+ counts.not_retested
189
+ } could not be retested.${
190
+ counts.new === 0
191
+ ? ''
192
+ : ` A further ${counts.new} ${counts.new === 1 ? 'issue was' : 'issues were'} found for the first time by this run.`
193
+ }`
194
+ }</p>
195
+ ${
196
+ retest.fixRate === null
197
+ ? ''
198
+ : `<p>${Math.round(retest.fixRate * 100)}% of the issues this run could check are fixed.</p>`
199
+ }
200
+ <table><thead><tr><th>Verdict</th><th>Issues</th></tr></thead><tbody>${verdicts}</tbody></table>
201
+ ${
202
+ counts.not_retested === 0
203
+ ? ''
204
+ : `<h2>Not retested</h2><p>${counts.not_retested} issue${
205
+ counts.not_retested === 1 ? '' : 's'
206
+ } could not be retested by this run: the run did not cover where they were found, or has not missed them often enough to call them resolved. <strong>They are not fixed.</strong> An issue nobody looked at is neither fixed nor unfixed, and it is excluded from the percentage above rather than counted either way.</p>`
207
+ }
208
+ <h2>Issue by issue</h2>
209
+ ${
210
+ rows === ''
211
+ ? '<p class="none">No issues.</p>'
212
+ : `<table><thead><tr><th>Issue</th><th>Severity</th><th>Verdict</th><th>Location</th><th>Open for</th></tr></thead><tbody>${rows}</tbody></table>`
213
+ }`;
214
+ }
215
+
216
+ /** The Executive Summary body. */
217
+ function executiveBody(model: ReportModel): string {
218
+ const resolvedCount = model.resolved.length;
219
+ const regressed = model.sections
220
+ .flatMap((s) => s.issues)
221
+ .filter((i) => i.change === 'regressed').length;
222
+ const urgent = model.outstanding.critical + model.outstanding.high;
223
+
224
+ const notes: string[] = [];
225
+ if (resolvedCount > 0) {
226
+ notes.push(
227
+ `<p>${resolvedCount} issue${resolvedCount === 1 ? ' was' : 's were'} resolved since the last run.</p>`,
228
+ );
229
+ }
230
+ if (regressed > 0) {
231
+ notes.push(
232
+ `<p><strong>${regressed} previously resolved issue${regressed === 1 ? ' has' : 's have'} returned.</strong> A returning issue usually means a fix was reverted or incompletely applied.</p>`,
233
+ );
234
+ }
235
+ if (model.breached > 0) {
236
+ notes.push(
237
+ `<p class="breached">${model.breached} ${model.breached === 1 ? 'is' : 'are'} past the remediation deadline set by the severity policy.</p>`,
238
+ );
239
+ }
240
+
241
+ const rows = SEVERITY_ORDER.filter((sev) => model.outstanding[sev] > 0)
242
+ .map(
243
+ (sev) =>
244
+ `<tr><td class="${sevClass(sev)}">${sev}</td><td>${model.outstanding[sev]}</td></tr>`,
245
+ )
246
+ .join('');
247
+
248
+ const notable = model.sections
249
+ .flatMap((s) => s.issues)
250
+ .filter((i) => ['critical', 'high'].includes(i.issue.effectiveSeverity))
251
+ .slice(0, 10);
252
+
253
+ return `<h2>Where things stand</h2>
254
+ <p>${
255
+ model.outstandingTotal === 0
256
+ ? 'Nothing is currently outstanding on this target.'
257
+ : `${model.outstandingTotal} issue${model.outstandingTotal === 1 ? '' : 's'} ${
258
+ model.outstandingTotal === 1 ? 'is' : 'are'
259
+ } currently outstanding, of which ${urgent} ${urgent === 1 ? 'is' : 'are'} critical or high.`
260
+ }</p>
261
+ ${notes.join('')}
262
+ <h2>Outstanding by severity</h2>
263
+ ${
264
+ rows === ''
265
+ ? '<p class="none">Nothing outstanding.</p>'
266
+ : `<table><thead><tr><th>Severity</th><th>Outstanding</th></tr></thead><tbody>${rows}</tbody></table>`
267
+ }
268
+ ${
269
+ notable.length === 0
270
+ ? ''
271
+ : `<h2>What needs attention first</h2><ul>${notable
272
+ .map(
273
+ (e) =>
274
+ `<li><span class="${sevClass(e.issue.effectiveSeverity)}">${e.issue.effectiveSeverity}</span> — ${esc(
275
+ e.issue.title,
276
+ )} (open ${e.daysOpen} days)</li>`,
277
+ )
278
+ .join(
279
+ '',
280
+ )}</ul><p class="lede">Full detail, including evidence, is in the accompanying technical report.</p>`
281
+ }
282
+ <p>Exposure score ${model.exposureScore}. This is a single number combining how many issues are open, how severe they are and how long they have been open. It falls as issues are fixed and rises while they are not.</p>`;
283
+ }
284
+
285
+ /** The Attestation Letter body. */
286
+ function attestationBody(model: ReportModel): string {
287
+ const { snapshot } = model;
288
+ const found = snapshot.issues.length + snapshot.suppressed.length;
289
+ const rows = SEVERITY_ORDER.map(
290
+ (sev) => `<tr><td class="${sevClass(sev)}">${sev}</td><td>${model.outstanding[sev]}</td></tr>`,
291
+ ).join('');
292
+
293
+ return `<h2>Statement</h2>
294
+ <p>${esc(model.preparedBy ?? 'Secureport')} carried out security testing of <strong>${esc(
295
+ snapshot.target.name,
296
+ )}</strong> (${esc(snapshot.target.url)}) on ${formatDate(snapshot.run.createdAt)}.</p>
297
+ <p>${esc(model.basis.statement)}</p>
298
+ <p>The testing identified ${found} issue${found === 1 ? '' : 's'} in total, of which ${
299
+ model.outstandingTotal
300
+ } ${model.outstandingTotal === 1 ? 'remains' : 'remain'} outstanding at the date of this letter:</p>
301
+ <table><thead><tr><th>Severity</th><th>Outstanding</th></tr></thead><tbody>${rows}</tbody></table>
302
+ <h2>What this letter does not establish</h2>
303
+ <ul>${model.limitations.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
304
+ <h2>Scope tested</h2>
305
+ <ul>${snapshot.run.coverage.paths.map((p) => `<li><code>${esc(p)}</code></li>`).join('')}</ul>`;
306
+ }
307
+
308
+ /**
309
+ * Renders a snapshot as a single self-contained HTML document.
310
+ *
311
+ * No external stylesheet, no script, no fonts to fetch: a report is a file that
312
+ * gets emailed, and one that needs a network to look right is one that
313
+ * eventually does not. The print rules ship here rather than in P7 because P7
314
+ * renders this same document through Chromium.
315
+ *
316
+ * Every value taken from the snapshot is escaped. A scanner's evidence routinely
317
+ * contains the payload that proved the weakness — `<script>alert(1)</script>` is
318
+ * a *normal* finding title — so a report that interpolates it unescaped attacks
319
+ * whoever opens it.
320
+ *
321
+ * @param snapshot - Issue state as of a run.
322
+ * @param options - Which report, attribution, and the render time.
323
+ * @returns A complete HTML document.
324
+ */
325
+ export function renderHtml(snapshot: Snapshot, options: ReportOptions): string {
326
+ const model = buildReportModel(snapshot, options);
327
+ const { target } = snapshot;
328
+
329
+ const meta = [
330
+ `<tr><td>Generated</td><td>${formatDate(model.generatedAt)}</td></tr>`,
331
+ `<tr><td>Run</td><td>${esc(snapshot.run.runId)} (${esc(snapshot.run.trigger)})</td></tr>`,
332
+ model.preparedFor === undefined
333
+ ? ''
334
+ : `<tr><td>Prepared for</td><td>${esc(model.preparedFor)}</td></tr>`,
335
+ model.preparedBy === undefined
336
+ ? ''
337
+ : `<tr><td>Prepared by</td><td>${esc(model.preparedBy)}</td></tr>`,
338
+ ].join('');
339
+
340
+ // The Attestation Letter carries the basis, the scope and the counts itself,
341
+ // in the order a letter reads. Adding the report furniture on top would state
342
+ // each of them twice, and a formal document that repeats itself is one a
343
+ // reader stops trusting.
344
+ const preamble =
345
+ model.kind === 'attest'
346
+ ? ''
347
+ : `<h2>Basis of testing</h2>
348
+ <p>${esc(model.basis.statement)}</p>
349
+ <h2>Scope</h2>
350
+ <p>This report covers only what the run exercised: ${snapshot.run.coverage.paths
351
+ .map((p) => `<code>${esc(p)}</code>`)
352
+ .join(', ')}. Anything outside that was not tested and is not described here.</p>
353
+ ${changeSummary(model)}`;
354
+
355
+ let findings: string;
356
+ if (model.kind === 'exec') {
357
+ findings = executiveBody(model);
358
+ } else if (model.kind === 'retest') {
359
+ findings = retestBody(model);
360
+ } else if (model.kind === 'attest') {
361
+ findings = attestationBody(model);
362
+ } else if (model.sections.length === 0) {
363
+ findings = '<h2>Findings</h2><p class="none">No issues were found.</p>';
364
+ } else if (model.kind === 'pen') {
365
+ findings =
366
+ '<h2>Findings</h2>' +
367
+ model.sections
368
+ .map(
369
+ (section) =>
370
+ `<h3 class="${sevClass(section.severity)}">${section.severity} (${section.issues.length})</h3>` +
371
+ section.issues.map(issueDetail).join(''),
372
+ )
373
+ .join('');
374
+ } else {
375
+ const rows = model.sections
376
+ .flatMap((s) => s.issues)
377
+ .map((entry) => {
378
+ const { issue } = entry;
379
+ const location =
380
+ issue.parameter === undefined ? issue.location : `${issue.location} (${issue.parameter})`;
381
+ return `<tr><td>${esc(issue.title)}</td><td class="${sevClass(issue.effectiveSeverity)}">${
382
+ issue.effectiveSeverity
383
+ }</td><td><code>${esc(location)}</code></td><td>${entry.change.replace('_', ' ')}</td><td>${
384
+ entry.daysOpen
385
+ }</td><td${entry.slaStatus === 'breached' ? ' class="breached"' : ''}>${entry.slaStatus.replace('_', ' ')}</td></tr>`;
386
+ })
387
+ .join('');
388
+ findings = `<h2>Findings</h2><table><thead><tr><th>Issue</th><th>Severity</th><th>Location</th><th>Change</th><th>Days open</th><th>Remediation</th></tr></thead><tbody>${rows}</tbody></table>`;
389
+ }
390
+
391
+ const resolved =
392
+ model.kind === 'exec' ||
393
+ model.kind === 'attest' ||
394
+ model.kind === 'retest' ||
395
+ model.resolved.length === 0
396
+ ? ''
397
+ : `<h2>Resolved since the last run</h2>
398
+ <p class="lede">${model.resolved.length} issue${model.resolved.length === 1 ? '' : 's'} no longer detected by a run that covered ${
399
+ model.resolved.length === 1 ? 'it' : 'them'
400
+ }.</p>
401
+ <table><thead><tr><th>Issue</th><th>Severity</th><th>Location</th><th>Open for</th></tr></thead><tbody>${model.resolved
402
+ .map(
403
+ (entry) =>
404
+ `<tr><td>${esc(entry.issue.title)}</td><td class="${sevClass(
405
+ entry.issue.effectiveSeverity,
406
+ )}">${entry.issue.effectiveSeverity}</td><td><code>${esc(
407
+ entry.issue.location,
408
+ )}</code></td><td>${entry.daysOpen} days</td></tr>`,
409
+ )
410
+ .join('')}</tbody></table>`;
411
+
412
+ const suppressed =
413
+ snapshot.suppressed.length === 0
414
+ ? '<h2>Suppressed findings</h2><p class="none">None.</p>'
415
+ : `<h2>Suppressed findings</h2>
416
+ <p class="lede">Accepted, out of scope or dismissed. Listed because an accepted risk nobody can see is not an accepted risk.</p>
417
+ <table><thead><tr><th>Issue</th><th>Severity when suppressed</th><th>Reason</th><th>By</th><th>Expires</th><th>Justification</th></tr></thead><tbody>${snapshot.suppressed
418
+ .map(
419
+ (s) =>
420
+ `<tr><td>${esc(s.issue.title)}</td><td class="${sevClass(s.severityAtIgnore)}">${
421
+ s.severityAtIgnore
422
+ }</td><td>${s.reason.replace('_', ' ')}</td><td>${esc(s.ignoredBy)}</td><td>${
423
+ s.expiresAt === undefined ? 'never' : formatDate(s.expiresAt)
424
+ }</td><td>${esc(s.comment)}</td></tr>`,
425
+ )
426
+ .join('')}</tbody></table>`;
427
+
428
+ return `<!doctype html>
429
+ <html lang="en">
430
+ <head>
431
+ <meta charset="utf-8">
432
+ <meta name="viewport" content="width=device-width, initial-scale=1">
433
+ <title>${esc(model.title)} — ${esc(target.name)}</title>
434
+ <style>${STYLES}</style>
435
+ </head>
436
+ <body>
437
+ <h1>${esc(model.title)}</h1>
438
+ <p class="lede"><strong>${esc(target.name)}</strong> — ${esc(target.url)}</p>
439
+ <table class="meta"><tbody>${meta}</tbody></table>
440
+ ${preamble}
441
+ ${findings}
442
+ ${resolved}
443
+ ${suppressed}
444
+ <footer>Generated by Secureport from run ${esc(snapshot.run.runId)}. Issue state as of ${formatDate(
445
+ snapshot.run.createdAt,
446
+ )}.</footer>
447
+ </body>
448
+ </html>`;
449
+ }
@@ -0,0 +1,134 @@
1
+ import type { Snapshot } from '../snapshot.js';
2
+ import {
3
+ buildReportModel,
4
+ type ReportKind,
5
+ type ReportOptions,
6
+ type RetestVerdict,
7
+ } from './model.js';
8
+
9
+ /**
10
+ * The machine-readable report.
11
+ *
12
+ * A stable, documented shape rather than the raw snapshot: the snapshot is an
13
+ * internal contract that will grow, and a consumer building a dashboard on a
14
+ * report should not have to track that. It carries the derived answers — what
15
+ * changed, what is outstanding, how the findings were produced — beside the
16
+ * snapshot itself, so a consumer can use either.
17
+ */
18
+ export interface JsonReport {
19
+ /** Format version. Bumped when the shape changes incompatibly. */
20
+ readonly reportVersion: 1;
21
+
22
+ /** Which report this is. */
23
+ readonly kind: ReportKind;
24
+
25
+ /** The title as printed. */
26
+ readonly title: string;
27
+
28
+ /** When it was rendered, ISO 8601. */
29
+ readonly generatedAt: string;
30
+
31
+ /** Who prepared it, if stated. */
32
+ readonly preparedBy?: string;
33
+
34
+ /** Who it is for, if stated. */
35
+ readonly preparedFor?: string;
36
+
37
+ /**
38
+ * How the findings were produced.
39
+ *
40
+ * Present in the JSON as well as the prose because a consumer that renders
41
+ * its own view must not be able to present automated results as manual
42
+ * testing simply by not reading the sentence.
43
+ */
44
+ readonly basis: {
45
+ readonly automated: boolean;
46
+ readonly uploaded: boolean;
47
+ readonly includesManual: boolean;
48
+ readonly engines: readonly string[];
49
+ readonly statement: string;
50
+ };
51
+
52
+ /**
53
+ * What the report does not establish.
54
+ *
55
+ * Here for the same reason as {@link JsonReport.basis}: a consumer rendering
56
+ * its own view must not be able to drop the limits by not reading the prose.
57
+ */
58
+ readonly limitations: readonly string[];
59
+
60
+ /**
61
+ * What this run established about the issues carried into it.
62
+ *
63
+ * Present whenever the snapshot has a baseline. Verdicts are keyed by issue
64
+ * id rather than repeating the issues, which are already in the snapshot.
65
+ */
66
+ readonly retest?: {
67
+ readonly baselineRunId: string;
68
+ readonly counts: Readonly<Record<RetestVerdict, number>>;
69
+ readonly fixRate: number | null;
70
+ readonly verdicts: Readonly<Record<string, RetestVerdict>>;
71
+ };
72
+
73
+ /** Open and regressed issues by severity, and their total. */
74
+ readonly outstanding: Readonly<Record<string, number>> & { readonly total: number };
75
+
76
+ /** Exposure at the end of the run. */
77
+ readonly exposureScore: number;
78
+
79
+ /** Issues past their remediation deadline. */
80
+ readonly breached: number;
81
+
82
+ /** The snapshot this was rendered from. */
83
+ readonly snapshot: Snapshot;
84
+ }
85
+
86
+ /**
87
+ * Renders a snapshot as JSON.
88
+ *
89
+ * Deterministic and key-ordered, so two renders of one snapshot are
90
+ * byte-identical and a diff between two reports shows only what actually
91
+ * changed.
92
+ *
93
+ * @param snapshot - Issue state as of a run.
94
+ * @param options - Which report, attribution, and the render time.
95
+ * @returns The report as a JSON string.
96
+ */
97
+ export function renderJson(snapshot: Snapshot, options: ReportOptions): string {
98
+ const model = buildReportModel(snapshot, options);
99
+
100
+ const report: JsonReport = {
101
+ reportVersion: 1,
102
+ kind: model.kind,
103
+ title: model.title,
104
+ generatedAt: model.generatedAt.toISOString(),
105
+ ...(model.preparedBy === undefined ? {} : { preparedBy: model.preparedBy }),
106
+ ...(model.preparedFor === undefined ? {} : { preparedFor: model.preparedFor }),
107
+ basis: {
108
+ automated: model.basis.automated,
109
+ uploaded: model.basis.uploaded,
110
+ includesManual: model.basis.includesManual,
111
+ engines: model.basis.engines,
112
+ statement: model.basis.statement,
113
+ },
114
+ limitations: model.limitations,
115
+ ...(model.retest === undefined
116
+ ? {}
117
+ : {
118
+ retest: {
119
+ baselineRunId: model.retest.baseline.runId,
120
+ counts: model.retest.counts,
121
+ fixRate: model.retest.fixRate,
122
+ verdicts: Object.fromEntries(
123
+ model.retest.entries.map((e) => [e.issue.issue.id, e.verdict]),
124
+ ),
125
+ },
126
+ }),
127
+ outstanding: { ...model.outstanding, total: model.outstandingTotal },
128
+ exposureScore: model.exposureScore,
129
+ breached: model.breached,
130
+ snapshot,
131
+ };
132
+
133
+ return JSON.stringify(report, null, 2);
134
+ }