@sagentix-inc/evidence-collector 0.3.1 → 0.3.3
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 +21 -0
- package/apps/sos-app/src/data/checks/prowler_aws.json +1455 -34
- package/apps/sos-app/src/data/checks/prowler_azure.json +497 -0
- package/apps/sos-app/src/data/checks/prowler_cloudflare.json +205 -1
- package/apps/sos-app/src/data/checks/prowler_gcp.json +175 -0
- package/apps/sos-app/src/data/checks/prowler_github.json +191 -1
- package/apps/sos-app/src/data/checks/prowler_googleworkspace.json +457 -1
- package/apps/sos-app/src/data/checks/prowler_kubernetes.json +133 -0
- package/apps/sos-app/src/data/checks/prowler_m365.json +574 -0
- package/apps/sos-app/src/data/checks/prowler_okta.json +205 -1
- package/collector/package.json +1 -1
- package/collector/src/agent.js +38 -1
- package/collector/src/channel.js +23 -2
- package/collector/src/cli.js +43 -4
- package/collector/src/config.js +18 -0
- package/collector/src/deliveries.js +244 -0
- package/collector/src/report.js +3 -1
- package/collector/src/sources/prowler.js +80 -25
- package/collector/src/vault.js +303 -0
- package/package.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// What sos-app may deliver to the vault (apps/sos-app/docs/COLLECTOR_CHANNEL.md, section 8), and the vault's summary the
|
|
2
|
+
// check-in reports. A check-in answer may carry `deliveries`; each is one of four things, and nothing else:
|
|
3
|
+
//
|
|
4
|
+
// set a value for a name the config's `credentials` lists, with that name's kind: a secret or file sealed in
|
|
5
|
+
// the browser to this collector's key (sos-app cannot open it), or a setting in clear
|
|
6
|
+
// remove the vault's value for such a name
|
|
7
|
+
// import-env move such names' values from sagentix-collector.env into the vault, then drop those lines
|
|
8
|
+
// test run one connector of the config dry (resident mode only), and report its counts
|
|
9
|
+
//
|
|
10
|
+
// A delivery for a name the config does not list, or with another kind, is refused and reported, never applied: what
|
|
11
|
+
// sos-app says cannot widen what this collector reads. No delivery can name a file, a command or a source option.
|
|
12
|
+
// The outcome of each goes back in a signed POST /api/collect/vault/ack, with the vault's new summary.
|
|
13
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
14
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
15
|
+
import { signedPost, writePrivate } from "./channel.js";
|
|
16
|
+
import { scrubText } from "./report.js";
|
|
17
|
+
import { KINDS, MAX_VALUE, NAME, Vault, canonicalBind } from "./vault.js";
|
|
18
|
+
export const ACK_PATH = "/api/collect/vault/ack";
|
|
19
|
+
export const ENV_FILE = "sagentix-collector.env";
|
|
20
|
+
export const MAX_DELIVERIES = 50;
|
|
21
|
+
const ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
22
|
+
/** Each name a connector reads, with its kind: the only names a delivery may touch. */
|
|
23
|
+
export function knownNames(creds) {
|
|
24
|
+
const m = new Map();
|
|
25
|
+
for (const c of Object.values(creds)) {
|
|
26
|
+
for (const n of c.secrets ?? [])
|
|
27
|
+
m.set(n, "secret");
|
|
28
|
+
for (const n of c.files ?? [])
|
|
29
|
+
m.set(n, "file");
|
|
30
|
+
for (const n of Object.values(c.instead ?? {}))
|
|
31
|
+
if (!m.has(n))
|
|
32
|
+
m.set(n, n.endsWith("_FILE") ? "file" : "secret");
|
|
33
|
+
for (const n of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
|
|
34
|
+
if (!m.has(n))
|
|
35
|
+
m.set(n, "setting");
|
|
36
|
+
}
|
|
37
|
+
return m;
|
|
38
|
+
}
|
|
39
|
+
/** The settings of the connector a secret belongs to, as the vault holds them now: what a sealed value binds to. */
|
|
40
|
+
export function bindFor(creds, name, v) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const k of settingsOf(creds, name)) {
|
|
43
|
+
const val = v.get(k);
|
|
44
|
+
if (val !== undefined)
|
|
45
|
+
out[k] = val;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** The settings (required and optional) of every connector that reads this secret or file. */
|
|
50
|
+
export function settingsOf(creds, name) {
|
|
51
|
+
const out = new Set();
|
|
52
|
+
for (const c of Object.values(creds)) {
|
|
53
|
+
if (![...(c.secrets ?? []), ...(c.files ?? []), ...Object.values(c.instead ?? {})].includes(name))
|
|
54
|
+
continue;
|
|
55
|
+
for (const k of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
|
|
56
|
+
out.add(k);
|
|
57
|
+
}
|
|
58
|
+
return [...out];
|
|
59
|
+
}
|
|
60
|
+
/** The config's credentials, or none when the file cannot be read. */
|
|
61
|
+
export function credentialsIn(configPath) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, "")).credentials ?? {};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** A file's text when it exists and is at most MAX_VALUE bytes; one open, so the size checked is the file read. */
|
|
70
|
+
function readSmall(path) {
|
|
71
|
+
let fd;
|
|
72
|
+
try {
|
|
73
|
+
fd = openSync(path, "r");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (fstatSync(fd).size > MAX_VALUE)
|
|
80
|
+
return null;
|
|
81
|
+
const buf = Buffer.alloc(MAX_VALUE + 1);
|
|
82
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
83
|
+
return n > MAX_VALUE ? null : buf.subarray(0, n).toString("utf8");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The vault beside a config, as the check-in sees it. `test`: the agent's dry run of some sources (absent in `run`). */
|
|
90
|
+
export function vaultBridge(o) {
|
|
91
|
+
const dir = dirname(o.configPath), envPath = join(dir, ENV_FILE);
|
|
92
|
+
let vault;
|
|
93
|
+
const v = () => (vault ??= new Vault(dir, o.holder));
|
|
94
|
+
const creds = () => {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(readFileSync(o.configPath, "utf8").replace(/^/, "")).credentials ?? {};
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const envLines = (known) => !existsSync(envPath) ? [] : readFileSync(envPath, "utf8").split(/\r?\n/)
|
|
103
|
+
.map((l) => /^(?:export\s+)?([A-Z][A-Z0-9_]*)=(.*)$/.exec(l.trim()))
|
|
104
|
+
.filter((m) => !!m && known.has(m[1]) && m[2].length > 0)
|
|
105
|
+
.map((m) => ({ name: m[1], value: m[2].replace(/^(['"])(.*)\1$/, "$2") }));
|
|
106
|
+
function summary() {
|
|
107
|
+
const c = creds(), known = knownNames(c);
|
|
108
|
+
let sealKey = null, entries = [], error = null, protection = null;
|
|
109
|
+
try {
|
|
110
|
+
protection = v().protection;
|
|
111
|
+
// A setting's value goes along (a URL, a tenant id), so sos-app shows it and the browser binds a secret to it.
|
|
112
|
+
entries = v().list().map((e) => e.kind === "setting" ? { ...e, value: v().get(e.name) } : e);
|
|
113
|
+
sealKey = Object.keys(c).length ? v().sealPublicKey() : null;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
error = String(e?.message ?? e).slice(0, 300);
|
|
117
|
+
}
|
|
118
|
+
return { sealKey, protection, error, entries, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
|
|
119
|
+
}
|
|
120
|
+
function importEnv(known) {
|
|
121
|
+
const moved = [], leftFiles = [], problems = [], c = creds();
|
|
122
|
+
// Settings first, so each secret binds to the settings it came with.
|
|
123
|
+
for (const l of envLines(known).sort((x, y) => Number(known.get(x.name) !== "setting") - Number(known.get(y.name) !== "setting"))) {
|
|
124
|
+
const kind = known.get(l.name);
|
|
125
|
+
try {
|
|
126
|
+
if (kind === "file") {
|
|
127
|
+
const path = isAbsolute(l.value) ? l.value : join(dir, l.value);
|
|
128
|
+
const text = readSmall(path);
|
|
129
|
+
if (text === null) {
|
|
130
|
+
problems.push(`${l.name}: the file it names is missing or too large`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
v().set(l.name, text, { kind, now: o.now(), bind: bindFor(c, l.name, v()) });
|
|
134
|
+
leftFiles.push(l.value);
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
v().set(l.name, l.value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: bindFor(c, l.name, v()) } : {}) });
|
|
138
|
+
moved.push(l.name);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
problems.push(`${l.name}: ${String(e?.message ?? e)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (moved.length) {
|
|
145
|
+
const keep = readFileSync(envPath, "utf8").split(/\r?\n/).filter((l) => !moved.includes(/^(?:export\s+)?([A-Z][A-Z0-9_]*)=/.exec(l.trim())?.[1] ?? ""));
|
|
146
|
+
writePrivate(envPath, keep.join("\n").replace(/\n*$/, "\n"));
|
|
147
|
+
}
|
|
148
|
+
return { moved, leftFiles, ...(problems.length ? { error: problems.join("; ") } : {}) };
|
|
149
|
+
}
|
|
150
|
+
async function apply(raw, a) {
|
|
151
|
+
if (!Array.isArray(raw))
|
|
152
|
+
return [];
|
|
153
|
+
const c = creds(), known = knownNames(c), out = [];
|
|
154
|
+
for (const d of raw.slice(0, MAX_DELIVERIES)) {
|
|
155
|
+
if (typeof d !== "object" || d === null || typeof d.id !== "string" || !ID.test(d.id))
|
|
156
|
+
continue;
|
|
157
|
+
const refuse = (error) => { out.push({ id: d.id, ok: false, error }); o.log(`vault: refused delivery ${d.id}: ${error}`); };
|
|
158
|
+
try {
|
|
159
|
+
if (d.op === "set" || d.op === "remove") {
|
|
160
|
+
const name = String(d.name ?? ""), kind = known.get(name);
|
|
161
|
+
if (!NAME.test(name) || !kind) {
|
|
162
|
+
refuse(`${name.slice(0, 100)} is not a name this collector's connectors read`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (d.op === "remove") {
|
|
166
|
+
v().remove(name);
|
|
167
|
+
o.log(`vault: ${name} removed`);
|
|
168
|
+
out.push({ id: d.id, ok: true });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (d.kind !== kind || !KINDS.includes(d.kind)) {
|
|
172
|
+
refuse(`${name} is a ${kind}, not a ${String(d.kind)}`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let value;
|
|
176
|
+
if (kind === "setting") {
|
|
177
|
+
if (typeof d.value !== "string" || !d.value.trim()) {
|
|
178
|
+
refuse(`${name}: no value`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
value = d.value.trim();
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
if (!d.sealed || d.sealed.name !== name || d.sealed.kind !== kind) {
|
|
185
|
+
refuse(`${name}: a ${kind} must come sealed, for this name and kind`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// The connector's settings as they stand now must be the ones the person saw when sealing.
|
|
189
|
+
const bind = d.sealed.bind ?? {}, want = bindFor(c, name, v());
|
|
190
|
+
const missing = Object.values(c).filter((x) => [...(x.secrets ?? []), ...(x.files ?? []), ...Object.values(x.instead ?? {})].includes(name))
|
|
191
|
+
.flatMap((x) => x.settings ?? []).filter((k) => want[k] === undefined);
|
|
192
|
+
if (missing.length) {
|
|
193
|
+
refuse(`${name}: set ${missing.join(", ")} first, so the secret is bound to where it goes`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (canonicalBind(bind) !== canonicalBind(want)) {
|
|
197
|
+
refuse(`${name}: the connector's settings changed since it was sealed; enter it again`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
value = v().openSealed(o.keyId, d.sealed);
|
|
201
|
+
}
|
|
202
|
+
v().set(name, value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: d.sealed.bind ?? {} } : {}) });
|
|
203
|
+
o.log(`vault: ${name} set`);
|
|
204
|
+
out.push({ id: d.id, ok: true });
|
|
205
|
+
}
|
|
206
|
+
else if (d.op === "import-env") {
|
|
207
|
+
const r = importEnv(known);
|
|
208
|
+
o.log(`vault: ${r.moved?.length ?? 0} value(s) moved from ${ENV_FILE}`);
|
|
209
|
+
out.push({ id: d.id, ok: !r.error, ...r });
|
|
210
|
+
}
|
|
211
|
+
else if (d.op === "test") {
|
|
212
|
+
if (!a.agent || !o.test)
|
|
213
|
+
continue; // left for the resident agent, which can run it now
|
|
214
|
+
const id = String(d.connector ?? ""), cred = c[id];
|
|
215
|
+
if (!cred) {
|
|
216
|
+
refuse(`${id.slice(0, 40)} is not a connector of this collector`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const t = await o.test(cred.sources);
|
|
220
|
+
const secrets = v().all().filter((e) => e.kind !== "setting").map((e) => e.value).filter((x) => x.length >= 6);
|
|
221
|
+
out.push({ id: d.id, ok: true, test: { ...t, errors: t.errors.slice(0, 10).map((e) => scrubText(e, secrets).slice(0, 500)) } });
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
refuse(`unknown operation ${String(d.op).slice(0, 20)}`);
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
refuse(String(e?.message ?? e).slice(0, 300));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
return { summary, apply };
|
|
233
|
+
}
|
|
234
|
+
/** Reports what the deliveries did, with the vault's summary after them. Never throws. */
|
|
235
|
+
export async function ack(c, results, s, log) {
|
|
236
|
+
try {
|
|
237
|
+
const r = await signedPost(c, ACK_PATH, new TextEncoder().encode(JSON.stringify({ format: "sagentix-vault-ack/1", results, vault: s })));
|
|
238
|
+
if (r.status !== 200)
|
|
239
|
+
log(`warning: vault ack: HTTP ${r.status}`);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
log(`warning: vault ack failed: ${String(e?.message ?? e)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
package/collector/src/report.js
CHANGED
|
@@ -187,6 +187,7 @@ export class RunRecorder {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
// ── Building, trimming, sending ───────────────────────────────────────────────────────────────────
|
|
190
|
+
/** `secrets`: values to cut whatever their variable is called (the vault's secrets and files). */
|
|
190
191
|
export function buildReport(rec, o) {
|
|
191
192
|
const report = {
|
|
192
193
|
format: REPORT_FORMAT,
|
|
@@ -205,7 +206,8 @@ export function buildReport(rec, o) {
|
|
|
205
206
|
};
|
|
206
207
|
if (rec.log.length > MAX_LOG_LINES)
|
|
207
208
|
report.log[MAX_LOG_LINES - 1] = `(log cut: ${rec.log.length} lines, the first ${MAX_LOG_LINES - 1} kept)`;
|
|
208
|
-
|
|
209
|
+
const secrets = [...new Set([...envSecrets(o.env), ...(o.secrets ?? []).filter((v) => v.length >= 6)])].sort((a, b) => b.length - a.length);
|
|
210
|
+
return trimReport(scrubDeep(report, secrets));
|
|
209
211
|
}
|
|
210
212
|
const bytes = (r) => Buffer.byteLength(JSON.stringify(r, null, 2));
|
|
211
213
|
/** At most MAX_REPORT_BYTES: drops log lines first, then calls, then stacks, and says so in a last log line. */
|
|
@@ -31,6 +31,7 @@ export const REFUSED_ARGS = [
|
|
|
31
31
|
];
|
|
32
32
|
/** Environment variables whose values never leave the machine, even if Prowler echoes them in a detail. */
|
|
33
33
|
const SECRET_ENVS = [
|
|
34
|
+
"GITHUB_TOKEN", "CLOUDFLARE_API_TOKEN_READONLY", "ENTRA_CLIENT_SECRET", "M365_CERTIFICATE_CONTENT",
|
|
34
35
|
"AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AZURE_CLIENT_SECRET", "GITHUB_PERSONAL_ACCESS_TOKEN", "GITHUB_OAUTH_APP_TOKEN",
|
|
35
36
|
"GITHUB_APP_KEY", "OKTA_PRIVATE_KEY", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_KEY", "GOOGLEWORKSPACE_CREDENTIALS_CONTENT",
|
|
36
37
|
];
|
|
@@ -43,7 +44,12 @@ function catalogIds(dir = DEFAULT_CATALOG_DIR) {
|
|
|
43
44
|
return [];
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
const one = (v) =>
|
|
47
|
+
const one = (v) => {
|
|
48
|
+
const x = Array.isArray(v) ? v[0] : v;
|
|
49
|
+
return typeof x === "string" && x.trim() ? x.trim() : undefined;
|
|
50
|
+
};
|
|
51
|
+
/** --prowler-provider aws,azure or repeated, in order, without repeats. */
|
|
52
|
+
const providersOf = (v) => [...new Set((Array.isArray(v) ? v : typeof v === "string" ? [v] : []).flatMap((x) => String(x).split(",")).map((x) => x.trim()).filter(Boolean))];
|
|
47
53
|
/** Splits --prowler-args like a shell would for plain words and '…' or "…" quoting (no expansion). */
|
|
48
54
|
export function splitArgs(s) {
|
|
49
55
|
const out = [];
|
|
@@ -55,11 +61,22 @@ export function splitArgs(s) {
|
|
|
55
61
|
}
|
|
56
62
|
/** The provider's authentication flag, chosen from what the environment holds. Everything else Prowler reads from env itself. */
|
|
57
63
|
export function authArgs(provider, env) {
|
|
64
|
+
// The generated workflow names what the collector's own source reads, so Prowler reads the same scope
|
|
65
|
+
// (Prowler 5.47.0: aws --region, azure --subscription-ids, gcp --project-ids).
|
|
66
|
+
const scope = (name, flag) => { const v = (env[name] ?? "").split(/\s+/).filter(Boolean); return v.length ? [flag, ...v] : []; };
|
|
58
67
|
switch (provider) {
|
|
59
|
-
case "
|
|
68
|
+
case "aws": return scope("SAGENTIX_PROWLER_AWS_REGIONS", "--region");
|
|
69
|
+
case "gcp": return scope("SAGENTIX_PROWLER_GCP_PROJECTS", "--project-ids");
|
|
60
70
|
case "m365":
|
|
71
|
+
// A certificate signs in every Microsoft 365 service, Exchange and Teams included; a secret covers Graph only.
|
|
72
|
+
// --init-modules installs the PowerShell modules those services need (an ephemeral runner has none).
|
|
73
|
+
if (env.M365_CERTIFICATE_CONTENT)
|
|
74
|
+
return ["--certificate-auth", "--init-modules"];
|
|
75
|
+
return [...(env.AZURE_CLIENT_SECRET ? ["--sp-env-auth"] : ["--az-cli-auth"]), "--init-modules"];
|
|
76
|
+
case "azure":
|
|
61
77
|
// A client secret in env: service principal; otherwise the session `azure/login` (OIDC) left for the az CLI.
|
|
62
|
-
return env.AZURE_CLIENT_SECRET ? ["--sp-env-auth"] : ["--az-cli-auth"]
|
|
78
|
+
return [...(env.AZURE_CLIENT_SECRET ? ["--sp-env-auth"] : ["--az-cli-auth"]),
|
|
79
|
+
...(provider === "azure" ? scope("SAGENTIX_PROWLER_AZURE_SUBSCRIPTIONS", "--subscription-ids") : [])];
|
|
63
80
|
case "kubernetes":
|
|
64
81
|
return env.KUBECONFIG ? ["--kubeconfig-file", env.KUBECONFIG] : [];
|
|
65
82
|
default:
|
|
@@ -68,6 +85,25 @@ export function authArgs(provider, env) {
|
|
|
68
85
|
return [];
|
|
69
86
|
}
|
|
70
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Prowler reads its own variable names; the collector's native sources read theirs. So the same read-only credential
|
|
90
|
+
* the client already stored serves both ways (2026-11-01: each control collected at least two ways): the collector's
|
|
91
|
+
* name is copied to Prowler's when Prowler's is unset. Names from prowler/providers/<provider> in Prowler 5.47.0.
|
|
92
|
+
*/
|
|
93
|
+
export function prowlerEnv(provider, env) {
|
|
94
|
+
const alias = {
|
|
95
|
+
github: { GITHUB_PERSONAL_ACCESS_TOKEN: "GITHUB_TOKEN" },
|
|
96
|
+
cloudflare: { CLOUDFLARE_API_TOKEN: "CLOUDFLARE_API_TOKEN_READONLY" },
|
|
97
|
+
googleworkspace: { GOOGLEWORKSPACE_CREDENTIALS_FILE: "GOOGLE_SA_KEY_FILE", GOOGLEWORKSPACE_DELEGATED_USER: "GOOGLE_ADMIN_EMAIL" },
|
|
98
|
+
m365: { AZURE_TENANT_ID: "ENTRA_TENANT_ID", AZURE_CLIENT_ID: "ENTRA_CLIENT_ID", AZURE_CLIENT_SECRET: "ENTRA_CLIENT_SECRET" },
|
|
99
|
+
okta: { OKTA_ORG_DOMAIN: "OKTA_DOMAIN" },
|
|
100
|
+
};
|
|
101
|
+
const out = { ...env };
|
|
102
|
+
for (const [theirs, ours] of Object.entries(alias[provider] ?? {}))
|
|
103
|
+
if (!out[theirs] && env[ours])
|
|
104
|
+
out[theirs] = env[ours];
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
71
107
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
108
|
const s = (v) => (typeof v === "string" ? v : "");
|
|
73
109
|
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
@@ -184,44 +220,63 @@ export async function runProwler(provider, o) {
|
|
|
184
220
|
export const prowler = {
|
|
185
221
|
name: "prowler",
|
|
186
222
|
options: {
|
|
187
|
-
"prowler-provider": { type: "string", help: `Prowler
|
|
223
|
+
"prowler-provider": { type: "string", multiple: true, help: `Prowler providers to run, comma-separated: ${PROWLER_PROVIDERS.join(", ")} (one failing never stops the others)` },
|
|
188
224
|
"prowler-bin": { type: "string", help: "the prowler executable (default prowler on PATH)" },
|
|
189
225
|
"prowler-args": { type: "string", help: "extra Prowler options, written --prowler-args=\"--region us-east-1 ca-central-1\" (never output, send or fix options)" },
|
|
190
226
|
"prowler-output": { type: "string", help: "read this json-ocsf file instead of running Prowler" },
|
|
191
227
|
},
|
|
192
228
|
emits: catalogIds(),
|
|
193
229
|
describe(opts) {
|
|
194
|
-
const p =
|
|
230
|
+
const p = providersOf(opts["prowler-provider"]).join(", ") || "(no provider)";
|
|
195
231
|
return one(opts["prowler-output"]) ? `Prowler ${p} findings from a json-ocsf file` : `Prowler ${PROWLER_VERSION} ${p} scan`;
|
|
196
232
|
},
|
|
197
233
|
async run(ctx) {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const known = new Set(catalogIds(one(ctx.opts.catalog) ?? DEFAULT_CATALOG_DIR));
|
|
203
|
-
if (![...known].some((id) => id.startsWith(`prowler.${provider}.`))) {
|
|
204
|
-
ctx.warn(`prowler: sos-app's catalogue lists no ${provider} check (Prowler maps none to a KSI or SP 800-53 control); every finding will be dropped.`);
|
|
234
|
+
const providers = providersOf(ctx.opts["prowler-provider"]);
|
|
235
|
+
const bad = providers.filter((p) => !PROWLER_PROVIDERS.includes(p));
|
|
236
|
+
if (!providers.length || bad.length) {
|
|
237
|
+
throw new Error(`--prowler-provider is required: one or more of ${PROWLER_PROVIDERS.join(", ")}${bad.length ? ` (not: ${bad.join(", ")})` : ""}.`);
|
|
205
238
|
}
|
|
206
239
|
const file = one(ctx.opts["prowler-output"]);
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
if (file && providers.length > 1)
|
|
241
|
+
throw new Error("--prowler-output reads one provider's file; name one --prowler-provider with it.");
|
|
242
|
+
const known = new Set(catalogIds(one(ctx.opts.catalog) ?? DEFAULT_CATALOG_DIR));
|
|
243
|
+
const results = [];
|
|
244
|
+
const failures = [];
|
|
245
|
+
for (const provider of providers) {
|
|
246
|
+
if (![...known].some((id) => id.startsWith(`prowler.${provider}.`))) {
|
|
247
|
+
ctx.warn(`prowler: sos-app's catalogue lists no ${provider} check; every finding will be dropped.`);
|
|
248
|
+
}
|
|
249
|
+
let text;
|
|
209
250
|
try {
|
|
210
|
-
|
|
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
|
+
}
|
|
258
|
+
}
|
|
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 });
|
|
263
|
+
}
|
|
211
264
|
}
|
|
212
265
|
catch (e) {
|
|
213
|
-
|
|
266
|
+
// 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);
|
|
268
|
+
if (providers.length > 1)
|
|
269
|
+
ctx.warn(`prowler: ${provider} did not run (${e.message}); its controls keep their other ways.`);
|
|
270
|
+
continue;
|
|
214
271
|
}
|
|
272
|
+
const { results: r, summary } = parseOcsf(text, provider, known);
|
|
273
|
+
results.push(...r);
|
|
274
|
+
ctx.warn(`prowler: ${summary.findings} findings; ${summary.kept} results (${summary.merged} merged into the same check and resource, ` +
|
|
275
|
+
`${summary.muted} muted and ${summary.manual} manual sent as skipped); ${summary.dropped} findings dropped from ${summary.droppedChecks.length} checks not in the catalogue` +
|
|
276
|
+
(summary.droppedChecks.length ? `: ${summary.droppedChecks.slice(0, 20).join(", ")}${summary.droppedChecks.length > 20 ? ", …" : ""}` : ""));
|
|
215
277
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const extra = one(ctx.opts["prowler-args"]) ? splitArgs(one(ctx.opts["prowler-args"])) : [];
|
|
219
|
-
text = await runProwler(provider, { bin: one(ctx.opts["prowler-bin"]) ?? "prowler", extra, env: ctx.env, spawner });
|
|
220
|
-
}
|
|
221
|
-
const { results, summary } = parseOcsf(text, provider, known);
|
|
222
|
-
ctx.warn(`prowler: ${summary.findings} findings; ${summary.kept} results (${summary.merged} merged into the same check and resource, ` +
|
|
223
|
-
`${summary.muted} muted and ${summary.manual} manual sent as skipped); ${summary.dropped} findings dropped from ${summary.droppedChecks.length} checks not in the catalogue` +
|
|
224
|
-
(summary.droppedChecks.length ? `: ${summary.droppedChecks.slice(0, 20).join(", ")}${summary.droppedChecks.length > 20 ? ", …" : ""}` : ""));
|
|
278
|
+
if (failures.length === providers.length)
|
|
279
|
+
throw failures[0];
|
|
225
280
|
return scrub(results, SECRET_ENVS.map((n) => ctx.env[n]));
|
|
226
281
|
},
|
|
227
282
|
};
|