@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,462 @@
1
+ import type { RunSummary } from '../run.js';
2
+ import type { Severity } from '../severity.js';
3
+ import { SEVERITY_ORDER } from '../severity.js';
4
+ import type { Snapshot, SnapshotIssue } from '../snapshot.js';
5
+
6
+ /**
7
+ * Which report to render.
8
+ *
9
+ * - `pen` — Penetration Test. Every issue in full, with its evidence.
10
+ * - `vap` — Vulnerability Assessment. The same issues, tabulated.
11
+ * - `exec` — Executive Summary. What changed and what it means, no technical
12
+ * detail, for a reader who will not open the others.
13
+ * - `attest` — Attestation Letter. A formal statement that testing was carried
14
+ * out, with its limitations stated as prominently as its findings.
15
+ * - `retest` — Retest. The difference between two runs: what was fixed, what is
16
+ * still there, and what came back. The only kind that requires a baseline.
17
+ *
18
+ * **They are presentations of one snapshot, not four analyses.** Anything that
19
+ * differs between them is layout or level of detail; a number that differed
20
+ * would be a bug.
21
+ */
22
+ export type ReportKind = 'pen' | 'vap' | 'exec' | 'attest' | 'retest';
23
+
24
+ /** What a caller can say about a report that the snapshot does not know. */
25
+ export interface ReportOptions {
26
+ /** Which report to render. */
27
+ readonly kind: ReportKind;
28
+
29
+ /** Overrides the default title. */
30
+ readonly title?: string;
31
+
32
+ /** Who prepared it. White-labelling is a branding option, not a report type. */
33
+ readonly preparedBy?: string;
34
+
35
+ /** Who it is for. */
36
+ readonly preparedFor?: string;
37
+
38
+ /**
39
+ * When it was rendered.
40
+ *
41
+ * An argument, like everywhere else in this package: a report re-rendered
42
+ * from the same snapshot must come out identical, or it cannot be compared
43
+ * with the copy somebody was sent.
44
+ */
45
+ readonly now: Date;
46
+ }
47
+
48
+ /**
49
+ * How the findings in a report were actually produced.
50
+ *
51
+ * **This exists because of the honesty rule in `00-DOMAIN.md` §7.** That rule
52
+ * is written about the Attestation Letter, but a "Penetration Test" report
53
+ * built entirely from automated scanner output is the same over-claim: it is
54
+ * the fastest way to lose an auditor's trust, and with it the compliance
55
+ * business.
56
+ *
57
+ * So the basis is derived from the data rather than asserted by the template. A
58
+ * report over `scan` runs says automated; one that includes findings a person
59
+ * recorded says so; one over an upload says the results were brought from
60
+ * elsewhere. Nobody has to remember to be honest.
61
+ *
62
+ * It also carries the advice that automated findings **should be verified by a
63
+ * qualified technician before the report is submitted as audit evidence**. That
64
+ * is the practical form of the same rule: a scanner's output is a starting
65
+ * point for an assessment, not the assessment, and saying so protects both the
66
+ * reader and whoever hands them the document.
67
+ *
68
+ * The method is described; **the engines are not named**. Which scanner found
69
+ * something is an implementation detail, and naming the stack in a
70
+ * customer-facing report gives away more than it explains.
71
+ */
72
+ export interface TestingBasis {
73
+ /** Whether any finding came from a human rather than an engine. */
74
+ readonly includesManual: boolean;
75
+
76
+ /** Whether Secureport originated the traffic. */
77
+ readonly automated: boolean;
78
+
79
+ /** Whether results were brought from another tool. */
80
+ readonly uploaded: boolean;
81
+
82
+ /**
83
+ * The engines involved, in the order the run recorded them.
84
+ *
85
+ * **Available, but deliberately not printed.** Which scanner produced a
86
+ * finding is an implementation detail of the assessment, not a fact the
87
+ * reader needs, and naming the stack in a customer-facing document gives
88
+ * away more than it explains. The prose describes the *method*; this field is
89
+ * here for a consumer that has a reason to know.
90
+ */
91
+ readonly engines: readonly string[];
92
+
93
+ /** One sentence, ready to print. */
94
+ readonly statement: string;
95
+ }
96
+
97
+ /** A group of issues sharing a severity, most urgent first. */
98
+ export interface ReportSection {
99
+ /** The severity this section covers. */
100
+ readonly severity: Severity;
101
+
102
+ /** Its issues, most recently seen first. */
103
+ readonly issues: readonly SnapshotIssue[];
104
+ }
105
+
106
+ /** Everything a renderer needs, derived once so no two formats disagree. */
107
+ export interface ReportModel {
108
+ /** Which report this is. */
109
+ readonly kind: ReportKind;
110
+
111
+ /** The title as printed. */
112
+ readonly title: string;
113
+
114
+ /** The snapshot it was built from. */
115
+ readonly snapshot: Snapshot;
116
+
117
+ /** When it was rendered. */
118
+ readonly generatedAt: Date;
119
+
120
+ /** Who prepared it, if stated. */
121
+ readonly preparedBy?: string;
122
+
123
+ /** Who it is for, if stated. */
124
+ readonly preparedFor?: string;
125
+
126
+ /** How the findings were produced. */
127
+ readonly basis: TestingBasis;
128
+
129
+ /**
130
+ * Outstanding issues grouped by severity, most urgent first, empties dropped.
131
+ *
132
+ * **Resolved issues are not here.** A fixed issue is not a finding — it is
133
+ * evidence of remediation, and listing it under "Findings" makes a report
134
+ * read as though the work is still outstanding. It has its own section.
135
+ */
136
+ readonly sections: readonly ReportSection[];
137
+
138
+ /**
139
+ * Issues this run resolved, most recently seen first.
140
+ *
141
+ * Kept and shown rather than dropped: "what you fixed" is the story the
142
+ * product exists to tell, and a report that silently omits it throws away its
143
+ * best evidence.
144
+ */
145
+ readonly resolved: readonly SnapshotIssue[];
146
+
147
+ /** Open and regressed issues, by severity. What the reader owes work on. */
148
+ readonly outstanding: Readonly<Record<Severity, number>>;
149
+
150
+ /** Total outstanding, so a header does not have to sum a table. */
151
+ readonly outstandingTotal: number;
152
+
153
+ /** Exposure at the end of the run. */
154
+ readonly exposureScore: number;
155
+
156
+ /** Issues past their remediation deadline. */
157
+ readonly breached: number;
158
+
159
+ /**
160
+ * What this report does not establish.
161
+ *
162
+ * **Stated as prominently as the findings, and not optional.** An attestation
163
+ * that lists what was found and stays quiet about what it cannot support is
164
+ * the over-claim `00-DOMAIN.md` §7 warns about — and it is the fastest way to
165
+ * lose an auditor's trust, and with it the compliance business.
166
+ *
167
+ * Derived, so the limitations match the report rather than being boilerplate
168
+ * somebody forgot to update: a report over automated results says so, a
169
+ * partial scope says so, and none of them claims compliance with a standard
170
+ * nothing here assessed.
171
+ */
172
+ readonly limitations: readonly string[];
173
+
174
+ /**
175
+ * What this run established about the issues carried into it.
176
+ *
177
+ * Present whenever the snapshot has a baseline, whatever the kind. Absent on
178
+ * a first run, where there is nothing to compare against.
179
+ */
180
+ readonly retest?: RetestOutcome;
181
+ }
182
+
183
+ /**
184
+ * What a retest established about one issue.
185
+ *
186
+ * - `fixed` — carried in from the baseline and no longer detected by a run that
187
+ * covered it. The only verdict that is evidence of remediation.
188
+ * - `still_present` — detected again by this run.
189
+ * - `returned` — was resolved and this run found it again.
190
+ * - `not_retested` — **not detected, and not fixed either.** The run did not
191
+ * cover it, or has not missed it often enough to resolve it. A retest that
192
+ * quietly counted these as fixed would be reporting a coverage gap as
193
+ * remediation, which is the worst thing this document could do.
194
+ * - `new` — this run's own finding, not part of what was being retested.
195
+ */
196
+ export type RetestVerdict = 'fixed' | 'still_present' | 'returned' | 'not_retested' | 'new';
197
+
198
+ /** One issue, with what the retest established about it. */
199
+ export interface RetestEntry {
200
+ /** The issue as the snapshot sees it. */
201
+ readonly issue: SnapshotIssue;
202
+
203
+ /** What this run established about it. */
204
+ readonly verdict: RetestVerdict;
205
+ }
206
+
207
+ /**
208
+ * The difference between two runs.
209
+ *
210
+ * Present whenever the snapshot has a baseline, whatever the report kind — it
211
+ * is a fact about the data, not a feature of one template. The `retest` report
212
+ * requires it; the others may use it.
213
+ */
214
+ export interface RetestOutcome {
215
+ /** The run being retested against. */
216
+ readonly baseline: RunSummary;
217
+
218
+ /** Every issue, most urgent first, with its verdict. */
219
+ readonly entries: readonly RetestEntry[];
220
+
221
+ /** How many issues fell into each verdict. */
222
+ readonly counts: Readonly<Record<RetestVerdict, number>>;
223
+
224
+ /**
225
+ * Fixed as a proportion of what could actually be checked, 0–1.
226
+ *
227
+ * **`not_retested` is excluded from the denominator, not counted as a
228
+ * failure.** An issue nobody looked at is neither fixed nor unfixed, and
229
+ * folding it either way turns a coverage gap into a number somebody quotes.
230
+ * `null` when there was nothing to check.
231
+ */
232
+ readonly fixRate: number | null;
233
+ }
234
+
235
+ /** Works out what a run established about the issues carried into it. */
236
+ function describeRetest(snapshot: Snapshot): RetestOutcome | undefined {
237
+ const { baseline } = snapshot;
238
+ if (baseline === undefined) return undefined;
239
+
240
+ const entries: RetestEntry[] = snapshot.issues.map((issue) => ({
241
+ issue,
242
+ verdict: verdictFor(issue),
243
+ }));
244
+ // `SEVERITY_ORDER` rather than `severityRank`, which counts the other way:
245
+ // it returns 4 for `critical` so that a larger number is a worse problem.
246
+ // Sorting on it ascending put the advisories at the top of the table, which
247
+ // is how this shipped until a golden file showed it.
248
+ entries.sort((a, b) => {
249
+ const bySeverity =
250
+ SEVERITY_ORDER.indexOf(a.issue.issue.effectiveSeverity) -
251
+ SEVERITY_ORDER.indexOf(b.issue.issue.effectiveSeverity);
252
+ return bySeverity === 0
253
+ ? b.issue.issue.lastSeen.getTime() - a.issue.issue.lastSeen.getTime()
254
+ : bySeverity;
255
+ });
256
+
257
+ const counts: Record<RetestVerdict, number> = {
258
+ fixed: 0,
259
+ still_present: 0,
260
+ returned: 0,
261
+ not_retested: 0,
262
+ new: 0,
263
+ };
264
+ for (const entry of entries) counts[entry.verdict]++;
265
+
266
+ const checkable = counts.fixed + counts.still_present + counts.returned;
267
+ return {
268
+ baseline,
269
+ entries,
270
+ counts,
271
+ fixRate: checkable === 0 ? null : counts.fixed / checkable,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The verdict for one issue.
277
+ *
278
+ * Turns on whether this run produced evidence for it, not on its status alone:
279
+ * an issue left open because the run never covered it looks identical to one
280
+ * left open because the run found it again, and telling a reader those are the
281
+ * same thing is the failure this report exists to avoid.
282
+ */
283
+ function verdictFor(entry: SnapshotIssue): RetestVerdict {
284
+ if (entry.change === 'resolved') return 'fixed';
285
+ if (entry.change === 'regressed') return 'returned';
286
+ if (entry.change === 'new') return 'new';
287
+ return entry.findings.length > 0 ? 'still_present' : 'not_retested';
288
+ }
289
+
290
+ /** Works out what a report cannot establish, from the run rather than a template. */
291
+ function describeLimitations(snapshot: Snapshot, basis: TestingBasis): string[] {
292
+ const limitations: string[] = [];
293
+
294
+ if (!basis.includesManual) {
295
+ limitations.push(
296
+ 'This is evidence of automated security testing. It is not a human-led penetration ' +
297
+ 'test and does not carry the assurance of one.',
298
+ );
299
+ }
300
+ limitations.push(
301
+ 'Findings should be verified by a qualified technician before this document is relied ' +
302
+ 'upon as audit evidence.',
303
+ );
304
+ limitations.push(
305
+ `Testing covered only ${snapshot.run.coverage.paths.join(', ')}. Anything outside that ` +
306
+ 'was not examined, and its absence from this document is not evidence that it is sound.',
307
+ );
308
+ limitations.push(
309
+ `This reflects the state of the target as of ${snapshot.run.createdAt
310
+ .toISOString()
311
+ .slice(0, 10)}. It says nothing about the target before or after that date.`,
312
+ );
313
+ limitations.push(
314
+ 'Automated testing cannot establish the absence of a vulnerability. A clean result means ' +
315
+ 'nothing was detected, not that nothing is there.',
316
+ );
317
+ if (snapshot.suppressed.length > 0) {
318
+ limitations.push(
319
+ `${snapshot.suppressed.length} finding${snapshot.suppressed.length === 1 ? ' has' : 's have'} been ` +
320
+ 'suppressed and excluded from the counts above. They are listed in full in the appendix.',
321
+ );
322
+ }
323
+ limitations.push(
324
+ 'This document does not certify compliance with any standard, framework or regulation.',
325
+ );
326
+
327
+ return limitations;
328
+ }
329
+
330
+ const DEFAULT_TITLES: Readonly<Record<ReportKind, string>> = Object.freeze({
331
+ pen: 'Penetration Test Report',
332
+ vap: 'Vulnerability Assessment Report',
333
+ exec: 'Executive Summary',
334
+ attest: 'Attestation of Security Testing',
335
+ retest: 'Retest Report',
336
+ });
337
+
338
+ /** Works out how the findings were produced, from the run rather than a claim. */
339
+ function describeBasis(snapshot: Snapshot): TestingBasis {
340
+ const kind = snapshot.run.kind;
341
+ const engines = snapshot.run.engines;
342
+ const includesManual = snapshot.issues.some((i) => i.issue.origin === 'manual');
343
+ const automated = kind === 'scan';
344
+ const uploaded = kind === 'upload';
345
+
346
+ const parts: string[] = [];
347
+ if (automated) {
348
+ parts.push(
349
+ 'Findings were produced by automated vulnerability scanning against the scope described below.',
350
+ );
351
+ }
352
+ if (uploaded) {
353
+ parts.push(
354
+ 'Findings were imported from an external testing tool and reconciled against previous runs.',
355
+ );
356
+ }
357
+ if (kind === 'manual') parts.push('Findings were recorded manually by a tester.');
358
+ if (includesManual && kind !== 'manual') {
359
+ parts.push('Some findings were recorded manually by a tester.');
360
+ }
361
+ if (!includesManual && kind !== 'manual') {
362
+ parts.push('No part of this assessment constitutes a manual penetration test.');
363
+ }
364
+ if (automated || uploaded) {
365
+ parts.push(
366
+ 'Automated findings should be verified by a qualified technician before this report is ' +
367
+ 'submitted as audit evidence.',
368
+ );
369
+ }
370
+
371
+ return { includesManual, automated, uploaded, engines, statement: parts.join(' ') };
372
+ }
373
+
374
+ /**
375
+ * Derives everything a report shows, once, from a snapshot.
376
+ *
377
+ * Both renderers consume this rather than the snapshot, so a number in the
378
+ * Markdown and the same number in the HTML cannot drift apart — they are the
379
+ * same value formatted twice.
380
+ *
381
+ * Suppressed issues are already separated by the snapshot and stay separated
382
+ * here: they never reach {@link ReportModel.sections}, are excluded from
383
+ * {@link ReportModel.outstanding} and from the exposure score, and appear only
384
+ * in the suppressed appendix (invariant 7).
385
+ *
386
+ * @param snapshot - Issue state as of a run.
387
+ * @param options - Title, attribution, and the render time.
388
+ * @returns The model both renderers format.
389
+ */
390
+ export function buildReportModel(snapshot: Snapshot, options: ReportOptions): ReportModel {
391
+ const outstanding: Record<Severity, number> = {
392
+ critical: 0,
393
+ high: 0,
394
+ medium: 0,
395
+ low: 0,
396
+ advisory: 0,
397
+ };
398
+
399
+ const bySeverity = new Map<Severity, SnapshotIssue[]>();
400
+ const resolved: SnapshotIssue[] = [];
401
+ let breached = 0;
402
+
403
+ for (const entry of snapshot.issues) {
404
+ if (entry.issue.status === 'resolved') {
405
+ resolved.push(entry);
406
+ continue;
407
+ }
408
+
409
+ const severity = entry.issue.effectiveSeverity;
410
+ const group = bySeverity.get(severity);
411
+ if (group) group.push(entry);
412
+ else bySeverity.set(severity, [entry]);
413
+
414
+ if (entry.issue.status === 'open' || entry.issue.status === 'regressed') {
415
+ outstanding[severity]++;
416
+ if (entry.slaStatus === 'breached') breached++;
417
+ }
418
+ }
419
+ resolved.sort((a, b) => b.issue.lastSeen.getTime() - a.issue.lastSeen.getTime());
420
+
421
+ const sections: ReportSection[] = [];
422
+ for (const severity of SEVERITY_ORDER) {
423
+ const issues = bySeverity.get(severity);
424
+ if (issues === undefined || issues.length === 0) continue;
425
+ sections.push({
426
+ severity,
427
+ // Most recently seen first: what a reader wants at the top of a section
428
+ // is what the latest run actually found.
429
+ issues: [...issues].sort((a, b) => b.issue.lastSeen.getTime() - a.issue.lastSeen.getTime()),
430
+ });
431
+ }
432
+
433
+ const outstandingTotal = SEVERITY_ORDER.reduce((sum, s) => sum + outstanding[s], 0);
434
+ const basis = describeBasis(snapshot);
435
+ const retest = describeRetest(snapshot);
436
+
437
+ // Rendering a retest of nothing is the over-claim §7 warns about: the
438
+ // document's whole content is a comparison, and one run does not have one.
439
+ if (options.kind === 'retest' && retest === undefined) {
440
+ throw new TypeError(
441
+ 'a retest report needs a baseline: build the snapshot with `previous` set to the run being retested',
442
+ );
443
+ }
444
+
445
+ return {
446
+ kind: options.kind,
447
+ title: options.title ?? DEFAULT_TITLES[options.kind],
448
+ snapshot,
449
+ generatedAt: options.now,
450
+ ...(options.preparedBy === undefined ? {} : { preparedBy: options.preparedBy }),
451
+ ...(options.preparedFor === undefined ? {} : { preparedFor: options.preparedFor }),
452
+ basis,
453
+ limitations: describeLimitations(snapshot, basis),
454
+ sections,
455
+ resolved,
456
+ outstanding,
457
+ outstandingTotal,
458
+ exposureScore: snapshot.run.exposureScore,
459
+ breached,
460
+ ...(retest === undefined ? {} : { retest }),
461
+ };
462
+ }
package/src/run.ts ADDED
@@ -0,0 +1,163 @@
1
+ import type { Severity } from './severity.js';
2
+
3
+ /**
4
+ * What kind of run this was.
5
+ *
6
+ * - `scan` — Secureport originated the traffic.
7
+ * - `upload` — someone brought results from elsewhere.
8
+ * - `manual` — a human recorded a finding directly.
9
+ *
10
+ * **An upload is first-class, not a lesser scan.** Much of the value is in
11
+ * tracking results a customer already has, and a model that treated uploads as
12
+ * second-class would make that path feel second-class too.
13
+ */
14
+ export type RunKind = 'scan' | 'upload' | 'manual';
15
+
16
+ /**
17
+ * What set the run going.
18
+ *
19
+ * Recorded on every run from the first line of code, because it is what lets
20
+ * you say "the Action ran 340 times this quarter" — the sentence that shows
21
+ * automation is actually being used rather than merely installed.
22
+ */
23
+ export type RunTrigger = 'github_action' | 'cli' | 'api' | 'scheduled' | 'web';
24
+
25
+ /**
26
+ * What a run actually exercised.
27
+ *
28
+ * The reason auto-resolution is safe. **A run only resolves what it could have
29
+ * found** (invariant 5): a quick profile that skipped `/admin` must not close
30
+ * an `/admin` issue, because not looking is not the same as not finding.
31
+ *
32
+ * Scan runs record what they genuinely reached. Upload runs take a declared
33
+ * scope, defaulting to the whole target — the person uploading is asserting
34
+ * what their results cover.
35
+ */
36
+ export interface Coverage {
37
+ /**
38
+ * Location patterns the run covered, as globs, e.g.
39
+ * `https://app.example.com/**`.
40
+ *
41
+ * An issue whose location matches none of these is untouched by this run,
42
+ * whatever else happened.
43
+ */
44
+ readonly paths: readonly string[];
45
+
46
+ /** Ports exercised, where the run was port-aware. */
47
+ readonly ports?: readonly number[];
48
+
49
+ /** Engines that took part. */
50
+ readonly engines: readonly string[];
51
+ }
52
+
53
+ /**
54
+ * One execution against a target.
55
+ */
56
+ export interface Run {
57
+ /** Unique id. */
58
+ readonly id: string;
59
+
60
+ /** Organisation this run belongs to. */
61
+ readonly orgId: string;
62
+
63
+ /** Target it ran against. */
64
+ readonly targetId: string;
65
+
66
+ /** What kind of run it was. */
67
+ readonly kind: RunKind;
68
+
69
+ /** What set it going. */
70
+ readonly trigger: RunTrigger;
71
+
72
+ /** What it exercised. */
73
+ readonly coverage: Coverage;
74
+
75
+ /**
76
+ * The verification this run relied on, for runs that originate traffic.
77
+ *
78
+ * Recorded per run rather than per organisation, because verification
79
+ * belongs to a target: verifying production must never authorise scanning
80
+ * staging, even on the same wildcard domain (invariant 11).
81
+ */
82
+ readonly verificationId?: string;
83
+
84
+ /** When it started. */
85
+ readonly startedAt: Date;
86
+
87
+ /** When it finished. */
88
+ readonly finishedAt?: Date;
89
+ }
90
+
91
+ /**
92
+ * A count of issues per {@link Severity}.
93
+ *
94
+ * Every severity is present, including zeroes, so a report renders a complete
95
+ * table without deciding what a missing key means.
96
+ */
97
+ export type SeverityCounts = Readonly<Record<Severity, number>>;
98
+
99
+ /**
100
+ * What a run did, in the terms a reader cares about.
101
+ *
102
+ * The change summary every report opens with, and the row analytics aggregate.
103
+ * Computed once by reconciliation and stored, so no report has to recount
104
+ * findings at render time.
105
+ */
106
+ export interface RunSummary {
107
+ /** The run this summarises. */
108
+ readonly runId: string;
109
+
110
+ /** Organisation it belongs to. */
111
+ readonly orgId: string;
112
+
113
+ /** Target it ran against. */
114
+ readonly targetId: string;
115
+
116
+ /** What kind of run it was. */
117
+ readonly kind: RunKind;
118
+
119
+ /** What set it going. */
120
+ readonly trigger: RunTrigger;
121
+
122
+ /** Engines that took part. */
123
+ readonly engines: readonly string[];
124
+
125
+ /** What the run exercised. */
126
+ readonly coverage: Coverage;
127
+
128
+ /** How long it took. */
129
+ readonly durationMs: number;
130
+
131
+ /** Issues opened for the first time by this run. */
132
+ readonly new: SeverityCounts;
133
+
134
+ /** Issues already open that this run saw again. */
135
+ readonly stillOpen: SeverityCounts;
136
+
137
+ /** Issues this run resolved. */
138
+ readonly resolved: SeverityCounts;
139
+
140
+ /** Issues that were resolved and that this run found again. */
141
+ readonly regressed: SeverityCounts;
142
+
143
+ /**
144
+ * Issues currently suppressed.
145
+ *
146
+ * Reported separately and **never folded into the other counts**: an ignored
147
+ * issue is excluded from metrics but never from the suppressed appendix
148
+ * (invariant 7).
149
+ */
150
+ readonly ignored: SeverityCounts;
151
+
152
+ /**
153
+ * Exposure at the end of this run.
154
+ *
155
+ * `Σ over open issues of severityWeight × min(daysOpen, 90)`. Simple,
156
+ * explainable and monotone — it can only fall by fixing things or by time not
157
+ * passing.
158
+ */
159
+ readonly exposureScore: number;
160
+
161
+ /** When the summary was computed. */
162
+ readonly createdAt: Date;
163
+ }