@sagentix-inc/evidence-collector 0.2.1 → 0.3.0

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.
@@ -2,19 +2,22 @@
2
2
  // Sagentix evidence collector. Reads the customer's systems read-only, runs checks, signs the result
3
3
  // with an Ed25519 key and pushes it OUT to sos-app. `run` never listens for connections (only the one-time
4
4
  // `google-login` takes one redirect on 127.0.0.1), and sos-app never holds the customer's credentials.
5
+ // Every run also checks in (a signed update may be named) and sends a run report; a source that fails
6
+ // becomes skipped results and the run goes on (apps/sos-app/docs/COLLECTOR_CHANNEL.md).
5
7
  //
6
8
  // collector keygen [--out <path>]
7
9
  // collector run --config collector.config.json [any option below, which overrides the file]
10
+ // collector agent --config collector.config.json stays connected (outbound WebSocket); runs when sos-app asks
8
11
  // collector config-schema prints the JSON schema of the config file
9
12
  // collector google-login --client <client JSON> [--token <file>] one-time Google Workspace sign-in
10
13
  // collector run --endpoint <url> --key-id <id> [--key <pem> | env SAGENTIX_COLLECTOR_KEY]
11
14
  // --source cloudflare[,...] [source options] [--catalog <dir>] [--dry-run] [--out <file>]
12
15
  // [--fedramp-target <id> [--class A|B|C|D]]
13
16
  //
14
- // Exit codes: 0 sent (or dry run), 1 error (bad key, network), 2 usage, 3 nothing to send,
15
- // 4 sos-app refused the push (its message is printed).
17
+ // Exit codes: 0 sent (or dry run), 1 error (bad key, network, a source that failed), 2 usage, 3 nothing to send,
18
+ // 4 sos-app refused the push (its message is printed); `agent` only: 75 a signed update was written, restart me.
16
19
  import { readFileSync, realpathSync, writeFileSync } from "node:fs";
17
- import { resolve } from "node:path";
20
+ import { dirname, resolve } from "node:path";
18
21
  import { fileURLToPath } from "node:url";
19
22
  import { parseArgs } from "node:util";
20
23
  import { DEFAULT_CATALOG_DIR, loadCatalog } from "./catalog.js";
@@ -25,9 +28,13 @@ import { CONFIG_SCHEMA, loadConfig } from "./config.js";
25
28
  import { googleLogin } from "./google_login.js";
26
29
  import { V1_COLLECT_PATH, v1Payload } from "./v1/index.js";
27
30
  import { CLASSES } from "./v1/rules.js";
31
+ import { EXIT_UPDATE, checkin, detectRunner, modeOf } from "./channel.js";
32
+ import { RunRecorder, buildReport, deliverReport } from "./report.js";
33
+ import { agent } from "./agent.js";
28
34
  export const COLLECT_PATH = "/api/collect";
29
35
  export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
30
- export const EXIT = { ok: 0, error: 1, usage: 2, nothing: 3, refused: 4 };
36
+ /** `update` (75) is the agent's only: it wrote a signed update and exits so it is restarted on it. */
37
+ export const EXIT = { ok: 0, error: 1, usage: 2, nothing: 3, refused: 4, update: EXIT_UPDATE };
31
38
  export function defaultDeps() {
32
39
  return {
33
40
  fetch: globalThis.fetch,
@@ -57,6 +64,7 @@ function usage(sources) {
57
64
  return `usage:
58
65
  collector keygen [--out sagentix-collector.pem]
59
66
  collector run --config collector.config.json [options that override the file]
67
+ collector agent --config collector.config.json (stays connected to sos-app; runs when it asks)
60
68
  collector config-schema
61
69
  collector google-login --client <Desktop app client JSON> [--token sagentix-google-token.json] [--port <n>] [--login-hint <email>] [--no-browser]
62
70
  collector run --endpoint <url> --key-id <id> [--key <pem file>] --source ${sources.map((s) => s.name).join("|")}[,...]
@@ -65,7 +73,12 @@ function usage(sources) {
65
73
  The private key may come from ${KEY_ENV} (PEM text) instead of --key.
66
74
  Source options:
67
75
  ${opts.join("\n")}
68
- Exit codes: 0 sent or dry run, 1 error, 2 usage, 3 nothing to send, 4 sos-app refused the push.`;
76
+ Every run checks in first (an update sos-app names is taken only with a valid Sagentix release signature, and only
77
+ as sagentix-collector.ref or .image beside the config, read on the next start) and ends by writing
78
+ sagentix-run-report.json beside the config and sending it (a dry run writes it only).
79
+ A source that fails gives skipped results; the run goes on, pushes the rest and exits 1.
80
+ Exit codes: 0 sent or dry run, 1 error (or a source failed), 2 usage, 3 nothing to send, 4 sos-app refused the push;
81
+ agent only: 75 a signed update was written (after any run in progress ended), so restart it on the new code.`;
69
82
  }
70
83
  /** https, or http on localhost; a bare origin gets the collect path appended. */
71
84
  export function collectUrl(endpoint, path = COLLECT_PATH) {
@@ -103,6 +116,9 @@ function serverMessage(text) {
103
116
  }
104
117
  }
105
118
  export async function run(argv, deps) {
119
+ // Every line the run writes to stderr also goes into its report (scrubbed there).
120
+ const rec = new RunRecorder(deps.now);
121
+ deps = { ...deps, err: rec.tee(deps.err) };
106
122
  const sourceOptions = {};
107
123
  for (const s of deps.sources) {
108
124
  for (const [n, o] of Object.entries(s.options)) {
@@ -119,6 +135,7 @@ export async function run(argv, deps) {
119
135
  deps.err(`${e.message}\n${usage(deps.sources)}`);
120
136
  return EXIT.usage;
121
137
  }
138
+ const configPath = typeof values.config === "string" ? values.config : undefined;
122
139
  if (typeof values.config === "string") {
123
140
  const c = loadConfig(values.config, deps.sources);
124
141
  if ("problems" in c) {
@@ -192,99 +209,149 @@ export async function run(argv, deps) {
192
209
  return EXIT.usage;
193
210
  }
194
211
  const catalog = loadCatalog(typeof values.catalog === "string" ? values.catalog : DEFAULT_CATALOG_DIR);
195
- // Run every chosen source, then keep only what the server will accept.
196
- const now = deps.now();
197
- const raw = [];
198
- for (const s of chosen) {
199
- const got = await s.run({ opts: values, env: deps.env, clients: deps.clients, now, warn: deps.err });
200
- const stray = got.filter((r) => !s.emits.includes(r.check)).map((r) => r.check);
201
- if (stray.length)
202
- deps.err(`warning: source ${s.name} emitted checks it does not declare: ${[...new Set(stray)].join(", ")}`);
203
- raw.push(...got);
212
+ // From here on every run ends with a report; the check-in comes first, before any source reads.
213
+ const runner = detectRunner(deps.env), mode = modeOf(deps.env);
214
+ const dir = configPath ? dirname(resolve(configPath)) : (deps.cwd ?? process.cwd());
215
+ const channel = pem && keyId
216
+ ? { origin: new URL(url).origin, keyId, pem, fetch: deps.channel ?? deps.fetch, dir, runner, mode, env: deps.env, now: deps.now }
217
+ : undefined;
218
+ if (channel)
219
+ rec.note("checkin", (await checkin(channel, { write: !dry, log: deps.err })).warnings);
220
+ else
221
+ deps.err("check-in skipped: a dry run without a key cannot sign one.");
222
+ let code;
223
+ try {
224
+ code = await collect();
204
225
  }
205
- const vetted = vet(raw, catalog);
206
- if (vetted.unknown.length)
207
- deps.err(`refused (not in the catalogue at ${typeof values.catalog === "string" ? values.catalog : DEFAULT_CATALOG_DIR}): ${vetted.unknown.join(", ")}`);
208
- if (vetted.duplicates.length)
209
- deps.err(`dropped duplicate results: ${vetted.duplicates.slice(0, 10).join(", ")}`);
210
- const results = vetted.results;
211
- const skipped = results.filter((r) => r.result === "skipped");
212
- if (v1Target && results.length > MAX_RESULTS) {
213
- deps.err(`${results.length} results; the v1 push takes at most ${MAX_RESULTS}. Narrow the run (e.g. --zone).`);
214
- return EXIT.error;
226
+ catch (e) {
227
+ deps.err(`collector error: ${String(e?.message ?? e)}`);
228
+ code = EXIT.error;
215
229
  }
216
- const issuedAt = now.toISOString();
217
- // One push, or several when the results exceed what sos-app takes at once (5,000 results, 4 MB). Each is signed alone.
218
- const pushes = [];
219
- if (v1Target) {
220
- const cpoUri = typeof values["cpo-uri"] === "string" ? values["cpo-uri"] : new URL(`/api/fedramp/targets/${encodeURIComponent(v1Target)}/export/cpo`, url).toString();
221
- const v1 = v1Payload(results, catalog, { targetId: v1Target, cls, issuedAt, cpoUri });
222
- if (v1.problems.length) {
223
- deps.err(`The SDR is not valid against FedRAMP's schema:\n ${v1.problems.join("\n ")}`);
230
+ await deliverReport(buildReport(rec, { now: deps.now(), exitCode: code, runner, mode, env: deps.env }), { dry, channel, dir, log: deps.err });
231
+ return code;
232
+ async function collect() {
233
+ // Run every chosen source inside a guard, then keep only what the server will accept. A source that throws gives
234
+ // one skipped result per check it declares, and the run goes on (section 1 of COLLECTOR_CHANNEL.md).
235
+ const now = deps.now();
236
+ const raw = [];
237
+ const failed = [];
238
+ for (const s of chosen) {
239
+ const { value, error, report } = await rec.source(s.name, () => s.run({ opts: values, env: deps.env, clients: deps.clients, now, warn: deps.err }));
240
+ let got;
241
+ if (error !== undefined || !Array.isArray(value)) {
242
+ const message = error !== undefined ? String(error?.message ?? error) : "the source returned no list of results";
243
+ if (!report.error)
244
+ report.error = { message, stack: "" };
245
+ deps.err(`error in source ${s.name}: ${message}; its ${s.emits.length} checks are sent as skipped and the run goes on.`);
246
+ failed.push(s.name);
247
+ got = s.emits.map((check) => ({ check, resource: `the ${s.name} source`, result: "skipped", detail: `collector error in ${s.name}: ${message}` }));
248
+ }
249
+ else
250
+ got = value;
251
+ const stray = got.filter((r) => !s.emits.includes(r.check)).map((r) => r.check);
252
+ if (stray.length)
253
+ deps.err(`warning: source ${s.name} emitted checks it does not declare: ${[...new Set(stray)].join(", ")}`);
254
+ for (const r of got)
255
+ report[r.result === "pass" ? "pass" : r.result === "fail" ? "fail" : "skipped"]++;
256
+ raw.push(...got);
257
+ }
258
+ // A failed source makes the run exit 1 once everything else is done, as a failed engine run always has.
259
+ const done = (c) => (c === EXIT.ok || c === EXIT.nothing) && failed.length ? EXIT.error : c;
260
+ const vetted = vet(raw, catalog);
261
+ if (vetted.unknown.length)
262
+ deps.err(`refused (not in the catalogue at ${typeof values.catalog === "string" ? values.catalog : DEFAULT_CATALOG_DIR}): ${vetted.unknown.join(", ")}`);
263
+ if (vetted.duplicates.length)
264
+ deps.err(`dropped duplicate results: ${vetted.duplicates.slice(0, 10).join(", ")}`);
265
+ const results = vetted.results;
266
+ const skipped = results.filter((r) => r.result === "skipped");
267
+ if (v1Target && results.length > MAX_RESULTS) {
268
+ deps.err(`${results.length} results; the v1 push takes at most ${MAX_RESULTS}. Narrow the run (e.g. --zone).`);
224
269
  return EXIT.error;
225
270
  }
226
- if (!dry && !v1.payload.results.length) {
227
- deps.err("Nothing to send: no KSI had a check that passed or failed.");
228
- return EXIT.nothing;
271
+ const issuedAt = now.toISOString();
272
+ // One push, or several when the results exceed what sos-app takes at once (5,000 results, 4 MB). Each is signed alone.
273
+ const pushes = [];
274
+ if (v1Target) {
275
+ const cpoUri = typeof values["cpo-uri"] === "string" ? values["cpo-uri"] : new URL(`/api/fedramp/targets/${encodeURIComponent(v1Target)}/export/cpo`, url).toString();
276
+ const v1 = v1Payload(results, catalog, { targetId: v1Target, cls, issuedAt, cpoUri });
277
+ if (v1.problems.length) {
278
+ deps.err(`The SDR is not valid against FedRAMP's schema:\n ${v1.problems.join("\n ")}`);
279
+ return EXIT.error;
280
+ }
281
+ if (!dry && !v1.payload.results.length) {
282
+ deps.err("Nothing to send: no KSI had a check that passed or failed.");
283
+ return done(EXIT.nothing);
284
+ }
285
+ const body = new TextEncoder().encode(JSON.stringify(v1.payload));
286
+ pushes.push({ body, bodyText: new TextDecoder().decode(body), summary: { ksis: v1.payload.results.map((r) => `${r.ksiId} ${r.status}`), omitted: v1.omitted } });
229
287
  }
230
- const body = new TextEncoder().encode(JSON.stringify(v1.payload));
231
- pushes.push({ body, bodyText: new TextDecoder().decode(body), summary: { ksis: v1.payload.results.map((r) => `${r.ksiId} ${r.status}`), omitted: v1.omitted } });
232
- }
233
- else {
234
- const sources = chosen.map((s) => ({ source: s.name, scope: s.describe(values) }));
235
- const parts = results.length ? splitResults(results) : [[]];
236
- if (parts.length > 1)
237
- deps.err(`${results.length} results go out as ${parts.length} signed pushes (sos-app takes at most ${MAX_RESULTS} results or 4 MB per push).`);
238
- for (const part of parts) {
239
- const body = new TextEncoder().encode(JSON.stringify(buildPayload({ issuedAt, results: part, sources })));
240
- pushes.push({ body, bodyText: new TextDecoder().decode(body),
241
- summary: { passed: part.filter((r) => r.result === "pass").length, failed: part.filter((r) => r.result === "fail").length, skipped: part.filter((r) => r.result === "skipped").length } });
288
+ else {
289
+ const sources = chosen.map((s) => ({ source: s.name, scope: s.describe(values) }));
290
+ const parts = results.length ? splitResults(results) : [[]];
291
+ if (parts.length > 1)
292
+ deps.err(`${results.length} results go out as ${parts.length} signed pushes (sos-app takes at most ${MAX_RESULTS} results or 4 MB per push).`);
293
+ for (const part of parts) {
294
+ const body = new TextEncoder().encode(JSON.stringify(buildPayload({ issuedAt, results: part, sources })));
295
+ pushes.push({ body, bodyText: new TextDecoder().decode(body),
296
+ summary: { passed: part.filter((r) => r.result === "pass").length, failed: part.filter((r) => r.result === "fail").length, skipped: part.filter((r) => r.result === "skipped").length } });
297
+ }
242
298
  }
243
- }
244
- for (const p of pushes)
245
- p.signature = pem ? signBody(pem, p.body) : undefined;
246
- const publicKey = pem ? publicKeyFromPrivate(pem) : null;
247
- if (typeof values.out === "string") {
248
- // The customer's own copy: the exact bytes sent, and what verifies them (one entry per push when split).
249
- const copy = pushes.length === 1
250
- ? { endpoint: url, keyId: keyId ?? null, publicKey, signature: pushes[0].signature ?? null, body: pushes[0].bodyText }
251
- : { endpoint: url, keyId: keyId ?? null, publicKey, pushes: pushes.map((p) => ({ signature: p.signature ?? null, body: p.bodyText })) };
252
- writeFileSync(values.out, JSON.stringify(copy, null, 2) + "\n");
253
- deps.err(`signed payload written to ${values.out}`);
254
- }
255
- if (dry) {
256
- const head = (p) => ({ "x-sagentix-key-id": keyId ?? null, "x-sagentix-signature": p.signature ?? null });
257
- deps.out(JSON.stringify(pushes.length === 1
258
- ? { dryRun: true, endpoint: url, headers: head(pushes[0]), publicKey, summary: pushes[0].summary, payload: JSON.parse(pushes[0].bodyText) }
259
- : { dryRun: true, endpoint: url, publicKey, pushes: pushes.map((p) => ({ headers: head(p), summary: p.summary, payload: JSON.parse(p.bodyText) })) }, null, 2));
260
- return EXIT.ok;
261
- }
262
- if (!results.some((r) => r.result !== "skipped")) {
263
- deps.err("Nothing to send: every check was skipped. Run with --dry-run to see why.");
264
- for (const s of skipped.slice(0, 50))
265
- deps.err(` skipped ${s.check} [${s.resource}]: ${s.detail}`);
266
- return EXIT.nothing;
267
- }
268
- for (const s of skipped)
269
- deps.err(`skipped ${s.check} [${s.resource}]: ${s.detail}`);
270
- for (const [i, p] of pushes.entries()) {
271
- const which = pushes.length > 1 ? ` (push ${i + 1} of ${pushes.length}${i ? `; pushes 1 to ${i} were accepted` : ""})` : "";
272
- let res;
273
- try {
274
- res = await deps.fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-sagentix-key-id": keyId, "x-sagentix-signature": p.signature }, body: p.body });
299
+ for (const p of pushes)
300
+ p.signature = pem ? signBody(pem, p.body) : undefined;
301
+ const publicKey = pem ? publicKeyFromPrivate(pem) : null;
302
+ if (typeof values.out === "string") {
303
+ // The customer's own copy: the exact bytes sent, and what verifies them (one entry per push when split).
304
+ const copy = pushes.length === 1
305
+ ? { endpoint: url, keyId: keyId ?? null, publicKey, signature: pushes[0].signature ?? null, body: pushes[0].bodyText }
306
+ : { endpoint: url, keyId: keyId ?? null, publicKey, pushes: pushes.map((p) => ({ signature: p.signature ?? null, body: p.bodyText })) };
307
+ writeFileSync(values.out, JSON.stringify(copy, null, 2) + "\n");
308
+ deps.err(`signed payload written to ${values.out}`);
275
309
  }
276
- catch (e) {
277
- deps.err(`POST ${url} failed${which}: ${String(e)}`);
278
- return EXIT.error;
310
+ if (dry) {
311
+ const head = (p) => ({ "x-sagentix-key-id": keyId ?? null, "x-sagentix-signature": p.signature ?? null });
312
+ deps.out(JSON.stringify(pushes.length === 1
313
+ ? { dryRun: true, endpoint: url, headers: head(pushes[0]), publicKey, summary: pushes[0].summary, payload: JSON.parse(pushes[0].bodyText) }
314
+ : { dryRun: true, endpoint: url, publicKey, pushes: pushes.map((p) => ({ headers: head(p), summary: p.summary, payload: JSON.parse(p.bodyText) })) }, null, 2));
315
+ return done(EXIT.ok);
316
+ }
317
+ if (!results.some((r) => r.result !== "skipped")) {
318
+ deps.err("Nothing to send: every check was skipped. Run with --dry-run to see why.");
319
+ for (const s of skipped.slice(0, 50))
320
+ deps.err(` skipped ${s.check} [${s.resource}]: ${s.detail}`);
321
+ return done(EXIT.nothing);
279
322
  }
280
- const text = await res.text();
281
- if (!res.ok) {
282
- deps.err(`sos-app refused the push${which} (HTTP ${res.status}): ${serverMessage(text)}`);
283
- return EXIT.refused;
323
+ for (const s of skipped)
324
+ deps.err(`skipped ${s.check} [${s.resource}]: ${s.detail}`);
325
+ // A push that fails is recorded and ends the pushing (the later parts would fail the same way); the report still goes.
326
+ for (const [i, p] of pushes.entries()) {
327
+ const which = pushes.length > 1 ? ` (push ${i + 1} of ${pushes.length}${i ? `; pushes 1 to ${i} were accepted` : ""})` : "";
328
+ let res;
329
+ try {
330
+ res = await deps.fetch(url, { method: "POST", headers: { "content-type": "application/json", "x-sagentix-key-id": keyId, "x-sagentix-signature": p.signature }, body: p.body });
331
+ }
332
+ catch (e) {
333
+ deps.err(`POST ${url} failed${which}: ${String(e)}`);
334
+ rec.pushes.push({ status: 0, error: String(e?.message ?? e) });
335
+ return EXIT.error;
336
+ }
337
+ const text = await res.text();
338
+ if (!res.ok) {
339
+ deps.err(`sos-app refused the push${which} (HTTP ${res.status}): ${serverMessage(text)}`);
340
+ rec.pushes.push({ status: res.status, error: serverMessage(text).slice(0, 500) });
341
+ return EXIT.refused;
342
+ }
343
+ let runId;
344
+ try {
345
+ const j = JSON.parse(text);
346
+ if (typeof j?.runId === "string")
347
+ runId = j.runId;
348
+ }
349
+ catch { /* not JSON */ }
350
+ rec.pushes.push({ status: res.status, ...(runId ? { runId } : {}) });
351
+ deps.out(text);
284
352
  }
285
- deps.out(text);
353
+ return done(EXIT.ok);
286
354
  }
287
- return EXIT.ok;
288
355
  }
289
356
  export async function main(argv, deps = defaultDeps()) {
290
357
  const [cmd, ...rest] = argv;
@@ -293,6 +360,8 @@ export async function main(argv, deps = defaultDeps()) {
293
360
  return keygen(rest, deps);
294
361
  if (cmd === "run")
295
362
  return await run(rest, deps);
363
+ if (cmd === "agent")
364
+ return await agent(rest, deps);
296
365
  if (cmd === "config-schema") {
297
366
  deps.out(JSON.stringify(CONFIG_SCHEMA, null, 2));
298
367
  return EXIT.ok;
@@ -315,5 +384,7 @@ catch {
315
384
  return resolve(p);
316
385
  } };
317
386
  if (process.argv[1] && invokedPath(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
318
- main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
387
+ // The agent exits for real (75 after an update) even should a closed socket's handle linger.
388
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; if (process.argv[2] === "agent")
389
+ process.exit(code); });
319
390
  }
@@ -0,0 +1,9 @@
1
+ // The Sagentix release public keys a check-in `update` must verify against (apps/sos-app/docs/COLLECTOR_CHANNEL.md,
2
+ // section 4). Raw 32-byte Ed25519 public keys, base64. A list, so a key can be rotated: add the new key, release,
3
+ // then sign with it, then drop the old one in a later release.
4
+ //
5
+ // 2026-10-28: created on the release Mac; the private half lives only in its macOS Keychain (service
6
+ // `sagentix-collector-release`), and apps/sos-app/scripts/collector-channel.mjs signs with it.
7
+ export const RELEASE_PUBLIC_KEYS = [
8
+ "Puo1SstVhUheQhkyFbepFDyLL3RYwOS4mxeSIa9Y+4E=",
9
+ ];
@@ -0,0 +1,300 @@
1
+ // The run report (apps/sos-app/docs/COLLECTOR_CHANNEL.md, section 2): what every run did, source by source, so a bug,
2
+ // a refused call or a mis-read shows up in sos-app without anyone asking the client for a log. It carries counts,
3
+ // timings, the shape of each outbound call (method, host, templated path, status) and the stderr lines, scrubbed;
4
+ // never a response body, a request header, a query string or a credential.
5
+ //
6
+ // Written beside the config as sagentix-run-report.json before it is sent, so the client can read exactly what
7
+ // leaves. A send that fails leaves sagentix-pending-report.json, sent first ("late": true) by the next run.
8
+ import { existsSync, readFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { REPORT_PATH, collectorInfo, osInfo, signedPost, writePrivate } from "./channel.js";
11
+ export const REPORT_FORMAT = "sagentix-run-report/1";
12
+ export const REPORT_FILE = "sagentix-run-report.json";
13
+ export const PENDING_FILE = "sagentix-pending-report.json";
14
+ export const MAX_REPORT_BYTES = 512 * 1024;
15
+ export const MAX_LOG_LINES = 1000;
16
+ export const MAX_LINE = 500;
17
+ export const MAX_WARNINGS = 50;
18
+ // ── Scrubbing ─────────────────────────────────────────────────────────────────────────────────────
19
+ const SECRET_ENV = /(_TOKEN|_SECRET|_KEY|_PASSWORD|_KEY_JSON)$/;
20
+ const PEM = /-----BEGIN [A-Z0-9 ]+-----[\s\S]*?(-----END [A-Z0-9 ]+-----|$)/g;
21
+ const JWT = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g;
22
+ const AUTH = /\b(Bearer|Basic|SSWS)\s+[A-Za-z0-9._~+/=-]{6,}/gi;
23
+ const AWS_KEY_ID = /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g;
24
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
25
+ /** The values of the environment's secrets (section 2): names ending _TOKEN, _SECRET, _KEY, _PASSWORD, _KEY_JSON, or SAGENTIX_COLLECTOR_KEY. */
26
+ export function envSecrets(env) {
27
+ return Object.entries(env)
28
+ .filter(([k, v]) => typeof v === "string" && v.length >= 6 && (SECRET_ENV.test(k) || k === "SAGENTIX_COLLECTOR_KEY"))
29
+ .map(([, v]) => v)
30
+ .sort((a, b) => b.length - a.length); // the longest first, so a secret containing another is cut whole
31
+ }
32
+ /** Text with every secret value, token shape and email address replaced. */
33
+ export function scrubText(text, secrets) {
34
+ let t = text;
35
+ for (const s of secrets)
36
+ t = t.split(s).join("<secret>");
37
+ return t.replace(PEM, "<secret>").replace(JWT, "<secret>").replace(AUTH, (_m, scheme) => `${scheme} <secret>`)
38
+ .replace(AWS_KEY_ID, "<secret>").replace(EMAIL, "<email>");
39
+ }
40
+ /** Every string in a value scrubbed (keys are the report's own field names and stay). */
41
+ function scrubDeep(v, secrets) {
42
+ if (typeof v === "string")
43
+ return scrubText(v, secrets);
44
+ if (Array.isArray(v))
45
+ return v.map((x) => scrubDeep(x, secrets));
46
+ if (v && typeof v === "object")
47
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrubDeep(x, secrets)]));
48
+ return v;
49
+ }
50
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
51
+ const HEX20 = /^[0-9a-f]{20,}$/i;
52
+ // Base64 or base64url of 20 or more characters with a digit and a letter in it: an id, not a word like
53
+ // "deviceManagementConfigurations".
54
+ const B64_20 = /^(?=.*\d)(?=.*[A-Za-z])[A-Za-z0-9+/=_-]{20,}$/;
55
+ /** A URL path with its ids replaced by `:id` (numbers, UUIDs, 12-digit account ids, long hex or base64, emails). */
56
+ export function templatePath(path) {
57
+ return path.split("?")[0].split("#")[0].split("/").map((seg) => {
58
+ if (!seg)
59
+ return seg;
60
+ let d = seg;
61
+ try {
62
+ d = decodeURIComponent(seg);
63
+ }
64
+ catch { /* keep it */ }
65
+ return /^\d+$/.test(d) || UUID.test(d) || HEX20.test(d) || B64_20.test(d) || /@/.test(d) ? ":id" : seg;
66
+ }).join("/");
67
+ }
68
+ // ── Recording ─────────────────────────────────────────────────────────────────────────────────────
69
+ /** Everything one run does that goes into its report. */
70
+ export class RunRecorder {
71
+ startedAt;
72
+ sources = [];
73
+ pushes = [];
74
+ log = [];
75
+ calls = new Map();
76
+ current;
77
+ clock;
78
+ constructor(now, clock = () => performance.now()) {
79
+ this.startedAt = now();
80
+ this.clock = clock;
81
+ }
82
+ /** An err() that also records each line (and, while a source runs, counts it as that source's warning). */
83
+ tee(err) {
84
+ return (s) => {
85
+ for (const line of String(s).split("\n")) {
86
+ this.log.push(line);
87
+ if (this.current && this.current.warnings.length < MAX_WARNINGS)
88
+ this.current.warnings.push(line);
89
+ }
90
+ err(s);
91
+ };
92
+ }
93
+ /** Runs one source's work with its outbound HTTP counted, and its time and warnings recorded. */
94
+ async source(name, work) {
95
+ const report = { name, ms: 0, pass: 0, fail: 0, skipped: 0, error: null, warnings: [] };
96
+ this.sources.push(report);
97
+ this.current = report;
98
+ const t0 = this.clock();
99
+ const restore = this.instrumentFetch(name);
100
+ try {
101
+ return { value: await work(), report };
102
+ }
103
+ catch (e) {
104
+ const err = e instanceof Error ? e : new Error(String(e));
105
+ report.error = { message: err.message, stack: err.stack ?? "" };
106
+ return { error: e, report };
107
+ }
108
+ finally {
109
+ restore();
110
+ report.ms = Math.round(this.clock() - t0);
111
+ this.current = undefined;
112
+ }
113
+ }
114
+ /** Warnings that belong to no source (the check-in's), as their own entry. */
115
+ note(name, warnings) {
116
+ if (warnings.length)
117
+ this.sources.push({ name, ms: 0, pass: 0, fail: 0, skipped: 0, error: null, warnings: warnings.slice(0, MAX_WARNINGS) });
118
+ }
119
+ /** One outbound call, by its shape only. */
120
+ call(source, method, url, status, ms, headers) {
121
+ let host = "", path = "";
122
+ try {
123
+ const u = new URL(url);
124
+ host = u.host;
125
+ path = templatePath(u.pathname);
126
+ }
127
+ catch {
128
+ host = "(not a URL)";
129
+ }
130
+ const key = `${source}\n${method}\n${host}\n${path}\n${status}`;
131
+ const c = this.calls.get(key) ?? { source, method, host, path, status, count: 0, ms: 0 };
132
+ c.count++;
133
+ c.ms += Math.round(ms);
134
+ // Dates or "true", never more: a header value is the provider's, but it is cut short all the same.
135
+ const dep = headers?.get("deprecation"), sun = headers?.get("sunset");
136
+ if (dep)
137
+ c.deprecation = dep.slice(0, 64);
138
+ if (sun)
139
+ c.sunset = sun.slice(0, 64);
140
+ this.calls.set(key, c);
141
+ }
142
+ callList() { return [...this.calls.values()]; }
143
+ /** A warning in the source's own list, once per endpoint: the provider retires (410) or plans to retire it. */
144
+ deprecated(source, method, url, status, sunset) {
145
+ let where = url;
146
+ try {
147
+ const u = new URL(url);
148
+ where = `${u.host}${templatePath(u.pathname)}`;
149
+ }
150
+ catch { /* the raw string, scrubbed later */ }
151
+ const text = status === 410 ? `provider endpoint gone (HTTP 410): ${method} ${where}`
152
+ : `provider marks this endpoint deprecated${sunset ? `, sunset ${sunset.slice(0, 64)}` : ""}: ${method} ${where}`;
153
+ const s = this.sources.find((x) => x.name === source);
154
+ // The source's entry exists from the moment it starts (source() pushes it first).
155
+ if (s && !s.warnings.includes(text) && s.warnings.length < MAX_WARNINGS)
156
+ s.warnings.push(text);
157
+ }
158
+ /**
159
+ * 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.
161
+ */
162
+ instrumentFetch(source) {
163
+ const original = globalThis.fetch;
164
+ if (typeof original !== "function")
165
+ return () => { };
166
+ const rec = this;
167
+ const wrapped = async function (input, init) {
168
+ const url = input instanceof Request ? input.url : String(input);
169
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
170
+ const t0 = rec.clock();
171
+ try {
172
+ const res = await original(input, init);
173
+ rec.call(source, method, url, res.status, rec.clock() - t0, res.headers);
174
+ 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);
176
+ }
177
+ return res;
178
+ }
179
+ catch (e) {
180
+ rec.call(source, method, url, 0, rec.clock() - t0);
181
+ throw e;
182
+ }
183
+ };
184
+ globalThis.fetch = wrapped;
185
+ return () => { if (globalThis.fetch === wrapped)
186
+ globalThis.fetch = original; };
187
+ }
188
+ }
189
+ // ── Building, trimming, sending ───────────────────────────────────────────────────────────────────
190
+ export function buildReport(rec, o) {
191
+ const report = {
192
+ format: REPORT_FORMAT,
193
+ issuedAt: o.now.toISOString(),
194
+ late: false,
195
+ collector: collectorInfo(o.env),
196
+ runner: o.runner,
197
+ mode: o.mode,
198
+ os: osInfo(),
199
+ node: process.version,
200
+ startedAt: rec.startedAt.toISOString(), endedAt: o.now.toISOString(), exitCode: o.exitCode,
201
+ sources: rec.sources.map((s) => ({ ...s, warnings: s.warnings.slice(0, MAX_WARNINGS).map((w) => w.slice(0, MAX_LINE)) })),
202
+ calls: rec.callList(),
203
+ pushes: rec.pushes,
204
+ log: rec.log.slice(0, MAX_LOG_LINES).map((l) => l.slice(0, MAX_LINE)),
205
+ };
206
+ if (rec.log.length > MAX_LOG_LINES)
207
+ report.log[MAX_LOG_LINES - 1] = `(log cut: ${rec.log.length} lines, the first ${MAX_LOG_LINES - 1} kept)`;
208
+ return trimReport(scrubDeep(report, envSecrets(o.env)));
209
+ }
210
+ const bytes = (r) => Buffer.byteLength(JSON.stringify(r, null, 2));
211
+ /** At most MAX_REPORT_BYTES: drops log lines first, then calls, then stacks, and says so in a last log line. */
212
+ export function trimReport(r, max = MAX_REPORT_BYTES) {
213
+ if (bytes(r) <= max)
214
+ return r;
215
+ const out = { ...r, log: [...r.log], calls: [...r.calls], sources: r.sources.map((s) => ({ ...s, error: s.error && { ...s.error } })) };
216
+ const cut = [];
217
+ const note = () => `(report trimmed to ${max} bytes: ${cut.join(", ")})`;
218
+ const fits = () => bytes({ ...out, log: [...out.log, note()] }) <= max;
219
+ const logs = out.log.length;
220
+ while (out.log.length && !fits())
221
+ out.log.splice(Math.floor(out.log.length / 2));
222
+ if (out.log.length < logs)
223
+ cut.push(`log ${logs} to ${out.log.length} lines`);
224
+ if (!fits()) {
225
+ const calls = out.calls.length;
226
+ out.calls.sort((a, b) => b.count - a.count);
227
+ while (out.calls.length && !fits())
228
+ out.calls.splice(Math.floor(out.calls.length / 2));
229
+ cut.push(`calls ${calls} to ${out.calls.length}`);
230
+ }
231
+ if (!fits()) {
232
+ for (const s of out.sources)
233
+ if (s.error)
234
+ s.error.stack = "";
235
+ cut.push("stacks removed");
236
+ }
237
+ if (out.log.length >= MAX_LOG_LINES)
238
+ out.log.pop();
239
+ out.log.push(note());
240
+ return out;
241
+ }
242
+ /** The report's bytes, as written and as sent. */
243
+ export const reportBytes = (r) => new TextEncoder().encode(JSON.stringify(r, null, 2) + "\n");
244
+ /** HTTP answers that will never accept this body; a pending copy of it would be refused on every later run. */
245
+ const PERMANENT = new Set([400, 413]);
246
+ /**
247
+ * Writes the report beside the config, then (not on a dry run) sends any pending report first and this one after.
248
+ * Never throws; what happened goes to `log`.
249
+ */
250
+ export async function deliverReport(r, o) {
251
+ const file = join(o.dir, REPORT_FILE), pending = join(o.dir, PENDING_FILE);
252
+ try {
253
+ writePrivate(file, new TextDecoder().decode(reportBytes(r)));
254
+ }
255
+ catch (e) {
256
+ o.log(`warning: cannot write ${file}: ${e.code ?? String(e)}`);
257
+ }
258
+ if (o.dry || !o.channel)
259
+ return;
260
+ const send = async (body) => {
261
+ try {
262
+ const res = await signedPost(o.channel, REPORT_PATH, body);
263
+ if (res.status >= 200 && res.status < 300)
264
+ return { ok: true, keep: false, why: "" };
265
+ return { ok: false, keep: !PERMANENT.has(res.status), why: `HTTP ${res.status}` };
266
+ }
267
+ catch (e) {
268
+ return { ok: false, keep: true, why: String(e?.message ?? e) };
269
+ }
270
+ };
271
+ if (existsSync(pending)) {
272
+ let body;
273
+ try {
274
+ body = new Uint8Array(readFileSync(pending));
275
+ }
276
+ catch { /* unreadable: leave it */ }
277
+ if (body) {
278
+ const s = await send(body);
279
+ if (s.ok || !s.keep) {
280
+ rmSync(pending, { force: true });
281
+ if (!s.ok)
282
+ o.log(`warning: the report left from an earlier run was refused (${s.why}) and removed`);
283
+ }
284
+ else
285
+ o.log(`warning: the report left from an earlier run is still not sent (${s.why}); kept as ${pending}`);
286
+ }
287
+ }
288
+ const s = await send(reportBytes(r));
289
+ if (s.ok)
290
+ return;
291
+ if (!s.keep) {
292
+ o.log(`warning: sos-app refused the run report (${s.why}); a copy stays in ${file}`);
293
+ return;
294
+ }
295
+ try {
296
+ writePrivate(pending, new TextDecoder().decode(reportBytes({ ...r, late: true })));
297
+ }
298
+ catch { /* the report file is still there */ }
299
+ o.log(`warning: the run report was not sent (${s.why}); kept as ${pending}, sent first next run`);
300
+ }