@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.
@@ -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,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
+ }
@@ -5,6 +5,8 @@
5
5
  //
6
6
  // Written beside the config as sagentix-run-report.json before it is sent, so the client can read exactly what
7
7
  // leaves. A send that fails leaves sagentix-pending-report.json, sent first ("late": true) by the next run.
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import { subscribe, unsubscribe } from "node:diagnostics_channel";
8
10
  import { existsSync, readFileSync, rmSync } from "node:fs";
9
11
  import { join } from "node:path";
10
12
  import { REPORT_PATH, collectorInfo, osInfo, signedPost, writePrivate } from "./channel.js";
@@ -66,39 +68,88 @@ export function templatePath(path) {
66
68
  }).join("/");
67
69
  }
68
70
  // ── Recording ─────────────────────────────────────────────────────────────────────────────────────
71
+ /** Where the HTTP helpers announce a retry (readonly_http.ts, shared-cloud.ts, cloudflare/api.ts); the recorder counts it
72
+ * against the source whose call it was. diagnostics_channel publishes synchronously, so the source is still current. */
73
+ export const RETRY_CHANNEL = "sagentix:collector:retry";
69
74
  /** Everything one run does that goes into its report. */
70
75
  export class RunRecorder {
71
76
  startedAt;
72
77
  sources = [];
73
78
  pushes = [];
74
79
  log = [];
80
+ stats = new Map();
75
81
  calls = new Map();
76
- current;
82
+ /** The source whose work is running, across awaits: sources may run side by side (cli.ts --concurrency). */
83
+ current = new AsyncLocalStorage();
77
84
  clock;
78
- constructor(now, clock = () => performance.now()) {
85
+ /** Set once the options are parsed (cli.ts): the timeout and the --verbose hooks. */
86
+ options;
87
+ holds = 0;
88
+ release;
89
+ constructor(now, clock = () => performance.now(), opts = {}) {
79
90
  this.startedAt = now();
80
91
  this.clock = clock;
92
+ this.options = opts;
81
93
  }
82
94
  /** An err() that also records each line (and, while a source runs, counts it as that source's warning). */
83
95
  tee(err) {
84
96
  return (s) => {
97
+ const cur = this.current.getStore();
85
98
  for (const line of String(s).split("\n")) {
86
99
  this.log.push(line);
87
- if (this.current && this.current.warnings.length < MAX_WARNINGS)
88
- this.current.warnings.push(line);
100
+ if (cur && cur.warnings.length < MAX_WARNINGS)
101
+ cur.warnings.push(line);
89
102
  }
90
103
  err(s);
91
104
  };
92
105
  }
106
+ /** The counters of one source, made on first use. */
107
+ statsOf(name) {
108
+ let st = this.stats.get(name);
109
+ if (!st) {
110
+ st = { calls: 0, retries: 0, httpErrors: 0 };
111
+ this.stats.set(name, st);
112
+ }
113
+ return st;
114
+ }
115
+ /**
116
+ * Keeps the fetch instrumentation and the retry counter in place until the returned function is called. Nested holds
117
+ * share one wrapper, so every client built during a run, by any source, sees the same `fetch` (oauth.ts shares a
118
+ * token per fetch function).
119
+ */
120
+ hold() {
121
+ if (this.holds++ === 0) {
122
+ const restoreFetch = this.instrumentFetch();
123
+ const onRetry = (m) => {
124
+ const cur = this.current.getStore();
125
+ if (!cur)
126
+ return;
127
+ this.statsOf(cur.name).retries++;
128
+ this.options.onRetry?.(cur.name, m);
129
+ };
130
+ subscribe(RETRY_CHANNEL, onRetry);
131
+ this.release = () => { restoreFetch(); unsubscribe(RETRY_CHANNEL, onRetry); };
132
+ }
133
+ let done = false;
134
+ return () => {
135
+ if (done)
136
+ return;
137
+ done = true;
138
+ if (--this.holds === 0) {
139
+ this.release?.();
140
+ this.release = undefined;
141
+ }
142
+ };
143
+ }
93
144
  /** Runs one source's work with its outbound HTTP counted, and its time and warnings recorded. */
94
145
  async source(name, work) {
95
146
  const report = { name, ms: 0, pass: 0, fail: 0, skipped: 0, error: null, warnings: [] };
96
147
  this.sources.push(report);
97
- this.current = report;
148
+ this.statsOf(name);
98
149
  const t0 = this.clock();
99
- const restore = this.instrumentFetch(name);
150
+ const release = this.hold();
100
151
  try {
101
- return { value: await work(), report };
152
+ return { value: await this.current.run(report, work), report };
102
153
  }
103
154
  catch (e) {
104
155
  const err = e instanceof Error ? e : new Error(String(e));
@@ -106,9 +157,8 @@ export class RunRecorder {
106
157
  return { error: e, report };
107
158
  }
108
159
  finally {
109
- restore();
160
+ release();
110
161
  report.ms = Math.round(this.clock() - t0);
111
- this.current = undefined;
112
162
  }
113
163
  }
114
164
  /** Warnings that belong to no source (the check-in's), as their own entry. */
@@ -117,7 +167,7 @@ export class RunRecorder {
117
167
  this.sources.push({ name, ms: 0, pass: 0, fail: 0, skipped: 0, error: null, warnings: warnings.slice(0, MAX_WARNINGS) });
118
168
  }
119
169
  /** One outbound call, by its shape only. */
120
- call(source, method, url, status, ms, headers) {
170
+ call(source, method, url, status, ms, headers, error) {
121
171
  let host = "", path = "";
122
172
  try {
123
173
  const u = new URL(url);
@@ -138,6 +188,11 @@ export class RunRecorder {
138
188
  if (sun)
139
189
  c.sunset = sun.slice(0, 64);
140
190
  this.calls.set(key, c);
191
+ const st = this.statsOf(source);
192
+ st.calls++;
193
+ if (status === 0 || status >= 400)
194
+ st.httpErrors++;
195
+ this.options.onCall?.({ source, method, host, path, status, ms: Math.round(ms), ...(error ? { error } : {}) });
141
196
  }
142
197
  callList() { return [...this.calls.values()]; }
143
198
  /** A warning in the source's own list, once per endpoint: the provider retires (410) or plans to retire it. */
@@ -157,27 +212,44 @@ export class RunRecorder {
157
212
  }
158
213
  /**
159
214
  * Every source reaches the network through the global fetch (the default `fetchFn` of each client, read when the
160
- * client is built), so wrapping it while one source runs counts that source's calls, and only them.
215
+ * client is built), so one wrapper in place while sources run counts each call against the source whose work made
216
+ * it (AsyncLocalStorage), and abandons it after `timeoutMs`. A call made outside any source passes through untouched.
161
217
  */
162
- instrumentFetch(source) {
218
+ instrumentFetch() {
163
219
  const original = globalThis.fetch;
164
220
  if (typeof original !== "function")
165
221
  return () => { };
166
222
  const rec = this;
223
+ const timeoutMs = this.options.timeoutMs ?? 0;
167
224
  const wrapped = async function (input, init) {
225
+ const cur = rec.current.getStore();
226
+ if (!cur)
227
+ return original(input, init);
168
228
  const url = input instanceof Request ? input.url : String(input);
169
229
  const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
230
+ let signal = init?.signal ?? undefined;
231
+ if (timeoutMs > 0)
232
+ signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
170
233
  const t0 = rec.clock();
171
234
  try {
172
- const res = await original(input, init);
173
- rec.call(source, method, url, res.status, rec.clock() - t0, res.headers);
235
+ const res = await original(input, signal ? { ...init, signal } : init);
236
+ rec.call(cur.name, method, url, res.status, rec.clock() - t0, res.headers);
174
237
  if (res.headers?.get("deprecation") || res.headers?.get("sunset") || res.status === 410) {
175
- rec.deprecated(source, method, url, res.status, res.headers?.get("sunset") ?? null);
238
+ rec.deprecated(cur.name, method, url, res.status, res.headers?.get("sunset") ?? null);
176
239
  }
177
240
  return res;
178
241
  }
179
242
  catch (e) {
180
- rec.call(source, method, url, 0, rec.clock() - t0);
243
+ const timedOut = e?.name === "TimeoutError";
244
+ let host = "";
245
+ try {
246
+ host = new URL(url).host;
247
+ }
248
+ catch { /* not a URL */ }
249
+ const why = timedOut ? `no answer from ${host} within ${Math.round(timeoutMs / 1000)} s (--http-timeout)` : String(e?.message ?? e);
250
+ rec.call(cur.name, method, url, 0, rec.clock() - t0, undefined, why);
251
+ if (timedOut)
252
+ throw new Error(why, { cause: e });
181
253
  throw e;
182
254
  }
183
255
  };
@@ -1,19 +1,39 @@
1
1
  // Read-only Cloudflare API v4 client. It has a GET method and nothing else, so a bug here cannot
2
2
  // change the customer's account. Tests pass a fake client; nothing in tests touches the network.
3
+ import { realSleep, withRetry } from "../readonly_http.js";
3
4
  export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
4
5
  export const TOKEN_ENV = "CLOUDFLARE_API_TOKEN_READONLY";
5
- export function fetchCloudflareApi(token, fetchFn = fetch, base = CF_API_BASE) {
6
- return {
6
+ /**
7
+ * GET only. A 429 is retried up to 4 times and a 502, 503, 504 or network failure up to twice, waiting Retry-After
8
+ * when given (Cloudflare's rate limit is 1,200 requests per five minutes per user,
9
+ * https://developers.cloudflare.com/fundamentals/api/reference/limits/, read 2026-11-07), else 1 s doubling.
10
+ */
11
+ export function fetchCloudflareApi(token, fetchFn = fetch, base = CF_API_BASE, sleep = realSleep) {
12
+ const raw = {
7
13
  async get(path) {
8
14
  const res = await fetchFn(`${base}${path}`, { method: "GET", headers: { authorization: `Bearer ${token}`, accept: "application/json" } });
15
+ const headers = {};
16
+ res.headers?.forEach((v, k) => { headers[k.toLowerCase()] = v; });
17
+ // Should an answer ever echo the token, it is cut out before anything reads it (as fetchRest does).
18
+ const text = token.length >= 8 ? (await res.text()).split(token).join("[redacted]") : await res.text();
9
19
  let body = null;
10
20
  try {
11
- body = await res.json();
21
+ body = text ? JSON.parse(text) : null;
12
22
  }
13
23
  catch {
14
24
  body = null;
15
25
  }
16
- return { status: res.status, body };
26
+ return { status: res.status, headers, body };
27
+ },
28
+ };
29
+ const api = withRetry(raw, sleep);
30
+ return {
31
+ async get(path) {
32
+ const r = await api.get(path);
33
+ // A thrown failure comes back as status 0, in the shape safeGet gives it.
34
+ if (r.status === 0 && isObj(r.body) && typeof r.body.message === "string")
35
+ return { status: 0, body: { success: false, errors: [{ message: r.body.message }] } };
36
+ return { status: r.status, body: r.body };
17
37
  },
18
38
  };
19
39
  }