@sagentix-inc/evidence-collector 0.3.5 → 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.
Files changed (49) hide show
  1. package/README.md +137 -9
  2. package/apps/sos-app/src/data/checks/aws.json +12 -0
  3. package/apps/sos-app/src/data/checks/azure.json +18 -0
  4. package/apps/sos-app/src/data/checks/bamboohr.json +40 -0
  5. package/apps/sos-app/src/data/checks/entra.json +1 -1
  6. package/apps/sos-app/src/data/checks/gcp.json +12 -0
  7. package/apps/sos-app/src/data/checks/intune.json +7 -1
  8. package/apps/sos-app/src/data/checks/jamf.json +8 -2
  9. package/apps/sos-app/src/data/checks/kandji.json +7 -1
  10. package/apps/sos-app/src/data/checks/rippling.json +40 -0
  11. package/apps/sos-app/src/data/checks/vulnscan.json +27 -0
  12. package/apps/sos-app/src/data/checks/web.json +31 -1
  13. package/collector/package.json +1 -1
  14. package/collector/src/cli.js +344 -37
  15. package/collector/src/config.js +12 -0
  16. package/collector/src/doctor.js +382 -0
  17. package/collector/src/policies.js +160 -0
  18. package/collector/src/pool.js +19 -0
  19. package/collector/src/report.js +88 -16
  20. package/collector/src/sources/aws/checks.js +64 -0
  21. package/collector/src/sources/aws.js +6 -1
  22. package/collector/src/sources/azure/api.js +4 -0
  23. package/collector/src/sources/azure/checks.js +91 -1
  24. package/collector/src/sources/azure.js +8 -2
  25. package/collector/src/sources/bamboohr.js +133 -0
  26. package/collector/src/sources/cloudflare/api.js +24 -4
  27. package/collector/src/sources/device_common.js +42 -0
  28. package/collector/src/sources/directory.js +198 -0
  29. package/collector/src/sources/engines.js +2 -1
  30. package/collector/src/sources/entra.js +4 -3
  31. package/collector/src/sources/gcp/project.js +47 -3
  32. package/collector/src/sources/gcp.js +5 -2
  33. package/collector/src/sources/github.js +46 -8
  34. package/collector/src/sources/google_oauth.js +3 -3
  35. package/collector/src/sources/google_workspace.js +4 -3
  36. package/collector/src/sources/hris_common.js +189 -0
  37. package/collector/src/sources/index.js +3 -0
  38. package/collector/src/sources/intune.js +40 -3
  39. package/collector/src/sources/jamf.js +65 -8
  40. package/collector/src/sources/kandji.js +46 -7
  41. package/collector/src/sources/oauth.js +22 -0
  42. package/collector/src/sources/prowler.js +178 -52
  43. package/collector/src/sources/readonly_http.js +27 -5
  44. package/collector/src/sources/rippling.js +114 -0
  45. package/collector/src/sources/shared-cloud.js +14 -6
  46. package/collector/src/sources/vulnscan.js +386 -0
  47. package/collector/src/sources/web.js +106 -11
  48. package/collector/src/sources/web_privacy.js +266 -0
  49. package/package.json +1 -1
@@ -0,0 +1,382 @@
1
+ // `collector doctor`: a smoke test a customer runs before the first real run (2026-11-07). For each configured source it
2
+ // checks the credentials are there and makes ONE cheap read per permission the source needs, then prints pass or fail
3
+ // with the fix: the exact scope, role or permission named in that source's own header (its provider-docs citations).
4
+ // It collects nothing and sends nothing: no check-in, no push, no run report. File-based sources check their files are
5
+ // readable; engines check their executable is on PATH without running it; the host sources check the platform.
6
+ //
7
+ // Every read below is one the source itself makes (same path, with a page size of 1 where the API takes one), so a
8
+ // pass here means the same call will be allowed during a run. GET only, except GCP's :getIamPolicy, a read the GCP
9
+ // source makes as a POST.
10
+ import { accessSync, constants, statSync } from "node:fs";
11
+ import { delimiter, isAbsolute, join } from "node:path";
12
+ import { isObj, realSleep, why, withRetry } from "./sources/readonly_http.js";
13
+ import { oktaFromEnv, OKTA_ENV } from "./sources/okta.js";
14
+ import { entraFromEnv, ENTRA_ENV } from "./sources/entra.js";
15
+ import { jumpcloudFromEnv, JUMPCLOUD_ENV } from "./sources/jumpcloud.js";
16
+ import { GOOGLE_ENV, googleFromEnv } from "./sources/google_workspace.js";
17
+ import { OAUTH_ENV } from "./sources/google_oauth.js";
18
+ import { githubFromEnv, GITHUB_TOKEN_ENV } from "./sources/github.js";
19
+ import { vercelFromEnv, VERCEL_TOKEN_ENV } from "./sources/vercel.js";
20
+ import { pagerdutyFromEnv, PAGERDUTY_ENV } from "./sources/pagerduty.js";
21
+ import { stripeFromEnv, STRIPE_ENV } from "./sources/stripe.js";
22
+ import { resendFromEnv, RESEND_ENV } from "./sources/resend.js";
23
+ import { fleetFromEnv, FLEET_ENV } from "./sources/fleet.js";
24
+ import { intuneFromEnv, INTUNE_ENV } from "./sources/intune.js";
25
+ import { jamfFromEnv, JAMF_ENV, inventoryPath, VERSIONS as JAMF_VERSIONS } from "./sources/jamf.js";
26
+ import { kandjiFromEnv, KANDJI_ENV } from "./sources/kandji.js";
27
+ import { defenderFromEnv, DEFENDER_ENV } from "./sources/defender_endpoint.js";
28
+ import { ws1FromEnv, WS1_ENV } from "./sources/workspace_one.js";
29
+ import { configmgrFromEnv, CONFIGMGR_ENV } from "./sources/configmgr.js";
30
+ import { googleDevicesFromEnv } from "./sources/google_endpoints.js";
31
+ import { ivantiMdmFromEnv, IVANTI_MDM_ENV, metadataPath } from "./sources/ivanti_mdm.js";
32
+ import { ninjaoneFromEnv, NINJAONE_ENV } from "./sources/ninjaone.js";
33
+ import { endpointCentralFromEnv, EC_ENV, SCAN_PATH } from "./sources/endpoint_central.js";
34
+ import { bamboohrFromEnv, BAMBOOHR_ENV, employeesPath } from "./sources/bamboohr.js";
35
+ import { ripplingFromEnv, RIPPLING_ENV } from "./sources/rippling.js";
36
+ import { fetchCloudflareApi, TOKEN_ENV as CF_TOKEN_ENV } from "./sources/cloudflare/api.js";
37
+ import { Aws, callerAccount, envCredentials, fetchAwsTransport, queryReq, awsWhy } from "./sources/aws/api.js";
38
+ import { API as AZURE_API, ARM, armGet, fetchAzure, tokenFromEnv as azureToken } from "./sources/azure/api.js";
39
+ import { Gcp, GCP_KEY_ENV, GCP_TOKEN_ENV, fetchGcpTransport, gcpKeyToken } from "./sources/gcp/api.js";
40
+ import { fetchJira, JIRA_ENV } from "./sources/jira.js";
41
+ const one = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v[0] : undefined);
42
+ const many = (v) => (Array.isArray(v) ? v : typeof v === "string" ? [v] : []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
43
+ const sleepOf = (ctx) => ctx.clients.sleep ?? realSleep;
44
+ /** The verdict on one answer to `call`: the fix names `permission` on a 403, `creds` on a 401. `label` names the
45
+ * permission shortly for the pass line. */
46
+ export function judge(label, call, r, creds, permission) {
47
+ const what = call;
48
+ if (r.status >= 200 && r.status < 300)
49
+ return { what, verdict: "pass", detail: `HTTP ${r.status} (needs ${label})` };
50
+ const detail = r.message;
51
+ if (r.status === 401)
52
+ return { what, verdict: "fail", detail, fix: `the credential was refused: check ${creds} (expired, revoked, or for another tenant)` };
53
+ if (r.status === 403)
54
+ return { what, verdict: "fail", detail, fix: `grant ${permission}` };
55
+ if (r.status === 404)
56
+ return { what, verdict: "fail", detail, fix: `not found: check the organization, team, project or URL given (or the feature is not on this plan); needs ${permission}` };
57
+ if (r.status === 429)
58
+ return { what, verdict: "fail", detail, fix: "rate limited even after retries: run the doctor again in a few minutes" };
59
+ if (r.status === 0)
60
+ return { what, verdict: "fail", detail, fix: "no answer: check the network path from this runner (proxy, firewall, DNS) and the URL in the credentials" };
61
+ return { what, verdict: "fail", detail, fix: `the provider answered HTTP ${r.status}; try again later, then check its status page` };
62
+ }
63
+ const credsCheck = (ok, creds, why) => ok ? { what: "credentials", verdict: "pass", detail: `${creds}: present` }
64
+ : { what: "credentials", verdict: "fail", detail: why ?? `${creds}: not set`, fix: `set ${creds} (in the vault, the CI secrets or the environment)` };
65
+ /** A REST source: the client the source builds (or the one injected), then one GET per [path, permission]. */
66
+ function rest(o) {
67
+ return async (ctx) => {
68
+ const given = ctx.clients[o.key];
69
+ const built = given !== undefined ? (given ?? "no client") : o.build(ctx);
70
+ if (typeof built === "string")
71
+ return [credsCheck(false, o.creds, built)];
72
+ const api = withRetry(built, sleepOf(ctx));
73
+ const out = [credsCheck(true, o.creds)];
74
+ for (const [path, permission] of o.calls(ctx)) {
75
+ let r;
76
+ try {
77
+ r = await api.get(path);
78
+ }
79
+ catch (e) {
80
+ r = { status: 0, headers: {}, body: { message: String(e) } };
81
+ }
82
+ out.push(judge(permission, `GET ${path.split("?")[0]}`, { status: r.status, message: why(r) }, o.creds, permission));
83
+ // A refused credential refuses every call: one line says it.
84
+ if (r.status === 401)
85
+ break;
86
+ }
87
+ return out;
88
+ };
89
+ }
90
+ /** Each named file is there and readable. `need` lists options of which one must be given. */
91
+ function files(need, optional = []) {
92
+ return async (ctx) => {
93
+ const out = [];
94
+ const given = need.filter((k) => many(ctx.opts[k]).length);
95
+ if (!given.length)
96
+ out.push({ what: "input", verdict: "fail", detail: `no --${need.join(" or --")} given`, fix: `give --${need[0]} <file> (in the config file or on the command line)` });
97
+ for (const k of [...given, ...optional.filter((x) => many(ctx.opts[x]).length)]) {
98
+ for (const p of many(ctx.opts[k])) {
99
+ try {
100
+ accessSync(p, constants.R_OK);
101
+ const st = statSync(p);
102
+ out.push({ what: `--${k}`, verdict: "pass", detail: `${p}: readable, ${st.isDirectory() ? "a folder" : `${st.size} bytes`}, modified ${st.mtime.toISOString().slice(0, 10)}` });
103
+ }
104
+ catch (e) {
105
+ out.push({ what: `--${k}`, verdict: "fail", detail: `${p}: ${e.code ?? String(e)}`, fix: "produce the file before the collector runs (the engine's step in the workflow), or fix the path" });
106
+ }
107
+ }
108
+ }
109
+ return out;
110
+ };
111
+ }
112
+ /** Whether an executable is on PATH (or at the path given), found without running it. */
113
+ export function onPath(bin, env, platform = process.platform) {
114
+ const exts = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
115
+ const candidates = isAbsolute(bin) || bin.includes("/") ? [bin] : (env.PATH ?? "").split(delimiter).filter(Boolean).map((d) => join(d, bin));
116
+ for (const c of candidates)
117
+ for (const x of exts) {
118
+ try {
119
+ accessSync(c + x, constants.X_OK);
120
+ if (statSync(c + x).isFile())
121
+ return c + x;
122
+ }
123
+ catch { /* next */ }
124
+ }
125
+ return undefined;
126
+ }
127
+ const binCheck = (bin, env, install) => {
128
+ const at = onPath(bin, env);
129
+ return at ? { what: "executable", verdict: "pass", detail: `${bin}: ${at}` } : { what: "executable", verdict: "fail", detail: `${bin}: not on PATH`, fix: install };
130
+ };
131
+ const graphCreds = (pre) => `${pre.tenant}, ${pre.client} and ${pre.secret} or ${pre.cert} (or their ENTRA_* twins)`;
132
+ const ENTRA_CREDS = `${ENTRA_ENV.tenant}, ${ENTRA_ENV.client} and ${ENTRA_ENV.secret} or ${ENTRA_ENV.cert}`;
133
+ const GOOGLE_CREDS = `${GOOGLE_ENV.keyFile} and ${GOOGLE_ENV.admin} (service account with domain-wide delegation), or ${OAUTH_ENV.clientFile} after \`collector google-login\``;
134
+ const googleCustomer = (env) => env[GOOGLE_ENV.customer] || "my_customer";
135
+ export const PROBES = {
136
+ // ── Identity and code ────────────────────────────────────────────────────────────────────────────
137
+ okta: rest({ key: "okta", creds: `${OKTA_ENV.domain} with ${OKTA_ENV.token} (a Read-Only Administrator's token) or ${OKTA_ENV.access}`, build: (c) => oktaFromEnv(c.env),
138
+ calls: () => [["/api/v1/policies?type=PASSWORD", "okta.policies.read"], ["/api/v1/authenticators", "okta.authenticators.read (Identity Engine)"],
139
+ ["/api/v1/iam/assignees/users?limit=1", "okta.roles.read"], ["/api/v1/users?limit=1", "okta.users.read"], ["/api/v1/logs?limit=1", "okta.logs.read"]] }),
140
+ entra: rest({ key: "entra", creds: ENTRA_CREDS, build: (c) => entraFromEnv(c.env),
141
+ calls: () => [["/policies/authorizationPolicy", "the application permission Policy.Read.All"],
142
+ ["/identity/conditionalAccess/policies?$top=1", "the application permission Policy.Read.All (Conditional Access needs Entra ID P1)"],
143
+ ["/roleManagement/directory/roleAssignments?$top=1", "the application permission RoleManagement.Read.Directory"],
144
+ ["/users?$top=1&$select=id", "the application permission User.Read.All"],
145
+ ["/auditLogs/directoryAudits?$top=1", "the application permission AuditLog.Read.All"],
146
+ ["/reports/authenticationMethods/userRegistrationDetails?$top=1", "the application permission AuditLog.Read.All (the report needs Entra ID P1 or P2)"],
147
+ ["/subscribedSkus", "the application permission LicenseAssignment.Read.All"]] }),
148
+ jumpcloud: rest({ key: "jumpcloud", creds: `${JUMPCLOUD_ENV.key} (an administrator with the Read Only role)`,
149
+ build: (c) => jumpcloudFromEnv(c.env, { region: one(c.opts["jumpcloud-region"]), orgId: one(c.opts["jumpcloud-org-id"]) }),
150
+ calls: () => [["/organizations?limit=1", "the API key scope organizations.readonly"], ["/systemusers?limit=1&skip=0", "the API key scope users.readonly"],
151
+ ["/v2/authn/policies?limit=1&skip=0", "the API key scope authn.readonly"], ["/systems?limit=1&skip=0", "the API key scope systems.readonly"]] }),
152
+ google_workspace: rest({ key: "google", creds: GOOGLE_CREDS, build: (c) => googleFromEnv(c.env),
153
+ calls: (c) => [[`/admin/directory/v1/users?customer=${encodeURIComponent(googleCustomer(c.env))}&maxResults=1&projection=basic`, "the scope https://www.googleapis.com/auth/admin.directory.user.readonly in domain-wide delegation"],
154
+ ["/admin/reports/v1/activity/users/all/applications/admin?maxResults=1", "the scope https://www.googleapis.com/auth/admin.reports.audit.readonly in domain-wide delegation"]] }),
155
+ github: rest({ key: "github", creds: `${GITHUB_TOKEN_ENV} (a fine-grained token, read-only)`, build: (c) => githubFromEnv(c.env),
156
+ calls: (c) => {
157
+ const org = one(c.opts["github-org"]);
158
+ if (!org)
159
+ return [["/rate_limit", "no permission (the token is only checked; give --github-org to check the organization's)"]];
160
+ const o = encodeURIComponent(org);
161
+ return [[`/orgs/${o}`, "membership of the organization"], [`/orgs/${o}/members?role=admin&per_page=1`, "the fine-grained permission Organization: Members (read)"],
162
+ [`/orgs/${o}/actions/permissions`, "the fine-grained permission Organization: Administration (read)"],
163
+ [`/orgs/${o}/repos?type=all&per_page=1`, "the fine-grained permission Repository: Metadata (read), on all repositories"]];
164
+ } }),
165
+ vercel: rest({ key: "vercel", creds: VERCEL_TOKEN_ENV, build: (c) => vercelFromEnv(c.env),
166
+ calls: (c) => {
167
+ const team = one(c.opts["vercel-team"]);
168
+ const tq = team ? `teamId=${encodeURIComponent(team)}` : "";
169
+ return [...(team ? [[`/v2/teams/${encodeURIComponent(team)}`, "a token scoped to the team, from a member who can read its settings"]] : []),
170
+ [`/v10/projects?limit=1${tq ? `&${tq}` : ""}`, "a token scoped to the team (or account) that owns the projects"]];
171
+ } }),
172
+ pagerduty: rest({ key: "pagerduty", creds: `${PAGERDUTY_ENV.token} (a read-only API key)`, build: (c) => pagerdutyFromEnv(c.env),
173
+ calls: () => [["/services?limit=1", "services.read"], ["/incidents?limit=1", "incidents.read"], ["/escalation_policies?limit=1", "escalation_policies.read"],
174
+ ["/incident_workflows?limit=1", "incident_workflows.read"]] }),
175
+ stripe: rest({ key: "stripe", creds: `${STRIPE_ENV.token} (a restricted key, rk_...)`, build: (c) => stripeFromEnv(c.env),
176
+ calls: () => [["/v1/webhook_endpoints?limit=1", "the restricted key permission Webhook Endpoints: Read"]] }),
177
+ resend: rest({ key: "resend", creds: RESEND_ENV.token, build: (c) => resendFromEnv(c.env),
178
+ calls: () => [["/domains?limit=1", "a key with full_access (Resend has no read-only key)"], ["/api-keys?limit=1", "a key with full_access (Resend has no read-only key)"]] }),
179
+ // ── HR ───────────────────────────────────────────────────────────────────────────────────────────
180
+ bamboohr: rest({ key: "bamboohr", creds: `${BAMBOOHR_ENV.key} and --bamboohr-company`, build: (c) => bamboohrFromEnv(c.env, one(c.opts["bamboohr-company"])),
181
+ calls: () => [[employeesPath("active", ["status"]).replace(/page%5Blimit%5D=\d+/, "page%5Blimit%5D=1"), "an API key of a user allowed to see employees' status (BambooHR access level)"]] }),
182
+ rippling: rest({ key: "rippling", creds: RIPPLING_ENV.token, build: (c) => ripplingFromEnv(c.env),
183
+ calls: () => [["/workers/?limit=1", "the token scope to read workers (workers:read)"]] }),
184
+ // ── Devices ──────────────────────────────────────────────────────────────────────────────────────
185
+ fleet: rest({ key: "fleet", creds: `${FLEET_ENV.url} and ${FLEET_ENV.token} (an API-only user with the global Observer role)`, build: (c) => fleetFromEnv(c.env),
186
+ calls: () => [["/hosts?page=0&per_page=1&order_key=id&order_direction=asc&populate_policies=true", "the global Observer role"]] }),
187
+ intune: rest({ key: "intune", creds: graphCreds(INTUNE_ENV), build: (c) => intuneFromEnv(c.env),
188
+ calls: () => [["/deviceManagement/managedDevices?$top=1&$select=id", "the application permission DeviceManagementManagedDevices.Read.All"],
189
+ ["/deviceManagement/deviceCompliancePolicies?$top=1", "the application permission DeviceManagementConfiguration.Read.All"]] }),
190
+ jamf: rest({ key: "jamf", creds: `${JAMF_ENV.url}, ${JAMF_ENV.client} and ${JAMF_ENV.secret} (an API client)`, build: (c) => jamfFromEnv(c.env),
191
+ calls: () => [[inventoryPath(JAMF_VERSIONS[0], 0).replace(/page-size=\d+/, "page-size=1"), "the API role privilege Read Computers"],
192
+ ["/v2/mdm/commands?page=0&page-size=1", "the API role privilege View MDM command information in Jamf Pro API"]] }),
193
+ kandji: rest({ key: "kandji", creds: `${KANDJI_ENV.url} and ${KANDJI_ENV.token}`, build: (c) => kandjiFromEnv(c.env),
194
+ calls: () => [["/v1/devices?platform=Mac&limit=1&offset=0", "the token permission List Devices"],
195
+ ["/v1/prism/application_firewall?device_families=Mac&limit=1&cursor=", "the token permission Prism: Application firewall"]] }),
196
+ defender_endpoint: rest({ key: "defender_endpoint", creds: graphCreds(DEFENDER_ENV), build: (c) => defenderFromEnv(c.env),
197
+ calls: () => [["/api/machines?$top=1", "the WindowsDefenderATP application permission Machine.Read.All"],
198
+ ["/api/vulnerabilities/machinesVulnerabilities?$top=1", "the WindowsDefenderATP application permission Vulnerability.Read.All"],
199
+ ["/api/recommendations?$top=1", "the WindowsDefenderATP application permission SecurityRecommendation.Read.All"]] }),
200
+ workspace_one: async (ctx) => {
201
+ const given = ctx.clients.workspace_one;
202
+ const b = given !== undefined ? (given === null ? "no client" : "get" in given ? given : given.v3) : ((w) => (typeof w === "string" ? w : w.v3))(ws1FromEnv(ctx.env));
203
+ return rest({ key: "\0", creds: `${WS1_ENV.url}, ${WS1_ENV.tokenUrl}, ${WS1_ENV.client} and ${WS1_ENV.secret} (an OAuth client)`, build: () => b,
204
+ calls: () => [["/mdm/devices/search?page=0&page_size=1", "the role permission REST > Devices > REST API Devices Read"]] })(ctx);
205
+ },
206
+ configmgr: rest({ key: "configmgr", creds: `${CONFIGMGR_ENV.provider} (run on a domain-joined Windows host as a Read-only Analyst)`, build: (c) => configmgrFromEnv(c.env),
207
+ calls: () => [["/SMS_CombinedDeviceResources?$top=1", "the Configuration Manager security role Read-only Analyst"]] }),
208
+ google_endpoints: rest({ key: "google_endpoints", creds: `${GOOGLE_ENV.keyFile} and ${GOOGLE_ENV.admin} (service account with domain-wide delegation)`, build: (c) => googleDevicesFromEnv(c.env),
209
+ calls: (c) => [[`/v1/devices?customer=${encodeURIComponent(`customers/${googleCustomer(c.env)}`)}&pageSize=1&view=USER_ASSIGNED_DEVICES`, "the scope https://www.googleapis.com/auth/cloud-identity.devices.readonly in domain-wide delegation"]] }),
210
+ ivanti_mdm: rest({ key: "ivanti_mdm", creds: `${IVANTI_MDM_ENV.url}, ${IVANTI_MDM_ENV.user} and ${IVANTI_MDM_ENV.password} (an admin with the Device Read Only role)`, build: (c) => ivantiMdmFromEnv(c.env),
211
+ calls: () => [[metadataPath, "the admin role Device Read Only (API access enabled for the user)"]] }),
212
+ ninjaone: rest({ key: "ninjaone", creds: `${NINJAONE_ENV.url}, ${NINJAONE_ENV.client} and ${NINJAONE_ENV.secret} (an API Services client app)`, build: (c) => ninjaoneFromEnv(c.env),
213
+ calls: () => [["/v2/devices?pageSize=1", "the client app scope Monitoring"], ["/v2/queries/operating-systems?pageSize=1", "the client app scope Monitoring"]] }),
214
+ endpoint_central: rest({ key: "endpoint_central", creds: `${EC_ENV.url}, ${EC_ENV.client}, ${EC_ENV.secret} and ${EC_ENV.refresh} (a Zoho self client)`, build: (c) => endpointCentralFromEnv(c.env),
215
+ calls: () => [[SCAN_PATH, "the scope DesktopCentralCloud.Inventory.READ and a role with Inventory read access"],
216
+ ["/dcapi/patch/reports/systemHealth?page=1&pageLimit=1", "the scope DesktopCentralCloud.PatchMgmt.READ and a role with Patch Management read access"]] }),
217
+ // ── Clouds and ticketing ─────────────────────────────────────────────────────────────────────────
218
+ cloudflare: async (ctx) => {
219
+ const token = ctx.env[CF_TOKEN_ENV];
220
+ const given = ctx.clients.cloudflare;
221
+ const api = given !== undefined ? given : token ? fetchCloudflareApi(token, fetch, undefined, sleepOf(ctx)) : null;
222
+ if (!api)
223
+ return [credsCheck(false, CF_TOKEN_ENV)];
224
+ const out = [credsCheck(true, CF_TOKEN_ENV)];
225
+ const get = async (path, permission) => {
226
+ let r;
227
+ try {
228
+ r = await api.get(path);
229
+ }
230
+ catch (e) {
231
+ r = { status: 0, body: { errors: [{ message: String(e) }] } };
232
+ }
233
+ const errs = isObj(r.body) && Array.isArray(r.body.errors) ? r.body.errors.map((e) => (isObj(e) ? String(e.message ?? "") : "")).filter(Boolean).join("; ") : "";
234
+ out.push(judge(permission, `GET ${path.split("?")[0]}`, { status: r.status, message: `HTTP ${r.status}${errs ? `: ${errs.slice(0, 200)}` : ""}` }, CF_TOKEN_ENV, permission));
235
+ };
236
+ for (const z of many(ctx.opts.zone).slice(0, 1))
237
+ await get(`/zones/${encodeURIComponent(z)}`, "the token permission Zone: Zone Read");
238
+ if (!many(ctx.opts.zone).length)
239
+ await get("/zones?per_page=5&page=1", "the token permission Zone: Zone Read");
240
+ for (const a of many(ctx.opts.account))
241
+ await get(`/accounts/${encodeURIComponent(a)}/members?per_page=5&page=1`, "the token permission Account: Account Settings Read");
242
+ return out;
243
+ },
244
+ aws: async (ctx) => {
245
+ const region = many(ctx.opts["aws-regions"])[0] || ctx.env.AWS_REGION || ctx.env.AWS_DEFAULT_REGION || "us-east-1";
246
+ const creds = "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE";
247
+ let t = ctx.clients.aws;
248
+ if (t === null)
249
+ return [credsCheck(false, creds)];
250
+ if (t === undefined) {
251
+ const c = await envCredentials(ctx.env, region);
252
+ if (!c)
253
+ return [credsCheck(false, creds)];
254
+ if ("error" in c)
255
+ return [credsCheck(false, creds, c.error)];
256
+ t = fetchAwsTransport(c);
257
+ }
258
+ const api = new Aws(t);
259
+ const out = [];
260
+ const who = await callerAccount(api, region);
261
+ if ("error" in who)
262
+ return [{ what: "credentials", verdict: "fail", detail: `sts:GetCallerIdentity: ${who.error}`, fix: `check ${creds} (expired session or wrong account)` }];
263
+ out.push({ what: "credentials", verdict: "pass", detail: `sts:GetCallerIdentity: account ${who.account}` });
264
+ const r = await api.send(queryReq("iam", "us-east-1", "GetAccountSummary", "2010-05-08"));
265
+ out.push(judge("iam:GetAccountSummary", "iam GetAccountSummary", { status: r.status, message: awsWhy(r) }, creds, "the AWS managed policy SecurityAudit (or iam:GetAccountSummary) to the role"));
266
+ return out;
267
+ },
268
+ azure: async (ctx) => {
269
+ const creds = "AZURE_ACCESS_TOKEN, or AZURE_TENANT_ID and AZURE_CLIENT_ID with AZURE_FEDERATED_TOKEN_FILE or AZURE_CLIENT_SECRET";
270
+ const given = ctx.clients.azure;
271
+ const t = given !== undefined ? given : ((tok) => (tok ? fetchAzure(tok) : null))(azureToken(ctx.env));
272
+ if (!t)
273
+ return [credsCheck(false, creds)];
274
+ const out = [credsCheck(true, creds)];
275
+ const subs = many(ctx.opts["azure-subscription"]);
276
+ for (const url of subs.length ? subs.map((s) => `${ARM}/subscriptions/${encodeURIComponent(s)}?api-version=${AZURE_API.subscriptions}`) : [`${ARM}/subscriptions?api-version=${AZURE_API.subscriptions}`]) {
277
+ const r = await armGet(t, url);
278
+ out.push(judge("Reader", `GET ${new URL(url).pathname}`, { status: r.status, message: `HTTP ${r.status}` }, creds, "the Reader role (and Security Reader) on the subscription to the app"));
279
+ }
280
+ return out;
281
+ },
282
+ gcp: async (ctx) => {
283
+ const creds = `${GCP_TOKEN_ENV}, or ${GCP_KEY_ENV}`;
284
+ const token = ctx.env[GCP_TOKEN_ENV];
285
+ const keyed = token ? undefined : gcpKeyToken(ctx.env);
286
+ const t = ctx.clients.gcp !== undefined ? ctx.clients.gcp : token ? fetchGcpTransport(token) : typeof keyed === "function" ? fetchGcpTransport(keyed) : null;
287
+ if (!t)
288
+ return [credsCheck(false, creds, typeof keyed === "string" ? keyed : undefined)];
289
+ const projects = many(ctx.opts["gcp-project"]);
290
+ const out = [credsCheck(true, creds)];
291
+ if (!projects.length)
292
+ out.push({ what: "input", verdict: "fail", detail: "no --gcp-project given", fix: "give --gcp-project <id>" });
293
+ const api = new Gcp(t);
294
+ for (const p of projects) {
295
+ const r = await api.call("POST", `https://cloudresourcemanager.googleapis.com/v1/projects/${encodeURIComponent(p)}:getIamPolicy`, { options: { requestedPolicyVersion: 3 } });
296
+ out.push(judge("roles/iam.securityReviewer", `POST projects/${p}:getIamPolicy`, { status: r.status, message: `HTTP ${r.status}` }, creds, "roles/iam.securityReviewer and roles/viewer on the project to the service account"));
297
+ }
298
+ return out;
299
+ },
300
+ jira: async (ctx) => {
301
+ const creds = `${JIRA_ENV.base}, ${JIRA_ENV.email} and ${JIRA_ENV.token}`;
302
+ let t = ctx.clients.jira;
303
+ if (t === null)
304
+ return [credsCheck(false, creds)];
305
+ if (t === undefined) {
306
+ const base = ctx.env[JIRA_ENV.base], email = ctx.env[JIRA_ENV.email], token = ctx.env[JIRA_ENV.token];
307
+ if (!base || !email || !token)
308
+ return [credsCheck(false, creds)];
309
+ try {
310
+ t = fetchJira(base, email, token);
311
+ }
312
+ catch (e) {
313
+ return [credsCheck(false, creds, e.message)];
314
+ }
315
+ }
316
+ const out = [credsCheck(true, creds)];
317
+ const jql = Object.entries(ctx.opts).find(([k, v]) => k.startsWith("jira-") && k.endsWith("-jql") && typeof v === "string")?.[1];
318
+ const path = `/rest/api/3/search/jql?jql=${encodeURIComponent(jql ?? "order by created DESC")}&maxResults=1&fields=key`;
319
+ const r = await t.get(path);
320
+ out.push(judge("Browse projects", "GET /rest/api/3/search/jql", { status: r.status, message: `HTTP ${r.status}` }, creds, "the project permission Browse projects on the projects the JQL names"));
321
+ return out;
322
+ },
323
+ // ── Files, engines and hosts ─────────────────────────────────────────────────────────────────────
324
+ lms: files(["training-csv"], ["roster-csv"]),
325
+ scubagoggles: files(["scubagoggles-results"]),
326
+ scubagear: files(["scubagear-results"]),
327
+ trivy: files(["trivy-report"]),
328
+ grype: files(["grype-report"]),
329
+ syft: files(["sbom-file"]),
330
+ gitleaks: files(["gitleaks-report"]),
331
+ scorecard: files(["scorecard-result"]),
332
+ kubescape: files(["kubescape-report"]),
333
+ checkov: files(["checkov-report"]),
334
+ vulnscan: files(["vulnscan-report"], ["vulnscan-inventory"]),
335
+ prowler: async (ctx) => {
336
+ const providers = many(ctx.opts["prowler-provider"]);
337
+ const out = [providers.length ? { what: "input", verdict: "pass", detail: `providers ${providers.join(", ")}` }
338
+ : { what: "input", verdict: "fail", detail: "no --prowler-provider given", fix: "give --prowler-provider aws (or azure, gcp, kubernetes, m365, github, googleworkspace, okta, cloudflare)" }];
339
+ if (one(ctx.opts["prowler-output"]))
340
+ return [...out, ...await files(["prowler-output"])(ctx)];
341
+ if (ctx.clients.prowler)
342
+ return [...out, { what: "executable", verdict: "pass", detail: "a test runner stands in for prowler" }];
343
+ return [...out, binCheck(one(ctx.opts["prowler-bin"]) ?? "prowler", ctx.env, "install Prowler (pip install prowler, or the runner image) or pass --prowler-bin; the provider's own credentials are checked by Prowler itself")];
344
+ },
345
+ stig: async (ctx) => {
346
+ if (one(ctx.opts["cinc-report"]) || one(ctx.opts["stig-input-file"]))
347
+ return files(["cinc-report", "stig-input-file"])(ctx);
348
+ const out = [];
349
+ if (!many(ctx.opts["stig-target"]).length)
350
+ out.push({ what: "input", verdict: "fail", detail: "no --stig-target or --cinc-report given", fix: "give --stig-target ssh://user@host with --stig-profile, or --cinc-report <file>" });
351
+ if (ctx.clients.cinc)
352
+ out.push({ what: "executable", verdict: "pass", detail: "a test runner stands in for cinc-auditor" });
353
+ else
354
+ out.push(binCheck(one(ctx.opts["cinc-bin"]) ?? "cinc-auditor", ctx.env, "install Cinc Auditor (or use the runner image), or pass --cinc-bin"));
355
+ return out;
356
+ },
357
+ macos: async (ctx) => [ctx.clients.macos || process.platform === "darwin"
358
+ ? { what: "platform", verdict: "pass", detail: "macOS: this Mac's own settings are read with its own tools" }
359
+ : { what: "platform", verdict: "fail", detail: `this computer runs ${process.platform}`, fix: "run the macos source on the Mac to check (a LaunchAgent from sos-app's setup)" }],
360
+ windows: async (ctx) => [ctx.clients.windows || process.platform === "win32"
361
+ ? { what: "platform", verdict: "pass", detail: "Windows: this PC's own settings are read with PowerShell" }
362
+ : { what: "platform", verdict: "fail", detail: `this computer runs ${process.platform}`, fix: "run the windows source on the PC to check (a scheduled task from sos-app's setup)" }],
363
+ web: async (ctx) => {
364
+ const urls = many(ctx.opts["web-url"]), proofs = new Set(many(ctx.opts["web-proof"]).map((p) => p.split("=")[0].toLowerCase()));
365
+ if (!urls.length && !many(ctx.opts["web-privacy-url"]).length)
366
+ return [{ what: "input", verdict: "fail", detail: "no --web-url given", fix: "give --web-url https://<host>/ with --web-proof <host>=<token> from sos-app" }];
367
+ return urls.map((u) => {
368
+ let host = "";
369
+ try {
370
+ host = new URL(u).hostname.toLowerCase();
371
+ }
372
+ catch {
373
+ return { what: "--web-url", verdict: "fail", detail: `${u}: not a URL`, fix: "give an https URL" };
374
+ }
375
+ if (proofs.has(host) || ctx.opts["web-skip-proof"] === true)
376
+ return { what: "--web-url", verdict: "pass", detail: `${host}: proof token given (checked against DNS or the well-known file during the run)` };
377
+ return { what: "--web-url", verdict: "fail", detail: `${host}: no --web-proof`, fix: `give --web-proof ${host}=<the token sos-app issued for this host>` };
378
+ });
379
+ },
380
+ };
381
+ /** The fix for a source's refused call, for the run's own messages (cli.ts): "check it with collector doctor". */
382
+ export const doctorHint = (source) => `run \`collector doctor --config <file> --source ${source}\` to see which permission is missing`;
@@ -0,0 +1,160 @@
1
+ // THE ORGANIZATION'S POLICIES, PUSHED FROM WHERE IT KEEPS THEM (Stéphane, 2026-11-05: "the collector would point to the
2
+ // location where the policies are stored - then the collector push them to the app - and the app match them
3
+ // automatically to the proper security controls").
4
+ //
5
+ // collector policies --config collector.config.json reads the config's "policies" block
6
+ //
7
+ // Each PDF, Word or text file in the folders goes to sos-app's signed document door (POST /api/collect/document,
8
+ // apps/sos-app/src/routes/documents.ts) with target {policy: "auto"}: sos-app reads its title and text, matches it to a
9
+ // NIST SP 800-53 control family and files it as that family's draft policy for a person to approve, or says why not.
10
+ // Signed with a 'documents' key, never the collector key, so a collector key in CI never files documents. Files are only
11
+ // read; sending the same bytes again files nothing (sos-app answers "duplicate"). Nothing is deleted or moved.
12
+ import { createHash } from "node:crypto";
13
+ import { constants as fsConstants, copyFileSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname, extname, join } from "node:path";
15
+ import { signBody } from "./keys.js";
16
+ export const DOCUMENTS_KEY_ENV = "SAGENTIX_DOCUMENTS_KEY";
17
+ export const DOCUMENT_PATH = "/api/collect/document";
18
+ export const POLICY_TYPES = {
19
+ ".pdf": "application/pdf",
20
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
21
+ ".txt": "text/plain", ".md": "text/plain",
22
+ };
23
+ const MAX_BYTES = 10 * 1024 * 1024;
24
+ /** One file per policy when the same name comes in several formats (POL-06.pdf, .docx, .md): the PDF first, the document
25
+ * of record a reader opens, then Word, then text (Sagentix's folder holds all three, 2026-11-05). */
26
+ export const FORMAT_ORDER = [".pdf", ".docx", ".md", ".txt"];
27
+ /** The policy files in the folders, sorted, one level deep (no hidden files, no sub-folders: an archive folder stays out). */
28
+ export function policyFiles(dirs) {
29
+ const all = allPolicyFiles(dirs), best = new Map();
30
+ const rank = (n) => FORMAT_ORDER.indexOf(extname(n).toLowerCase());
31
+ for (const f of all) {
32
+ const key = join(dirname(f.path), basename(f.name, extname(f.name)).toLowerCase());
33
+ const had = best.get(key);
34
+ if (!had || rank(f.name) < rank(had.name))
35
+ best.set(key, f);
36
+ }
37
+ return [...best.values()].sort((a, b) => a.name.localeCompare(b.name));
38
+ }
39
+ function allPolicyFiles(dirs) {
40
+ const out = [];
41
+ for (const d of dirs) {
42
+ for (const name of readdirSync(d).sort()) {
43
+ const type = POLICY_TYPES[extname(name).toLowerCase()];
44
+ if (!type || name.startsWith(".") || name.startsWith("~$"))
45
+ continue;
46
+ const path = join(d, name);
47
+ if (statSync(path).isFile())
48
+ out.push({ path, name, type });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ const b64url = (b) => Buffer.from(b).toString("base64url");
54
+ /** Pushes every policy file; one outcome per file. `organization` is the key's organization id (sos-app checks it). */
55
+ export async function pushPolicies(o) {
56
+ const url = new URL(DOCUMENT_PATH, o.endpoint).toString();
57
+ const results = [];
58
+ for (const f of policyFiles(o.dirs)) {
59
+ const bytes = readFileSync(f.path);
60
+ if (bytes.byteLength > MAX_BYTES || bytes.byteLength === 0) {
61
+ results.push({ file: f.name, status: 0, outcome: "skipped", detail: "empty or over 10 MB" });
62
+ continue;
63
+ }
64
+ const manifest = {
65
+ format: "sagentix-document/1", issuedAt: o.now().toISOString(), organization: o.organization, target: { policy: "auto" },
66
+ filename: f.name.slice(0, 120), contentType: f.type, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.byteLength, kind: "policy",
67
+ };
68
+ const mBytes = new TextEncoder().encode(JSON.stringify(manifest));
69
+ let res;
70
+ try {
71
+ res = await o.fetch(url, { method: "POST", body: bytes, headers: { "content-type": f.type, "x-sagentix-key-id": o.keyId,
72
+ "x-sagentix-signature": signBody(o.pem, mBytes), "x-sagentix-document": b64url(mBytes) } });
73
+ }
74
+ catch (e) {
75
+ results.push({ file: f.name, status: 0, outcome: "refused", detail: String(e?.message ?? e) });
76
+ continue;
77
+ }
78
+ const text = await res.text();
79
+ let j = {};
80
+ try {
81
+ j = JSON.parse(text);
82
+ }
83
+ catch { /* not JSON */ }
84
+ const fam = typeof j.family === "string" ? j.family.toUpperCase() : "";
85
+ if (res.status === 201 && j.family)
86
+ results.push({ file: f.name, status: 201, outcome: "filed", detail: `${fam} version ${j.version}, a draft for approval` });
87
+ else if (res.status === 201)
88
+ results.push({ file: f.name, status: 201, outcome: "evidence", detail: `policy evidence on ${String(j.control ?? "?").toUpperCase()}` });
89
+ else if (res.status === 200 && j.duplicate)
90
+ results.push({ file: f.name, status: 200, outcome: "duplicate", detail: fam ? `${fam} version ${j.version}, unchanged` : "unchanged" });
91
+ else if (j.code === "policy_unmatched")
92
+ results.push({ file: f.name, status: res.status, outcome: "unmatched", detail: String(j.error ?? "") });
93
+ else if (j.code === "policy_family_taken")
94
+ results.push({ file: f.name, status: res.status, outcome: "taken", detail: String(j.error ?? "") });
95
+ else
96
+ results.push({ file: f.name, status: res.status, outcome: "refused", detail: String(j.error ?? text).slice(0, 300) });
97
+ }
98
+ return results;
99
+ }
100
+ export const PULL_PATH = "/api/collect/policies/approved";
101
+ export const APPROVED_DIR = "approved";
102
+ /** The organization's approved policies, read with the documents key (sos-app src/routes/documents.ts pullApprovedPolicies). */
103
+ export async function pullApproved(o) {
104
+ const m = new TextEncoder().encode(JSON.stringify({ format: "sagentix-policies-pull/1", issuedAt: o.now().toISOString(), organization: o.organization }));
105
+ const res = await o.fetch(new URL(PULL_PATH, o.endpoint).toString(), { method: "POST", headers: { "x-sagentix-key-id": o.keyId,
106
+ "x-sagentix-signature": signBody(o.pem, m), "x-sagentix-document": b64url(m) } });
107
+ const text = await res.text();
108
+ if (!res.ok)
109
+ throw new Error(`sos-app answered HTTP ${res.status} for the approved policies: ${text.slice(0, 200)}`);
110
+ return JSON.parse(text).policies;
111
+ }
112
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
113
+ /** Writes each approved version into <first folder>/approved/ (Stéphane, 2026-11-05: an approved policy must never be
114
+ * overwritten by the next push, and the folder should hold the text in force). A version made in sos-app is written as
115
+ * Markdown; an approved uploaded file is copied from the folder when the folder holds those exact bytes. Each name
116
+ * carries its version (…v3.md), so nothing is ever overwritten; earlier approved copies stay as history. The push never
117
+ * reads sub-folders, so these copies are never sent back. */
118
+ export function writeApproved(dirs, approved) {
119
+ const out = [];
120
+ if (!dirs.length)
121
+ return out;
122
+ const target = join(dirs[0], APPROVED_DIR);
123
+ const local = allPolicyFiles(dirs).map((f) => ({ ...f, sha: createHash("sha256").update(readFileSync(f.path)).digest("hex") }));
124
+ for (const p of approved) {
125
+ const fromFile = p.origin === "uploaded" ? local.find((f) => f.sha === p.sha256) : undefined;
126
+ const stem = fromFile ? basename(fromFile.name, extname(fromFile.name)) : p.filename ? basename(p.filename, extname(p.filename)) : `${p.family}-${slug(p.title)}`;
127
+ const name = `${stem}.v${p.version}${fromFile ? extname(fromFile.name).toLowerCase() : ".md"}`;
128
+ const dest = join(target, name);
129
+ // One exclusive create each (flag "wx", COPYFILE_EXCL), never a check then a write (CodeQL js/file-system-race):
130
+ // a file already there is the answer "present", and nothing is ever overwritten.
131
+ const present = (e) => e?.code === "EEXIST";
132
+ if (p.body !== undefined) {
133
+ mkdirSync(target, { recursive: true });
134
+ try {
135
+ writeFileSync(dest, `<!-- ${p.title}, version ${p.version}, approved ${p.approvedAt} in Sagentix Comply. This is the text in force: edit a copy of it in the folder above. -->\n\n${p.body}`, { flag: "wx" });
136
+ out.push({ file: name, outcome: "written", detail: `${p.family.toUpperCase()} version ${p.version}, the text approved in sos-app` });
137
+ }
138
+ catch (e) {
139
+ if (!present(e))
140
+ throw e;
141
+ out.push({ file: name, outcome: "present", detail: `${p.family.toUpperCase()} version ${p.version}, already here` });
142
+ }
143
+ }
144
+ else if (fromFile) {
145
+ mkdirSync(target, { recursive: true });
146
+ try {
147
+ copyFileSync(fromFile.path, dest, fsConstants.COPYFILE_EXCL);
148
+ out.push({ file: name, outcome: "written", detail: `${p.family.toUpperCase()} version ${p.version}, your approved file` });
149
+ }
150
+ catch (e) {
151
+ if (!present(e))
152
+ throw e;
153
+ out.push({ file: name, outcome: "present", detail: `${p.family.toUpperCase()} version ${p.version}, already here` });
154
+ }
155
+ }
156
+ else
157
+ out.push({ file: name, outcome: "missing", detail: `${p.family.toUpperCase()} version ${p.version} is an uploaded file this folder no longer holds (SHA-256 ${p.sha256.slice(0, 12)}…)` });
158
+ }
159
+ return out;
160
+ }
@@ -0,0 +1,19 @@
1
+ // A bounded pool: runs `work` over the items with at most `limit` in flight, and returns each outcome at its item's
2
+ // index, so a caller that takes them in order gets the same order a serial loop would (cli.ts --concurrency, Prowler).
3
+ export async function pool(items, limit, work) {
4
+ const out = new Array(items.length);
5
+ let next = 0;
6
+ const lane = async () => {
7
+ for (let i = next++; i < items.length; i = next++) {
8
+ try {
9
+ out[i] = { status: "fulfilled", value: await work(items[i], i) };
10
+ }
11
+ catch (reason) {
12
+ out[i] = { status: "rejected", reason };
13
+ }
14
+ }
15
+ };
16
+ const n = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
17
+ await Promise.all(Array.from({ length: n }, lane));
18
+ return out;
19
+ }