@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
package/src/reconcile.ts CHANGED
@@ -1,32 +1,436 @@
1
- import type { Issue } from './issue.js';
1
+ import type { Finding } from './finding.js';
2
+ import type { Issue, IssueEvent } from './issue.js';
3
+ import type { Run, RunKind, RunSummary, SeverityCounts } from './run.js';
4
+ import {
5
+ DEFAULT_SLA_POLICY,
6
+ SEVERITY_ORDER,
7
+ SEVERITY_WEIGHTS,
8
+ severityRank,
9
+ slaDueAt,
10
+ type SlaPolicy,
11
+ } from './severity.js';
12
+ import { coversLocation } from './coverage.js';
2
13
 
3
- /** A raw scanner result, not yet folded into the tracked issue set. */
4
- export interface Finding {
5
- title: string;
6
- severity: Issue['severity'];
14
+ /**
15
+ * Everything reconciliation needs, and nothing it could get for itself.
16
+ *
17
+ * The clock and the id generator are arguments because the function is pure:
18
+ * no `Date.now()`, no `randomUUID()`, no I/O. The same inputs give the same
19
+ * issues and the same events, on any machine, which is what makes golden-file
20
+ * testing possible and what lets a run be replayed.
21
+ */
22
+ export interface ReconcileInput {
23
+ /**
24
+ * Every known issue for this run's target.
25
+ *
26
+ * Issues for other targets must not be passed in: the fingerprint already
27
+ * includes the target, so they could never match, and including them would
28
+ * return them as untouched output and invite a caller to write them back.
29
+ */
30
+ readonly issues: readonly Issue[];
31
+
32
+ /** The findings this run produced. */
33
+ readonly findings: readonly Finding[];
34
+
35
+ /** The run itself, which supplies the target, the kind and the coverage. */
36
+ readonly run: Run;
37
+
38
+ /** The moment the run is being reconciled at. */
39
+ readonly now: Date;
40
+
41
+ /**
42
+ * Supplies ids for issues and events.
43
+ *
44
+ * Injected rather than generated so the function stays deterministic. A
45
+ * caller in production passes `randomUUID`; a test passes a counter.
46
+ */
47
+ readonly newId: () => string;
48
+
49
+ /**
50
+ * Who to record as having made these changes.
51
+ *
52
+ * Defaults to `reconciler`. An audit trail that cannot say who is not one.
53
+ */
54
+ readonly actor?: string;
55
+
56
+ /** Remediation windows, for recomputing `slaDueAt`. */
57
+ readonly slaPolicy?: SlaPolicy;
58
+
59
+ /**
60
+ * How many consecutive covering runs may miss an issue before it resolves.
61
+ *
62
+ * **The flapping guard.** Defaults to **1 for uploads** — the person
63
+ * uploading is asserting a complete result — and **2 for scans**, where a
64
+ * timeout or a rate limit causes a miss that means nothing. Between the first
65
+ * miss and resolution the issue stays open, and reports can say "not seen in
66
+ * last run".
67
+ *
68
+ * Per organisation in the hosted product; an argument here, because this
69
+ * function has nowhere to read configuration from.
70
+ */
71
+ readonly autoResolveThreshold?: number;
72
+
73
+ /** How long the run took, for the summary. */
74
+ readonly durationMs?: number;
75
+ }
76
+
77
+ /**
78
+ * What reconciliation decided.
79
+ *
80
+ * Returns **every** issue for the target, changed or not, so a caller can write
81
+ * the set back without working out which ones moved. Events are returned rather
82
+ * than applied: persisting them is the caller's transaction, and reconciliation
83
+ * has no I/O.
84
+ */
85
+ export interface ReconcileResult {
86
+ /** Every issue for the target, after reconciliation. */
87
+ readonly issues: readonly Issue[];
88
+
89
+ /** What changed, in the order it was decided. */
90
+ readonly events: readonly IssueEvent[];
91
+
92
+ /** What the run did, in the terms a report opens with. */
93
+ readonly summary: RunSummary;
7
94
  }
8
95
 
9
96
  /**
10
- * Merges incoming findings into the tracked issue set. A finding whose title
11
- * matches an open issue is folded into it; anything new opens an issue.
97
+ * The key an issue and a finding must share to be the same issue.
12
98
  *
13
- * Pure: no I/O, no clock, no randomness. Callers supply the id for any issue
14
- * this opens via `nextId`.
99
+ * **The fingerprint version is part of it, not decoration.** A fingerprint means
100
+ * nothing without the algorithm that produced it, so an `fp_v1` finding must
101
+ * never match an `fp_v2` issue — reconciling across versions would silently
102
+ * merge two things that were never computed the same way. Crossing a version is
103
+ * what a re-fingerprint migration is for.
104
+ *
105
+ * The separator is an escape rather than a literal control character, so the
106
+ * source stays plain text: an invisible NUL here once passed prettier, eslint,
107
+ * tsc and the whole test suite, and was only caught because `grep` went quiet
108
+ * on a file it could no longer read.
15
109
  */
16
- export function reconcile(findings: Finding[], openIssues: Issue[], nextId: () => string): Issue[] {
17
- const byTitle = new Map(openIssues.map((issue) => [issue.title, issue]));
110
+ function issueKey(of: {
111
+ readonly fingerprintVersion: string;
112
+ readonly fingerprint: string;
113
+ }): string {
114
+ return `${of.fingerprintVersion}\u0000${of.fingerprint}`;
115
+ }
18
116
 
117
+ /** Groups this run's findings by the issue they belong to. */
118
+ function groupByFingerprint(findings: readonly Finding[]): Map<string, Finding[]> {
119
+ const groups = new Map<string, Finding[]>();
19
120
  for (const finding of findings) {
20
- const existing = byTitle.get(finding.title);
121
+ // The version is part of the key. A fingerprint means nothing without the
122
+ // algorithm that produced it, so a `fp_v1` finding must never match a
123
+ // `fp_v2` issue — that is what a re-fingerprint migration is for.
124
+ const key = issueKey(finding);
125
+ const existing = groups.get(key);
126
+ if (existing) existing.push(finding);
127
+ else groups.set(key, [finding]);
128
+ }
129
+ return groups;
130
+ }
131
+
132
+ /** The most urgent severity among a group of findings for one issue. */
133
+ function highestSeverity(findings: readonly Finding[]): Finding {
134
+ return findings.reduce((worst, candidate) =>
135
+ severityRank(candidate.detectedSeverity) > severityRank(worst.detectedSeverity)
136
+ ? candidate
137
+ : worst,
138
+ );
139
+ }
140
+
141
+ /** The default flapping guard for a run kind. */
142
+ function defaultThreshold(kind: RunKind): number {
143
+ // An upload asserts a complete result, so one miss is enough. A scan can miss
144
+ // for reasons that say nothing about the issue.
145
+ return kind === 'upload' ? 1 : 2;
146
+ }
147
+
148
+ /** An all-zero count, so a report never has to decide what a missing key means. */
149
+ function emptyCounts(): Record<string, number> {
150
+ return Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
151
+ }
152
+
153
+ /** Whole days between two moments, floored. */
154
+ function daysBetween(from: Date, to: Date): number {
155
+ return Math.max(0, Math.floor((to.getTime() - from.getTime()) / 86_400_000));
156
+ }
157
+
158
+ /**
159
+ * Turns a run's findings into issue state.
160
+ *
161
+ * **The only writer of issue state, and a pure function.** Given the same
162
+ * issues, findings, run and clock it produces the same result every time. It
163
+ * performs no I/O, reads no clock and generates no randomness; the caller
164
+ * supplies `now` and `newId` and persists what comes back.
165
+ *
166
+ * This half handles everything a **present** finding causes:
167
+ *
168
+ * - a fingerprint that matches nothing opens an issue (`created`);
169
+ * - a fingerprint that matches attaches evidence and refreshes `lastSeen`,
170
+ * clearing the miss counter (`seen`);
171
+ * - a `resolved` issue seen again becomes `regressed` (`reopened`) — a returning
172
+ * problem is a different and more interesting event than a new one;
173
+ * - a changed detected severity updates the issue, and updates the *effective*
174
+ * severity and the SLA deadline only where nobody has overridden it
175
+ * (`severity_detected_changed`);
176
+ * - an `ignored` issue whose detected severity has risen above what was
177
+ * accepted comes back (`unignored`) — accepting the risk of a medium is not
178
+ * accepting the risk of the critical it turned out to be.
179
+ *
180
+ * And everything an **absent** finding causes:
181
+ *
182
+ * - an issue the run covered but did not see takes a miss, and resolves once it
183
+ * has missed enough consecutive covering runs (`resolved`);
184
+ * - an issue **outside** the run's coverage is untouched — not resolved, and not
185
+ * even counted as a miss, so a sequence of narrow scans cannot accumulate
186
+ * misses against a path none of them looked at (invariant 5);
187
+ * - a **manual-origin** issue never auto-resolves; a human closes what a human
188
+ * opened (invariant 6);
189
+ * - a suppression whose expiry has passed lapses (`unignored`), regardless of
190
+ * coverage — an ignore expiring is a decision timing out, not an observation.
191
+ *
192
+ * Finally it computes the {@link RunSummary}: counts by severity for new, still
193
+ * open, resolved, regressed and ignored, plus the exposure score. Ignored issues
194
+ * are excluded from every count but their own, and from exposure, and never from
195
+ * the suppressed appendix (invariant 7).
196
+ *
197
+ * `firstSeen` is never reset, by anything here (invariant 4).
198
+ *
199
+ * @param input - Issues, findings, the run, the clock and an id source.
200
+ * @returns Every issue for the target, plus the events that explain the changes.
201
+ */
202
+ export function reconcile(input: ReconcileInput): ReconcileResult {
203
+ const { issues, findings, run, now, newId } = input;
204
+ const actor = input.actor ?? 'reconciler';
205
+ const slaPolicy = input.slaPolicy ?? DEFAULT_SLA_POLICY;
206
+
207
+ const events: IssueEvent[] = [];
208
+ const emit = (issueId: string, type: IssueEvent['type'], payload?: Record<string, unknown>) => {
209
+ events.push({
210
+ id: newId(),
211
+ orgId: run.orgId,
212
+ issueId,
213
+ runId: run.id,
214
+ type,
215
+ actor,
216
+ ...(payload === undefined ? {} : { payload }),
217
+ createdAt: now,
218
+ });
219
+ };
220
+
221
+ const byKey = new Map(issues.map((i) => [issueKey(i), i]));
222
+ const updated = new Map<string, Issue>();
223
+
224
+ for (const [key, group] of groupByFingerprint(findings)) {
225
+ const existing = byKey.get(key);
226
+ const worst = highestSeverity(group);
227
+ const findingIds = group.map((f) => f.id);
228
+
21
229
  if (!existing) {
22
- byTitle.set(finding.title, {
23
- id: nextId(),
24
- title: finding.title,
25
- severity: finding.severity,
230
+ const id = newId();
231
+ const issue: Issue = {
232
+ id,
233
+ orgId: run.orgId,
234
+ targetId: run.targetId,
235
+ fingerprint: worst.fingerprint,
236
+ fingerprintVersion: worst.fingerprintVersion,
237
+ title: worst.title,
238
+ vulnKey: worst.vulnKey,
239
+ ...(worst.cwe === undefined ? {} : { cwe: worst.cwe }),
240
+ location: worst.location,
241
+ ...(worst.parameter === undefined ? {} : { parameter: worst.parameter }),
26
242
  status: 'open',
243
+ detectedSeverity: worst.detectedSeverity,
244
+ effectiveSeverity: worst.detectedSeverity,
245
+ firstSeen: now,
246
+ lastSeen: now,
247
+ consecutiveMisses: 0,
248
+ origin: run.kind,
249
+ slaDueAt: slaDueAt(worst.detectedSeverity, now, slaPolicy),
250
+ };
251
+ updated.set(key, issue);
252
+ emit(id, 'created', { findingIds, severity: worst.detectedSeverity });
253
+ continue;
254
+ }
255
+
256
+ // One `seen` per issue per run, not one per finding. `issue_events` is what
257
+ // every analytic counts, and a weakness two engines both detected would
258
+ // otherwise register as two sightings of one issue.
259
+ let issue: Issue = { ...existing, lastSeen: now, consecutiveMisses: 0 };
260
+ emit(issue.id, 'seen', { findingIds });
261
+
262
+ if (issue.status === 'resolved') {
263
+ issue = { ...issue, status: 'regressed', reopenedAt: now };
264
+ emit(issue.id, 'reopened', { resolvedAt: existing.resolvedAt?.toISOString() });
265
+ }
266
+
267
+ if (worst.detectedSeverity !== issue.detectedSeverity) {
268
+ const overridden = issue.severityOverriddenAt !== undefined;
269
+ const raisedAboveOverride =
270
+ overridden && severityRank(worst.detectedSeverity) > severityRank(issue.effectiveSeverity);
271
+
272
+ emit(issue.id, 'severity_detected_changed', {
273
+ from: issue.detectedSeverity,
274
+ to: worst.detectedSeverity,
275
+ // "Flag for review" in the spec, which names no column to flag it in.
276
+ // Recorded on the event rather than invented as a field: the detected
277
+ // severity has risen above a human's override, and somebody should look.
278
+ needsReview: raisedAboveOverride,
27
279
  });
280
+
281
+ issue = overridden
282
+ ? { ...issue, detectedSeverity: worst.detectedSeverity }
283
+ : {
284
+ ...issue,
285
+ detectedSeverity: worst.detectedSeverity,
286
+ effectiveSeverity: worst.detectedSeverity,
287
+ slaDueAt: slaDueAt(worst.detectedSeverity, issue.firstSeen, slaPolicy),
288
+ };
289
+ }
290
+
291
+ if (
292
+ issue.status === 'ignored' &&
293
+ issue.severityAtIgnore !== undefined &&
294
+ severityRank(worst.detectedSeverity) > severityRank(issue.severityAtIgnore)
295
+ ) {
296
+ emit(issue.id, 'unignored', {
297
+ reason: 'severity increased',
298
+ from: issue.severityAtIgnore,
299
+ to: worst.detectedSeverity,
300
+ });
301
+ issue = {
302
+ ...issue,
303
+ status: 'open',
304
+ ignoreReason: undefined,
305
+ ignoreComment: undefined,
306
+ ignoreScope: undefined,
307
+ ignoreExpiresAt: undefined,
308
+ ignoredBy: undefined,
309
+ severityAtIgnore: undefined,
310
+ };
311
+ }
312
+
313
+ updated.set(key, issue);
314
+ }
315
+
316
+ const created = new Set([...updated.keys()].filter((k) => !byKey.has(k)));
317
+ const regressedNow = new Set(
318
+ [...updated].filter(([k, i]) => !created.has(k) && i.status === 'regressed').map(([k]) => k),
319
+ );
320
+ const resolvedNow = new Set<string>();
321
+
322
+ // ---- what an absent finding causes -------------------------------------
323
+ //
324
+ // A run resolves only what it could have found. An issue outside coverage is
325
+ // left entirely alone: not resolved, and not counted as a miss either, so a
326
+ // sequence of narrow scans cannot accumulate misses against a path none of
327
+ // them looked at.
328
+ const threshold = input.autoResolveThreshold ?? defaultThreshold(run.kind);
329
+
330
+ for (const original of issues) {
331
+ const key = issueKey(original);
332
+ if (updated.has(key)) continue; // seen this run
333
+ if (original.status !== 'open' && original.status !== 'regressed') continue;
334
+ // A human closes what a human opened (invariant 6).
335
+ if (original.origin === 'manual') continue;
336
+ if (!coversLocation(original.location, run.coverage)) continue;
337
+
338
+ const misses = original.consecutiveMisses + 1;
339
+ if (misses < threshold) {
340
+ updated.set(key, { ...original, consecutiveMisses: misses });
341
+ continue;
28
342
  }
343
+
344
+ updated.set(key, {
345
+ ...original,
346
+ consecutiveMisses: misses,
347
+ status: 'resolved',
348
+ resolvedAt: now,
349
+ });
350
+ resolvedNow.add(key);
351
+ emit(original.id, 'resolved', { consecutiveMisses: misses, threshold });
29
352
  }
30
353
 
31
- return [...byTitle.values()];
354
+ // ---- suppressions that have lapsed --------------------------------------
355
+ //
356
+ // Independent of coverage and of whether the issue was seen: an ignore
357
+ // expiring is a decision timing out, not an observation.
358
+ for (const original of issues) {
359
+ const key = issueKey(original);
360
+ const current = updated.get(key) ?? original;
361
+ if (current.status !== 'ignored') continue;
362
+ if (current.ignoreExpiresAt === undefined) continue;
363
+ if (current.ignoreExpiresAt.getTime() > now.getTime()) continue;
364
+
365
+ emit(current.id, 'unignored', {
366
+ reason: 'expired',
367
+ expiredAt: current.ignoreExpiresAt.toISOString(),
368
+ });
369
+ updated.set(key, {
370
+ ...current,
371
+ status: 'open',
372
+ ignoreReason: undefined,
373
+ ignoreComment: undefined,
374
+ ignoreScope: undefined,
375
+ ignoreExpiresAt: undefined,
376
+ ignoredBy: undefined,
377
+ severityAtIgnore: undefined,
378
+ });
379
+ }
380
+
381
+ // Untouched issues are returned unchanged, in their original order, so a
382
+ // caller can write the whole set back without diffing.
383
+ const result = issues.map((i) => updated.get(issueKey(i)) ?? i);
384
+ for (const [key, issue] of updated) if (!byKey.has(key)) result.push(issue);
385
+
386
+ // ---- the summary --------------------------------------------------------
387
+ const counts = {
388
+ new: emptyCounts(),
389
+ stillOpen: emptyCounts(),
390
+ resolved: emptyCounts(),
391
+ regressed: emptyCounts(),
392
+ ignored: emptyCounts(),
393
+ };
394
+ let exposureScore = 0;
395
+
396
+ for (const issue of result) {
397
+ const key = issueKey(issue);
398
+ const severity = issue.effectiveSeverity;
399
+
400
+ if (issue.status === 'ignored') {
401
+ // Excluded from every other count and from exposure, never from the
402
+ // suppressed appendix (invariant 7).
403
+ counts.ignored[severity]++;
404
+ continue;
405
+ }
406
+
407
+ if (created.has(key)) counts.new[severity]++;
408
+ else if (resolvedNow.has(key)) counts.resolved[severity]++;
409
+ else if (regressedNow.has(key)) counts.regressed[severity]++;
410
+ else if (updated.has(key)) counts.stillOpen[severity]++;
411
+
412
+ if (issue.status === 'open' || issue.status === 'regressed') {
413
+ exposureScore += SEVERITY_WEIGHTS[severity] * Math.min(daysBetween(issue.firstSeen, now), 90);
414
+ }
415
+ }
416
+
417
+ const summary: RunSummary = {
418
+ runId: run.id,
419
+ orgId: run.orgId,
420
+ targetId: run.targetId,
421
+ kind: run.kind,
422
+ trigger: run.trigger,
423
+ engines: run.coverage.engines,
424
+ coverage: run.coverage,
425
+ durationMs: input.durationMs ?? 0,
426
+ new: counts.new as SeverityCounts,
427
+ stillOpen: counts.stillOpen as SeverityCounts,
428
+ resolved: counts.resolved as SeverityCounts,
429
+ regressed: counts.regressed as SeverityCounts,
430
+ ignored: counts.ignored as SeverityCounts,
431
+ exposureScore,
432
+ createdAt: now,
433
+ };
434
+
435
+ return { issues: result, events, summary };
32
436
  }