@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.
- package/README.md +36 -0
- package/collector/package.json +1 -1
- package/collector/src/cli.js +281 -36
- package/collector/src/doctor.js +382 -0
- package/collector/src/pool.js +19 -0
- package/collector/src/report.js +88 -16
- package/collector/src/sources/cloudflare/api.js +24 -4
- package/collector/src/sources/directory.js +37 -0
- package/collector/src/sources/entra.js +4 -3
- package/collector/src/sources/google_oauth.js +3 -3
- package/collector/src/sources/google_workspace.js +4 -3
- package/collector/src/sources/oauth.js +22 -0
- package/collector/src/sources/prowler.js +178 -52
- package/collector/src/sources/readonly_http.js +27 -5
- package/collector/src/sources/shared-cloud.js +14 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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(
|
|
393
|
+
failures.push(o.reason);
|
|
268
394
|
if (providers.length > 1)
|
|
269
|
-
ctx.warn(`prowler: ${provider} did not run (${
|
|
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 } =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|