@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "web",
3
- "title": "Websites and APIs by URL (passive GET, TLS handshake and DNS, for hosts the organization proved it controls)",
3
+ "title": "Websites and APIs by URL (passive GET, TLS handshake and DNS, for hosts the organization proved it controls), including its privacy notice and consent banner",
4
4
  "readScopes": [
5
5
  "None: the collector reads what any browser sees. Each host must first be proven: a TXT record at _sagentix-collector.<host> holding sagentix-collector-verification=<token>, or https://<host>/.well-known/sagentix-collector.txt holding that line, with the token sos-app issued."
6
6
  ],
@@ -82,6 +82,36 @@
82
82
  "title": "The domain limits which certificate authorities may issue for it (CAA); recommended",
83
83
  "scope": "The CAA record set at the domain or its nearest parent that has one (RFC 8659 section 3).",
84
84
  "evidenceType": "Configuration"
85
+ },
86
+ {
87
+ "id": "web.consent_banner",
88
+ "title": "The declared page loads a consent-management platform before its third-party scripts can track visitors",
89
+ "scope": "One GET of each declared URL, where robots.txt allows: the page's HTML is searched for the script host each vendor's installation guide names (OneTrust, Osano, Cookiebot, Usercentrics, TrustArc, Termly, iubenda, Didomi, CookieYes, Transcend). A page with no script from another site is skipped; a banner a tag manager loads later is not seen.",
90
+ "evidenceType": "Configuration"
91
+ },
92
+ {
93
+ "id": "web.privacy_notice_published",
94
+ "title": "A privacy notice is published and reachable at the address the organization declared",
95
+ "scope": "GET of each --web-privacy-url on a proven host, where robots.txt allows: HTTP 200, an HTML page, and a title or first heading that names privacy or personal information (English or French). Redirects are not followed.",
96
+ "evidenceType": "Configuration"
97
+ },
98
+ {
99
+ "id": "web.privacy_notice_dated",
100
+ "title": "The privacy notice shows a last-updated or effective date within the review period (default one year)",
101
+ "scope": "The first date that follows a label such as 'Last updated', 'Effective date' or 'Dernière mise à jour' in the notice's text; --web-privacy-max-age-days sets the period (default 365).",
102
+ "evidenceType": "Configuration"
103
+ },
104
+ {
105
+ "id": "web.privacy_notice_topics",
106
+ "title": "The privacy notice covers what is collected, why, who receives it, how long it is kept, access and correction, whom to contact, and consent",
107
+ "scope": "Seven topics, each a set of English and French phrases, searched in the notice's text (NIST SP 800-53 Rev. 5 PT-5; PIPEDA Schedule 1 Principles 4.5 and 4.8; OPC Guidelines for obtaining meaningful consent). A phrase found is a clue: a person confirms the wording.",
108
+ "evidenceType": "Configuration"
109
+ },
110
+ {
111
+ "id": "web.privacy_request_channel",
112
+ "title": "The privacy notice tells people how to ask for, correct or delete their personal information (a privacy mailbox or a request form)",
113
+ "scope": "Links on the notice: a mailto to a privacy-named mailbox (privacy@, dpo@, ...) or a link whose address or text names a privacy or access request. Only the mail domain is kept, never the mailbox.",
114
+ "evidenceType": "Configuration"
85
115
  }
86
116
  ]
87
117
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "type": "module"
5
5
  }
@@ -10,6 +10,9 @@
10
10
  // collector agent --config collector.config.json stays connected (outbound WebSocket); runs when sos-app asks
11
11
  // collector config-schema prints the JSON schema of the config file
12
12
  // collector google-login --client <client JSON> [--token <file>] one-time Google Workspace sign-in
13
+ // collector policies --config collector.config.json pushes the policy folders for sos-app to match (src/policies.ts)
14
+ // collector doctor --config collector.config.json [--source x] credentials and permissions per source; sends nothing (src/doctor.ts)
15
+ // run options for every source: --concurrency <n> (default 4), --http-timeout <s> (default 60), --verbose, --debug
13
16
  // collector run --endpoint <url> --key-id <id> [--key <pem> | env SAGENTIX_COLLECTOR_KEY]
14
17
  // --source cloudflare[,...] [source options] [--catalog <dir>] [--dry-run] [--out <file>]
15
18
  // [--fedramp-target <id> [--class A|B|C|D]]
@@ -17,7 +20,7 @@
17
20
  // Exit codes: 0 sent (or dry run), 1 error (bad key, network, a source that failed), 2 usage, 3 nothing to send,
18
21
  // 4 sos-app refused the push (its message is printed); `agent` only: 75 a signed update was written, restart me.
19
22
  import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
- import { dirname, join, resolve } from "node:path";
23
+ import { dirname, isAbsolute, join, resolve } from "node:path";
21
24
  import { fileURLToPath } from "node:url";
22
25
  import { parseArgs } from "node:util";
23
26
  import { DEFAULT_CATALOG_DIR, loadCatalog } from "./catalog.js";
@@ -29,11 +32,14 @@ import { googleLogin } from "./google_login.js";
29
32
  import { V1_COLLECT_PATH, v1Payload } from "./v1/index.js";
30
33
  import { CLASSES } from "./v1/rules.js";
31
34
  import { EXIT_UPDATE, checkin, detectRunner, modeOf } from "./channel.js";
32
- import { RunRecorder, buildReport, deliverReport } from "./report.js";
35
+ import { RunRecorder, buildReport, deliverReport, envSecrets, scrubText } from "./report.js";
36
+ import { pool } from "./pool.js";
37
+ import { PROBES, doctorHint } from "./doctor.js";
33
38
  import { agent } from "./agent.js";
34
39
  import { VAULT_FILE, Vault, keyHolderFor, runEnv } from "./vault.js";
35
40
  import { applyRefs } from "./refs.js";
36
41
  import { credentialsIn, settingsOf, vaultBridge } from "./deliveries.js";
42
+ import { APPROVED_DIR, DOCUMENTS_KEY_ENV, pullApproved, pushPolicies, writeApproved } from "./policies.js";
37
43
  export const COLLECT_PATH = "/api/collect";
38
44
  export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
39
45
  /** Set by the agent on a connector test asked from sos-app (a dry run of one connector). */
@@ -63,7 +69,26 @@ const COMMON_OPTIONS = {
63
69
  class: { type: "string" },
64
70
  "cpo-uri": { type: "string" },
65
71
  config: { type: "string" },
72
+ concurrency: { type: "string" },
73
+ "http-timeout": { type: "string" },
74
+ verbose: { type: "boolean" },
75
+ debug: { type: "boolean" },
66
76
  };
77
+ /** Sources run side by side, at most this many at once (--concurrency; 1 is the old one-after-another). */
78
+ export const DEFAULT_CONCURRENCY = 4;
79
+ export const MAX_CONCURRENCY = 16;
80
+ /** Seconds before an outbound call made by a source is abandoned (--http-timeout; 0 never). */
81
+ export const DEFAULT_HTTP_TIMEOUT_S = 60;
82
+ /** --concurrency and --http-timeout, or why they are wrong. */
83
+ export function runTuning(values) {
84
+ const c = values.concurrency === undefined ? DEFAULT_CONCURRENCY : Number(values.concurrency);
85
+ if (!Number.isInteger(c) || c < 1 || c > MAX_CONCURRENCY)
86
+ return `--concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}.`;
87
+ const t = values["http-timeout"] === undefined ? DEFAULT_HTTP_TIMEOUT_S : Number(values["http-timeout"]);
88
+ if (!Number.isFinite(t) || t < 0 || t > 3600)
89
+ return "--http-timeout must be a number of seconds from 0 (never) to 3600.";
90
+ return { concurrency: c, timeoutMs: Math.round(t * 1000) };
91
+ }
67
92
  function usage(sources) {
68
93
  const opts = sources.flatMap((s) => Object.entries(s.options).map(([n, o]) => ` --${n}${o.type === "string" ? " <value>" : ""}${o.multiple ? " (repeatable)" : ""} [${s.name}] ${o.help}`));
69
94
  return `usage:
@@ -72,10 +97,14 @@ function usage(sources) {
72
97
  collector agent --config collector.config.json (stays connected to sos-app; runs when it asks)
73
98
  collector config-schema
74
99
  collector google-login --client <Desktop app client JSON> [--token sagentix-google-token.json] [--port <n>] [--login-hint <email>] [--no-browser]
100
+ collector doctor --config collector.config.json [--source <name>] checks each source's credentials and permissions; collects and sends nothing
75
101
  collector run --endpoint <url> --key-id <id> [--key <pem file>] --source ${sources.map((s) => s.name).join("|")}[,...]
76
102
  [source options] [--catalog <checks dir>] [--dry-run] [--out <file>]
103
+ [--concurrency <1-${MAX_CONCURRENCY}, default ${DEFAULT_CONCURRENCY}>] [--http-timeout <seconds, default ${DEFAULT_HTTP_TIMEOUT_S}>] [--verbose] [--debug]
77
104
  [--fedramp-target <id> [--class A|B|C|D] [--cpo-uri <url>]]
78
105
  The private key may come from ${KEY_ENV} (PEM text) instead of --key.
106
+ --verbose prints each outbound call (method, host, path with ids as :id; never a query, header or body) and each retry;
107
+ --debug also prints a failed source's stack. A table of every source (checks, calls, retries, time) ends each run.
79
108
  Source options:
80
109
  ${opts.join("\n")}
81
110
  Every run checks in first (an update sos-app names is taken only with a valid Sagentix release signature, and only
@@ -120,10 +149,77 @@ function serverMessage(text) {
120
149
  return text;
121
150
  }
122
151
  }
123
- export async function run(argv, deps) {
124
- // Every line the run writes to stderr also goes into its report (scrubbed there).
125
- const rec = new RunRecorder(deps.now);
126
- deps = { ...deps, err: rec.tee(deps.err) };
152
+ /**
153
+ * The connectors' secrets and settings, set in sos-app and sealed for this collector (src/vault.ts), when the run has a
154
+ * config and a key id. A vault that cannot be opened is a warning: the sources that need its values fail on their own.
155
+ */
156
+ async function vaultEnv(o) {
157
+ const { configPath, keyId, dir, runner, deps } = o;
158
+ if (!configPath || !keyId)
159
+ return { env: deps.env, cleanup: () => { }, secrets: [] };
160
+ try {
161
+ const creds = credentialsIn(resolve(configPath));
162
+ const holder = deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner });
163
+ const r = runEnv(dir, holder, deps.env, (name) => settingsOf(creds, name));
164
+ // Connectors read from the client's own vault (src/refs.ts) take all their names from there, after the local vault.
165
+ const refs = existsSync(join(dir, VAULT_FILE)) ? new Vault(dir, holder).refs() : {};
166
+ const f = Object.keys(refs).length ? await applyRefs(dir, refs, creds, r.env, deps.channel ?? deps.fetch) : null;
167
+ const used = r.used.length + (f?.used.length ?? 0), warnings = [...r.warnings, ...(f?.warnings ?? [])];
168
+ if (used)
169
+ deps.err(`vault: ${used} value(s) from ${VAULT_FILE}${f ? " and your own vault" : ""}`);
170
+ for (const w of warnings)
171
+ deps.err(`warning: vault: ${w}`);
172
+ if (warnings.length)
173
+ o.note(warnings);
174
+ return { env: f?.env ?? r.env, cleanup: () => { r.cleanup(); f?.cleanup(); }, secrets: [...r.secrets, ...(f?.secrets ?? [])] };
175
+ }
176
+ catch (e) {
177
+ const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
178
+ deps.err(w);
179
+ o.note([w]);
180
+ return { env: deps.env, cleanup: () => { }, secrets: [] };
181
+ }
182
+ }
183
+ /** One line of the --verbose call log: the call's shape, scrubbed of the run's secrets. */
184
+ function callLine(c, secrets) {
185
+ return scrubText(`[${c.source}] ${c.method} ${c.host}${c.path} -> ${c.status || "no answer"} in ${c.ms} ms${c.error ? ` (${c.error})` : ""}`, secrets);
186
+ }
187
+ /** The end-of-run table, one row per source: what it judged, what it called, how long it took. */
188
+ export function summaryTable(reports, stats) {
189
+ const rows = reports.map((r) => {
190
+ const st = stats.get(r.name) ?? { calls: 0, retries: 0, httpErrors: 0 };
191
+ return [r.name, String(r.pass + r.fail + r.skipped), String(r.pass), String(r.fail), String(r.skipped), String(st.calls), String(st.retries), String(st.httpErrors),
192
+ `${(r.ms / 1000).toFixed(1)} s`, r.error ? "ERROR" : r.pass + r.fail ? "read" : "nothing read"];
193
+ });
194
+ const head = ["source", "results", "pass", "fail", "skipped", "calls", "retries", "http errors", "time", "source status"];
195
+ const total = rows.length > 1 ? [["total", ...[1, 2, 3, 4, 5, 6, 7].map((i) => String(rows.reduce((a, r) => a + Number(r[i]), 0))),
196
+ `${(reports.reduce((a, r) => Math.max(a, r.ms), 0) / 1000).toFixed(1)} s max`, ""]] : [];
197
+ const all = [head, ...rows, ...total];
198
+ const w = head.map((_, i) => Math.max(...all.map((r) => r[i].length)));
199
+ const line = (r) => r.map((c, i) => (i === 0 || i === r.length - 1 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(" ").trimEnd();
200
+ return [line(head), w.map((n) => "-".repeat(n)).join(" "), ...rows.map(line), ...(total.length ? [w.map((n) => "-".repeat(n)).join(" "), line(total[0])] : [])];
201
+ }
202
+ /** A short fix for each refused call in the results: source, check, HTTP status and what to grant (src/doctor.ts). */
203
+ export function problemLines(results, max = 20) {
204
+ const seen = new Set(), out = [];
205
+ for (const r of results) {
206
+ const st = r.apiError?.status;
207
+ if (r.result !== "skipped" || st === undefined || (st !== 401 && st !== 403))
208
+ continue;
209
+ const source = r.check.split(".")[0];
210
+ const k = `${r.check}|${st}`;
211
+ if (seen.has(k))
212
+ continue;
213
+ seen.add(k);
214
+ if (out.length < max)
215
+ out.push(` ${r.check} [${r.resource}]: HTTP ${st}${st === 401 ? " (credential refused)" : " (permission missing)"}; fix: ${doctorHint(source)}`);
216
+ }
217
+ if (seen.size > max)
218
+ out.push(` … and ${seen.size - max} more`);
219
+ return out;
220
+ }
221
+ /** The run's options: the command line over the config file (--source adds to the file's), or the usage exit code. */
222
+ function parseRunArgs(argv, deps) {
127
223
  const sourceOptions = {};
128
224
  for (const s of deps.sources) {
129
225
  for (const [n, o] of Object.entries(s.options)) {
@@ -152,8 +248,147 @@ export async function run(argv, deps) {
152
248
  const src = [...(c.values.source ?? []), ...(given.source ?? [])];
153
249
  values = { ...c.values, ...given, source: src };
154
250
  }
251
+ return { values, configPath };
252
+ }
253
+ /**
254
+ * `collector doctor`: per configured source, its credentials and one cheap read per permission it needs (src/doctor.ts),
255
+ * printed as pass or fail with the fix. Nothing is collected, signed or sent: no check-in, no push, no run report.
256
+ * With --config, --source narrows the file's sources to those named. Exit 0 when every check passes, 1 otherwise.
257
+ */
258
+ export async function doctorCommand(argv, deps) {
259
+ const parsed = parseRunArgs(argv, deps);
260
+ if (typeof parsed === "number")
261
+ return parsed;
262
+ const { values, configPath } = parsed;
263
+ const tuning = runTuning(values);
264
+ if (typeof tuning === "string") {
265
+ deps.err(tuning);
266
+ return EXIT.usage;
267
+ }
268
+ // From the config, --source picks among the file's sources rather than adding to them.
269
+ const cli = argv.flatMap((a, i) => (a === "--source" ? [argv[i + 1]] : a.startsWith("--source=") ? [a.slice(9)] : [])).flatMap((s) => (s ?? "").split(",")).map((s) => s.trim()).filter(Boolean);
270
+ const all = (values.source ?? []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
271
+ const names = [...new Set(configPath && cli.length ? cli : all)];
272
+ if (!names.length) {
273
+ deps.err(`doctor: name the sources (--config with a "sources" block, or --source ${deps.sources.map((s) => s.name).join("|")}).`);
274
+ return EXIT.usage;
275
+ }
276
+ const unknown = names.filter((n) => !deps.sources.some((s) => s.name === n));
277
+ if (unknown.length) {
278
+ deps.err(`unknown source ${unknown.join(", ")}; known: ${deps.sources.map((x) => x.name).join(", ")}.`);
279
+ return EXIT.usage;
280
+ }
281
+ const keyId = typeof values["key-id"] === "string" ? values["key-id"] : undefined;
282
+ const dir = configPath ? dirname(resolve(configPath)) : (deps.cwd ?? process.cwd());
283
+ const runner = detectRunner(deps.env);
284
+ const lines = [];
285
+ const out = (s) => lines.push(s);
286
+ // The collector key, read and parsed locally; it is not used to sign anything here.
287
+ const keyChecks = [];
288
+ let pem;
289
+ if (typeof values.key === "string") {
290
+ try {
291
+ pem = readFileSync(values.key, "utf8");
292
+ }
293
+ catch (e) {
294
+ keyChecks.push({ what: "collector key", verdict: "fail", detail: `${values.key}: ${e.code ?? String(e)}`, fix: "point keyFile (or --key) at the PEM `collector keygen` wrote" });
295
+ }
296
+ }
297
+ else
298
+ pem = deps.env[KEY_ENV];
299
+ if (pem) {
300
+ try {
301
+ keyChecks.push({ what: "collector key", verdict: "pass", detail: `Ed25519 public key ${publicKeyFromPrivate(pem).slice(0, 12)}…${keyId ? `, key id ${keyId}` : " (no keyId: only --dry-run runs)"}` });
302
+ }
303
+ catch {
304
+ keyChecks.push({ what: "collector key", verdict: "fail", detail: "not an Ed25519 private key", fix: "use the PEM `collector keygen` wrote" });
305
+ }
306
+ }
307
+ else if (!keyChecks.length)
308
+ keyChecks.push({ what: "collector key", verdict: "skip", detail: `no --key and no ${KEY_ENV}: only --dry-run runs` });
309
+ if (typeof values.endpoint === "string") {
310
+ try {
311
+ collectUrl(values.endpoint);
312
+ keyChecks.push({ what: "endpoint", verdict: "pass", detail: new URL(values.endpoint).origin });
313
+ }
314
+ catch (e) {
315
+ keyChecks.push({ what: "endpoint", verdict: "fail", detail: e.message, fix: "set endpoint to your sos-app origin, https://…" });
316
+ }
317
+ }
318
+ const { env, cleanup, secrets } = await vaultEnv({ configPath, keyId, dir, runner, deps, note: () => { } });
319
+ const scrub = [...new Set([...envSecrets(env), ...secrets.filter((v) => v.length >= 6)])].sort((a, b) => b.length - a.length);
320
+ const results = [{ source: "collector", checks: keyChecks }];
321
+ const now = deps.now();
322
+ // One wrapper around fetch, with the run's timeout, so a probe that hangs ends like a run's call would.
323
+ const rec = new RunRecorder(deps.now, undefined, { timeoutMs: tuning.timeoutMs });
324
+ const release = rec.hold();
325
+ try {
326
+ const outcomes = await pool(names, tuning.concurrency, (name) => rec.source(name, async () => {
327
+ const probe = PROBES[name];
328
+ if (!probe)
329
+ return [{ what: "self-test", verdict: "skip", detail: "this source has no self-test; try `collector run --dry-run --source " + name + "`" }];
330
+ return probe({ opts: values, env, clients: deps.clients, now, warn: () => { } });
331
+ }));
332
+ for (const [i, o] of outcomes.entries()) {
333
+ const v = o.status === "fulfilled" ? o.value : { error: o.reason, value: undefined };
334
+ results.push({ source: names[i], checks: v.value ?? [{ what: "self-test", verdict: "fail", detail: `the probe failed: ${String(v.error?.message ?? v.error)}`, fix: "report this to Sagentix with the line above" }] });
335
+ }
336
+ }
337
+ finally {
338
+ release();
339
+ cleanup();
340
+ }
341
+ let failed = 0, passed = 0, skipped = 0;
342
+ out(`collector doctor: ${names.length} source(s); read-only checks, nothing collected or sent`);
343
+ for (const r of results) {
344
+ out(r.source);
345
+ const w = Math.min(44, Math.max(...r.checks.map((c) => c.what.length), 8));
346
+ for (const c of r.checks) {
347
+ if (c.verdict === "pass")
348
+ passed++;
349
+ else if (c.verdict === "fail")
350
+ failed++;
351
+ else
352
+ skipped++;
353
+ out(` ${c.verdict.toUpperCase().padEnd(4)} ${c.what.padEnd(w)} ${c.detail}`);
354
+ if (c.fix && c.verdict !== "pass")
355
+ out(` fix: ${c.fix}`);
356
+ }
357
+ }
358
+ out(`doctor: ${passed} passed, ${failed} failed, ${skipped} skipped${failed ? "; fix the lines marked FAIL, then run the doctor again" : "; ready for `collector run`"}`);
359
+ for (const l of lines)
360
+ deps.out(scrubText(l, scrub));
361
+ return failed ? EXIT.error : EXIT.ok;
362
+ }
363
+ export async function run(argv, deps) {
364
+ // Every line the run writes to stderr also goes into its report (scrubbed there); the --verbose call log and the
365
+ // closing table go to stderr only (the report already holds every call, and every source's counts and time).
366
+ const rec = new RunRecorder(deps.now);
367
+ const rawErr = deps.err;
368
+ deps = { ...deps, err: rec.tee(deps.err) };
369
+ const parsed = parseRunArgs(argv, deps);
370
+ if (typeof parsed === "number")
371
+ return parsed;
372
+ let { values } = parsed;
373
+ const { configPath } = parsed;
155
374
  const dry = values["dry-run"] === true;
156
375
  const v1Target = typeof values["fedramp-target"] === "string" ? values["fedramp-target"] : undefined;
376
+ const tuning = runTuning(values);
377
+ if (typeof tuning === "string") {
378
+ deps.err(tuning);
379
+ return EXIT.usage;
380
+ }
381
+ const { concurrency } = tuning;
382
+ const verbose = values.verbose === true || values.debug === true, debug = values.debug === true;
383
+ // Read when a line is printed, by then holding the vault's values too.
384
+ let lineSecrets = envSecrets(deps.env);
385
+ rec.options = {
386
+ timeoutMs: tuning.timeoutMs,
387
+ ...(verbose ? {
388
+ onCall: (c) => rawErr(callLine(c, lineSecrets)),
389
+ onRetry: (source, e) => rawErr(scrubText(`[${source}] retry ${e.attempt} after ${e.status ? `HTTP ${e.status}` : "no answer"}, waiting ${e.waitMs} ms`, lineSecrets)),
390
+ } : {}),
391
+ };
157
392
  const names = (values.source ?? []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
158
393
  if (!names.length) {
159
394
  deps.err(`--source is required (${deps.sources.map((s) => s.name).join(", ")}).`);
@@ -231,35 +466,10 @@ export async function run(argv, deps) {
231
466
  }
232
467
  else
233
468
  deps.err("check-in skipped: a dry run without a key cannot sign one.");
234
- // The connectors' secrets and settings, set in sos-app and sealed for this collector (src/vault.ts), when the run has a config and a key id.
235
- // A vault that cannot be opened is a warning: the sources that need its values fail on their own, the run goes on.
236
- let env = deps.env, cleanup = () => { }, vaultSecrets = [];
237
- if (configPath && keyId) {
238
- try {
239
- const creds = credentialsIn(resolve(configPath));
240
- const holder = deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner });
241
- const r = runEnv(dir, holder, deps.env, (name) => settingsOf(creds, name));
242
- // Connectors read from the client's own vault (src/refs.ts) take all their names from there, after the local vault.
243
- const refs = existsSync(join(dir, VAULT_FILE)) ? new Vault(dir, holder).refs() : {};
244
- const f = Object.keys(refs).length ? await applyRefs(dir, refs, creds, r.env, deps.channel ?? deps.fetch) : null;
245
- env = f?.env ?? r.env;
246
- cleanup = () => { r.cleanup(); f?.cleanup(); };
247
- vaultSecrets = [...r.secrets, ...(f?.secrets ?? [])];
248
- const used = r.used.length + (f?.used.length ?? 0), warnings = [...r.warnings, ...(f?.warnings ?? [])];
249
- if (used)
250
- deps.err(`vault: ${used} value(s) from ${VAULT_FILE}${f ? " and your own vault" : ""}`);
251
- for (const w of warnings)
252
- deps.err(`warning: vault: ${w}`);
253
- if (warnings.length)
254
- rec.note("vault", warnings);
255
- }
256
- catch (e) {
257
- const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
258
- deps.err(w);
259
- rec.note("vault", [w]);
260
- }
261
- }
469
+ const { env, cleanup, secrets: vaultSecrets } = await vaultEnv({ configPath, keyId, dir, runner, deps, note: (w) => rec.note("vault", w) });
470
+ lineSecrets = [...new Set([...envSecrets(env), ...vaultSecrets.filter((v) => v.length >= 6)])].sort((a, b) => b.length - a.length);
262
471
  let code;
472
+ let judged = [];
263
473
  try {
264
474
  code = await collect();
265
475
  }
@@ -270,6 +480,19 @@ export async function run(argv, deps) {
270
480
  finally {
271
481
  cleanup();
272
482
  }
483
+ // The closing table (stderr only), and the refused calls with what to grant.
484
+ const ran = rec.sources.filter((r) => chosen.some((s) => s.name === r.name));
485
+ if (ran.length) {
486
+ rawErr(`\nsummary (${ran.length} source(s), up to ${concurrency} at once):`);
487
+ for (const l of summaryTable(ran, rec.stats))
488
+ rawErr(l);
489
+ const problems = problemLines(judged);
490
+ if (problems.length) {
491
+ rawErr("refused calls (their checks were sent as skipped):");
492
+ for (const l of problems)
493
+ rawErr(scrubText(l, lineSecrets));
494
+ }
495
+ }
273
496
  // The report is scrubbed of every value in the run's environment, the vault's included.
274
497
  if (connectorTest)
275
498
  return code;
@@ -281,14 +504,35 @@ export async function run(argv, deps) {
281
504
  const now = deps.now();
282
505
  const raw = [];
283
506
  const failed = [];
284
- for (const s of chosen) {
285
- const { value, error, report } = await rec.source(s.name, () => s.run({ opts: values, env, clients: deps.clients, now, warn: deps.err }));
507
+ // The sources run side by side, at most --concurrency at once, under one fetch wrapper (src/report.ts) that counts each
508
+ // call against its source. Their outcomes are then taken in the order the sources were named, so the results, and
509
+ // the bytes signed, are those of a run one source after another (test/e2e.test.ts holds a golden copy).
510
+ const release = rec.hold();
511
+ let outcomes;
512
+ try {
513
+ outcomes = (await pool(chosen, concurrency, (s) => rec.source(s.name, () => s.run({ opts: values, env, clients: deps.clients, now, warn: deps.err }))))
514
+ .map((o) => (o.status === "fulfilled" ? o.value : { error: o.reason, report: { name: "?", ms: 0, pass: 0, fail: 0, skipped: 0, error: null, warnings: [] } }));
515
+ }
516
+ finally {
517
+ release();
518
+ }
519
+ // The report lists the sources in the order named, not the order they finished.
520
+ const order = new Map(chosen.map((s, i) => [s.name, i]));
521
+ const ranked = rec.sources.filter((r) => order.has(r.name)).sort((a, b) => order.get(a.name) - order.get(b.name));
522
+ let k = 0;
523
+ for (let i = 0; i < rec.sources.length; i++)
524
+ if (order.has(rec.sources[i].name))
525
+ rec.sources[i] = ranked[k++];
526
+ for (const [i, s] of chosen.entries()) {
527
+ const { value, error, report } = outcomes[i];
286
528
  let got;
287
529
  if (error !== undefined || !Array.isArray(value)) {
288
530
  const message = error !== undefined ? String(error?.message ?? error) : "the source returned no list of results";
289
531
  if (!report.error)
290
532
  report.error = { message, stack: "" };
291
- deps.err(`error in source ${s.name}: ${message}; its ${s.emits.length} checks are sent as skipped and the run goes on.`);
533
+ deps.err(`error in source ${s.name}: ${message}; its ${s.emits.length} checks are sent as skipped and the run goes on. To see why: ${doctorHint(s.name)}.`);
534
+ if (debug && report.error.stack)
535
+ rawErr(scrubText(report.error.stack, lineSecrets));
292
536
  failed.push(s.name);
293
537
  got = s.emits.map((check) => ({ check, resource: `the ${s.name} source`, result: "skipped", detail: `collector error in ${s.name}: ${message}` }));
294
538
  }
@@ -301,6 +545,7 @@ export async function run(argv, deps) {
301
545
  report[r.result === "pass" ? "pass" : r.result === "fail" ? "fail" : "skipped"]++;
302
546
  raw.push(...got);
303
547
  }
548
+ judged = raw;
304
549
  // A failed source makes the run exit 1 once everything else is done, as a failed engine run always has.
305
550
  const done = (c) => (c === EXIT.ok || c === EXIT.nothing) && failed.length ? EXIT.error : c;
306
551
  const vetted = vet(raw, catalog);
@@ -399,6 +644,64 @@ export async function run(argv, deps) {
399
644
  return done(EXIT.ok);
400
645
  }
401
646
  }
647
+ /** `collector policies --config <file>`: push each file of the config's policy folders for sos-app to match (src/policies.ts). */
648
+ export async function policiesCommand(argv, deps) {
649
+ let config;
650
+ try {
651
+ ({ values: { config } } = parseArgs({ args: argv, options: { config: { type: "string" } }, strict: true }));
652
+ }
653
+ catch (e) {
654
+ deps.err(e.message);
655
+ return EXIT.usage;
656
+ }
657
+ if (!config) {
658
+ deps.err("usage: collector policies --config <collector.config.json>");
659
+ return EXIT.usage;
660
+ }
661
+ const c = loadConfig(config, deps.sources);
662
+ if ("problems" in c) {
663
+ deps.err(`${config} is not a valid collector config:\n ${c.problems.join("\n ")}`);
664
+ return EXIT.usage;
665
+ }
666
+ const doc = JSON.parse(readFileSync(config, "utf8").replace(/^\uFEFF/, ""));
667
+ const p = doc.policies;
668
+ if (!p) {
669
+ deps.err(`${config} has no "policies" block (dir, keyId, organization): nothing to push.`);
670
+ return EXIT.nothing;
671
+ }
672
+ const base = dirname(config);
673
+ const dirs = (Array.isArray(p.dir) ? p.dir : [p.dir]).map((d) => (isAbsolute(d) ? d : join(base, d)));
674
+ const pem = p.keyFile ? readFileSync(isAbsolute(p.keyFile) ? p.keyFile : join(base, p.keyFile), "utf8") : deps.env[DOCUMENTS_KEY_ENV];
675
+ if (!pem) {
676
+ deps.err(`No documents key: give policies.keyFile, or set ${DOCUMENTS_KEY_ENV}.`);
677
+ return EXIT.usage;
678
+ }
679
+ let out;
680
+ try {
681
+ out = await pushPolicies({ endpoint: doc.endpoint, organization: p.organization, keyId: p.keyId, pem, dirs, fetch: deps.fetch, now: deps.now });
682
+ }
683
+ catch (e) {
684
+ deps.err(`policies: ${e.message}`);
685
+ return EXIT.error;
686
+ }
687
+ for (const r of out)
688
+ deps.out(`${r.outcome.padEnd(9)} ${r.file}: ${r.detail}`);
689
+ // Then the approved versions back into <folder>/approved/, so the next edit starts from the text in force (2026-11-05).
690
+ try {
691
+ const approved = await pullApproved({ endpoint: doc.endpoint, organization: p.organization, keyId: p.keyId, pem, fetch: deps.fetch, now: deps.now });
692
+ for (const w of writeApproved(dirs, approved))
693
+ if (w.outcome !== "present")
694
+ deps.out(`${(w.outcome === "written" ? "approved" : "missing").padEnd(9)} ${APPROVED_DIR}/${w.file}: ${w.detail}`);
695
+ }
696
+ catch (e) {
697
+ deps.err(`policies: the approved copies were not written: ${e.message}`);
698
+ }
699
+ const count = (k) => out.filter((r) => r.outcome === k).length;
700
+ deps.err(`policies: ${out.length} file(s); ${count("filed")} filed as drafts, ${count("duplicate")} unchanged, ${count("evidence")} as evidence, ${count("unmatched") + count("taken")} not matched, ${count("refused")} refused.`);
701
+ if (!out.length)
702
+ return EXIT.nothing;
703
+ return count("refused") === out.length ? EXIT.refused : EXIT.ok;
704
+ }
402
705
  export async function main(argv, deps = defaultDeps()) {
403
706
  const [cmd, ...rest] = argv;
404
707
  try {
@@ -414,6 +717,10 @@ export async function main(argv, deps = defaultDeps()) {
414
717
  }
415
718
  if (cmd === "google-login")
416
719
  return await googleLogin(rest, deps);
720
+ if (cmd === "policies")
721
+ return await policiesCommand(rest, deps);
722
+ if (cmd === "doctor" || cmd === "selftest")
723
+ return await doctorCommand(rest, deps);
417
724
  }
418
725
  catch (e) {
419
726
  deps.err(String(e.message ?? e));
@@ -28,6 +28,18 @@ export const CONFIG_SCHEMA = {
28
28
  out: { type: "string", minLength: 1, description: "Also write the signed payload to this file." },
29
29
  dryRun: { type: "boolean", description: "Print the payload and send nothing." },
30
30
  catalog: { type: "string", minLength: 1, description: "The check catalogue directory." },
31
+ policies: {
32
+ type: "object",
33
+ additionalProperties: false,
34
+ required: ["dir", "keyId", "organization"],
35
+ description: "Where the organization keeps its policies; `collector policies` pushes each file there for sos-app to match to its control family (2026-11-05).",
36
+ properties: {
37
+ dir: { anyOf: [{ type: "string", minLength: 1 }, { type: "array", minItems: 1, items: { type: "string", minLength: 1 } }], description: "A folder (or several) of policy files: PDF, Word or text." },
38
+ keyId: { type: "string", minLength: 1, maxLength: 100, description: "The id of a 'documents' key registered in sos-app (not the collector key)." },
39
+ organization: { type: "string", minLength: 1, maxLength: 100, description: "The organization id the documents key belongs to (sos-app shows it with the key)." },
40
+ keyFile: { type: "string", minLength: 1, description: "Path to that key's Ed25519 private key (PEM). Omit to read SAGENTIX_DOCUMENTS_KEY." },
41
+ },
42
+ },
31
43
  credentials: {
32
44
  type: "object",
33
45
  description: "Per connector, the names of what it reads (sos-app writes them; sos-app sends a sealed value only for a name listed here). Names only, never a value.",