@sagentix-inc/evidence-collector 0.4.0 → 0.4.1

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.
@@ -128,8 +128,45 @@ export function jumpcloudDirectory(api) {
128
128
  },
129
129
  };
130
130
  }
131
+ // One directory per identity provider for the whole run (2026-11-07): BambooHR and Rippling joined with the same provider
132
+ // share it, and a leaver both name is looked up once. Keyed by the run's clients object, which every source of one run
133
+ // shares (cli.ts) and each test builds afresh.
134
+ const directories = new WeakMap();
135
+ /** A directory whose answers are kept per email for the rest of the run; an error is not kept, so the next asker tries again. */
136
+ export function memoDirectory(d) {
137
+ const seen = new Map();
138
+ return {
139
+ idp: d.idp,
140
+ lookup(email) {
141
+ const k = email.toLowerCase();
142
+ let p = seen.get(k);
143
+ if (!p) {
144
+ p = d.lookup(email).then((a) => { if (a.state === "error")
145
+ seen.delete(k); return a; });
146
+ seen.set(k, p);
147
+ }
148
+ return p;
149
+ },
150
+ };
151
+ }
131
152
  /** The directory for an identity provider, built from the client a test injected or that provider's own variables; else why not. */
132
153
  export function directoryFor(idp, ctx) {
154
+ const key = `${idp}\n${String(ctx.opts["jumpcloud-region"] ?? "")}\n${String(ctx.opts["jumpcloud-org-id"] ?? "")}`;
155
+ let byIdp = directories.get(ctx.clients);
156
+ if (!byIdp) {
157
+ byIdp = new Map();
158
+ directories.set(ctx.clients, byIdp);
159
+ }
160
+ let d = byIdp.get(key);
161
+ if (d === undefined) {
162
+ d = buildDirectory(idp, ctx);
163
+ if (typeof d !== "string")
164
+ d = memoDirectory(d);
165
+ byIdp.set(key, d);
166
+ }
167
+ return d;
168
+ }
169
+ function buildDirectory(idp, ctx) {
133
170
  const sleep = ctx.clients.sleep ?? realSleep;
134
171
  const pick = (key, build) => {
135
172
  const given = ctx.clients[key];
@@ -80,7 +80,7 @@
80
80
  //
81
81
  // Personal data: results carry counts and Entra object ids, never user principal names or names.
82
82
  import { readFileSync } from "node:fs";
83
- import { cachedToken, entraClientAssertion, tokenRequest } from "./oauth.js";
83
+ import { cachedToken, entraClientAssertion, sharedToken, tokenRequest } from "./oauth.js";
84
84
  import { DAY_MS, Emitter, arr, enc, fetchRest, firstIds, getAll, intOpt, isObj, ok, realSleep, redact, str, why, withRetry, listRead } from "./readonly_http.js";
85
85
  export const GRAPH_BASE = "https://graph.microsoft.com/v1.0";
86
86
  export const ENTRA_ENV = { tenant: "ENTRA_TENANT_ID", client: "ENTRA_CLIENT_ID", secret: "ENTRA_CLIENT_SECRET", cert: "ENTRA_CLIENT_CERT_FILE" };
@@ -109,7 +109,8 @@ export function entraFromEnv(env, fetchFn = fetch, target = { base: GRAPH_BASE,
109
109
  if (!tenant || !client || (!secret && !certFile))
110
110
  return `${ENTRA_ENV.tenant}, ${ENTRA_ENV.client} and ${ENTRA_ENV.secret} or ${ENTRA_ENV.cert} are not all set`;
111
111
  const url = `https://login.microsoftonline.com/${enc(tenant)}/oauth2/v2.0/token`;
112
- const token = cachedToken(() => {
112
+ // Shared with every other client of this app and scope in the run (Intune, the HR leaver join): one token request.
113
+ const token = sharedToken(fetchFn, ["entra", tenant, client, target.scope, secret ?? "", certFile ?? ""], () => cachedToken(() => {
113
114
  const form = { grant_type: "client_credentials", client_id: client, scope: target.scope };
114
115
  if (certFile) {
115
116
  form.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
@@ -118,7 +119,7 @@ export function entraFromEnv(env, fetchFn = fetch, target = { base: GRAPH_BASE,
118
119
  else
119
120
  form.client_secret = secret;
120
121
  return tokenRequest(url, form, fetchFn);
121
- });
122
+ }));
122
123
  return fetchRest({ base: target.base, fetchFn, headers: async () => ({ authorization: `Bearer ${await token()}` }) });
123
124
  }
124
125
  const enabled = (p) => p.state === "enabled";
@@ -19,7 +19,7 @@
19
19
  import { randomBytes, createHash } from "node:crypto";
20
20
  import { closeSync, fstatSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
21
  import { dirname, join } from "node:path";
22
- import { cachedToken, tokenRequest } from "./oauth.js";
22
+ import { cachedToken, sharedToken, tokenRequest } from "./oauth.js";
23
23
  export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
24
24
  export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
25
25
  export const OAUTH_ENV = { clientFile: "GOOGLE_OAUTH_CLIENT_FILE", tokenFile: "GOOGLE_OAUTH_TOKEN_FILE" };
@@ -113,7 +113,7 @@ export function googleOAuthFromEnv(env, scopes, fetchFn = fetch) {
113
113
  const refresh = readTokenFile(tokenPath(env), client, scopes);
114
114
  if (typeof refresh !== "string")
115
115
  return refresh.error;
116
- return cachedToken(async () => {
116
+ return sharedToken(fetchFn, ["google-user", clientFile, client.clientId, refresh, ...scopes], () => cachedToken(async () => {
117
117
  try {
118
118
  return await tokenRequest(GOOGLE_TOKEN_URL, {
119
119
  grant_type: "refresh_token", refresh_token: refresh, client_id: client.clientId,
@@ -125,5 +125,5 @@ export function googleOAuthFromEnv(env, scopes, fetchFn = fetch) {
125
125
  // invalid_grant: revoked, unused for six months, or pushed out by newer tokens; only a new sign-in mends it.
126
126
  throw new Error(/invalid_grant/.test(m) ? `${m}: the sign-in was revoked or has expired; run \`collector google-login\` again` : m);
127
127
  }
128
- });
128
+ }));
129
129
  }
@@ -22,7 +22,7 @@
22
22
  // GOOGLE_ADMIN_EMAIL): an install whose domain-wide delegation lacks the scope, or whose admin is not a super
23
23
  // admin, gets drive_external_sharing_restricted skipped and keeps every other check.
24
24
  import { readFileSync } from "node:fs";
25
- import { cachedToken, googleAssertion, tokenRequest } from "./oauth.js";
25
+ import { cachedToken, googleAssertion, sharedToken, tokenRequest } from "./oauth.js";
26
26
  import { OAUTH_ENV, googleOAuthFromEnv } from "./google_oauth.js";
27
27
  import { DAY_MS, Emitter, enc, fetchRest, firstIds, getAll, intOpt, isObj, ok, realSleep, redact, str, why, withRetry, listRead, stampRead } from "./readonly_http.js";
28
28
  export const ADMIN_BASE = "https://admin.googleapis.com";
@@ -77,10 +77,11 @@ export function googleFromEnv(env, fetchFn = fetch) {
77
77
  // The key file names its own token endpoint; a tampered file could send the signed assertion elsewhere.
78
78
  if (!isGoogleTokenUri(tokenUri))
79
79
  return `${GOOGLE_ENV.keyFile} names a token_uri outside googleapis.com over HTTPS`;
80
- const token = cachedToken(() => tokenRequest(tokenUri, {
80
+ // Shared with the HR leaver join, which builds this same client (directory.ts): one token request per run.
81
+ const token = sharedToken(fetchFn, ["google-sa", tokenUri, key.client_email, admin, ...GOOGLE_SCOPES], () => cachedToken(() => tokenRequest(tokenUri, {
81
82
  grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
82
83
  assertion: googleAssertion(key, admin, GOOGLE_SCOPES, Math.floor(Date.now() / 1000)),
83
- }, fetchFn));
84
+ }, fetchFn)));
84
85
  return fetchRest({ base: ADMIN_BASE, fetchFn, headers: async () => ({ authorization: `Bearer ${await token()}` }) });
85
86
  }
86
87
  /** A client for the Cloud Identity Policy API with a token that holds only POLICY_SCOPE. */
@@ -71,3 +71,25 @@ export function cachedToken(fetchToken) {
71
71
  return cur.token;
72
72
  };
73
73
  }
74
+ // One token per credential and scope for the whole run, whichever source asks first (2026-11-07): Entra ID, Intune and
75
+ // an HR source joined with Entra ID used to make three identical client-credentials requests. Keyed by the fetch
76
+ // function the clients share (one per run, src/report.ts hold()) and a SHA-256 of the credential's parts, so no secret
77
+ // is kept as a key and a test's fake fetch never sees another test's token.
78
+ const shared = new WeakMap();
79
+ /** The token getter for these credential parts, made once per fetch function by `make`. */
80
+ export function sharedToken(fetchFn, parts, make) {
81
+ let byKey = shared.get(fetchFn);
82
+ if (!byKey) {
83
+ byKey = new Map();
84
+ shared.set(fetchFn, byKey);
85
+ }
86
+ const key = createHash("sha256").update(parts.join("\u0000")).digest("hex");
87
+ let t = byKey.get(key);
88
+ if (!t) {
89
+ const getter = make();
90
+ // A failed request is not kept: the next source asks again (and says why on its own).
91
+ t = () => getter().catch((e) => { byKey.delete(key); throw e; });
92
+ byKey.set(key, t);
93
+ }
94
+ return t;
95
+ }
@@ -15,11 +15,12 @@
15
15
  // The collector never passes a Prowler option that writes, fixes or sends anywhere (Security Hub, S3,
16
16
  // Slack, Shodan, Prowler Cloud, secret validation), and refuses one given in --prowler-args.
17
17
  import { spawn } from "node:child_process";
18
- import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
18
+ import { accessSync, constants, createReadStream, mkdtempSync, readdirSync, rmSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { DEFAULT_CATALOG_DIR, loadCatalog } from "../catalog.js";
22
22
  import { scrub } from "./shared-cloud.js";
23
+ import { pool } from "../pool.js";
23
24
  export const PROWLER_PROVIDERS = ["aws", "azure", "gcp", "kubernetes", "m365", "github", "googleworkspace", "okta", "cloudflare"];
24
25
  /** The version the catalogues were built from (apps/sos-app/scripts/build_prowler_catalog.py). */
25
26
  export const PROWLER_VERSION = "5.47.0";
@@ -115,37 +116,33 @@ export function outcomeOf(f) {
115
116
  return code === "PASS" ? "pass" : code === "FAIL" ? "fail" : "skipped";
116
117
  }
117
118
  /**
118
- * Maps a Prowler OCSF array (Detection Findings) to results. Findings whose check the catalogue does not list
119
- * are dropped and counted. Several findings on one (check, resource) merge into one result: fail if any
120
- * failed, else pass if any passed, else skipped; their details are joined.
119
+ * Maps Prowler OCSF findings (Detection Findings), one at a time, to results. Findings whose check the catalogue does
120
+ * not list are dropped and counted. Several findings on one (check, resource) merge into one result: fail if any
121
+ * failed, else pass if any passed, else skipped; their details are joined (only as far as the 1,000 characters sent).
121
122
  */
122
- export function parseOcsf(text, provider, known) {
123
- let doc;
124
- try {
125
- doc = JSON.parse(text);
126
- }
127
- catch (e) {
128
- throw new Error(`the Prowler output is not JSON (${e.message}); pass a json-ocsf file.`);
129
- }
130
- if (!Array.isArray(doc))
131
- throw new Error("the Prowler output is not an OCSF array of findings; pass a json-ocsf file.");
132
- const summary = { findings: doc.length, kept: 0, dropped: 0, droppedChecks: [], muted: 0, manual: 0, merged: 0 };
133
- const dropped = new Set();
134
- const byKey = new Map();
135
- for (const f of doc) {
123
+ class OcsfMerger {
124
+ summary = { findings: 0, kept: 0, dropped: 0, droppedChecks: [], muted: 0, manual: 0, merged: 0 };
125
+ dropped = new Set();
126
+ byKey = new Map();
127
+ provider;
128
+ known;
129
+ constructor(provider, known) { this.provider = provider; this.known = known; }
130
+ add(f) {
131
+ this.summary.findings++;
136
132
  if (!isObj(f))
137
- continue;
133
+ return;
134
+ const summary = this.summary;
138
135
  const meta = isObj(f.metadata) ? f.metadata : {};
139
136
  const info = isObj(f.finding_info) ? f.finding_info : {};
140
137
  const analytic = isObj(info.analytic) ? info.analytic : {};
141
138
  const checkId = s(meta.event_code) || s(analytic.uid);
142
139
  const unmapped = isObj(f.unmapped) ? f.unmapped : {};
143
- const prov = s(unmapped.provider) || provider;
140
+ const prov = s(unmapped.provider) || this.provider;
144
141
  const check = `prowler.${prov}.${checkId}`;
145
- if (!checkId || !known.has(check)) {
142
+ if (!checkId || !this.known.has(check)) {
146
143
  summary.dropped++;
147
- dropped.add(check);
148
- continue;
144
+ this.dropped.add(check);
145
+ return;
149
146
  }
150
147
  if (s(f.status) === "Suppressed")
151
148
  summary.muted++;
@@ -165,22 +162,142 @@ export function parseOcsf(text, provider, known) {
165
162
  const detail = (s(f.status_detail) || s(f.message)).replace(EMAIL, "[email]");
166
163
  const result = outcomeOf(f);
167
164
  const key = `${check}|${resource}`;
168
- const prev = byKey.get(key);
165
+ const prev = this.byKey.get(key);
169
166
  if (!prev) {
170
- byKey.set(key, { r: { check, resource, result, detail: "" }, details: detail ? [detail] : [] });
171
- continue;
167
+ this.byKey.set(key, { r: { check, resource, result, detail: "" }, details: detail ? [detail] : [], len: detail.length });
168
+ return;
172
169
  }
173
170
  summary.merged++;
174
171
  const rank = (o) => (o === "fail" ? 2 : o === "pass" ? 1 : 0);
175
172
  if (rank(result) > rank(prev.r.result))
176
173
  prev.r.result = result;
177
- if (detail && !prev.details.includes(detail))
174
+ // Past 1,000 characters a further detail cannot change what is sent, so it is not kept (a large account merges
175
+ // thousands of findings into one result).
176
+ if (detail && prev.len < 1000 && !prev.details.includes(detail)) {
177
+ prev.len += (prev.details.length ? 3 : 0) + detail.length;
178
178
  prev.details.push(detail);
179
+ }
180
+ }
181
+ finish() {
182
+ const results = [...this.byKey.values()].map(({ r, details }) => ({ ...r, detail: details.join(" | ").slice(0, 1000) }));
183
+ this.summary.kept = results.length;
184
+ this.summary.droppedChecks = [...this.dropped].sort();
185
+ return { results, summary: this.summary };
179
186
  }
180
- const results = [...byKey.values()].map(({ r, details }) => ({ ...r, detail: details.join(" | ").slice(0, 1000) }));
181
- summary.kept = results.length;
182
- summary.droppedChecks = [...dropped].sort();
183
- return { results, summary };
187
+ }
188
+ /** The output is not OCSF: the file is wrong, whichever way it was made. */
189
+ export class OcsfError extends Error {
190
+ }
191
+ const NOT_JSON = (why) => new OcsfError(`the Prowler output is not JSON (${why}); pass a json-ocsf file.`);
192
+ const NOT_ARRAY = () => new OcsfError("the Prowler output is not an OCSF array of findings; pass a json-ocsf file.");
193
+ /** Maps a Prowler OCSF array held in memory (tests, small files). */
194
+ export function parseOcsf(text, provider, known) {
195
+ let doc;
196
+ try {
197
+ doc = JSON.parse(text);
198
+ }
199
+ catch (e) {
200
+ throw NOT_JSON(e.message);
201
+ }
202
+ if (!Array.isArray(doc))
203
+ throw NOT_ARRAY();
204
+ const m = new OcsfMerger(provider, known);
205
+ for (const f of doc)
206
+ m.add(f);
207
+ return m.finish();
208
+ }
209
+ const WS = (c) => c === 0x20 || c === 0x0a || c === 0x0d || c === 0x09;
210
+ /**
211
+ * The elements of a top-level JSON array, as text, read chunk by chunk: only one element is held at a time, so a
212
+ * Prowler output of any size never becomes one string (V8 caps a string near 512 MiB; a large AWS organization's
213
+ * OCSF file passes that). Strings and escapes are tracked so brackets inside them do not count. No regular expression.
214
+ */
215
+ export async function* jsonArrayElements(chunks) {
216
+ let buf = "", i = 0, start = -1, depth = 0, inStr = false, esc = false, state = "pre", afterComma = false;
217
+ for await (const chunk of chunks) {
218
+ buf += chunk;
219
+ for (; i < buf.length; i++) {
220
+ const c = buf.charCodeAt(i);
221
+ if (state === "pre") {
222
+ if (WS(c) || c === 0xfeff)
223
+ continue;
224
+ if (c === 0x5b /* [ */) {
225
+ state = "in";
226
+ continue;
227
+ }
228
+ throw NOT_ARRAY();
229
+ }
230
+ if (state === "post") {
231
+ if (WS(c))
232
+ continue;
233
+ throw NOT_JSON(`text after the closing bracket at offset ${i}`);
234
+ }
235
+ if (inStr) {
236
+ if (esc)
237
+ esc = false;
238
+ else if (c === 0x5c /* \ */)
239
+ esc = true;
240
+ else if (c === 0x22 /* " */)
241
+ inStr = false;
242
+ continue;
243
+ }
244
+ if (c === 0x22) {
245
+ inStr = true;
246
+ if (start < 0)
247
+ start = i;
248
+ continue;
249
+ }
250
+ if (depth === 0) {
251
+ if (c === 0x2c /* , */ || c === 0x5d /* ] */) {
252
+ if (start >= 0) {
253
+ yield buf.slice(start, i);
254
+ start = -1;
255
+ afterComma = c === 0x2c;
256
+ }
257
+ else if (c === 0x2c || afterComma)
258
+ throw NOT_JSON("an empty array element");
259
+ if (c === 0x5d)
260
+ state = "post";
261
+ continue;
262
+ }
263
+ if (WS(c))
264
+ continue;
265
+ if (start < 0)
266
+ start = i;
267
+ }
268
+ if (c === 0x7b /* { */ || c === 0x5b)
269
+ depth++;
270
+ else if (c === 0x7d /* } */ || c === 0x5d) {
271
+ if (--depth < 0)
272
+ throw NOT_JSON("unbalanced brackets");
273
+ }
274
+ }
275
+ // Drop what has been read; keep the element in progress.
276
+ const keep = start >= 0 ? start : i;
277
+ buf = buf.slice(keep);
278
+ i -= keep;
279
+ if (start >= 0)
280
+ start = 0;
281
+ }
282
+ if (state === "pre")
283
+ throw NOT_ARRAY();
284
+ if (state !== "post")
285
+ throw NOT_JSON("the file ends inside the array");
286
+ }
287
+ /** Maps a Prowler OCSF file, streamed (see jsonArrayElements). */
288
+ export async function parseOcsfFile(path, provider, known) {
289
+ const m = new OcsfMerger(provider, known);
290
+ for await (const el of jsonArrayElements(createReadStream(path, { encoding: "utf8", highWaterMark: 1 << 20 }))) {
291
+ let f;
292
+ try {
293
+ f = JSON.parse(el);
294
+ }
295
+ catch (e) {
296
+ throw NOT_JSON(e.message);
297
+ }
298
+ m.add(f);
299
+ }
300
+ return m.finish();
184
301
  }
185
302
  export const spawnProwler = (bin, args, env) => new Promise((resolve, reject) => {
186
303
  const p = spawn(bin, args, { env, stdio: ["ignore", "ignore", "pipe"] });
@@ -189,7 +306,7 @@ export const spawnProwler = (bin, args, env) => new Promise((resolve, reject) =>
189
306
  p.on("error", reject);
190
307
  p.on("close", (code) => resolve({ code, stderr: err }));
191
308
  });
192
- /** Runs Prowler into a fresh directory and returns the OCSF text. A non-zero exit is an error. */
309
+ /** Runs Prowler into a fresh directory and maps its OCSF file, streamed, before the directory goes. A non-zero exit is an error. */
193
310
  export async function runProwler(provider, o) {
194
311
  const bad = o.extra.filter((a) => REFUSED_ARGS.includes(a.split("=")[0]));
195
312
  if (bad.length)
@@ -210,12 +327,14 @@ export async function runProwler(provider, o) {
210
327
  const file = readdirSync(dir).find((f) => f.endsWith(".ocsf.json"));
211
328
  if (!file)
212
329
  throw new Error(`${o.bin} ${provider} wrote no .ocsf.json in ${dir}.`);
213
- return readFileSync(join(dir, file), "utf8");
330
+ return await parseOcsfFile(join(dir, file), provider, o.known);
214
331
  }
215
332
  finally {
216
333
  rmSync(dir, { recursive: true, force: true });
217
334
  }
218
335
  }
336
+ /** Prowler scans run side by side, at most this many at once (each is a Python process of its own). */
337
+ export const PROWLER_PARALLEL = 2;
219
338
  // ── Source ──────────────────────────────────────────────────────────────────────────────────────────
220
339
  export const prowler = {
221
340
  name: "prowler",
@@ -240,36 +359,43 @@ export const prowler = {
240
359
  if (file && providers.length > 1)
241
360
  throw new Error("--prowler-output reads one provider's file; name one --prowler-provider with it.");
242
361
  const known = new Set(catalogIds(one(ctx.opts.catalog) ?? DEFAULT_CATALOG_DIR));
243
- const results = [];
244
- const failures = [];
245
362
  for (const provider of providers) {
246
363
  if (![...known].some((id) => id.startsWith(`prowler.${provider}.`))) {
247
364
  ctx.warn(`prowler: sos-app's catalogue lists no ${provider} check; every finding will be dropped.`);
248
365
  }
249
- let text;
250
- try {
251
- if (file) {
252
- try {
253
- text = readFileSync(file, "utf8");
254
- }
255
- catch (e) {
256
- throw new Error(`cannot read --prowler-output ${file}: ${e.code ?? String(e)}`);
257
- }
366
+ }
367
+ // Each provider read or scanned, PROWLER_PARALLEL at a time; the outcomes are then taken in the order given, so the
368
+ // results and their order are those of one provider after another.
369
+ const one_ = async (provider) => {
370
+ if (file) {
371
+ try {
372
+ accessSync(file, constants.R_OK);
258
373
  }
259
- else {
260
- const spawner = ctx.clients.prowler ?? spawnProwler;
261
- const extra = one(ctx.opts["prowler-args"]) ? splitArgs(one(ctx.opts["prowler-args"])) : [];
262
- text = await runProwler(provider, { bin: one(ctx.opts["prowler-bin"]) ?? "prowler", extra, env: prowlerEnv(provider, ctx.env), spawner });
374
+ catch (e) {
375
+ throw new Error(`cannot read --prowler-output ${file}: ${e.code ?? String(e)}`);
263
376
  }
377
+ return parseOcsfFile(file, provider, known);
264
378
  }
265
- catch (e) {
379
+ const spawner = ctx.clients.prowler ?? spawnProwler;
380
+ const extra = one(ctx.opts["prowler-args"]) ? splitArgs(one(ctx.opts["prowler-args"])) : [];
381
+ return runProwler(provider, { bin: one(ctx.opts["prowler-bin"]) ?? "prowler", extra, env: prowlerEnv(provider, ctx.env), spawner, known });
382
+ };
383
+ const outcomes = await pool(providers, PROWLER_PARALLEL, one_);
384
+ const results = [];
385
+ const failures = [];
386
+ for (const [i, o] of outcomes.entries()) {
387
+ const provider = providers[i];
388
+ if (o.status === "rejected") {
389
+ // An output that is not OCSF is the file's fault, not the scan's: it stops the source, as it always has.
390
+ if (o.reason instanceof OcsfError)
391
+ throw o.reason;
266
392
  // A second way that fails leaves the control to its other ways: say so and go on with the next provider.
267
- failures.push(e);
393
+ failures.push(o.reason);
268
394
  if (providers.length > 1)
269
- ctx.warn(`prowler: ${provider} did not run (${e.message}); its controls keep their other ways.`);
395
+ ctx.warn(`prowler: ${provider} did not run (${o.reason.message}); its controls keep their other ways.`);
270
396
  continue;
271
397
  }
272
- const { results: r, summary } = parseOcsf(text, provider, known);
398
+ const { results: r, summary } = o.value;
273
399
  results.push(...r);
274
400
  ctx.warn(`prowler: ${summary.findings} findings; ${summary.kept} results (${summary.merged} merged into the same check and resource, ` +
275
401
  `${summary.muted} muted and ${summary.manual} manual sent as skipped); ${summary.dropped} findings dropped from ${summary.droppedChecks.length} checks not in the catalogue` +
@@ -2,6 +2,18 @@
2
2
  // GitHub): a GET-only JSON client, retry on rate limits, pagination, "skipped" details and token
3
3
  // redaction. The only non-GET request any of these sources makes is the OAuth token request in
4
4
  // oauth.ts, to the identity provider's token endpoint.
5
+ import { channel } from "node:diagnostics_channel";
6
+ /** The recorder (src/report.ts RETRY_CHANNEL) counts retries per source from here; a plain string, so this file needs no import of it. */
7
+ const retries = channel("sagentix:collector:retry");
8
+ /** Tells the run's recorder that a call is about to be retried (a no-op when nothing listens). */
9
+ export function announceRetry(status, waitMs, attempt) {
10
+ if (retries.hasSubscribers)
11
+ retries.publish({ status, waitMs, attempt });
12
+ }
13
+ /** Gateway and availability errors worth one or two more tries; a 500 is usually the request itself and is not retried. */
14
+ export const TRANSIENT_STATUS = new Set([502, 503, 504]);
15
+ /** Retries after a transient error (a 502/503/504 or a network failure), on top of the rate-limit retries. */
16
+ export const TRANSIENT_RETRIES = 2;
5
17
  export const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
18
  export const str = (v) => (typeof v === "string" ? v : undefined);
7
19
  export const arr = (v) => (Array.isArray(v) ? v : []);
@@ -71,21 +83,30 @@ export function retryDelayMs(h, attempt, nowMs) {
71
83
  return Math.min(cap, Math.max(1000, reset * 1000 - nowMs + 1000));
72
84
  return Math.min(cap, 1000 * 2 ** attempt);
73
85
  }
74
- /** Retries a rate-limited GET up to `max` times; a thrown network error becomes status 0. */
86
+ /**
87
+ * Retries a rate-limited GET up to `max` times, and a transient failure (502, 503, 504, or a thrown network error or
88
+ * timeout) up to TRANSIENT_RETRIES times, waiting Retry-After when the answer gives one. A thrown error becomes status 0.
89
+ */
75
90
  export function withRetry(api, sleep = realSleep, max = 4, now = Date.now) {
76
91
  return {
77
92
  async get(url) {
93
+ let transient = 0;
78
94
  for (let attempt = 0;; attempt++) {
79
- let r;
95
+ let r, thrown = false;
80
96
  try {
81
97
  r = await api.get(url);
82
98
  }
83
99
  catch (e) {
84
100
  r = { status: 0, headers: {}, body: { message: `request failed: ${String(e)}` } };
101
+ thrown = true;
85
102
  }
86
- if (!isRateLimited(r) || attempt >= max)
103
+ const rate = isRateLimited(r);
104
+ const again = rate ? attempt < max : (thrown || TRANSIENT_STATUS.has(r.status)) && transient++ < TRANSIENT_RETRIES;
105
+ if (!again)
87
106
  return r;
88
- await sleep(retryDelayMs(r.headers, attempt, now()));
107
+ const wait = retryDelayMs(r.headers, rate ? attempt : transient - 1, now());
108
+ announceRetry(r.status, wait, attempt + 1);
109
+ await sleep(wait);
89
110
  }
90
111
  },
91
112
  };
@@ -120,7 +141,8 @@ export function why(r) {
120
141
  msg = msg.replace(/\s+/g, " ").trim().slice(0, 300);
121
142
  const hint = r.status === 401 || r.status === 403
122
143
  ? " (the credential may lack the read permission for this call, or the feature is not licensed)"
123
- : r.status === 429 ? " (rate limited; retries exhausted)" : "";
144
+ : r.status === 429 ? " (rate limited; retries exhausted)"
145
+ : TRANSIENT_STATUS.has(r.status) ? " (the provider is unavailable; retried, try the run again later)" : "";
124
146
  return `HTTP ${r.status}${msg ? `: ${msg}` : ""}${hint}`;
125
147
  }
126
148
  /**
@@ -1,6 +1,6 @@
1
1
  // Helpers shared by the AWS, Azure, GCP, Jira and LMS sources: option lists, retry with backoff on
2
2
  // throttling, a result builder, and a scrubber that keeps secret values out of every detail.
3
- import { redact } from "./readonly_http.js";
3
+ import { TRANSIENT_RETRIES, TRANSIENT_STATUS, announceRetry, redact } from "./readonly_http.js";
4
4
  export const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
5
5
  export const arr = (v) => (Array.isArray(v) ? v : []);
6
6
  export const str = (v) => (typeof v === "string" ? v : undefined);
@@ -20,25 +20,33 @@ export function retryAfterMs(headers, now = Date.now()) {
20
20
  }
21
21
  /**
22
22
  * Calls `send` until it gives a reply that is not throttled, or the attempts run out (then the last
23
- * throttled reply is returned, and the caller turns it into "skipped"). A thrown error is a reply
24
- * with status 0.
23
+ * throttled reply is returned, and the caller turns it into "skipped"). A transient failure (502 or 504,
24
+ * or a thrown network error or timeout) is tried again up to TRANSIENT_RETRIES times within the same
25
+ * attempts. A thrown error is a reply with status 0.
25
26
  */
26
27
  export async function withRetry(send, o = {}) {
27
28
  const attempts = o.attempts ?? 5, base = o.baseMs ?? 500, max = o.maxMs ?? 20_000;
28
29
  const sleep = o.sleep ?? realSleep;
29
30
  const throttled = o.throttled ?? ((r) => r.status === 429 || r.status === 503);
30
31
  let last = { status: 0, headers: {}, body: "" };
32
+ let transient = 0;
31
33
  for (let i = 0; i < attempts; i++) {
34
+ let thrown = false;
32
35
  try {
33
36
  last = await send();
34
37
  }
35
38
  catch (e) {
36
39
  last = { status: 0, headers: {}, body: `request failed: ${String(e)}` };
40
+ thrown = true;
37
41
  }
38
- if (!throttled(last))
42
+ const again = throttled(last) || ((thrown || TRANSIENT_STATUS.has(last.status)) && transient++ < TRANSIENT_RETRIES);
43
+ if (!again)
39
44
  return last;
40
- if (i < attempts - 1)
41
- await sleep(Math.min(max, retryAfterMs(last.headers) ?? base * 2 ** i));
45
+ if (i < attempts - 1) {
46
+ const wait = Math.min(max, retryAfterMs(last.headers) ?? base * 2 ** i);
47
+ announceRetry(last.status, wait, i + 1);
48
+ await sleep(wait);
49
+ }
42
50
  }
43
51
  return last;
44
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Customer-side evidence collector: reads systems read-only, runs checks, signs the results and pushes them outbound to sos-app.",
5
5
  "type": "module",
6
6
  "engines": {