@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/client.d.ts +135 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +191 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +80 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +118 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/pagination.d.ts +27 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +19 -0
- package/dist/pagination.js.map +1 -0
- package/dist/resources/branding.d.ts +49 -0
- package/dist/resources/branding.d.ts.map +1 -0
- package/dist/resources/branding.js +38 -0
- package/dist/resources/branding.js.map +1 -0
- package/dist/resources/issues.d.ts +248 -0
- package/dist/resources/issues.d.ts.map +1 -0
- package/dist/resources/issues.js +100 -0
- package/dist/resources/issues.js.map +1 -0
- package/dist/resources/reports.d.ts +83 -0
- package/dist/resources/reports.d.ts.map +1 -0
- package/dist/resources/reports.js +50 -0
- package/dist/resources/reports.js.map +1 -0
- package/dist/resources/runs.d.ts +474 -0
- package/dist/resources/runs.d.ts.map +1 -0
- package/dist/resources/runs.js +281 -0
- package/dist/resources/runs.js.map +1 -0
- package/dist/resources/suppression-rules.d.ts +43 -0
- package/dist/resources/suppression-rules.d.ts.map +1 -0
- package/dist/resources/suppression-rules.js +33 -0
- package/dist/resources/suppression-rules.js.map +1 -0
- package/dist/resources/targets.d.ts +126 -0
- package/dist/resources/targets.d.ts.map +1 -0
- package/dist/resources/targets.js +83 -0
- package/dist/resources/targets.js.map +1 -0
- package/package.json +55 -0
- package/src/client.ts +328 -0
- package/src/errors.ts +120 -0
- package/src/index.ts +100 -0
- package/src/pagination.ts +41 -0
- package/src/resources/branding.ts +59 -0
- package/src/resources/issues.ts +344 -0
- package/src/resources/reports.ts +104 -0
- package/src/resources/runs.ts +635 -0
- package/src/resources/suppression-rules.ts +69 -0
- package/src/resources/targets.ts +179 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import type { ErrorCode } from './errors.js';
|
|
2
|
+
import {
|
|
3
|
+
FALLBACK_CODE_BY_STATUS,
|
|
4
|
+
RETRYABLE_STATUSES,
|
|
5
|
+
SecureportError,
|
|
6
|
+
SecureportNetworkError,
|
|
7
|
+
} from './errors.js';
|
|
8
|
+
import { BrandingResource } from './resources/branding.js';
|
|
9
|
+
import { IssuesResource } from './resources/issues.js';
|
|
10
|
+
import { ReportsResource } from './resources/reports.js';
|
|
11
|
+
import { RunsResource } from './resources/runs.js';
|
|
12
|
+
import { SuppressionRulesResource } from './resources/suppression-rules.js';
|
|
13
|
+
import { TargetsResource } from './resources/targets.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The narrow interface a resource (`targets`, and later `issues`/`runs`/
|
|
17
|
+
* `reports`) depends on — {@link SecureportClient.request} bound to its
|
|
18
|
+
* instance, not the whole client. Keeps a resource's own tests, if it ever
|
|
19
|
+
* needs its own, from having to construct a full client.
|
|
20
|
+
*/
|
|
21
|
+
export type Requester = <T = unknown>(input: RequestInput) => Promise<T>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The narrow interface {@link ReportsResource} depends on —
|
|
25
|
+
* {@link SecureportClient.requestRaw} bound to its instance, the same way
|
|
26
|
+
* {@link Requester} is {@link SecureportClient.request} bound.
|
|
27
|
+
*/
|
|
28
|
+
export type RawRequester = (input: RequestInput) => Promise<Response>;
|
|
29
|
+
|
|
30
|
+
/** The `fetch` shape the transport needs — the global function satisfies it. */
|
|
31
|
+
export type Fetch = typeof globalThis.fetch;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a `fetch` body may be — `RequestInit['body']` rather than the DOM
|
|
35
|
+
* lib's `BodyInit`, since `tsconfig.base.json`'s `lib` is `ES2023` only and
|
|
36
|
+
* does not carry that name; the global `RequestInit`/`Response` interfaces
|
|
37
|
+
* `@types/node` merges in are what `Fetch` itself already relies on.
|
|
38
|
+
*/
|
|
39
|
+
export type FetchBody = Exclude<RequestInit['body'], null | undefined>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What a client is constructed with.
|
|
43
|
+
*
|
|
44
|
+
* `apiKey` comes from wherever the caller reads it — this package takes no
|
|
45
|
+
* position on environment variables, unlike `@secureport/cli`'s hosted mode,
|
|
46
|
+
* which is a terminal program and can insist on one. A library has no
|
|
47
|
+
* terminal to insist to.
|
|
48
|
+
*/
|
|
49
|
+
export interface ClientOptions {
|
|
50
|
+
/** Sent as `Authorization: Bearer <apiKey>` on every request. */
|
|
51
|
+
readonly apiKey: string;
|
|
52
|
+
|
|
53
|
+
/** The API's base URL, with no trailing slash required. */
|
|
54
|
+
readonly baseUrl: string;
|
|
55
|
+
|
|
56
|
+
/** Overrides the transport's `fetch`. Defaults to the global. Tests use this. */
|
|
57
|
+
readonly fetch?: Fetch;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How many times a retryable response is retried before giving up.
|
|
61
|
+
*
|
|
62
|
+
* Sized for scan polling, not just upload-and-done calls: a hosted scan
|
|
63
|
+
* run can sit behind a busy queue for longer than an upload ever would, so
|
|
64
|
+
* the default favours patience over a fast failure.
|
|
65
|
+
*
|
|
66
|
+
* @defaultValue 4
|
|
67
|
+
*/
|
|
68
|
+
readonly maxAttempts?: number;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How long a single attempt may take before it is aborted and treated as a
|
|
72
|
+
* network failure.
|
|
73
|
+
*
|
|
74
|
+
* @defaultValue 30_000
|
|
75
|
+
*/
|
|
76
|
+
readonly timeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One HTTP call the transport makes. */
|
|
80
|
+
export interface RequestInput {
|
|
81
|
+
/** e.g. `'GET'`, `'POST'`. */
|
|
82
|
+
readonly method: string;
|
|
83
|
+
|
|
84
|
+
/** Path only, e.g. `'/issues'` — joined onto {@link ClientOptions.baseUrl}. */
|
|
85
|
+
readonly path: string;
|
|
86
|
+
|
|
87
|
+
/** Sent as `?name=value`. A `undefined` value is omitted, not sent empty. */
|
|
88
|
+
readonly query?: Readonly<Record<string, string | number | boolean | undefined>>;
|
|
89
|
+
|
|
90
|
+
/** JSON-serialised and sent with `content-type: application/json`. Omitted entirely when absent. */
|
|
91
|
+
readonly body?: unknown;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A body sent exactly as given, with this `content-type` — never
|
|
95
|
+
* JSON-encoded. Mutually exclusive with {@link RequestInput.body}; used
|
|
96
|
+
* only by `runs.import()`, the one route that accepts a raw scanner
|
|
97
|
+
* artifact rather than a JSON payload.
|
|
98
|
+
*/
|
|
99
|
+
readonly rawBody?: { readonly data: FetchBody; readonly contentType: string };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The API's one error envelope shape, read defensively — see {@link parseErrorBody}. */
|
|
103
|
+
interface ErrorEnvelope {
|
|
104
|
+
readonly error: {
|
|
105
|
+
readonly code: string;
|
|
106
|
+
readonly message: string;
|
|
107
|
+
readonly requestId: string;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const DEFAULT_MAX_ATTEMPTS = 4;
|
|
112
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
113
|
+
const BASE_DELAY_MS = 500;
|
|
114
|
+
const MAX_DELAY_MS = 8_000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The transport every resource client is built on: authentication, retry
|
|
118
|
+
* with backoff on the four statuses the API answers transiently with, and
|
|
119
|
+
* typed errors for everything else. Usable on its own via
|
|
120
|
+
* {@link SecureportClient.request}/{@link SecureportClient.requestRaw}
|
|
121
|
+
* without going through a resource.
|
|
122
|
+
*/
|
|
123
|
+
export class SecureportClient {
|
|
124
|
+
private readonly apiKey: string;
|
|
125
|
+
private readonly baseUrl: string;
|
|
126
|
+
private readonly fetchImpl: Fetch;
|
|
127
|
+
private readonly maxAttempts: number;
|
|
128
|
+
private readonly timeoutMs: number;
|
|
129
|
+
|
|
130
|
+
/** Targets, and the per-target verification flow every hosted scan needs. */
|
|
131
|
+
readonly targets: TargetsResource;
|
|
132
|
+
|
|
133
|
+
/** Issues — the tracked entity findings reconcile into. */
|
|
134
|
+
readonly issues: IssuesResource;
|
|
135
|
+
|
|
136
|
+
/** Runs — one execution against a target, and the scan lifecycle around it. */
|
|
137
|
+
readonly runs: RunsResource;
|
|
138
|
+
|
|
139
|
+
/** Reports — rendering a run's snapshot into a document or a machine-readable export. */
|
|
140
|
+
readonly reports: ReportsResource;
|
|
141
|
+
|
|
142
|
+
/** Branding — the one record per organisation reports render with. */
|
|
143
|
+
readonly branding: BrandingResource;
|
|
144
|
+
|
|
145
|
+
/** Suppression rules — pre-emptive "never open an issue for this" globs. */
|
|
146
|
+
readonly suppressionRules: SuppressionRulesResource;
|
|
147
|
+
|
|
148
|
+
constructor(options: ClientOptions) {
|
|
149
|
+
this.apiKey = options.apiKey;
|
|
150
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/u, '');
|
|
151
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
152
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
153
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
154
|
+
this.targets = new TargetsResource(this.request.bind(this));
|
|
155
|
+
this.issues = new IssuesResource(this.request.bind(this));
|
|
156
|
+
this.runs = new RunsResource(this.request.bind(this));
|
|
157
|
+
this.reports = new ReportsResource(this.requestRaw.bind(this));
|
|
158
|
+
this.branding = new BrandingResource(this.request.bind(this));
|
|
159
|
+
this.suppressionRules = new SuppressionRulesResource(this.request.bind(this));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Makes one logical call, retrying transient failures internally. Resolves
|
|
164
|
+
* with the parsed JSON body, or `undefined` for a `204`.
|
|
165
|
+
*
|
|
166
|
+
* @throws {SecureportError} for any response the API answered with, after
|
|
167
|
+
* retries (if any) are exhausted.
|
|
168
|
+
* @throws {SecureportNetworkError} if no response was ever received.
|
|
169
|
+
*/
|
|
170
|
+
async request<T = unknown>(input: RequestInput): Promise<T> {
|
|
171
|
+
const response = await this.requestRaw(input);
|
|
172
|
+
if (response.status === 204) return undefined as T;
|
|
173
|
+
return (await response.json()) as T;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The same authentication, retry-with-backoff and typed-error handling as
|
|
178
|
+
* {@link SecureportClient.request}, but without the JSON parse: resolves with the raw
|
|
179
|
+
* `Response` itself, for a route whose body is not JSON — currently just
|
|
180
|
+
* `reports.request()`, whose formats are streamed text (`text/csv`,
|
|
181
|
+
* `application/sarif+json`, …) rather than a parsed envelope.
|
|
182
|
+
*
|
|
183
|
+
* @throws {SecureportError} for any response the API answered with, after
|
|
184
|
+
* retries (if any) are exhausted.
|
|
185
|
+
* @throws {SecureportNetworkError} if no response was ever received.
|
|
186
|
+
*/
|
|
187
|
+
async requestRaw(input: RequestInput): Promise<Response> {
|
|
188
|
+
const url = this.buildUrl(input.path, input.query);
|
|
189
|
+
const { headers, body } = requestBody(input);
|
|
190
|
+
|
|
191
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
192
|
+
const response = await this.attempt(url, input.method, headers, body);
|
|
193
|
+
|
|
194
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < this.maxAttempts) {
|
|
195
|
+
await delay(retryDelayMs(response, attempt));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (response.status >= 200 && response.status < 300) return response;
|
|
200
|
+
|
|
201
|
+
throw await toSecureportError(response);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Unreachable while maxAttempts >= 1: the loop above always either
|
|
205
|
+
// returns or throws on its final iteration.
|
|
206
|
+
throw new Error('request() exhausted its attempts without a terminal response');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async attempt(
|
|
210
|
+
url: string,
|
|
211
|
+
method: string,
|
|
212
|
+
headers: Readonly<Record<string, string>>,
|
|
213
|
+
body: FetchBody | undefined,
|
|
214
|
+
): Promise<Response> {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
217
|
+
try {
|
|
218
|
+
return await this.fetchImpl(url, {
|
|
219
|
+
method,
|
|
220
|
+
headers: {
|
|
221
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
222
|
+
accept: 'application/json',
|
|
223
|
+
...headers,
|
|
224
|
+
},
|
|
225
|
+
...(body === undefined ? {} : { body }),
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
});
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new SecureportNetworkError(
|
|
230
|
+
`cannot reach ${this.baseUrl}: ${error instanceof Error ? error.message : String(error)}`,
|
|
231
|
+
error,
|
|
232
|
+
);
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private buildUrl(path: string, query: RequestInput['query']): string {
|
|
239
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
240
|
+
for (const [name, value] of Object.entries(query ?? {})) {
|
|
241
|
+
if (value !== undefined) url.searchParams.set(name, String(value));
|
|
242
|
+
}
|
|
243
|
+
return url.toString();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Constructs a {@link SecureportClient}. The package's one entry point. */
|
|
248
|
+
export function createClient(options: ClientOptions): SecureportClient {
|
|
249
|
+
return new SecureportClient(options);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** How long to wait before the next attempt, honouring `Retry-After` when the API sends one. */
|
|
253
|
+
function retryDelayMs(response: Response, attempt: number): number {
|
|
254
|
+
const retryAfter = response.headers.get('retry-after');
|
|
255
|
+
if (retryAfter !== null) {
|
|
256
|
+
const seconds = Number(retryAfter);
|
|
257
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
258
|
+
}
|
|
259
|
+
return Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function delay(ms: number): Promise<void> {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The headers and body one request sends — {@link RequestInput.rawBody} wins
|
|
268
|
+
* when both it and {@link RequestInput.body} are given, though a resource
|
|
269
|
+
* method never sets both.
|
|
270
|
+
*/
|
|
271
|
+
function requestBody(input: RequestInput): {
|
|
272
|
+
headers: Readonly<Record<string, string>>;
|
|
273
|
+
body: FetchBody | undefined;
|
|
274
|
+
} {
|
|
275
|
+
if (input.rawBody !== undefined) {
|
|
276
|
+
return { headers: { 'content-type': input.rawBody.contentType }, body: input.rawBody.data };
|
|
277
|
+
}
|
|
278
|
+
if (input.body !== undefined) {
|
|
279
|
+
return { headers: { 'content-type': 'application/json' }, body: JSON.stringify(input.body) };
|
|
280
|
+
}
|
|
281
|
+
return { headers: {}, body: undefined };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Builds the typed error for a terminal (non-retried, non-2xx) response.
|
|
286
|
+
*
|
|
287
|
+
* `502`/`504` are the edge answering for a dead or slow origin — the body is
|
|
288
|
+
* Cloudflare's, not this API's envelope — so those (and `429`, sent by this
|
|
289
|
+
* API but read defensively the same way) fall back to a status-derived code
|
|
290
|
+
* rather than assuming the body parses.
|
|
291
|
+
*/
|
|
292
|
+
async function toSecureportError(response: Response): Promise<SecureportError> {
|
|
293
|
+
const parsed = await parseErrorBody(response);
|
|
294
|
+
if (parsed !== undefined) {
|
|
295
|
+
return new SecureportError(
|
|
296
|
+
parsed.error.code as ErrorCode,
|
|
297
|
+
parsed.error.message,
|
|
298
|
+
response.status,
|
|
299
|
+
parsed.error.requestId,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const fallbackCode = FALLBACK_CODE_BY_STATUS[response.status];
|
|
304
|
+
return new SecureportError(
|
|
305
|
+
fallbackCode ?? 'internal',
|
|
306
|
+
fallbackCode === undefined
|
|
307
|
+
? `the API answered ${String(response.status)} with a body this SDK could not read.`
|
|
308
|
+
: `the API answered ${String(response.status)} with no readable body — the edge, not the API itself.`,
|
|
309
|
+
response.status,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The response body as this API's error envelope, or `undefined` if it doesn't parse as one. */
|
|
314
|
+
async function parseErrorBody(response: Response): Promise<ErrorEnvelope | undefined> {
|
|
315
|
+
try {
|
|
316
|
+
const body: unknown = await response.json();
|
|
317
|
+
if (typeof body !== 'object' || body === null || !('error' in body)) return undefined;
|
|
318
|
+
|
|
319
|
+
const error = body.error;
|
|
320
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
321
|
+
if (!('code' in error) || !('message' in error)) return undefined;
|
|
322
|
+
if (typeof error.code !== 'string' || typeof error.message !== 'string') return undefined;
|
|
323
|
+
|
|
324
|
+
return body as ErrorEnvelope;
|
|
325
|
+
} catch {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every error code the hosted API can answer with.
|
|
3
|
+
*
|
|
4
|
+
* Mirrored from `apps/api/src/errors.ts`'s `ERROR_CODES` rather than imported
|
|
5
|
+
* from it: `apps/api` is `"private": true` and pulls in Hono, drizzle and
|
|
6
|
+
* postgres, so a published package cannot depend on it. Kept honest by a
|
|
7
|
+
* drift assertion in `apps/api/tests/openapi.test.ts`, which compares this
|
|
8
|
+
* list against the live `/openapi.json` document's `Error` schema — the same
|
|
9
|
+
* technique that file already uses to keep `docs/api-endpoints.md` from
|
|
10
|
+
* drifting.
|
|
11
|
+
*
|
|
12
|
+
* The union is closed on purpose: a `switch` over {@link ErrorCode} is
|
|
13
|
+
* exhaustive, and adding a code here without the API test agreeing fails
|
|
14
|
+
* that test rather than shipping quietly out of sync.
|
|
15
|
+
*/
|
|
16
|
+
export const ERROR_CODES = [
|
|
17
|
+
'malformed',
|
|
18
|
+
'validation',
|
|
19
|
+
'unauthorized',
|
|
20
|
+
'payment_required',
|
|
21
|
+
'forbidden',
|
|
22
|
+
'not_entitled',
|
|
23
|
+
'org_deleted',
|
|
24
|
+
'terms_required',
|
|
25
|
+
'verification_required',
|
|
26
|
+
'not_found',
|
|
27
|
+
'method_not_allowed',
|
|
28
|
+
'not_acceptable',
|
|
29
|
+
'request_timeout',
|
|
30
|
+
'conflict',
|
|
31
|
+
'gone',
|
|
32
|
+
'precondition_failed',
|
|
33
|
+
'payload_too_large',
|
|
34
|
+
'uri_too_long',
|
|
35
|
+
'unsupported_media_type',
|
|
36
|
+
'misdirected_request',
|
|
37
|
+
'rate_limited',
|
|
38
|
+
'quota_exceeded',
|
|
39
|
+
'headers_too_large',
|
|
40
|
+
'internal',
|
|
41
|
+
'not_implemented',
|
|
42
|
+
'bad_gateway',
|
|
43
|
+
'unavailable',
|
|
44
|
+
'gateway_timeout',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** One of {@link ERROR_CODES}. What a caller branches on. */
|
|
48
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The four statuses the transport retries with backoff, and nothing else.
|
|
52
|
+
*
|
|
53
|
+
* `terms_required` and `verification_required` both answer `403` — a settled
|
|
54
|
+
* refusal, not a transient one — so `403` is deliberately absent here even
|
|
55
|
+
* though it shares a status family with codes that could look retryable.
|
|
56
|
+
* Retrying an authorisation refusal would turn a clear "do this first" into a
|
|
57
|
+
* slow, confusing one.
|
|
58
|
+
*/
|
|
59
|
+
export const RETRYABLE_STATUSES: ReadonlySet<number> = new Set([429, 502, 503, 504]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The code a response is assumed to carry when its body cannot be read as
|
|
63
|
+
* this API's error envelope — which happens exactly on `502`/`504`, because
|
|
64
|
+
* those are the edge answering for a dead or slow origin and the body is
|
|
65
|
+
* Cloudflare's, not this API's. Each of the four statuses here maps to
|
|
66
|
+
* exactly one {@link ErrorCode}, so the fallback is unambiguous; no other
|
|
67
|
+
* status gets one; a `403` with an unparseable body, for instance, could be
|
|
68
|
+
* any of four different codes and none should be silently guessed.
|
|
69
|
+
*/
|
|
70
|
+
export const FALLBACK_CODE_BY_STATUS: Readonly<Partial<Record<number, ErrorCode>>> = {
|
|
71
|
+
429: 'rate_limited',
|
|
72
|
+
502: 'bad_gateway',
|
|
73
|
+
503: 'unavailable',
|
|
74
|
+
504: 'gateway_timeout',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Thrown for every non-2xx response the API returns, and for a response the
|
|
79
|
+
* transport gave up retrying.
|
|
80
|
+
*
|
|
81
|
+
* `requestId` is the same id the API's own `x-request-id` response header and
|
|
82
|
+
* error envelope carry, worth including in a support request. It is absent
|
|
83
|
+
* only when the body could not be read as this API's envelope at all (the
|
|
84
|
+
* `502`/`504` edge case above).
|
|
85
|
+
*/
|
|
86
|
+
export class SecureportError extends Error {
|
|
87
|
+
constructor(
|
|
88
|
+
/** What to branch on. */
|
|
89
|
+
readonly code: ErrorCode,
|
|
90
|
+
message: string,
|
|
91
|
+
/** The HTTP status the response actually carried. */
|
|
92
|
+
readonly status: number,
|
|
93
|
+
/** The API's `x-request-id` for this call, when the body could be read as its envelope. */
|
|
94
|
+
readonly requestId?: string,
|
|
95
|
+
) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = 'SecureportError';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Thrown when the request never reached a server at all — DNS failure,
|
|
103
|
+
* connection refused, TLS error. Distinct from {@link SecureportError}
|
|
104
|
+
* because there is no status and no code: nothing answered.
|
|
105
|
+
*
|
|
106
|
+
* Not retried by the transport. A network failure of this kind is usually a
|
|
107
|
+
* configuration problem (wrong `baseUrl`, no connectivity) rather than a
|
|
108
|
+
* transient one, and retrying it silently would turn a five-second failure
|
|
109
|
+
* into a much slower one for no better odds of success.
|
|
110
|
+
*/
|
|
111
|
+
export class SecureportNetworkError extends Error {
|
|
112
|
+
constructor(
|
|
113
|
+
message: string,
|
|
114
|
+
/** Whatever `fetch` itself threw or rejected with. */
|
|
115
|
+
readonly cause: unknown,
|
|
116
|
+
) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = 'SecureportNetworkError';
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Secureport SDK.
|
|
3
|
+
*
|
|
4
|
+
* A typed client for the hosted API. Its types mirror `@secureport/core`'s
|
|
5
|
+
* domain model structurally rather than importing it — the wire always
|
|
6
|
+
* carries ISO 8601 date strings, never a `Date`, and this package depends on
|
|
7
|
+
* nothing at runtime. Authenticates with an API key, retries the four
|
|
8
|
+
* statuses the API answers transiently with, and throws a typed
|
|
9
|
+
* {@link SecureportError} for everything else.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
export type {
|
|
14
|
+
ClientOptions,
|
|
15
|
+
Fetch,
|
|
16
|
+
FetchBody,
|
|
17
|
+
RawRequester,
|
|
18
|
+
Requester,
|
|
19
|
+
RequestInput,
|
|
20
|
+
} from './client.js';
|
|
21
|
+
export { SecureportClient, createClient } from './client.js';
|
|
22
|
+
export type { ErrorCode } from './errors.js';
|
|
23
|
+
export { ERROR_CODES, SecureportError, SecureportNetworkError } from './errors.js';
|
|
24
|
+
export type { Page, PageQuery } from './pagination.js';
|
|
25
|
+
export { paginate } from './pagination.js';
|
|
26
|
+
export type {
|
|
27
|
+
NewSuppressionRule,
|
|
28
|
+
SuppressionRule,
|
|
29
|
+
SuppressionRulePage,
|
|
30
|
+
} from './resources/suppression-rules.js';
|
|
31
|
+
export { SuppressionRulesResource } from './resources/suppression-rules.js';
|
|
32
|
+
export type {
|
|
33
|
+
IssuedTargetVerification,
|
|
34
|
+
NewTarget,
|
|
35
|
+
NewTargetVerification,
|
|
36
|
+
Target,
|
|
37
|
+
TargetPage,
|
|
38
|
+
TargetPatch,
|
|
39
|
+
TargetVerification,
|
|
40
|
+
TargetVerificationCheckResult,
|
|
41
|
+
VerificationMethod,
|
|
42
|
+
VerificationState,
|
|
43
|
+
} from './resources/targets.js';
|
|
44
|
+
export { TargetsResource } from './resources/targets.js';
|
|
45
|
+
export type {
|
|
46
|
+
Finding,
|
|
47
|
+
IgnoreInput,
|
|
48
|
+
IgnoreReason,
|
|
49
|
+
IgnoreScope,
|
|
50
|
+
Issue,
|
|
51
|
+
IssueComment,
|
|
52
|
+
IssueDetail,
|
|
53
|
+
IssueEvent,
|
|
54
|
+
IssueEventType,
|
|
55
|
+
IssueFilters,
|
|
56
|
+
IssueOrigin,
|
|
57
|
+
IssuePage,
|
|
58
|
+
IssuePatch,
|
|
59
|
+
IssueStatus,
|
|
60
|
+
NewIssue,
|
|
61
|
+
NewIssueComment,
|
|
62
|
+
ResolveInput,
|
|
63
|
+
Severity,
|
|
64
|
+
} from './resources/issues.js';
|
|
65
|
+
export { IssuesResource } from './resources/issues.js';
|
|
66
|
+
export type {
|
|
67
|
+
ArtifactProcessingStatus,
|
|
68
|
+
CompleteRunImport,
|
|
69
|
+
Coverage,
|
|
70
|
+
FinishRunInput,
|
|
71
|
+
NewRun,
|
|
72
|
+
NewRunImport,
|
|
73
|
+
NewRunImportUrl,
|
|
74
|
+
Run,
|
|
75
|
+
RunArtifact,
|
|
76
|
+
RunArtifactList,
|
|
77
|
+
RunFilters,
|
|
78
|
+
RunKind,
|
|
79
|
+
RunPage,
|
|
80
|
+
RunStatus,
|
|
81
|
+
RunSummary,
|
|
82
|
+
RunTrigger,
|
|
83
|
+
RunUploadUrl,
|
|
84
|
+
ScanPhase,
|
|
85
|
+
ScanProgress,
|
|
86
|
+
ScanSize,
|
|
87
|
+
SeverityCounts,
|
|
88
|
+
WaitForArtifactOptions,
|
|
89
|
+
WaitUntilFinishedOptions,
|
|
90
|
+
} from './resources/runs.js';
|
|
91
|
+
export { RunsResource } from './resources/runs.js';
|
|
92
|
+
export type {
|
|
93
|
+
EvidenceVerbosity,
|
|
94
|
+
ReportFormat,
|
|
95
|
+
ReportKind,
|
|
96
|
+
ReportRequest,
|
|
97
|
+
} from './resources/reports.js';
|
|
98
|
+
export { ReportsResource } from './resources/reports.js';
|
|
99
|
+
export type { Branding } from './resources/branding.js';
|
|
100
|
+
export { BrandingResource } from './resources/branding.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One page of `T`, as every list endpoint returns it — mirrors the wire shape
|
|
3
|
+
* `apps/api/src/http/pagination.ts`'s `pageSchema` defines.
|
|
4
|
+
*/
|
|
5
|
+
export interface Page<T> {
|
|
6
|
+
readonly data: readonly T[];
|
|
7
|
+
|
|
8
|
+
/** Rows matching the filters, across every page — not just this one. */
|
|
9
|
+
readonly total: number;
|
|
10
|
+
|
|
11
|
+
/** 1-based. */
|
|
12
|
+
readonly page: number;
|
|
13
|
+
|
|
14
|
+
readonly pageCount: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `?page=&limit=` — every list method takes this. */
|
|
18
|
+
export interface PageQuery {
|
|
19
|
+
readonly page?: number;
|
|
20
|
+
readonly limit?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Walks every page of a list endpoint, yielding items one at a time.
|
|
25
|
+
*
|
|
26
|
+
* `fetchPage(1)` is called first; the walk stops once a page's own `page`
|
|
27
|
+
* number reaches its `pageCount`, so a caller that only reads a few items
|
|
28
|
+
* from the front (`break`s out of a `for await`) never fetches pages it
|
|
29
|
+
* doesn't use.
|
|
30
|
+
*/
|
|
31
|
+
export async function* paginate<T>(
|
|
32
|
+
fetchPage: (page: number) => Promise<Page<T>>,
|
|
33
|
+
): AsyncIterable<T> {
|
|
34
|
+
let page = 1;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const result = await fetchPage(page);
|
|
37
|
+
yield* result.data;
|
|
38
|
+
if (page >= result.pageCount) return;
|
|
39
|
+
page++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Requester } from '../client.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Your reports' branding — company name, colour, logo, watermark and the
|
|
5
|
+
* details printed in the footer. One record per organisation; there is no
|
|
6
|
+
* id and no collection, because a second one is not a thing that can exist.
|
|
7
|
+
*
|
|
8
|
+
* No date fields appear here — nothing about this record is time-ordered —
|
|
9
|
+
* so unlike other resources' wire types, there is no ISO-string-vs-`Date`
|
|
10
|
+
* distinction to document.
|
|
11
|
+
*/
|
|
12
|
+
export interface Branding {
|
|
13
|
+
readonly companyName?: string;
|
|
14
|
+
readonly primaryColour?: string;
|
|
15
|
+
readonly whiteLabel?: boolean;
|
|
16
|
+
readonly watermark?: string;
|
|
17
|
+
|
|
18
|
+
/** A `data:` URI, at most 512 KiB. */
|
|
19
|
+
readonly logo?: string;
|
|
20
|
+
readonly companyDetails?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Branding — the one record per organisation that reports render with. See
|
|
25
|
+
* {@link BrandingResource.replace} for why this is `PUT`, not `PATCH`.
|
|
26
|
+
*/
|
|
27
|
+
export class BrandingResource {
|
|
28
|
+
constructor(private readonly request: Requester) {}
|
|
29
|
+
|
|
30
|
+
/** @throws {SecureportError} `not_found` if no branding has been set. */
|
|
31
|
+
get(): Promise<Branding> {
|
|
32
|
+
return this.request({ method: 'GET', path: '/branding' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replaces your branding wholesale. Branding is one record, so this is
|
|
37
|
+
* `PUT` rather than `PATCH`: every field is optional, and a merge could
|
|
38
|
+
* not express "remove the logo" — omit a field and it is gone, not left
|
|
39
|
+
* as it was.
|
|
40
|
+
*
|
|
41
|
+
* @throws {SecureportError} `validation` if a field fails core's own
|
|
42
|
+
* check (a malformed colour, a logo that isn't a `data:` URI, or a
|
|
43
|
+
* disallowed `whiteLabel` combination) — not a shape problem, a meaning
|
|
44
|
+
* one, so it survives past normal request validation.
|
|
45
|
+
*/
|
|
46
|
+
replace(input: Branding): Promise<Branding> {
|
|
47
|
+
return this.request({ method: 'PUT', path: '/branding', body: input });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Removes your branding; reports fall back to the unbranded default.
|
|
52
|
+
*
|
|
53
|
+
* @throws {SecureportError} `not_found` if nothing was set — removing
|
|
54
|
+
* twice is not a no-op, it tells you the second attempt changed nothing.
|
|
55
|
+
*/
|
|
56
|
+
delete(): Promise<void> {
|
|
57
|
+
return this.request({ method: 'DELETE', path: '/branding' });
|
|
58
|
+
}
|
|
59
|
+
}
|