@sagentix-inc/evidence-collector 0.3.0 → 0.3.2

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module"
5
5
  }
@@ -411,8 +411,11 @@ async function accessAnalyzer(aws, out, rr, region) {
411
411
  if (!next)
412
412
  break;
413
413
  }
414
+ // Only ACCOUNT and ORGANIZATION analyzers report external access; the *_UNUSED_ACCESS and *_INTERNAL_ACCESS types
415
+ // analyze something else (AWS IAM Access Analyzer API Reference, AnalyzerSummary "type", read 2026-10-31).
414
416
  const active = all.filter((a) => a.status === "ACTIVE");
415
- out.add("access_analyzer_enabled", rr, active.length ? "pass" : "fail", `${all.length} analyzer(s), ${active.length} ACTIVE${active.length ? ` (types ${[...new Set(active.map((a) => String(a.type)))].join(", ")})` : ""}; one active analyzer required`);
417
+ const external = active.filter((a) => a.type === "ACCOUNT" || a.type === "ORGANIZATION");
418
+ out.add("access_analyzer_enabled", rr, external.length ? "pass" : "fail", `${all.length} analyzer(s), ${active.length} ACTIVE${active.length ? ` (types ${[...new Set(active.map((a) => String(a.type)))].join(", ")})` : ""}; one active external-access analyzer (ACCOUNT or ORGANIZATION) required`);
416
419
  }
417
420
  // ── Storage and encryption ──────────────────────────────────────────────────────────────────────
418
421
  const PAB = ["BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets"];
@@ -22,7 +22,7 @@ import { DEFAULT_CATALOG_DIR, loadCatalog } from "../catalog.js";
22
22
  import { scrub } from "./shared-cloud.js";
23
23
  export const PROWLER_PROVIDERS = ["aws", "azure", "gcp", "kubernetes", "m365", "github", "googleworkspace", "okta", "cloudflare"];
24
24
  /** The version the catalogues were built from (apps/sos-app/scripts/build_prowler_catalog.py). */
25
- export const PROWLER_VERSION = "5.45.0";
25
+ export const PROWLER_VERSION = "5.47.0";
26
26
  /** Options that make Prowler write, fix or send, or that change the output the collector reads. */
27
27
  export const REFUSED_ARGS = [
28
28
  "--push-to-cloud", "--security-hub", "--send-sh-only-fails", "--fixer", "--fixer-config", "--output-bucket",
@@ -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) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
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 "azure":
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 provider to run: ${PROWLER_PROVIDERS.join(", ")}` },
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 = one(opts["prowler-provider"]) ?? "(no provider)";
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 provider = one(ctx.opts["prowler-provider"]);
199
- if (!provider || !PROWLER_PROVIDERS.includes(provider)) {
200
- throw new Error(`--prowler-provider is required: one of ${PROWLER_PROVIDERS.join(", ")}.`);
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
- let text;
208
- if (file) {
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
- text = readFileSync(file, "utf8");
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
- throw new Error(`cannot read --prowler-output ${file}: ${e.code ?? String(e)}`);
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
- else {
217
- const spawner = ctx.clients.prowler ?? spawnProwler;
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {