@prodcycle/prodcycle 0.4.2 → 0.6.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/README.md +14 -5
- package/dist/api-client.d.ts +156 -3
- package/dist/api-client.js +471 -33
- package/dist/cli.d.ts +14 -1
- package/dist/cli.js +378 -13
- package/dist/index.d.ts +60 -20
- package/dist/index.js +78 -12
- package/dist/utils/fs.js +117 -4
- package/package.json +2 -1
package/dist/api-client.js
CHANGED
|
@@ -1,58 +1,496 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ComplianceApiClient = void 0;
|
|
3
|
+
exports.ComplianceApiClient = exports.ApiError = void 0;
|
|
4
|
+
exports.chunkFiles = chunkFiles;
|
|
5
|
+
/**
|
|
6
|
+
* Error thrown for any non-2xx response. Carries the parsed body + status so
|
|
7
|
+
* callers can branch on `details.suggestedEndpoint` (413 → chunked-session
|
|
8
|
+
* fallback) or `Retry-After` (429 / 503 → backoff + retry).
|
|
9
|
+
*/
|
|
10
|
+
class ApiError extends Error {
|
|
11
|
+
statusCode;
|
|
12
|
+
body;
|
|
13
|
+
retryAfterSeconds;
|
|
14
|
+
constructor(statusCode, body, retryAfterSeconds, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
this.body = body;
|
|
18
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
19
|
+
this.name = 'ApiError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.ApiError = ApiError;
|
|
23
|
+
const DEFAULT_API_URL = 'https://api.prodcycle.com';
|
|
24
|
+
/**
|
|
25
|
+
* Read a positive integer from an env var or fall back to a default. Used
|
|
26
|
+
* for the timeout / retry knobs below so operators can tune behavior in CI
|
|
27
|
+
* without forking the CLI.
|
|
28
|
+
*/
|
|
29
|
+
function envInt(name, fallback) {
|
|
30
|
+
const raw = process.env[name];
|
|
31
|
+
if (!raw)
|
|
32
|
+
return fallback;
|
|
33
|
+
const parsed = Number(raw);
|
|
34
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Maximum retry attempts for 429/503 responses. After this many tries we
|
|
38
|
+
* give up and surface the error to the caller.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_RETRY_ATTEMPTS = envInt('PC_MAX_RETRY_ATTEMPTS', 4);
|
|
41
|
+
/**
|
|
42
|
+
* Hard ceiling on Retry-After (seconds). Even if the server asks for more
|
|
43
|
+
* than this we cap it so the CLI doesn't appear to hang indefinitely on a
|
|
44
|
+
* misconfigured server.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_RETRY_AFTER_SECONDS = envInt('PC_MAX_RETRY_AFTER_SECONDS', 300);
|
|
47
|
+
/**
|
|
48
|
+
* Per-request fetch timeout. Without this a stalled connection would tie
|
|
49
|
+
* up the CLI indefinitely, bypassing both the retry cap and the async-poll
|
|
50
|
+
* deadline. Default is 2 minutes — long enough for the largest non-async
|
|
51
|
+
* sync `/validate` call, short enough that a hung TCP socket gets aborted.
|
|
52
|
+
*/
|
|
53
|
+
const REQUEST_TIMEOUT_MS = envInt('PC_REQUEST_TIMEOUT_MS', 120_000);
|
|
54
|
+
/**
|
|
55
|
+
* Conservative client-side chunk sizing for the chunked-session flow. The
|
|
56
|
+
* /chunks endpoint accepts up to 50 MB / 2000 files per request, but most
|
|
57
|
+
* customer payloads are well under this and smaller chunks shorten
|
|
58
|
+
* tail-latency on a single saturated chunk. The server's per-content
|
|
59
|
+
* findings cache means re-scans of unchanged files are O(1) regardless of
|
|
60
|
+
* chunk size, so picking on the smaller side costs little.
|
|
61
|
+
*/
|
|
62
|
+
const DEFAULT_CHUNK_MAX_BYTES = envInt('PC_DEFAULT_CHUNK_MAX_BYTES', 5 * 1024 * 1024);
|
|
63
|
+
const DEFAULT_CHUNK_MAX_FILES = envInt('PC_DEFAULT_CHUNK_MAX_FILES', 200);
|
|
64
|
+
/**
|
|
65
|
+
* Async-validate poll cadence. The server typically completes scans in
|
|
66
|
+
* 10–60 s; polling every 2 s keeps the round-trip overhead bounded while
|
|
67
|
+
* still feeling responsive in interactive use.
|
|
68
|
+
*/
|
|
69
|
+
const ASYNC_POLL_INTERVAL_MS = envInt('PC_ASYNC_POLL_INTERVAL_MS', 2000);
|
|
70
|
+
const ASYNC_POLL_TIMEOUT_MS = envInt('PC_ASYNC_POLL_TIMEOUT_MS', 10 * 60 * 1000);
|
|
71
|
+
/**
|
|
72
|
+
* Keys in `options.config` that route client-side behavior (sync /
|
|
73
|
+
* async / chunked dispatch, chunk sizing) and MUST NOT be forwarded to
|
|
74
|
+
* the server. Some endpoints validate `options` strictly and would 400
|
|
75
|
+
* on unknown keys, so leaking these would silently break every call.
|
|
76
|
+
*/
|
|
77
|
+
const CLIENT_ONLY_CONFIG_KEYS = new Set([
|
|
78
|
+
'mode',
|
|
79
|
+
'chunkMaxBytes',
|
|
80
|
+
'chunkMaxFiles',
|
|
81
|
+
]);
|
|
4
82
|
class ComplianceApiClient {
|
|
5
83
|
apiUrl;
|
|
6
84
|
apiKey;
|
|
7
85
|
constructor(apiUrl, apiKey) {
|
|
8
|
-
this.apiUrl = apiUrl || process.env.PC_API_URL ||
|
|
86
|
+
this.apiUrl = apiUrl || process.env.PC_API_URL || DEFAULT_API_URL;
|
|
9
87
|
this.apiKey = apiKey || process.env.PC_API_KEY || '';
|
|
10
|
-
if (!this.apiKey &&
|
|
11
|
-
|
|
88
|
+
if (!this.apiKey &&
|
|
89
|
+
process.env.NODE_ENV !== 'test' &&
|
|
90
|
+
!process.env.PC_SUPPRESS_WARNINGS) {
|
|
91
|
+
process.stderr.write('Warning: PC_API_KEY is not set. API calls will likely fail.\n');
|
|
12
92
|
}
|
|
13
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Synchronous validate. On a 413 with `details.suggestedEndpoint ===
|
|
96
|
+
* '/v1/compliance/scans'`, silently falls back to the chunked-session
|
|
97
|
+
* flow so large-repo CI jobs don't have to know the difference.
|
|
98
|
+
*/
|
|
14
99
|
async validate(files, frameworks, options = {}) {
|
|
15
|
-
|
|
100
|
+
try {
|
|
101
|
+
return await this.request('POST', '/v1/compliance/validate', {
|
|
102
|
+
files,
|
|
103
|
+
frameworks,
|
|
104
|
+
options: this.buildOptions(options),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
if (err instanceof ApiError &&
|
|
109
|
+
err.statusCode === 413 &&
|
|
110
|
+
err.body?.error?.details?.suggestedEndpoint === '/v1/compliance/scans') {
|
|
111
|
+
// Server says: this payload won't fit, use chunked sessions instead.
|
|
112
|
+
// Fall back transparently — the caller asked for `validate`, the
|
|
113
|
+
// semantics (single scanId with final findings) are preserved.
|
|
114
|
+
return this.validateChunked(files, frameworks, options);
|
|
115
|
+
}
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Hook endpoint — small per-write call from coding agents. No
|
|
121
|
+
* suggestedEndpoint fallback because /hook keeps the historical 50 MB
|
|
122
|
+
* ceiling; if a single hook write exceeds that, the caller's batching
|
|
123
|
+
* is the bug to fix.
|
|
124
|
+
*/
|
|
125
|
+
async hook(files, frameworks, options = {}) {
|
|
126
|
+
return this.request('POST', '/v1/compliance/hook', {
|
|
16
127
|
files,
|
|
17
128
|
frameworks,
|
|
18
|
-
options:
|
|
19
|
-
severity_threshold: options.severityThreshold,
|
|
20
|
-
fail_on: options.failOn,
|
|
21
|
-
...options.config,
|
|
22
|
-
},
|
|
129
|
+
options: this.buildOptions(options),
|
|
23
130
|
});
|
|
24
131
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
132
|
+
// ─── Chunked sessions ───────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Open a chunked scan session. Returns a `scanId` that subsequent
|
|
135
|
+
* `appendChunk` / `completeSession` calls reference. Server-side TTL is
|
|
136
|
+
* 30 minutes by default — abandoned sessions self-clean via the
|
|
137
|
+
* stale-session reaper.
|
|
138
|
+
*/
|
|
139
|
+
async openSession(frameworks, options = {}) {
|
|
140
|
+
return this.request('POST', '/v1/compliance/scans', {
|
|
28
141
|
frameworks,
|
|
29
|
-
options:
|
|
30
|
-
severity_threshold: options.severityThreshold,
|
|
31
|
-
fail_on: options.failOn,
|
|
32
|
-
...options.config,
|
|
33
|
-
},
|
|
142
|
+
options: this.buildOptions(options),
|
|
34
143
|
});
|
|
35
144
|
}
|
|
36
|
-
|
|
37
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Append a chunk of files to an open session. Each call has its own
|
|
147
|
+
* /hook-style cap (50 MB / 2000 files). The server caches per-content
|
|
148
|
+
* findings, so re-scans of unchanged files are O(1).
|
|
149
|
+
*/
|
|
150
|
+
async appendChunk(scanId, files) {
|
|
151
|
+
return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/chunks`, {
|
|
152
|
+
files,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Finalize a chunked session: flips status to COMPLETED, computes
|
|
157
|
+
* summary + passed, returns final findings.
|
|
158
|
+
*/
|
|
159
|
+
async completeSession(scanId) {
|
|
160
|
+
return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/complete`, {});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* High-level helper: open → append (in chunks) → complete. Returns the
|
|
164
|
+
* same shape as `validate()` so callers that auto-fallback don't have
|
|
165
|
+
* to special-case the result.
|
|
166
|
+
*
|
|
167
|
+
* Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
|
|
168
|
+
* to override the conservative defaults.
|
|
169
|
+
*/
|
|
170
|
+
async validateChunked(files, frameworks, options = {}) {
|
|
171
|
+
const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
|
|
172
|
+
const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
|
|
173
|
+
const session = await this.openSession(frameworks, options);
|
|
174
|
+
const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
|
|
175
|
+
for (const chunk of chunks) {
|
|
176
|
+
await this.appendChunk(session.scanId, chunk);
|
|
177
|
+
}
|
|
178
|
+
const result = await this.completeSession(session.scanId);
|
|
179
|
+
const enriched = await this.backfillFindingsIfMissing(session.scanId, result);
|
|
180
|
+
return { scanId: session.scanId, ...enriched };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Some server versions of `POST /scans/:id/complete` return only the summary,
|
|
184
|
+
* leaving `findings` empty even when `summary.total > 0`. The findings are
|
|
185
|
+
* persisted on the scan record and recoverable via `GET /scans/:id`. Call
|
|
186
|
+
* this after `completeSession` (and any other path where the response shape
|
|
187
|
+
* may be summary-only) so SARIF/JSON consumers always see structured findings,
|
|
188
|
+
* not just a count. No-op when findings are already present or the scan is
|
|
189
|
+
* genuinely clean.
|
|
190
|
+
*
|
|
191
|
+
* Timeout: the follow-up GET goes through `this.request`, which wraps every
|
|
192
|
+
* fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` (120 s default,
|
|
193
|
+
* tunable via `PC_REQUEST_TIMEOUT_MS`). A stalled server can't hang
|
|
194
|
+
* `validateChunked` indefinitely; if the abort fires, the catch below
|
|
195
|
+
* falls through with the original summary-only result.
|
|
196
|
+
*/
|
|
197
|
+
async backfillFindingsIfMissing(scanId, result) {
|
|
198
|
+
const findingsLength = Array.isArray(result.findings) ? result.findings.length : 0;
|
|
199
|
+
const summaryTotal = result.summary?.total ?? 0;
|
|
200
|
+
if (findingsLength > 0 || summaryTotal === 0)
|
|
201
|
+
return result;
|
|
38
202
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
203
|
+
const full = await this.getScan(scanId);
|
|
204
|
+
if (Array.isArray(full.findings) && full.findings.length > 0) {
|
|
205
|
+
return { ...result, findings: full.findings };
|
|
206
|
+
}
|
|
207
|
+
// GET succeeded but findings were empty despite `summary.total > 0`.
|
|
208
|
+
// Most likely cause: eventual consistency between `/complete`'s summary
|
|
209
|
+
// computation and the scan-record findings writer. Without surfacing a
|
|
210
|
+
// signal here, the caller would see exactly the silent-drop state the
|
|
211
|
+
// backfill was added to prevent. Mark as `BACKFILL_GET_RETURNED_EMPTY`
|
|
212
|
+
// (distinct from the throw case) so consumers can branch on retry vs.
|
|
213
|
+
// hard-fail behavior.
|
|
214
|
+
const message = `findings still empty after GET /scans/${scanId} (summary reports ${summaryTotal})`;
|
|
215
|
+
process.stderr.write(`⚠ Findings backfill ${message}. ` +
|
|
216
|
+
`Run \`prodcycle scans ${scanId}\` after a short delay to retry.\n`);
|
|
217
|
+
return {
|
|
218
|
+
...result,
|
|
219
|
+
backfillError: {
|
|
220
|
+
code: 'BACKFILL_GET_RETURNED_EMPTY',
|
|
221
|
+
message,
|
|
222
|
+
scanId,
|
|
223
|
+
summaryTotal,
|
|
44
224
|
},
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if
|
|
49
|
-
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
// Best-effort enrichment: if the follow-up GET fails, fall through with
|
|
229
|
+
// the original result rather than break the scan call. The user still
|
|
230
|
+
// has the summary + scanId.
|
|
231
|
+
//
|
|
232
|
+
// BUT — without a user-facing signal, the resulting state (`findings: []`
|
|
233
|
+
// alongside `summary.total > 0`) looks exactly like the original bug we
|
|
234
|
+
// were fixing, and the user has no way to know they need to retry via
|
|
235
|
+
// `prodcycle scans <id>`. Surface the failure as both:
|
|
236
|
+
// - a stderr warning (humans running the CLI interactively)
|
|
237
|
+
// - a structured `backfillError` field (programmatic consumers / SARIF)
|
|
238
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
239
|
+
process.stderr.write(`⚠ Findings backfill GET /scans/${scanId} failed (${message}). ` +
|
|
240
|
+
`${summaryTotal} finding(s) were detected but only the summary is available. ` +
|
|
241
|
+
`Run \`prodcycle scans ${scanId}\` to fetch the structured findings.\n`);
|
|
242
|
+
return {
|
|
243
|
+
...result,
|
|
244
|
+
backfillError: {
|
|
245
|
+
code: 'BACKFILL_GET_FAILED',
|
|
246
|
+
message,
|
|
247
|
+
scanId,
|
|
248
|
+
summaryTotal,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ─── Async validate ─────────────────────────────────────────────────────
|
|
254
|
+
/**
|
|
255
|
+
* Async-validate: returns a `scanId` immediately; caller polls
|
|
256
|
+
* `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
|
|
257
|
+
* runners that don't want to hold a connection for a 60 s scan.
|
|
258
|
+
*/
|
|
259
|
+
async validateAsync(files, frameworks, options = {}) {
|
|
260
|
+
return this.request('POST', '/v1/compliance/validate?async=true', {
|
|
261
|
+
files,
|
|
262
|
+
frameworks,
|
|
263
|
+
options: this.buildOptions(options),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Fetch the current state of any scan (sync, async, or chunked-session).
|
|
268
|
+
*/
|
|
269
|
+
async getScan(scanId) {
|
|
270
|
+
return this.request('GET', `/v1/compliance/scans/${encodeURIComponent(scanId)}`, null);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* High-level helper: kicks off an async-validate, polls until terminal,
|
|
274
|
+
* returns the same shape as `validate()`.
|
|
275
|
+
*/
|
|
276
|
+
async validateAndPoll(files, frameworks, options = {}) {
|
|
277
|
+
const { scanId } = await this.validateAsync(files, frameworks, options);
|
|
278
|
+
const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
|
|
279
|
+
// Always poll at least once, and always do one final poll *after*
|
|
280
|
+
// the last sleep before declaring a timeout — otherwise a scan that
|
|
281
|
+
// completes during the trailing sleep window would be reported as a
|
|
282
|
+
// timeout even though the result is sitting on the server.
|
|
283
|
+
while (true) {
|
|
284
|
+
const scan = await this.getScan(scanId);
|
|
285
|
+
if (scan.status === 'COMPLETED' || scan.status === 'FAILED') {
|
|
286
|
+
return { scanId, ...scan };
|
|
50
287
|
}
|
|
51
|
-
|
|
288
|
+
if (Date.now() >= deadline)
|
|
289
|
+
break;
|
|
290
|
+
await sleep(ASYNC_POLL_INTERVAL_MS);
|
|
52
291
|
}
|
|
53
|
-
|
|
54
|
-
|
|
292
|
+
throw new Error(`Async validate scan ${scanId} did not complete within ${ASYNC_POLL_TIMEOUT_MS / 1000}s. Re-run with the same scanId to keep polling: prodcycle scans ${scanId}`);
|
|
293
|
+
}
|
|
294
|
+
// ─── Internals ──────────────────────────────────────────────────────────
|
|
295
|
+
buildOptions(options) {
|
|
296
|
+
const merged = {
|
|
297
|
+
severity_threshold: options.severityThreshold,
|
|
298
|
+
fail_on: options.failOn,
|
|
299
|
+
};
|
|
300
|
+
if (options.config && typeof options.config === 'object') {
|
|
301
|
+
// Strip client-routing keys (mode / chunkMaxBytes / chunkMaxFiles)
|
|
302
|
+
// before forwarding — they steer this SDK, not the server. The
|
|
303
|
+
// server's `options` schema may reject unknown keys strictly, in
|
|
304
|
+
// which case leaking these would cause 400s on every call.
|
|
305
|
+
for (const [key, value] of Object.entries(options.config)) {
|
|
306
|
+
if (CLIENT_ONLY_CONFIG_KEYS.has(key))
|
|
307
|
+
continue;
|
|
308
|
+
merged[key] = value;
|
|
309
|
+
}
|
|
55
310
|
}
|
|
311
|
+
return merged;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Single HTTP request with:
|
|
315
|
+
* - auth header
|
|
316
|
+
* - 429/503 + Retry-After honoring (up to MAX_RETRY_ATTEMPTS)
|
|
317
|
+
* - structured error with parsed body so callers can introspect
|
|
318
|
+
* `details.suggestedEndpoint` etc.
|
|
319
|
+
*/
|
|
320
|
+
async request(method, endpoint, data) {
|
|
321
|
+
const url = `${this.apiUrl.replace(/\/+$/, '')}${endpoint}`;
|
|
322
|
+
let lastError = null;
|
|
323
|
+
for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
|
|
324
|
+
let response;
|
|
325
|
+
try {
|
|
326
|
+
response = await fetch(url, {
|
|
327
|
+
method,
|
|
328
|
+
headers: {
|
|
329
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
330
|
+
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
|
|
331
|
+
},
|
|
332
|
+
...(data !== null ? { body: JSON.stringify(data) } : {}),
|
|
333
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (networkErr) {
|
|
337
|
+
// Connection-level failures (DNS, TCP, TLS). Treat as retryable up
|
|
338
|
+
// to the same cap as 503 — the server may be momentarily down or
|
|
339
|
+
// the network blip may resolve.
|
|
340
|
+
lastError =
|
|
341
|
+
networkErr instanceof Error ? networkErr : new Error(String(networkErr));
|
|
342
|
+
if (attempt < MAX_RETRY_ATTEMPTS - 1) {
|
|
343
|
+
await sleep(retryBackoffMs(attempt));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
throw new Error(`Failed to connect to ProdCycle API: ${lastError.message}`);
|
|
347
|
+
}
|
|
348
|
+
const responseText = await response.text();
|
|
349
|
+
let parsed = null;
|
|
350
|
+
try {
|
|
351
|
+
parsed = responseText ? JSON.parse(responseText) : null;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Non-JSON body (e.g. ALB-level 502/504). Leave parsed as null;
|
|
355
|
+
// the retry path below handles based on status code.
|
|
356
|
+
}
|
|
357
|
+
if (response.ok) {
|
|
358
|
+
// Unwrap the API envelope only when the discriminant
|
|
359
|
+
// {status: "success" | "error", statusCode, data} is present.
|
|
360
|
+
// Bare key presence isn't enough: a `ScanResult` already has a
|
|
361
|
+
// `status` field (PASSED/FAILED/IN_PROGRESS) and an open index
|
|
362
|
+
// signature, so checking only for `'status' in parsed && 'data'
|
|
363
|
+
// in parsed` would misidentify a scan result that happens to
|
|
364
|
+
// include a `data` key as an envelope and silently drop the
|
|
365
|
+
// top-level `passed`, `scanId`, `findings` fields.
|
|
366
|
+
if (isApiEnvelope(parsed)) {
|
|
367
|
+
return parsed.data;
|
|
368
|
+
}
|
|
369
|
+
return parsed ?? {};
|
|
370
|
+
}
|
|
371
|
+
// Non-2xx. Inspect the body + Retry-After to decide whether to retry.
|
|
372
|
+
const retryAfterSeconds = parseRetryAfter(response.headers.get('retry-after'));
|
|
373
|
+
const errorBody = parsed ?? null;
|
|
374
|
+
const errorMessage = errorBody?.error?.message ?? `API request failed with status ${response.status}`;
|
|
375
|
+
const isRetryable = response.status === 429 || response.status === 503;
|
|
376
|
+
if (isRetryable && attempt < MAX_RETRY_ATTEMPTS - 1) {
|
|
377
|
+
const delayMs = retryAfterSeconds != null ? retryAfterSeconds * 1000 : retryBackoffMs(attempt);
|
|
378
|
+
const cappedDelayMs = Math.min(delayMs, MAX_RETRY_AFTER_SECONDS * 1000);
|
|
379
|
+
await sleep(cappedDelayMs);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
throw new ApiError(response.status, errorBody, retryAfterSeconds, errorMessage);
|
|
383
|
+
}
|
|
384
|
+
// Unreachable in practice: every iteration returns, throws, or
|
|
385
|
+
// continues. Kept only to satisfy the type-checker that this method
|
|
386
|
+
// always returns or throws.
|
|
387
|
+
/* istanbul ignore next */
|
|
388
|
+
throw lastError ?? new Error('Exhausted retries without a response');
|
|
56
389
|
}
|
|
57
390
|
}
|
|
58
391
|
exports.ComplianceApiClient = ComplianceApiClient;
|
|
392
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
393
|
+
function sleep(ms) {
|
|
394
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Exponential backoff with jitter for retryable errors that don't carry an
|
|
398
|
+
* explicit Retry-After (network failures, malformed 503).
|
|
399
|
+
*/
|
|
400
|
+
function retryBackoffMs(attempt) {
|
|
401
|
+
const base = 1000 * 2 ** attempt; // 1s, 2s, 4s, 8s, ...
|
|
402
|
+
const jitter = Math.random() * 500;
|
|
403
|
+
return base + jitter;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Parse Retry-After header. Spec allows either:
|
|
407
|
+
* - delta-seconds (an integer)
|
|
408
|
+
* - HTTP-date
|
|
409
|
+
* We support both. Returns seconds as a non-negative integer, or null if
|
|
410
|
+
* the header is missing/unparseable.
|
|
411
|
+
*/
|
|
412
|
+
/**
|
|
413
|
+
* Detect the API's *success* envelope shape strictly: a plain object
|
|
414
|
+
* whose `status` is exactly "success" and that carries a `data`
|
|
415
|
+
* payload. We deliberately do NOT match `status: "error"` here because
|
|
416
|
+
* `isApiEnvelope` is only consulted on the 2xx branch — accepting an
|
|
417
|
+
* error envelope on a 200 response would silently return `null as T`
|
|
418
|
+
* to the caller without ever raising, so downstream code would see
|
|
419
|
+
* `passed: undefined` with no signal that anything went wrong. Non-2xx
|
|
420
|
+
* error envelopes are handled by the explicit error path below.
|
|
421
|
+
*
|
|
422
|
+
* The strict-discriminant check is also necessary because `ScanResult`
|
|
423
|
+
* itself has a `status` field (PASSED/FAILED/IN_PROGRESS) and an open
|
|
424
|
+
* index signature, so a bare scan result with a `data` key would be
|
|
425
|
+
* misidentified as an envelope and have its top-level fields dropped.
|
|
426
|
+
*/
|
|
427
|
+
function isApiEnvelope(value) {
|
|
428
|
+
if (!value || typeof value !== 'object')
|
|
429
|
+
return false;
|
|
430
|
+
const v = value;
|
|
431
|
+
return v.status === 'success' && 'data' in v;
|
|
432
|
+
}
|
|
433
|
+
function parseRetryAfter(value) {
|
|
434
|
+
if (!value)
|
|
435
|
+
return null;
|
|
436
|
+
const asInt = Number.parseInt(value, 10);
|
|
437
|
+
if (!Number.isNaN(asInt))
|
|
438
|
+
return Math.max(0, asInt);
|
|
439
|
+
const asDate = Date.parse(value);
|
|
440
|
+
if (!Number.isNaN(asDate)) {
|
|
441
|
+
return Math.max(0, Math.ceil((asDate - Date.now()) / 1000));
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Split a `{ path: content }` map into chunks that respect both a byte
|
|
447
|
+
* cap and a file-count cap. UTF-8 byte-length is used since the server
|
|
448
|
+
* counts the request body's bytes after JSON serialisation; this is a
|
|
449
|
+
* conservative client-side approximation.
|
|
450
|
+
*/
|
|
451
|
+
// Per-file overhead in the JSON-serialized request body. Each entry is
|
|
452
|
+
// `"path":"content",` which adds two pairs of quotes (4 bytes), one
|
|
453
|
+
// colon, one comma, and a small margin for backslash-escapes inside the
|
|
454
|
+
// content. 16 bytes is a conservative upper bound; the goal is to avoid
|
|
455
|
+
// undersizing — the server enforces the real cap.
|
|
456
|
+
const PER_FILE_JSON_OVERHEAD = 16;
|
|
457
|
+
// One-time wrapper overhead for the chunk request body itself
|
|
458
|
+
// (`{"files":{...}}`).
|
|
459
|
+
const PER_CHUNK_JSON_OVERHEAD = 16;
|
|
460
|
+
function chunkFiles(files, maxBytes, maxFiles) {
|
|
461
|
+
const chunks = [];
|
|
462
|
+
let current = {};
|
|
463
|
+
let currentBytes = PER_CHUNK_JSON_OVERHEAD;
|
|
464
|
+
let currentCount = 0;
|
|
465
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
466
|
+
const fileBytes = Buffer.byteLength(content, 'utf8') +
|
|
467
|
+
Buffer.byteLength(filePath, 'utf8') +
|
|
468
|
+
PER_FILE_JSON_OVERHEAD;
|
|
469
|
+
// If a single file exceeds the cap on its own we can't split it further
|
|
470
|
+
// here — emit it as its own chunk and let the server's per-file cap (if
|
|
471
|
+
// any) reject if needed. Common case: huge SQL dumps, generated bundles.
|
|
472
|
+
if (fileBytes + PER_CHUNK_JSON_OVERHEAD > maxBytes) {
|
|
473
|
+
if (currentCount > 0) {
|
|
474
|
+
chunks.push(current);
|
|
475
|
+
current = {};
|
|
476
|
+
currentBytes = PER_CHUNK_JSON_OVERHEAD;
|
|
477
|
+
currentCount = 0;
|
|
478
|
+
}
|
|
479
|
+
chunks.push({ [filePath]: content });
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (currentBytes + fileBytes > maxBytes || currentCount + 1 > maxFiles) {
|
|
483
|
+
chunks.push(current);
|
|
484
|
+
current = {};
|
|
485
|
+
currentBytes = PER_CHUNK_JSON_OVERHEAD;
|
|
486
|
+
currentCount = 0;
|
|
487
|
+
}
|
|
488
|
+
current[filePath] = content;
|
|
489
|
+
currentBytes += fileBytes;
|
|
490
|
+
currentCount += 1;
|
|
491
|
+
}
|
|
492
|
+
if (currentCount > 0) {
|
|
493
|
+
chunks.push(current);
|
|
494
|
+
}
|
|
495
|
+
return chunks;
|
|
496
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Detect CI environment via well-known env vars set by the major
|
|
4
|
+
* platforms. When CI is detected, default `--format` flips to `sarif`
|
|
5
|
+
* (so output drops straight into GitHub code scanning / GitLab security
|
|
6
|
+
* dashboards / etc. without extra configuration). Users can still
|
|
7
|
+
* override with `--format table|json|prompt`.
|
|
8
|
+
*
|
|
9
|
+
* The flip is opt-out (set `--format table` explicitly to keep the
|
|
10
|
+
* human-readable output in CI logs). Heuristic, not load-bearing — if
|
|
11
|
+
* we miss a CI platform here the user gets the same default they
|
|
12
|
+
* would have anyway (`table`), they just have to add `--format sarif`
|
|
13
|
+
* by hand.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|