@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.
- package/README.md +36 -0
- package/collector/package.json +1 -1
- package/collector/src/cli.js +281 -36
- package/collector/src/doctor.js +382 -0
- package/collector/src/pool.js +19 -0
- package/collector/src/report.js +88 -16
- package/collector/src/sources/cloudflare/api.js +24 -4
- package/collector/src/sources/directory.js +37 -0
- package/collector/src/sources/entra.js +4 -3
- package/collector/src/sources/google_oauth.js +3 -3
- package/collector/src/sources/google_workspace.js +4 -3
- package/collector/src/sources/oauth.js +22 -0
- package/collector/src/sources/prowler.js +178 -52
- package/collector/src/sources/readonly_http.js +27 -5
- package/collector/src/sources/shared-cloud.js +14 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Node 24 or later. The source is TypeScript run by Node's type stripping, so ther
|
|
|
30
30
|
cd collector
|
|
31
31
|
npm ci # ajv 8.20.0 and ajv-formats 3.0.1 (v1 SDR validation); typescript 5.9.3, @types/node 24.13.6
|
|
32
32
|
npm test # node:test, fakes only, no network
|
|
33
|
+
npm run coverage # the same, with coverage floors
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
The OPA sub-source needs `opa` on PATH (`brew install opa`, or the `open-policy-agent/setup-opa` action) and OpenTofu 1.8 or later to produce the state.
|
|
@@ -91,6 +92,38 @@ node src/cli.ts run --source cloudflare \
|
|
|
91
92
|
|
|
92
93
|
Exit codes: 0 sent (or dry run); 1 error (unreadable key, network failure, or a source that failed: its checks went as `skipped` and everything else was still pushed and reported); 2 usage; 3 nothing to send, because every check was skipped; 4 sos-app refused the push, with its message printed (401 bad signature or revoked key, 409 replay, 400 unknown check or stale `issuedAt`); 75 `agent` only, after it wrote a signed update (below). `run` never exits 75.
|
|
93
94
|
|
|
95
|
+
### Check the setup first: `collector doctor` (0.4.1)
|
|
96
|
+
|
|
97
|
+
Run the doctor before the first real run, and again whenever a source starts coming back `skipped`. It reads the same config file and options as `run`, and the same vault, then for each source checks that its credentials are there and makes one read per permission the source needs. It collects nothing, signs nothing and sends nothing to sos-app: no check-in, no push, no run report.
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
node src/cli.ts doctor --config sagentix-collector.config.json # every source in the file
|
|
101
|
+
node src/cli.ts doctor --config sagentix-collector.config.json --source okta
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each line is `PASS`, `FAIL` or `SKIP`, and a `FAIL` carries its fix:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
okta
|
|
108
|
+
PASS credentials OKTA_DOMAIN with OKTA_API_TOKEN (a Read-Only Administrator's token) or OKTA_ACCESS_TOKEN: present
|
|
109
|
+
PASS GET /api/v1/users HTTP 200 (needs okta.users.read)
|
|
110
|
+
FAIL GET /api/v1/logs HTTP 403: The access token provided does not contain the required scopes.
|
|
111
|
+
fix: grant okta.logs.read
|
|
112
|
+
doctor: 6 passed, 1 failed, 1 skipped; fix the lines marked FAIL, then run the doctor again
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A 403 names the scope, role or permission to grant, as the source's own header cites it from the provider's documentation; a 401 names the variables that hold the credential; a 404 asks to check the organization, team, project or URL given. Every read is one the source itself makes, on the same path, with a page size of 1 where the API takes one, so a pass means the run's call will be allowed too. The reads are GET only, except Google Cloud's `:getIamPolicy`, which the GCP source also sends as a POST and which reads. A source that reads a file checks the file is there and readable; Prowler and Cinc Auditor check their executable is on `PATH` without running it; the `macos` and `windows` sources check the platform; `web` checks each URL has its proof token. The collector key is parsed locally and its public key shown, never used. The doctor exits 0 when nothing failed and 1 otherwise, so a workflow can run it as a step (`collector selftest` is the same command).
|
|
116
|
+
|
|
117
|
+
### Speed, timeouts and what a run prints (0.4.1)
|
|
118
|
+
|
|
119
|
+
Sources run side by side since 0.4.1, four at once by default; `--concurrency 1` restores the old order of one after another, and up to 16 are allowed. The results are taken in the order the sources were named, so the payload and its signature are byte for byte those of a serial run (`test/e2e.test.ts` holds a golden copy recorded before the change). On the recorded replies of every source, with 20 ms added to each call to stand in for a provider's round trip, a full dry run took 6.6 s one source at a time and 1.7 s four at a time (`node test/bench.ts --latency-ms 20`, 2026-11-07). Prowler runs up to two providers at once, and its OCSF output is now read as a stream, one finding at a time, so a file larger than what one string can hold (about 512 MiB in Node) no longer fails.
|
|
120
|
+
|
|
121
|
+
Within one run, Entra ID, Intune and an HR source joined with Entra ID share one token request per app and scope (they used to make three), the Google Workspace source and the HR join share theirs, and BambooHR and Rippling look each leaver up in the identity provider once.
|
|
122
|
+
|
|
123
|
+
Every call a source makes is abandoned after `--http-timeout` seconds (default 60; 0 never). A 429 is retried up to four times; a 502, 503 or 504, a network failure or a timeout up to twice more; each wait follows the answer's `Retry-After` when it has one, else it doubles from 1 second (0.5 second for the clouds, Jira and LMS), capped at 60 seconds (20 for those). The Cloudflare client retries too since 0.4.1, and cuts the token out of any answer that echoes it, as the other clients already did.
|
|
124
|
+
|
|
125
|
+
`--verbose` prints one line per outbound call (`[okta] GET acme.okta.com/api/v1/logs -> 403 in 85 ms`): the method, the host and the path with ids replaced by `:id`, never a query string, a header or a body, and each retry with its wait. `--debug` adds the stack of a source that failed. Every run, verbose or not, ends its stderr with a table, one row per source: results, pass, fail and skipped counts, outbound calls, retries, answers that were errors, and time. Below it, each check skipped because a call was refused (HTTP 401 or 403) is listed with its source, its check, the status and the doctor command that names the missing permission. Neither the call log nor the table goes into the run report, which already holds every call by its shape and every source's counts and time; both pass through the same scrubbing as the report.
|
|
126
|
+
|
|
94
127
|
### Run report, check-in and resident mode (0.3.0)
|
|
95
128
|
|
|
96
129
|
Since 0.3.0 the collector keeps a channel to sos-app beside the push, defined in `apps/sos-app/docs/COLLECTOR_CHANNEL.md`. Every message is signed with the collector key, exactly as a push is, and every connection still starts on the client's side.
|
|
@@ -1052,3 +1085,6 @@ A TLS 1.1 probe that this machine's OpenSSL cannot make, or a network failure, g
|
|
|
1052
1085
|
- `src/sources/prowler.ts` and `runner/`: Prowler as an engine (`--source prowler`) and the container that runs it; see `runner/README.md`. More than 5,000 results (or 4 MB) go out as several signed pushes, each with its own nonce.
|
|
1053
1086
|
- `src/sources/stig.ts`, `kubescape.ts`, `checkov.ts`: Cinc Auditor with MITRE's DISA STIG profiles (`--source stig`, which runs `cinc-auditor` over SSH or WinRM, or reads an InSpec report), and the Kubescape and Checkov report readers; listed through `engines.ts`, documented in `runner/README.md`, tested in `test/host_iac_engines.test.ts` with hand-built reports. Their catalogues (`stig_<profile>.json`, `kubescape.json`, `checkov.json`) come from `apps/sos-app/scripts/build_{stig,kubescape,checkov}_catalog.py`.
|
|
1054
1087
|
- `test/`: node:test suites with a fake Cloudflare API, a fake `opa` and a fake sos-app; one suite runs the real `opa` when it is installed.
|
|
1088
|
+
- `src/doctor.ts`: `collector doctor`, one self-test per source (a test fails when a source has none); `src/pool.ts`: the bounded pool behind `--concurrency`.
|
|
1089
|
+
- `test/e2e.test.ts` and `test/e2e_fixtures.ts`: every source in one run against recorded replies, no network: the payload's shape, the signature over the exact bytes, and the results against the golden file `test/fixtures/e2e/golden-results.jsonl` (`UPDATE_GOLDEN=1 node --test test/e2e.test.ts` rewrites it after a deliberate change). The replies are the ones each source's own suite uses, kept in `test/fixtures/rest/`, `cloud/` and `local/`. `test/run_qa.test.ts` covers concurrency, retries, timeouts, the call log, the doctor, and a check that no known secret reaches stdout, stderr, the payload or the run report even when a provider echoes it. `node test/bench.ts --latency-ms 20` times a full dry run.
|
|
1090
|
+
- `npm run coverage` runs every suite with Node's coverage and fails below 98% of lines, 82% of branches or 94% of functions (the level of 2026-11-07); `.github/workflows/collector-qa.yml` runs it, the doctor, a dry run as a process and the timing on every push that touches the collector.
|
package/collector/package.json
CHANGED
package/collector/src/cli.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
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
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
|
|
14
16
|
// collector run --endpoint <url> --key-id <id> [--key <pem> | env SAGENTIX_COLLECTOR_KEY]
|
|
15
17
|
// --source cloudflare[,...] [source options] [--catalog <dir>] [--dry-run] [--out <file>]
|
|
16
18
|
// [--fedramp-target <id> [--class A|B|C|D]]
|
|
@@ -30,7 +32,9 @@ import { googleLogin } from "./google_login.js";
|
|
|
30
32
|
import { V1_COLLECT_PATH, v1Payload } from "./v1/index.js";
|
|
31
33
|
import { CLASSES } from "./v1/rules.js";
|
|
32
34
|
import { EXIT_UPDATE, checkin, detectRunner, modeOf } from "./channel.js";
|
|
33
|
-
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";
|
|
34
38
|
import { agent } from "./agent.js";
|
|
35
39
|
import { VAULT_FILE, Vault, keyHolderFor, runEnv } from "./vault.js";
|
|
36
40
|
import { applyRefs } from "./refs.js";
|
|
@@ -65,7 +69,26 @@ const COMMON_OPTIONS = {
|
|
|
65
69
|
class: { type: "string" },
|
|
66
70
|
"cpo-uri": { type: "string" },
|
|
67
71
|
config: { type: "string" },
|
|
72
|
+
concurrency: { type: "string" },
|
|
73
|
+
"http-timeout": { type: "string" },
|
|
74
|
+
verbose: { type: "boolean" },
|
|
75
|
+
debug: { type: "boolean" },
|
|
68
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
|
+
}
|
|
69
92
|
function usage(sources) {
|
|
70
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}`));
|
|
71
94
|
return `usage:
|
|
@@ -74,10 +97,14 @@ function usage(sources) {
|
|
|
74
97
|
collector agent --config collector.config.json (stays connected to sos-app; runs when it asks)
|
|
75
98
|
collector config-schema
|
|
76
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
|
|
77
101
|
collector run --endpoint <url> --key-id <id> [--key <pem file>] --source ${sources.map((s) => s.name).join("|")}[,...]
|
|
78
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]
|
|
79
104
|
[--fedramp-target <id> [--class A|B|C|D] [--cpo-uri <url>]]
|
|
80
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.
|
|
81
108
|
Source options:
|
|
82
109
|
${opts.join("\n")}
|
|
83
110
|
Every run checks in first (an update sos-app names is taken only with a valid Sagentix release signature, and only
|
|
@@ -122,10 +149,77 @@ function serverMessage(text) {
|
|
|
122
149
|
return text;
|
|
123
150
|
}
|
|
124
151
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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) {
|
|
129
223
|
const sourceOptions = {};
|
|
130
224
|
for (const s of deps.sources) {
|
|
131
225
|
for (const [n, o] of Object.entries(s.options)) {
|
|
@@ -154,8 +248,147 @@ export async function run(argv, deps) {
|
|
|
154
248
|
const src = [...(c.values.source ?? []), ...(given.source ?? [])];
|
|
155
249
|
values = { ...c.values, ...given, source: src };
|
|
156
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;
|
|
157
374
|
const dry = values["dry-run"] === true;
|
|
158
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
|
+
};
|
|
159
392
|
const names = (values.source ?? []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
|
|
160
393
|
if (!names.length) {
|
|
161
394
|
deps.err(`--source is required (${deps.sources.map((s) => s.name).join(", ")}).`);
|
|
@@ -233,35 +466,10 @@ export async function run(argv, deps) {
|
|
|
233
466
|
}
|
|
234
467
|
else
|
|
235
468
|
deps.err("check-in skipped: a dry run without a key cannot sign one.");
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
let env = deps.env, cleanup = () => { }, vaultSecrets = [];
|
|
239
|
-
if (configPath && keyId) {
|
|
240
|
-
try {
|
|
241
|
-
const creds = credentialsIn(resolve(configPath));
|
|
242
|
-
const holder = deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner });
|
|
243
|
-
const r = runEnv(dir, holder, deps.env, (name) => settingsOf(creds, name));
|
|
244
|
-
// Connectors read from the client's own vault (src/refs.ts) take all their names from there, after the local vault.
|
|
245
|
-
const refs = existsSync(join(dir, VAULT_FILE)) ? new Vault(dir, holder).refs() : {};
|
|
246
|
-
const f = Object.keys(refs).length ? await applyRefs(dir, refs, creds, r.env, deps.channel ?? deps.fetch) : null;
|
|
247
|
-
env = f?.env ?? r.env;
|
|
248
|
-
cleanup = () => { r.cleanup(); f?.cleanup(); };
|
|
249
|
-
vaultSecrets = [...r.secrets, ...(f?.secrets ?? [])];
|
|
250
|
-
const used = r.used.length + (f?.used.length ?? 0), warnings = [...r.warnings, ...(f?.warnings ?? [])];
|
|
251
|
-
if (used)
|
|
252
|
-
deps.err(`vault: ${used} value(s) from ${VAULT_FILE}${f ? " and your own vault" : ""}`);
|
|
253
|
-
for (const w of warnings)
|
|
254
|
-
deps.err(`warning: vault: ${w}`);
|
|
255
|
-
if (warnings.length)
|
|
256
|
-
rec.note("vault", warnings);
|
|
257
|
-
}
|
|
258
|
-
catch (e) {
|
|
259
|
-
const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
|
|
260
|
-
deps.err(w);
|
|
261
|
-
rec.note("vault", [w]);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
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);
|
|
264
471
|
let code;
|
|
472
|
+
let judged = [];
|
|
265
473
|
try {
|
|
266
474
|
code = await collect();
|
|
267
475
|
}
|
|
@@ -272,6 +480,19 @@ export async function run(argv, deps) {
|
|
|
272
480
|
finally {
|
|
273
481
|
cleanup();
|
|
274
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
|
+
}
|
|
275
496
|
// The report is scrubbed of every value in the run's environment, the vault's included.
|
|
276
497
|
if (connectorTest)
|
|
277
498
|
return code;
|
|
@@ -283,14 +504,35 @@ export async function run(argv, deps) {
|
|
|
283
504
|
const now = deps.now();
|
|
284
505
|
const raw = [];
|
|
285
506
|
const failed = [];
|
|
286
|
-
|
|
287
|
-
|
|
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];
|
|
288
528
|
let got;
|
|
289
529
|
if (error !== undefined || !Array.isArray(value)) {
|
|
290
530
|
const message = error !== undefined ? String(error?.message ?? error) : "the source returned no list of results";
|
|
291
531
|
if (!report.error)
|
|
292
532
|
report.error = { message, stack: "" };
|
|
293
|
-
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));
|
|
294
536
|
failed.push(s.name);
|
|
295
537
|
got = s.emits.map((check) => ({ check, resource: `the ${s.name} source`, result: "skipped", detail: `collector error in ${s.name}: ${message}` }));
|
|
296
538
|
}
|
|
@@ -303,6 +545,7 @@ export async function run(argv, deps) {
|
|
|
303
545
|
report[r.result === "pass" ? "pass" : r.result === "fail" ? "fail" : "skipped"]++;
|
|
304
546
|
raw.push(...got);
|
|
305
547
|
}
|
|
548
|
+
judged = raw;
|
|
306
549
|
// A failed source makes the run exit 1 once everything else is done, as a failed engine run always has.
|
|
307
550
|
const done = (c) => (c === EXIT.ok || c === EXIT.nothing) && failed.length ? EXIT.error : c;
|
|
308
551
|
const vetted = vet(raw, catalog);
|
|
@@ -476,6 +719,8 @@ export async function main(argv, deps = defaultDeps()) {
|
|
|
476
719
|
return await googleLogin(rest, deps);
|
|
477
720
|
if (cmd === "policies")
|
|
478
721
|
return await policiesCommand(rest, deps);
|
|
722
|
+
if (cmd === "doctor" || cmd === "selftest")
|
|
723
|
+
return await doctorCommand(rest, deps);
|
|
479
724
|
}
|
|
480
725
|
catch (e) {
|
|
481
726
|
deps.err(String(e.message ?? e));
|