@secureport/sdk 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/client.d.ts +135 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +191 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/errors.d.ts +80 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +118 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +10 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/pagination.d.ts +27 -0
  16. package/dist/pagination.d.ts.map +1 -0
  17. package/dist/pagination.js +19 -0
  18. package/dist/pagination.js.map +1 -0
  19. package/dist/resources/branding.d.ts +49 -0
  20. package/dist/resources/branding.d.ts.map +1 -0
  21. package/dist/resources/branding.js +38 -0
  22. package/dist/resources/branding.js.map +1 -0
  23. package/dist/resources/issues.d.ts +248 -0
  24. package/dist/resources/issues.d.ts.map +1 -0
  25. package/dist/resources/issues.js +100 -0
  26. package/dist/resources/issues.js.map +1 -0
  27. package/dist/resources/reports.d.ts +83 -0
  28. package/dist/resources/reports.d.ts.map +1 -0
  29. package/dist/resources/reports.js +50 -0
  30. package/dist/resources/reports.js.map +1 -0
  31. package/dist/resources/runs.d.ts +474 -0
  32. package/dist/resources/runs.d.ts.map +1 -0
  33. package/dist/resources/runs.js +281 -0
  34. package/dist/resources/runs.js.map +1 -0
  35. package/dist/resources/suppression-rules.d.ts +43 -0
  36. package/dist/resources/suppression-rules.d.ts.map +1 -0
  37. package/dist/resources/suppression-rules.js +33 -0
  38. package/dist/resources/suppression-rules.js.map +1 -0
  39. package/dist/resources/targets.d.ts +126 -0
  40. package/dist/resources/targets.d.ts.map +1 -0
  41. package/dist/resources/targets.js +83 -0
  42. package/dist/resources/targets.js.map +1 -0
  43. package/package.json +55 -0
  44. package/src/client.ts +328 -0
  45. package/src/errors.ts +120 -0
  46. package/src/index.ts +100 -0
  47. package/src/pagination.ts +41 -0
  48. package/src/resources/branding.ts +59 -0
  49. package/src/resources/issues.ts +344 -0
  50. package/src/resources/reports.ts +104 -0
  51. package/src/resources/runs.ts +635 -0
  52. package/src/resources/suppression-rules.ts +69 -0
  53. package/src/resources/targets.ts +179 -0
@@ -0,0 +1,635 @@
1
+ import type { Snapshot } from '@secureport/core';
2
+ import { parseSnapshot } from '@secureport/core';
3
+ import type { FetchBody, Requester } from '../client.js';
4
+ import type { Severity } from './issues.js';
5
+ import type { Page, PageQuery } from '../pagination.js';
6
+ import { paginate } from '../pagination.js';
7
+
8
+ /**
9
+ * What kind of run this was — `scan` (Secureport originated the traffic,
10
+ * subject to `POST /runs`'s terms/verification gate), `upload` (results
11
+ * brought from elsewhere) or `manual` (a human recorded a finding directly).
12
+ * Mirrors `@secureport/core`'s `RunKind`; SDK-local for the same reason as
13
+ * `Issue`/`Finding` — see the module doc in `issues.ts`.
14
+ */
15
+ export type RunKind = 'scan' | 'upload' | 'manual';
16
+
17
+ /** What set the run going. Never defaulted — see {@link NewRun.trigger}. */
18
+ export type RunTrigger = 'github_action' | 'cli' | 'api' | 'scheduled' | 'web';
19
+
20
+ /**
21
+ * Where a run is in its lifecycle. `failed` and `cancelled` are both terminal
22
+ * and neither reconciles — see {@link RunsResource.cancel} for the difference.
23
+ */
24
+ export type RunStatus = 'running' | 'finished' | 'failed' | 'cancelled';
25
+
26
+ /**
27
+ * How much a scan does — the S/M/L/XL catalogue, smallest to largest, each
28
+ * size a strict superset of the one below. Mirrors `@secureport/core`.
29
+ */
30
+ export type ScanSize = 's' | 'm' | 'l' | 'xl';
31
+
32
+ /** What a run actually exercised. */
33
+ export interface Coverage {
34
+ /** Location patterns the run covered, as globs, e.g. `https://app.example.com/**`. */
35
+ readonly paths: readonly string[];
36
+
37
+ /** Ports exercised, where the run was port-aware. */
38
+ readonly ports?: readonly number[];
39
+
40
+ /**
41
+ * Severity classes whose checks the run exercised; absent means not
42
+ * severity-scoped. A scoped run cannot resolve issues outside its classes.
43
+ */
44
+ readonly severities?: readonly Severity[];
45
+
46
+ /** Engines that took part. */
47
+ readonly engines: readonly string[];
48
+ }
49
+
50
+ /**
51
+ * One execution against a target.
52
+ *
53
+ * Date fields are ISO 8601 strings, as the API sends them — see
54
+ * {@link RunsResource.snapshot} for the one method in this resource that
55
+ * returns real `Date`s instead.
56
+ */
57
+ export interface Run {
58
+ readonly id: string;
59
+ readonly orgId: string;
60
+ readonly targetId: string;
61
+ readonly kind: RunKind;
62
+ readonly trigger: RunTrigger;
63
+ readonly status: RunStatus;
64
+
65
+ /** How much this run does. Present exactly when `kind` is `'scan'`. */
66
+ readonly size?: ScanSize;
67
+ readonly coverage: Coverage;
68
+
69
+ /** The verification evidence a `kind: 'scan'` run was authorised against. */
70
+ readonly verificationId?: string;
71
+ readonly startedAt: string;
72
+ readonly finishedAt?: string;
73
+ }
74
+
75
+ export type RunPage = Page<Run>;
76
+
77
+ /** The filters `list`/`listAll` take, beside the page. Both optional and combinable. */
78
+ export interface RunFilters {
79
+ readonly targetId?: string;
80
+ readonly status?: RunStatus;
81
+ }
82
+
83
+ export interface NewRun {
84
+ readonly targetId: string;
85
+ readonly kind: RunKind;
86
+
87
+ /**
88
+ * Required, never defaulted — `00-DOMAIN.md` says trigger is set on every
89
+ * run from the first line of code, and a default would be a way of not
90
+ * saying.
91
+ */
92
+ readonly trigger: RunTrigger;
93
+
94
+ /**
95
+ * Scans only, defaulting to `'m'`; refused on kinds that originate no
96
+ * traffic. Sizes the service cannot run yet are refused, never downgraded.
97
+ */
98
+ readonly size?: ScanSize;
99
+
100
+ /**
101
+ * Absent means the whole target. Engines arrive with the results. Uploads
102
+ * only: a scan's coverage is derived server-side, and a claim is refused.
103
+ */
104
+ readonly coverage?: Coverage;
105
+ }
106
+
107
+ export interface FinishRunInput {
108
+ /**
109
+ * **`'failed'` reconciles nothing** — a failed run is not a complete
110
+ * observation, so {@link RunsResource.summary} and
111
+ * {@link RunsResource.snapshot} will 409 forever after. Only `'finished'`
112
+ * triggers reconciliation.
113
+ */
114
+ readonly status: 'finished' | 'failed';
115
+ }
116
+
117
+ /** A scan's phase, as {@link ScanProgress.phase} reports it. */
118
+ export type ScanPhase = 'queued' | 'launching' | 'scanning' | 'ended';
119
+
120
+ /**
121
+ * Where a scan is — {@link RunsResource.progress}'s answer.
122
+ *
123
+ * @remarks Deliberately no percentage: the engine reports nothing until it finishes, so the honest estimate is the time bound, not an invented fraction.
124
+ */
125
+ export interface ScanProgress {
126
+ readonly runId: string;
127
+ readonly status: RunStatus;
128
+
129
+ /**
130
+ * How far the scan has got: `'queued'` (accepted, not yet launched),
131
+ * `'launching'`, `'scanning'`, or `'ended'` — `status` says how.
132
+ */
133
+ readonly phase: ScanPhase;
134
+
135
+ /** Seconds since the run started — up to now, or to when it ended. */
136
+ readonly elapsedSeconds: number;
137
+
138
+ /** The size's time budget. The engine is stopped when it runs out. */
139
+ readonly budgetSeconds: number;
140
+
141
+ /**
142
+ * When the run will have ended by, one way or the other: the budget plus
143
+ * launch and upload grace. An ISO 8601 string, like every date here.
144
+ */
145
+ readonly expectedBy: string;
146
+ }
147
+
148
+ /** Every field in a {@link RunSummary} that counts issues by severity. */
149
+ export interface SeverityCounts {
150
+ readonly critical: number;
151
+ readonly high: number;
152
+ readonly medium: number;
153
+ readonly low: number;
154
+ readonly advisory: number;
155
+ }
156
+
157
+ /**
158
+ * What a run did, in the terms a report opens with. Written by
159
+ * reconciliation — see {@link RunsResource.summary}.
160
+ */
161
+ export interface RunSummary {
162
+ readonly runId: string;
163
+ readonly orgId: string;
164
+ readonly targetId: string;
165
+ readonly kind: RunKind;
166
+ readonly trigger: RunTrigger;
167
+ readonly engines: readonly string[];
168
+ readonly coverage: Coverage;
169
+ readonly durationMs: number;
170
+ readonly new: SeverityCounts;
171
+ readonly stillOpen: SeverityCounts;
172
+ readonly resolved: SeverityCounts;
173
+ readonly regressed: SeverityCounts;
174
+ readonly ignored: SeverityCounts;
175
+ readonly exposureScore: number;
176
+ readonly createdAt: string;
177
+ }
178
+
179
+ /** Where one upload's parse stands. */
180
+ export type ArtifactProcessingStatus = 'queued' | 'processing' | 'done' | 'failed';
181
+
182
+ /**
183
+ * A raw scanner artifact archived against a run — what {@link RunsResource.import}
184
+ * and {@link RunsResource.listArtifacts} both return.
185
+ *
186
+ * `findingCount`/`skippedLines` are present once `processingStatus` is
187
+ * `'done'`; absent while `'queued'` or `'processing'`, since the parse simply
188
+ * has not happened yet.
189
+ */
190
+ export interface RunArtifact {
191
+ readonly id: string;
192
+ readonly orgId: string;
193
+ readonly runId: string;
194
+ readonly objectName: string;
195
+ readonly filename: string;
196
+ readonly contentType: string;
197
+ readonly byteSize: number;
198
+ readonly sha256: string;
199
+ readonly sourceEngine?: string;
200
+ readonly expiresAt: string;
201
+ readonly processingStatus: ArtifactProcessingStatus;
202
+
203
+ /** Findings parsed from this upload. Present once `processingStatus` is `'done'`. */
204
+ readonly findingCount?: number;
205
+
206
+ /** Unreadable lines dropped from a line-delimited upload. Present with `'done'`. */
207
+ readonly skippedLines?: number;
208
+ readonly createdAt: string;
209
+ }
210
+
211
+ /** `GET /runs/{id}/artifacts`'s body — not a {@link Page}, since a run holds only a handful of files. */
212
+ export interface RunArtifactList {
213
+ readonly data: readonly RunArtifact[];
214
+ }
215
+
216
+ export interface NewRunImport {
217
+ /**
218
+ * The scanner's raw output, sent exactly as given — never JSON-encoded,
219
+ * whatever type it is. A `Buffer`, `Uint8Array`, `Blob` or `string` all work.
220
+ */
221
+ readonly body: FetchBody;
222
+
223
+ /**
224
+ * Sent verbatim as the request's `content-type` — chosen by the caller,
225
+ * never inferred from `body`. Must be one the API accepts (JSON, NDJSON,
226
+ * JSONL, XML or plain text/CSV); anything else is a `415`.
227
+ */
228
+ readonly contentType: string;
229
+
230
+ /** Which importer parses this file, e.g. `'nuclei'`, `'zap'`, `'burp'`, `'nessus'` or `'generic'`. */
231
+ readonly engine: string;
232
+
233
+ /** Recorded for the report; trusted for nothing. */
234
+ readonly filename?: string;
235
+ }
236
+
237
+ /**
238
+ * What {@link RunsResource.createUploadUrl} needs to mint a signed URL — the
239
+ * digest and size the caller already knows, having the whole file in hand to
240
+ * upload it. Computed client-side and not re-verified byte-for-byte by the
241
+ * API (see {@link RunsResource.createUploadUrl}'s own `@throws` and TSDoc):
242
+ * that is the entire point of a signed URL over {@link RunsResource.import} —
243
+ * the bytes never transit the API for it to check.
244
+ */
245
+ export interface NewRunImportUrl {
246
+ /** Lowercase hex SHA-256 of the file about to be uploaded. */
247
+ readonly sha256: string;
248
+
249
+ /** The file's exact size. Refused above the API's signed-upload ceiling. */
250
+ readonly byteSize: number;
251
+
252
+ /** Sent verbatim as the signed URL's bound content-type — must match the `PUT`'s own header exactly. */
253
+ readonly contentType: string;
254
+
255
+ /** Recorded for the report; trusted for nothing. */
256
+ readonly filename?: string;
257
+ }
258
+
259
+ /**
260
+ * A signed URL to `PUT` a large scanner artifact to, straight to the
261
+ * artifacts bucket — what {@link RunsResource.createUploadUrl} returns for a
262
+ * file not already archived on this run.
263
+ */
264
+ export interface RunUploadUrl {
265
+ /** `PUT` the file here — nothing else, no query string of your own. */
266
+ readonly uploadUrl: string;
267
+
268
+ /** Hand this back to {@link RunsResource.completeUpload} once the `PUT` succeeds. */
269
+ readonly objectName: string;
270
+
271
+ /**
272
+ * Every one of these must be sent verbatim as headers on the `PUT`, or the
273
+ * bucket refuses the upload — this is what carries the file's retention
274
+ * period through a path that never calls the API's own archiving code.
275
+ */
276
+ readonly requiredHeaders: Readonly<Record<string, string>>;
277
+
278
+ /** The URL itself stops accepting a `PUT` after this — independent of the artifact's own retention. */
279
+ readonly urlExpiresAt: string;
280
+ }
281
+
282
+ /**
283
+ * What {@link RunsResource.completeUpload} needs once a signed-URL `PUT`
284
+ * succeeded: the object it wrote to, and what to parse it as.
285
+ */
286
+ export interface CompleteRunImport {
287
+ /** From {@link RunUploadUrl.objectName} — the exact value {@link RunsResource.createUploadUrl} returned. */
288
+ readonly objectName: string;
289
+
290
+ /** Which importer parses this file, e.g. `'nuclei'`, `'zap'`, `'burp'`, `'nessus'` or `'generic'`. */
291
+ readonly engine: string;
292
+
293
+ /** Recorded for the report; trusted for nothing. */
294
+ readonly filename?: string;
295
+ }
296
+
297
+ /** {@link RunsResource.waitUntilFinished}'s options. */
298
+ export interface WaitUntilFinishedOptions {
299
+ /**
300
+ * How often to poll {@link RunsResource.get} while the run is still `running`.
301
+ *
302
+ * @defaultValue 5000
303
+ */
304
+ readonly pollIntervalMs?: number;
305
+
306
+ /**
307
+ * How long to poll before giving up and rejecting.
308
+ *
309
+ * Sized for a hosted scan, not an upload: a run behind a busy queue can take
310
+ * minutes, not seconds.
311
+ *
312
+ * @defaultValue 900000 (15 minutes)
313
+ */
314
+ readonly timeoutMs?: number;
315
+ }
316
+
317
+ /** {@link RunsResource.waitForArtifact}'s options — same shape as {@link WaitUntilFinishedOptions}. */
318
+ export interface WaitForArtifactOptions {
319
+ /**
320
+ * How often to poll {@link RunsResource.listArtifacts} while the artifact is
321
+ * still `queued` or `processing`.
322
+ *
323
+ * @defaultValue 5000
324
+ */
325
+ readonly pollIntervalMs?: number;
326
+
327
+ /**
328
+ * How long to poll before giving up and rejecting.
329
+ *
330
+ * @defaultValue 900000 (15 minutes)
331
+ */
332
+ readonly timeoutMs?: number;
333
+ }
334
+
335
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
336
+ const DEFAULT_WAIT_TIMEOUT_MS = 900_000;
337
+
338
+ /**
339
+ * Runs — one execution against a target, and the scan lifecycle around it
340
+ * (`00-DOMAIN.md` §3): started, evidence attached, finished, then
341
+ * reconciled into a summary and a snapshot.
342
+ */
343
+ export class RunsResource {
344
+ constructor(private readonly request: Requester) {}
345
+
346
+ list(query?: PageQuery & RunFilters): Promise<RunPage> {
347
+ return this.request({ method: 'GET', path: '/runs', query: { ...query } });
348
+ }
349
+
350
+ /** Every run matching `filters`, across every page, as an async iterator. */
351
+ listAll(filters?: RunFilters): AsyncIterable<Run> {
352
+ return paginate((page) => this.list({ ...filters, page }));
353
+ }
354
+
355
+ /**
356
+ * Starts a run against a target. It is created `running` and stays that way
357
+ * until {@link RunsResource.finish}: upload scanner output in between with
358
+ * {@link RunsResource.import}.
359
+ *
360
+ * @throws {SecureportError} `terms_required` for every kind if the
361
+ * organisation has not accepted the current terms — a human accepts them at
362
+ * `POST /orgs/{orgId}/terms`; an API key cannot. `verification_required` for
363
+ * `kind: 'scan'` if the target has no currently-passing verification,
364
+ * re-checked fresh as part of this same request rather than trusted from a
365
+ * stored flag. Neither is retried by the transport: both are settled
366
+ * refusals, not transient ones.
367
+ */
368
+ create(input: NewRun): Promise<Run> {
369
+ return this.request({ method: 'POST', path: '/runs', body: input });
370
+ }
371
+
372
+ /**
373
+ * One run by id. What it found is {@link RunsResource.summary}, once it has finished —
374
+ * this method never carries a summary or findings itself.
375
+ */
376
+ get(id: string): Promise<Run> {
377
+ return this.request({ method: 'GET', path: `/runs/${encodeURIComponent(id)}` });
378
+ }
379
+
380
+ /**
381
+ * Ends a run and, for `status: 'finished'`, reconciles it: the findings
382
+ * uploaded against it become new, still-open, resolved or regressed
383
+ * issues, and the run gets a summary. **`status: 'failed'` reconciles
384
+ * nothing** — see {@link FinishRunInput.status}.
385
+ *
386
+ * @remarks Uploads and manual runs only: a `kind: 'scan'` run is refused here — its engine reports its own end, with evidence this route cannot supply. Stop a scan early with {@link RunsResource.cancel} instead.
387
+ * @throws {SecureportError} `conflict` if any upload is still being
388
+ * parsed — poll {@link RunsResource.listArtifacts} and finish once every
389
+ * one is `done` or `failed` — or if the run had already ended;
390
+ * `validation` for a `kind: 'scan'` run.
391
+ */
392
+ finish(id: string, input: FinishRunInput): Promise<Run> {
393
+ return this.request({
394
+ method: 'POST',
395
+ path: `/runs/${encodeURIComponent(id)}/finish`,
396
+ body: input,
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Stops a running scan: the run ends as `'cancelled'` immediately, and the
402
+ * scan job behind it is cancelled best-effort.
403
+ *
404
+ * @remarks If the job cancel fails, the engine simply runs out its time budget and its late reports are discarded, so `'cancelled'` is final either way. Nothing is reconciled — a cancelled run resolves no issues, and findings already ingested stay recorded as partial evidence. That intent is what distinguishes it from `'failed'`, which means the scan itself went wrong.
405
+ * @throws {SecureportError} `conflict` if the run had already ended;
406
+ * `validation` for a run that is not a `kind: 'scan'` — an upload is ended
407
+ * by its own caller via {@link RunsResource.finish}.
408
+ */
409
+ cancel(id: string): Promise<Run> {
410
+ return this.request({ method: 'POST', path: `/runs/${encodeURIComponent(id)}/cancel` });
411
+ }
412
+
413
+ /**
414
+ * Where a scan is: its phase, how long it has run, its time budget, and
415
+ * when it will have ended by — see {@link ScanProgress} for why no percentage.
416
+ *
417
+ * @remarks Combine with {@link RunsResource.waitUntilFinished} for a poll that also knows when to give up: `expectedBy` is the honest bound.
418
+ * @throws {SecureportError} `validation` for a run that is not a
419
+ * `kind: 'scan'` — an upload's progress is whatever its caller is doing.
420
+ */
421
+ progress(id: string): Promise<ScanProgress> {
422
+ return this.request({ method: 'GET', path: `/runs/${encodeURIComponent(id)}/progress` });
423
+ }
424
+
425
+ /**
426
+ * Archives a raw scanner artifact against a run. Nothing is parsed on the
427
+ * client side — the whole file is handed to the API as `input.body`, with
428
+ * `input.contentType` sent verbatim, and the API decides how (and when) to
429
+ * parse it.
430
+ *
431
+ * **The response shape depends on size, not just success or failure.** A
432
+ * file under 5 MiB is parsed inline and answers `201` with
433
+ * `processingStatus: 'done'` and `findingCount`/`skippedLines` filled in. At
434
+ * or over 5 MiB the parse is queued and the API answers `202` with
435
+ * `processingStatus: 'queued'` instead — poll {@link RunsResource.listArtifacts}
436
+ * for the outcome. Both are success: check `processingStatus` on the result rather
437
+ * than assuming the synchronous shape. **25 MiB is a hard cap**, refused
438
+ * with `payload_too_large` rather than accepted and truncated.
439
+ *
440
+ * **Idempotent by content, not by request.** Re-uploading the exact same
441
+ * bytes against the same run answers `200` with the original row, verbatim
442
+ * — no re-parse — so a retried upload after a flaky network is never
443
+ * double-counted.
444
+ *
445
+ * @throws {SecureportError} `conflict` if the run has already ended;
446
+ * `payload_too_large` over 25 MiB; `unsupported_media_type` for a
447
+ * `contentType` the API does not accept, or a compressed body.
448
+ */
449
+ import(id: string, input: NewRunImport): Promise<RunArtifact> {
450
+ return this.request({
451
+ method: 'POST',
452
+ path: `/runs/${encodeURIComponent(id)}/import`,
453
+ query: { engine: input.engine, filename: input.filename },
454
+ rawBody: { data: input.body, contentType: input.contentType },
455
+ });
456
+ }
457
+
458
+ /**
459
+ * The first half of the signed-URL upload path for a file too large for
460
+ * {@link RunsResource.import} — Cloud Run caps that route's request body at
461
+ * 32 MiB whatever the API wants, so a genuinely large scan (a big Nessus or
462
+ * Burp export) goes here instead: mint a URL, `PUT` the file straight to
463
+ * the bucket yourself, then {@link RunsResource.completeUpload}.
464
+ *
465
+ * **Returns one of two shapes, told apart structurally.** If the same
466
+ * `sha256` is already archived on this run, this resolves with the
467
+ * existing {@link RunArtifact} directly — nothing minted, nothing to
468
+ * upload. Otherwise it resolves with a {@link RunUploadUrl}. Check for
469
+ * `'uploadUrl' in result` (or any field {@link RunUploadUrl} has and
470
+ * {@link RunArtifact} does not) to tell them apart.
471
+ *
472
+ * **You must `PUT` the bytes yourself, with a plain `fetch` — not through
473
+ * this SDK's own transport.** `uploadUrl` is a signed Cloud Storage URL:
474
+ * this client's `request`/`requestRaw` always attach your API key as a
475
+ * bearer token and always resolve paths against the API's own base URL,
476
+ * neither of which belongs on a request to Cloud Storage. Send every
477
+ * header in `result.requiredHeaders` verbatim — the bucket's signature
478
+ * check refuses the upload otherwise, silently from this SDK's point of
479
+ * view (it never sees that request).
480
+ *
481
+ * @throws {SecureportError} `conflict` if the run has already ended;
482
+ * `unsupported_media_type` for a `contentType` the API does not accept.
483
+ */
484
+ createUploadUrl(id: string, input: NewRunImportUrl): Promise<RunArtifact | RunUploadUrl> {
485
+ return this.request({
486
+ method: 'POST',
487
+ path: `/runs/${encodeURIComponent(id)}/import-url`,
488
+ body: input,
489
+ });
490
+ }
491
+
492
+ /**
493
+ * The second half of the signed-URL upload path — call once your own
494
+ * `PUT` to {@link RunUploadUrl.uploadUrl} has succeeded. Archives the
495
+ * object as a run artifact and queues it for parsing; unlike
496
+ * {@link RunsResource.import}, the result is never parsed synchronously
497
+ * here — anything that needed a signed URL is already well past the size
498
+ * the API parses inline. Poll {@link RunsResource.listArtifacts} (or use
499
+ * {@link RunsResource.waitForArtifact}) the same way a `202` from
500
+ * {@link RunsResource.import} is polled.
501
+ *
502
+ * **Idempotent by content, same as {@link RunsResource.import}.** Calling
503
+ * this twice for the same object answers `200` with the original row the
504
+ * second time.
505
+ *
506
+ * @throws {SecureportError} `conflict` if the run has already ended, or if
507
+ * nothing was actually uploaded to the signed URL (it may have expired).
508
+ */
509
+ completeUpload(id: string, input: CompleteRunImport): Promise<RunArtifact> {
510
+ return this.request({
511
+ method: 'POST',
512
+ path: `/runs/${encodeURIComponent(id)}/import-url/complete`,
513
+ body: input,
514
+ });
515
+ }
516
+
517
+ /**
518
+ * The uploads archived against a run, with where each parse stands — what
519
+ * a client polls after a `202` from {@link RunsResource.import}. Not
520
+ * paginated: a run holds the handful of files one scan produced.
521
+ */
522
+ listArtifacts(id: string): Promise<RunArtifactList> {
523
+ return this.request({ method: 'GET', path: `/runs/${encodeURIComponent(id)}/artifacts` });
524
+ }
525
+
526
+ /**
527
+ * What the run did, in the terms a report opens with.
528
+ *
529
+ * @throws {SecureportError} `conflict` if the run has not been reconciled
530
+ * — still running, or ended `failed` — rather than a zeroed summary that
531
+ * would read as "nothing found".
532
+ */
533
+ summary(id: string): Promise<RunSummary> {
534
+ return this.request({ method: 'GET', path: `/runs/${encodeURIComponent(id)}/summary` });
535
+ }
536
+
537
+ /**
538
+ * The snapshot a report is rendered from: the run summary, the target,
539
+ * every issue with what this run changed about it, and the suppressed
540
+ * appendix.
541
+ *
542
+ * **Returns `@secureport/core`'s own `Snapshot` type, with real `Date`s —
543
+ * the one method in this resource that does.** Every other method here
544
+ * returns ISO 8601 strings, mirroring the wire exactly; this one instead
545
+ * runs the response text through core's own `parseSnapshot`, since that
546
+ * is the exact function a `Snapshot` written to disk is read back
547
+ * with, and the API's wire shape is asserted `Exact<>` against core's type
548
+ * server-side. That is what makes `@secureport/core` this package's first
549
+ * real dependency, rather than a structural mirror.
550
+ *
551
+ * @throws {SecureportError} `conflict` if the run has not been reconciled
552
+ * — still running, or ended `failed`.
553
+ */
554
+ async snapshot(id: string): Promise<Snapshot> {
555
+ const body = await this.request<unknown>({
556
+ method: 'GET',
557
+ path: `/runs/${encodeURIComponent(id)}/snapshot`,
558
+ });
559
+ return parseSnapshot(JSON.stringify(body));
560
+ }
561
+
562
+ /**
563
+ * Polls {@link RunsResource.get} until the run leaves `'running'`,
564
+ * resolving with whatever it finished as. **Does not itself call
565
+ * {@link RunsResource.summary} or {@link RunsResource.snapshot}** — those
566
+ * still 409 until reconciliation completes, which is why this only
567
+ * watches `status`.
568
+ *
569
+ * Shaped for a long-running hosted scan from the start: today an upload
570
+ * run is finished by the caller directly, so this helper's payoff is
571
+ * mostly forward-looking until scans actually execute.
572
+ *
573
+ * @throws {Error} if `status` is still `'running'` once `timeoutMs` elapses.
574
+ */
575
+ async waitUntilFinished(id: string, options?: WaitUntilFinishedOptions): Promise<Run> {
576
+ const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
577
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
578
+ const deadline = Date.now() + timeoutMs;
579
+
580
+ for (;;) {
581
+ const run = await this.get(id);
582
+ if (run.status !== 'running') return run;
583
+
584
+ if (Date.now() + pollIntervalMs > deadline) {
585
+ throw new Error(`run ${id} did not finish within ${String(timeoutMs)}ms (still running).`);
586
+ }
587
+ await sleep(pollIntervalMs);
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Polls {@link RunsResource.listArtifacts} until one artifact's
593
+ * `processingStatus` leaves `'queued'`/`'processing'`, resolving with it —
594
+ * the artifact-level completion {@link RunsResource.finish} itself waits
595
+ * on (its own TSDoc: "poll listArtifacts and finish once every one is done
596
+ * or failed"). A large upload answers {@link RunsResource.import} with
597
+ * `202 queued` before the parse has even started; this is what a caller
598
+ * that started one polls instead of hand-rolling the same loop.
599
+ *
600
+ * @throws {Error} if the artifact never appears on the run (id mismatch),
601
+ * or if it is still `queued`/`processing` once `timeoutMs` elapses.
602
+ */
603
+ async waitForArtifact(
604
+ runId: string,
605
+ artifactId: string,
606
+ options?: WaitForArtifactOptions,
607
+ ): Promise<RunArtifact> {
608
+ const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
609
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
610
+ const deadline = Date.now() + timeoutMs;
611
+
612
+ for (;;) {
613
+ const { data } = await this.listArtifacts(runId);
614
+ const artifact = data.find((a) => a.id === artifactId);
615
+ if (artifact === undefined) {
616
+ throw new Error(`artifact ${artifactId} not found on run ${runId}.`);
617
+ }
618
+ if (artifact.processingStatus !== 'queued' && artifact.processingStatus !== 'processing') {
619
+ return artifact;
620
+ }
621
+
622
+ if (Date.now() + pollIntervalMs > deadline) {
623
+ throw new Error(
624
+ `artifact ${artifactId} did not finish processing within ${String(timeoutMs)}ms ` +
625
+ `(still ${artifact.processingStatus}).`,
626
+ );
627
+ }
628
+ await sleep(pollIntervalMs);
629
+ }
630
+ }
631
+ }
632
+
633
+ function sleep(ms: number): Promise<void> {
634
+ return new Promise((resolve) => setTimeout(resolve, ms));
635
+ }