@sagentix-inc/evidence-collector 0.3.2 → 0.3.3

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 CHANGED
@@ -143,6 +143,27 @@ Top-level keys are `endpoint` (required), `keyId`, `keyFile`, `out`, `dryRun` an
143
143
 
144
144
  The file holds no secret. The private key comes from the `keyFile` path or from `SAGENTIX_COLLECTOR_KEY`, and each source reads its credentials from the environment as before; a value that looks like a PEM block or a well-known token prefix (`ghp_`, `github_pat_`, `AKIA`, `xoxb-` …) is refused. Paths are relative to the directory the command runs in. Options given on the command line override the file's, and `--source` on the command line adds to the file's sources.
145
145
 
146
+ ### Vault: connector secrets set from sos-app (0.3.3)
147
+
148
+ Since 0.3.3 an owner or admin sets each connector's secrets and settings on sos-app's Connect screen (Your collectors,
149
+ Connectors). A secret is encrypted in their browser for this collector only (X25519, HKDF-SHA256, AES-256-GCM): sos-app
150
+ keeps and relays bytes it cannot open, and the collector opens them into `sagentix-collector.vault` beside the config
151
+ (mode 0600, each value sealed on its own). Each run reads the vault into its environment; a variable already set wins.
152
+ The collector takes a value only for a name its config's `credentials` lists, and a secret only while its connector's
153
+ settings are the ones it was entered with. Design: `apps/sos-app/docs/COLLECTOR_VAULT.md` in the Sagentix repository.
154
+
155
+ The vault's key is kept in the strongest place the computer has: a systemd credential, a Docker secret, the macOS
156
+ Keychain, Windows DPAPI, or else `sagentix-collector.vault.key` (mode 0600). With a plain key file, seal it:
157
+
158
+ - **systemd** (Linux, the collector run as a system service; the generated agent unit is a user unit, so this means
159
+ installing it under `/etc/systemd/system` instead): `sudo systemd-creds encrypt --name=sagentix-vault-key sagentix-collector.vault.key /etc/credstore.encrypted/sagentix-vault-key`,
160
+ add `LoadCredentialEncrypted=sagentix-vault-key` to the collector's unit, restart it, then delete `sagentix-collector.vault.key`.
161
+ - **Docker**: create a Docker secret named `sagentix-vault-key` from the key file and give it to the container
162
+ (`/run/secrets/sagentix-vault-key`), then delete `sagentix-collector.vault.key`.
163
+
164
+ Values written earlier to `sagentix-collector.env` by the setup scripts keep working; the Connect screen offers to move
165
+ them into the vault, after which the collector removes those lines.
166
+
146
167
  ### The older v1 push
147
168
 
148
169
  `--fedramp-target <id> [--class A|B|C|D]` sends the v1 format (`sagentix-fedramp-collect/1`) to `/api/fedramp/collect` instead. It carries the original 11 checks grouped into five KSIs with a Security Decision Record, validated locally against FedRAMP's schema. New installs should use the default v2 push; v1 stays for targets already wired to it.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module"
5
5
  }
@@ -11,6 +11,8 @@ import { fileURLToPath } from "node:url";
11
11
  import { parseArgs } from "node:util";
12
12
  import { CONNECT_PATH, EXIT_UPDATE, MODE_ENV, checkin, collectorInfo, detectRunner } from "./channel.js";
13
13
  import { loadConfig } from "./config.js";
14
+ import { vaultBridge } from "./deliveries.js";
15
+ import { keyHolderFor } from "./vault.js";
14
16
  import { signBody } from "./keys.js";
15
17
  export const PING_MS = 50_000;
16
18
  export const CHECKIN_MS = 6 * 3600_000;
@@ -111,6 +113,38 @@ export class AgentSession {
111
113
  /** Stops a run in progress (the agent is exiting). */
112
114
  stop() { this.busy?.child.kill(); }
113
115
  }
116
+ export const TEST_TIMEOUT_MS = 5 * 60_000;
117
+ /** A connector test asked from sos-app: this file's own cli.ts, `run --dry-run` of its sources, no check-in, no report. */
118
+ export function testRun(configPath, env, sources) {
119
+ return new Promise((done) => {
120
+ const child = spawn(process.execPath, [...process.execArgv, CLI_PATH, "run", "--config", configPath, "--dry-run", ...sources.flatMap((x) => ["--source", x])], { cwd: dirname(configPath), env: { ...env, SAGENTIX_CONNECTOR_TEST: "1" }, stdio: ["ignore", "pipe", "pipe"] });
121
+ let out = "", err = "", timedOut = false;
122
+ child.stdout.on("data", (d) => { if (out.length < 32 * 1024 * 1024)
123
+ out += d; });
124
+ child.stderr.on("data", (d) => { if (err.length < 256 * 1024)
125
+ err += d; });
126
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, TEST_TIMEOUT_MS);
127
+ child.once("error", (e) => { clearTimeout(timer); done({ exit: null, passed: 0, failed: 0, skipped: 0, errors: [String(e.message)] }); });
128
+ child.once("exit", (code) => {
129
+ clearTimeout(timer);
130
+ done({ ...summaryOf(out), exit: code, errors: err.split(/\r?\n/).filter((l) => /^error in source |^collector error|vault/.test(l)), ...(timedOut ? { timedOut } : {}) });
131
+ });
132
+ });
133
+ }
134
+ /** The pass, fail and skipped counts a dry run prints (one push, or several). */
135
+ export function summaryOf(stdout) {
136
+ const z = { passed: 0, failed: 0, skipped: 0 };
137
+ try {
138
+ const j = JSON.parse(stdout);
139
+ for (const sm of j.summary ? [j.summary] : (j.pushes ?? []).map((x) => x.summary)) {
140
+ z.passed += sm?.passed ?? 0;
141
+ z.failed += sm?.failed ?? 0;
142
+ z.skipped += sm?.skipped ?? 0;
143
+ }
144
+ }
145
+ catch { /* nothing printed: the errors say why */ }
146
+ return z;
147
+ }
114
148
  /** What a run started from sos-app executes (exported for the tests). */
115
149
  export function runCommand(configPath, exists, platform = process.platform) {
116
150
  const dir = dirname(resolve(configPath));
@@ -201,9 +235,12 @@ export async function agent(argv, deps, io = realAgentIo()) {
201
235
  // A signed update written by a check-in ends the agent with 75 once no run is going: Docker's wrapper loop restarts
202
236
  // the container on the new digest, a service manager restarts the script, which checks out the ref on start.
203
237
  let restartPending = false;
238
+ const dir = dirname(configPath);
239
+ const vault = vaultBridge({ configPath, keyId, now: deps.now, log, test: (sources) => testRun(configPath, deps.env, sources),
240
+ holder: deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner: detectRunner(deps.env) }) });
204
241
  const doCheckin = async () => {
205
242
  try {
206
- const out = await checkin(channel, { write: true, log });
243
+ const out = await checkin(channel, { write: true, log, vault });
207
244
  if (out.wrote && !restartPending) {
208
245
  restartPending = true;
209
246
  log(`agent: exiting with ${EXIT_UPDATE} to restart on the update${session.running ? ` once run ${session.running} ends` : ""}`);
@@ -2,7 +2,8 @@
2
2
  // collector is (version, commit, runner), the signed POST every message uses, and the check-in that may name a
3
3
  // signed update. Every connection goes out; nothing here listens, and nothing the server answers can make the
4
4
  // collector run a command or change a configuration. The most it can do is have `sagentix-collector.ref` (or
5
- // `.image`) written beside the config, which the generated script reads on its next start.
5
+ // `.image`) written beside the config, which the generated script reads on its next start, and deliver values to
6
+ // the vault for the names the config already lists (src/deliveries.ts: a secret only sealed for this collector).
6
7
  import { spawnSync } from "node:child_process";
7
8
  import { chmodSync, existsSync, writeFileSync } from "node:fs";
8
9
  import { arch, platform, release } from "node:os";
@@ -12,6 +13,7 @@ import { fileURLToPath } from "node:url";
12
13
  import { signBody } from "./keys.js";
13
14
  import { COLLECTOR_VERSION } from "./payload.js";
14
15
  import { RELEASE_PUBLIC_KEYS } from "./release_keys.js";
16
+ import { ack } from "./deliveries.js";
15
17
  export const CHECKIN_PATH = "/api/collect/checkin";
16
18
  export const REPORT_PATH = "/api/collect/report";
17
19
  export const CONNECT_PATH = "/api/collect/connect";
@@ -133,9 +135,17 @@ function targetOf(v) {
133
135
  export async function checkin(c, o) {
134
136
  const warnings = [];
135
137
  const warn = (s) => { warnings.push(s); o.log(`warning: check-in: ${s}`); };
138
+ // The vault's names, kinds, dates and sealing key (section 8): never a value.
139
+ let vault;
140
+ try {
141
+ vault = o.vault?.summary();
142
+ }
143
+ catch (e) {
144
+ warn(`the vault summary failed: ${String(e?.message ?? e)}`);
145
+ }
136
146
  const body = new TextEncoder().encode(JSON.stringify({
137
147
  format: "sagentix-checkin/1", issuedAt: c.now().toISOString(), collector: collectorInfo(c.env),
138
- runner: c.runner, mode: c.mode, os: osInfo(), node: process.version,
148
+ runner: c.runner, mode: c.mode, os: osInfo(), node: process.version, ...(vault ? { vault } : {}),
139
149
  }));
140
150
  let res;
141
151
  try {
@@ -157,6 +167,17 @@ export async function checkin(c, o) {
157
167
  warn("the answer is not JSON");
158
168
  return { action: "failed", warnings };
159
169
  }
170
+ // Values sent to the vault from sos-app (section 8), whatever the update answer. A dry run applies nothing.
171
+ if (o.vault && o.write && Array.isArray(a.deliveries) && a.deliveries.length) {
172
+ try {
173
+ const results = await o.vault.apply(a.deliveries, { agent: c.mode === "agent" });
174
+ if (results.length)
175
+ await ack(c, results, o.vault.summary(), o.log);
176
+ }
177
+ catch (e) {
178
+ warn(`the deliveries failed: ${String(e?.message ?? e)}`);
179
+ }
180
+ }
160
181
  const reason = typeof a.reason === "string" ? a.reason.slice(0, 500) : "";
161
182
  switch (a.action) {
162
183
  case "none": return { action: "none", warnings };
@@ -31,8 +31,12 @@ import { CLASSES } from "./v1/rules.js";
31
31
  import { EXIT_UPDATE, checkin, detectRunner, modeOf } from "./channel.js";
32
32
  import { RunRecorder, buildReport, deliverReport } from "./report.js";
33
33
  import { agent } from "./agent.js";
34
+ import { VAULT_FILE, keyHolderFor, runEnv } from "./vault.js";
35
+ import { credentialsIn, settingsOf, vaultBridge } from "./deliveries.js";
34
36
  export const COLLECT_PATH = "/api/collect";
35
37
  export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
38
+ /** Set by the agent on a connector test asked from sos-app (a dry run of one connector). */
39
+ export const TEST_ENV = "SAGENTIX_CONNECTOR_TEST";
36
40
  /** `update` (75) is the agent's only: it wrote a signed update and exits so it is restarted on it. */
37
41
  export const EXIT = { ok: 0, error: 1, usage: 2, nothing: 3, refused: 4, update: EXIT_UPDATE };
38
42
  export function defaultDeps() {
@@ -215,10 +219,39 @@ export async function run(argv, deps) {
215
219
  const channel = pem && keyId
216
220
  ? { origin: new URL(url).origin, keyId, pem, fetch: deps.channel ?? deps.fetch, dir, runner, mode, env: deps.env, now: deps.now }
217
221
  : undefined;
218
- if (channel)
219
- rec.note("checkin", (await checkin(channel, { write: !dry, log: deps.err })).warnings);
222
+ // A connector test asked from sos-app runs that connector dry: no check-in, and no run report, so the last real run's
223
+ // report beside the config stays what sos-app shows.
224
+ const connectorTest = dry && deps.env[TEST_ENV] === "1";
225
+ if (connectorTest) { /* neither */ }
226
+ else if (channel) {
227
+ const vault = configPath && keyId ? vaultBridge({ configPath: resolve(configPath), keyId, now: deps.now, log: deps.err,
228
+ holder: deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner }) }) : undefined;
229
+ rec.note("checkin", (await checkin(channel, { write: !dry, log: deps.err, vault })).warnings);
230
+ }
220
231
  else
221
232
  deps.err("check-in skipped: a dry run without a key cannot sign one.");
233
+ // 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.
234
+ // A vault that cannot be opened is a warning: the sources that need its values fail on their own, the run goes on.
235
+ let env = deps.env, cleanup = () => { }, vaultSecrets = [];
236
+ if (configPath && keyId) {
237
+ try {
238
+ const creds = credentialsIn(resolve(configPath));
239
+ const r = runEnv(dir, deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner }), deps.env, (name) => settingsOf(creds, name));
240
+ ({ env, cleanup } = r);
241
+ vaultSecrets = r.secrets;
242
+ if (r.used.length)
243
+ deps.err(`vault: ${r.used.length} value(s) from ${VAULT_FILE}`);
244
+ for (const w of r.warnings)
245
+ deps.err(`warning: vault: ${w}`);
246
+ if (r.warnings.length)
247
+ rec.note("vault", r.warnings);
248
+ }
249
+ catch (e) {
250
+ const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
251
+ deps.err(w);
252
+ rec.note("vault", [w]);
253
+ }
254
+ }
222
255
  let code;
223
256
  try {
224
257
  code = await collect();
@@ -227,7 +260,13 @@ export async function run(argv, deps) {
227
260
  deps.err(`collector error: ${String(e?.message ?? e)}`);
228
261
  code = EXIT.error;
229
262
  }
230
- await deliverReport(buildReport(rec, { now: deps.now(), exitCode: code, runner, mode, env: deps.env }), { dry, channel, dir, log: deps.err });
263
+ finally {
264
+ cleanup();
265
+ }
266
+ // The report is scrubbed of every value in the run's environment, the vault's included.
267
+ if (connectorTest)
268
+ return code;
269
+ await deliverReport(buildReport(rec, { now: deps.now(), exitCode: code, runner, mode, env, secrets: vaultSecrets }), { dry, channel, dir, log: deps.err });
231
270
  return code;
232
271
  async function collect() {
233
272
  // Run every chosen source inside a guard, then keep only what the server will accept. A source that throws gives
@@ -236,7 +275,7 @@ export async function run(argv, deps) {
236
275
  const raw = [];
237
276
  const failed = [];
238
277
  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 }));
278
+ const { value, error, report } = await rec.source(s.name, () => s.run({ opts: values, env, clients: deps.clients, now, warn: deps.err }));
240
279
  let got;
241
280
  if (error !== undefined || !Array.isArray(value)) {
242
281
  const message = error !== undefined ? String(error?.message ?? error) : "the source returned no list of results";
@@ -28,6 +28,24 @@ 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
+ credentials: {
32
+ type: "object",
33
+ 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.",
34
+ additionalProperties: {
35
+ type: "object",
36
+ additionalProperties: false,
37
+ required: ["sources"],
38
+ properties: {
39
+ sources: { type: "array", items: { type: "string" } },
40
+ secrets: { type: "array", items: { type: "string", pattern: "^[A-Z][A-Z0-9_]{0,99}$" } },
41
+ files: { type: "array", items: { type: "string", pattern: "^[A-Z][A-Z0-9_]{0,99}$" } },
42
+ settings: { type: "array", items: { type: "string", pattern: "^[A-Z][A-Z0-9_]{0,99}$" } },
43
+ optional: { type: "object", additionalProperties: { type: "string" } },
44
+ instead: { type: "object", additionalProperties: { type: "string" } },
45
+ signIn: { type: "boolean" },
46
+ },
47
+ },
48
+ },
31
49
  sources: {
32
50
  type: "object",
33
51
  minProperties: 1,
@@ -0,0 +1,244 @@
1
+ // What sos-app may deliver to the vault (apps/sos-app/docs/COLLECTOR_CHANNEL.md, section 8), and the vault's summary the
2
+ // check-in reports. A check-in answer may carry `deliveries`; each is one of four things, and nothing else:
3
+ //
4
+ // set a value for a name the config's `credentials` lists, with that name's kind: a secret or file sealed in
5
+ // the browser to this collector's key (sos-app cannot open it), or a setting in clear
6
+ // remove the vault's value for such a name
7
+ // import-env move such names' values from sagentix-collector.env into the vault, then drop those lines
8
+ // test run one connector of the config dry (resident mode only), and report its counts
9
+ //
10
+ // A delivery for a name the config does not list, or with another kind, is refused and reported, never applied: what
11
+ // sos-app says cannot widen what this collector reads. No delivery can name a file, a command or a source option.
12
+ // The outcome of each goes back in a signed POST /api/collect/vault/ack, with the vault's new summary.
13
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
14
+ import { dirname, isAbsolute, join } from "node:path";
15
+ import { signedPost, writePrivate } from "./channel.js";
16
+ import { scrubText } from "./report.js";
17
+ import { KINDS, MAX_VALUE, NAME, Vault, canonicalBind } from "./vault.js";
18
+ export const ACK_PATH = "/api/collect/vault/ack";
19
+ export const ENV_FILE = "sagentix-collector.env";
20
+ export const MAX_DELIVERIES = 50;
21
+ const ID = /^[A-Za-z0-9-]{1,64}$/;
22
+ /** Each name a connector reads, with its kind: the only names a delivery may touch. */
23
+ export function knownNames(creds) {
24
+ const m = new Map();
25
+ for (const c of Object.values(creds)) {
26
+ for (const n of c.secrets ?? [])
27
+ m.set(n, "secret");
28
+ for (const n of c.files ?? [])
29
+ m.set(n, "file");
30
+ for (const n of Object.values(c.instead ?? {}))
31
+ if (!m.has(n))
32
+ m.set(n, n.endsWith("_FILE") ? "file" : "secret");
33
+ for (const n of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
34
+ if (!m.has(n))
35
+ m.set(n, "setting");
36
+ }
37
+ return m;
38
+ }
39
+ /** The settings of the connector a secret belongs to, as the vault holds them now: what a sealed value binds to. */
40
+ export function bindFor(creds, name, v) {
41
+ const out = {};
42
+ for (const k of settingsOf(creds, name)) {
43
+ const val = v.get(k);
44
+ if (val !== undefined)
45
+ out[k] = val;
46
+ }
47
+ return out;
48
+ }
49
+ /** The settings (required and optional) of every connector that reads this secret or file. */
50
+ export function settingsOf(creds, name) {
51
+ const out = new Set();
52
+ for (const c of Object.values(creds)) {
53
+ if (![...(c.secrets ?? []), ...(c.files ?? []), ...Object.values(c.instead ?? {})].includes(name))
54
+ continue;
55
+ for (const k of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
56
+ out.add(k);
57
+ }
58
+ return [...out];
59
+ }
60
+ /** The config's credentials, or none when the file cannot be read. */
61
+ export function credentialsIn(configPath) {
62
+ try {
63
+ return JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, "")).credentials ?? {};
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ }
69
+ /** A file's text when it exists and is at most MAX_VALUE bytes; one open, so the size checked is the file read. */
70
+ function readSmall(path) {
71
+ let fd;
72
+ try {
73
+ fd = openSync(path, "r");
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ try {
79
+ if (fstatSync(fd).size > MAX_VALUE)
80
+ return null;
81
+ const buf = Buffer.alloc(MAX_VALUE + 1);
82
+ const n = readSync(fd, buf, 0, buf.length, 0);
83
+ return n > MAX_VALUE ? null : buf.subarray(0, n).toString("utf8");
84
+ }
85
+ finally {
86
+ closeSync(fd);
87
+ }
88
+ }
89
+ /** The vault beside a config, as the check-in sees it. `test`: the agent's dry run of some sources (absent in `run`). */
90
+ export function vaultBridge(o) {
91
+ const dir = dirname(o.configPath), envPath = join(dir, ENV_FILE);
92
+ let vault;
93
+ const v = () => (vault ??= new Vault(dir, o.holder));
94
+ const creds = () => {
95
+ try {
96
+ return JSON.parse(readFileSync(o.configPath, "utf8").replace(/^/, "")).credentials ?? {};
97
+ }
98
+ catch {
99
+ return {};
100
+ }
101
+ };
102
+ const envLines = (known) => !existsSync(envPath) ? [] : readFileSync(envPath, "utf8").split(/\r?\n/)
103
+ .map((l) => /^(?:export\s+)?([A-Z][A-Z0-9_]*)=(.*)$/.exec(l.trim()))
104
+ .filter((m) => !!m && known.has(m[1]) && m[2].length > 0)
105
+ .map((m) => ({ name: m[1], value: m[2].replace(/^(['"])(.*)\1$/, "$2") }));
106
+ function summary() {
107
+ const c = creds(), known = knownNames(c);
108
+ let sealKey = null, entries = [], error = null, protection = null;
109
+ try {
110
+ protection = v().protection;
111
+ // A setting's value goes along (a URL, a tenant id), so sos-app shows it and the browser binds a secret to it.
112
+ entries = v().list().map((e) => e.kind === "setting" ? { ...e, value: v().get(e.name) } : e);
113
+ sealKey = Object.keys(c).length ? v().sealPublicKey() : null;
114
+ }
115
+ catch (e) {
116
+ error = String(e?.message ?? e).slice(0, 300);
117
+ }
118
+ return { sealKey, protection, error, entries, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
119
+ }
120
+ function importEnv(known) {
121
+ const moved = [], leftFiles = [], problems = [], c = creds();
122
+ // Settings first, so each secret binds to the settings it came with.
123
+ for (const l of envLines(known).sort((x, y) => Number(known.get(x.name) !== "setting") - Number(known.get(y.name) !== "setting"))) {
124
+ const kind = known.get(l.name);
125
+ try {
126
+ if (kind === "file") {
127
+ const path = isAbsolute(l.value) ? l.value : join(dir, l.value);
128
+ const text = readSmall(path);
129
+ if (text === null) {
130
+ problems.push(`${l.name}: the file it names is missing or too large`);
131
+ continue;
132
+ }
133
+ v().set(l.name, text, { kind, now: o.now(), bind: bindFor(c, l.name, v()) });
134
+ leftFiles.push(l.value);
135
+ }
136
+ else
137
+ v().set(l.name, l.value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: bindFor(c, l.name, v()) } : {}) });
138
+ moved.push(l.name);
139
+ }
140
+ catch (e) {
141
+ problems.push(`${l.name}: ${String(e?.message ?? e)}`);
142
+ }
143
+ }
144
+ if (moved.length) {
145
+ const keep = readFileSync(envPath, "utf8").split(/\r?\n/).filter((l) => !moved.includes(/^(?:export\s+)?([A-Z][A-Z0-9_]*)=/.exec(l.trim())?.[1] ?? ""));
146
+ writePrivate(envPath, keep.join("\n").replace(/\n*$/, "\n"));
147
+ }
148
+ return { moved, leftFiles, ...(problems.length ? { error: problems.join("; ") } : {}) };
149
+ }
150
+ async function apply(raw, a) {
151
+ if (!Array.isArray(raw))
152
+ return [];
153
+ const c = creds(), known = knownNames(c), out = [];
154
+ for (const d of raw.slice(0, MAX_DELIVERIES)) {
155
+ if (typeof d !== "object" || d === null || typeof d.id !== "string" || !ID.test(d.id))
156
+ continue;
157
+ const refuse = (error) => { out.push({ id: d.id, ok: false, error }); o.log(`vault: refused delivery ${d.id}: ${error}`); };
158
+ try {
159
+ if (d.op === "set" || d.op === "remove") {
160
+ const name = String(d.name ?? ""), kind = known.get(name);
161
+ if (!NAME.test(name) || !kind) {
162
+ refuse(`${name.slice(0, 100)} is not a name this collector's connectors read`);
163
+ continue;
164
+ }
165
+ if (d.op === "remove") {
166
+ v().remove(name);
167
+ o.log(`vault: ${name} removed`);
168
+ out.push({ id: d.id, ok: true });
169
+ continue;
170
+ }
171
+ if (d.kind !== kind || !KINDS.includes(d.kind)) {
172
+ refuse(`${name} is a ${kind}, not a ${String(d.kind)}`);
173
+ continue;
174
+ }
175
+ let value;
176
+ if (kind === "setting") {
177
+ if (typeof d.value !== "string" || !d.value.trim()) {
178
+ refuse(`${name}: no value`);
179
+ continue;
180
+ }
181
+ value = d.value.trim();
182
+ }
183
+ else {
184
+ if (!d.sealed || d.sealed.name !== name || d.sealed.kind !== kind) {
185
+ refuse(`${name}: a ${kind} must come sealed, for this name and kind`);
186
+ continue;
187
+ }
188
+ // The connector's settings as they stand now must be the ones the person saw when sealing.
189
+ const bind = d.sealed.bind ?? {}, want = bindFor(c, name, v());
190
+ const missing = Object.values(c).filter((x) => [...(x.secrets ?? []), ...(x.files ?? []), ...Object.values(x.instead ?? {})].includes(name))
191
+ .flatMap((x) => x.settings ?? []).filter((k) => want[k] === undefined);
192
+ if (missing.length) {
193
+ refuse(`${name}: set ${missing.join(", ")} first, so the secret is bound to where it goes`);
194
+ continue;
195
+ }
196
+ if (canonicalBind(bind) !== canonicalBind(want)) {
197
+ refuse(`${name}: the connector's settings changed since it was sealed; enter it again`);
198
+ continue;
199
+ }
200
+ value = v().openSealed(o.keyId, d.sealed);
201
+ }
202
+ v().set(name, value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: d.sealed.bind ?? {} } : {}) });
203
+ o.log(`vault: ${name} set`);
204
+ out.push({ id: d.id, ok: true });
205
+ }
206
+ else if (d.op === "import-env") {
207
+ const r = importEnv(known);
208
+ o.log(`vault: ${r.moved?.length ?? 0} value(s) moved from ${ENV_FILE}`);
209
+ out.push({ id: d.id, ok: !r.error, ...r });
210
+ }
211
+ else if (d.op === "test") {
212
+ if (!a.agent || !o.test)
213
+ continue; // left for the resident agent, which can run it now
214
+ const id = String(d.connector ?? ""), cred = c[id];
215
+ if (!cred) {
216
+ refuse(`${id.slice(0, 40)} is not a connector of this collector`);
217
+ continue;
218
+ }
219
+ const t = await o.test(cred.sources);
220
+ const secrets = v().all().filter((e) => e.kind !== "setting").map((e) => e.value).filter((x) => x.length >= 6);
221
+ out.push({ id: d.id, ok: true, test: { ...t, errors: t.errors.slice(0, 10).map((e) => scrubText(e, secrets).slice(0, 500)) } });
222
+ }
223
+ else
224
+ refuse(`unknown operation ${String(d.op).slice(0, 20)}`);
225
+ }
226
+ catch (e) {
227
+ refuse(String(e?.message ?? e).slice(0, 300));
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+ return { summary, apply };
233
+ }
234
+ /** Reports what the deliveries did, with the vault's summary after them. Never throws. */
235
+ export async function ack(c, results, s, log) {
236
+ try {
237
+ const r = await signedPost(c, ACK_PATH, new TextEncoder().encode(JSON.stringify({ format: "sagentix-vault-ack/1", results, vault: s })));
238
+ if (r.status !== 200)
239
+ log(`warning: vault ack: HTTP ${r.status}`);
240
+ }
241
+ catch (e) {
242
+ log(`warning: vault ack failed: ${String(e?.message ?? e)}`);
243
+ }
244
+ }
@@ -187,6 +187,7 @@ export class RunRecorder {
187
187
  }
188
188
  }
189
189
  // ── Building, trimming, sending ───────────────────────────────────────────────────────────────────
190
+ /** `secrets`: values to cut whatever their variable is called (the vault's secrets and files). */
190
191
  export function buildReport(rec, o) {
191
192
  const report = {
192
193
  format: REPORT_FORMAT,
@@ -205,7 +206,8 @@ export function buildReport(rec, o) {
205
206
  };
206
207
  if (rec.log.length > MAX_LOG_LINES)
207
208
  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
+ const secrets = [...new Set([...envSecrets(o.env), ...(o.secrets ?? []).filter((v) => v.length >= 6)])].sort((a, b) => b.length - a.length);
210
+ return trimReport(scrubDeep(report, secrets));
209
211
  }
210
212
  const bytes = (r) => Buffer.byteLength(JSON.stringify(r, null, 2));
211
213
  /** At most MAX_REPORT_BYTES: drops log lines first, then calls, then stacks, and says so in a last log line. */
@@ -0,0 +1,303 @@
1
+ // The collector's vault (apps/sos-app/docs/COLLECTOR_VAULT.md): the connectors' secrets and settings. A person
2
+ // types them in sos-app; the browser seals each one to this collector's own X25519 key, so sos-app stores and relays
3
+ // only what it cannot open; the collector opens it here, and each run reads the vault into its own environment, so the
4
+ // sources do not change.
5
+ //
6
+ // One file beside the config, sagentix-collector.vault (mode 0600): each value sealed on its own with AES-256-GCM, its
7
+ // name as additional data, so a value cannot be moved under another name. The names and dates are readable without
8
+ // the key (the check-in reports them to sos-app); the values are not. The file is only as safe as its 256-bit key, so the key lives
9
+ // in the strongest place the computer offers, in this order:
10
+ //
11
+ // systemd $CREDENTIALS_DIRECTORY/sagentix-vault-key: a systemd credential (LoadCredentialEncrypted=, sealed to
12
+ // the TPM where there is one), never on disk in clear
13
+ // docker /run/secrets/sagentix-vault-key: a Docker secret
14
+ // keychain macOS: the Keychain item sagentix-collector-vault / <key id>, through `security` (base64 on stdin)
15
+ // dpapi Windows: sagentix-collector.vault.key.dpapi, the key protected by DPAPI for the collector's account
16
+ // (the protection Windows Credential Manager uses), through PowerShell (base64 on stdin)
17
+ // file anywhere else: sagentix-collector.vault.key, mode 0600; sos-app says the protection is the
18
+ // file's permission only, and how to seal it
19
+ //
20
+ // A value is never logged and never sent to sos-app in clear.
21
+ import { spawnSync } from "node:child_process";
22
+ import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from "node:crypto";
23
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { writePrivate } from "./channel.js";
26
+ export const VAULT_FILE = "sagentix-collector.vault";
27
+ export const KEY_FILE = "sagentix-collector.vault.key";
28
+ export const DPAPI_FILE = "sagentix-collector.vault.key.dpapi";
29
+ export const CREDENTIAL_NAME = "sagentix-vault-key";
30
+ export const DOCKER_SECRET = `/run/secrets/${CREDENTIAL_NAME}`;
31
+ export const KEYCHAIN_SERVICE = "sagentix-collector-vault";
32
+ /** A name the vault takes: an environment variable's. */
33
+ export const NAME = /^[A-Z][A-Z0-9_]{0,99}$/;
34
+ export const MAX_VALUE = 64 * 1024;
35
+ export const KINDS = ["secret", "file", "setting"];
36
+ export const SEAL_FORMAT = "sagentix-seal/1";
37
+ /** The bytes both sides bind in: the format, the collector's key id, the name and the kind. A value sealed for one
38
+ * name, kind or collector does not open as another. */
39
+ export const sealInfo = (keyId, name, kind, bind = {}) => Buffer.from(`${SEAL_FORMAT}\n${keyId}\n${name}\n${kind}\n${canonicalBind(bind)}`);
40
+ /** The settings a value is bound to, in one spelling both sides compute: names sorted, JSON. */
41
+ export const canonicalBind = (b) => JSON.stringify(Object.keys(b).sort().map((k) => [k, b[k]]));
42
+ export const realExec = (cmd, args, input) => {
43
+ const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout: 30_000, windowsHide: true });
44
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
45
+ };
46
+ const key32 = (b) => (b && b.length === 32 ? b : null);
47
+ const fromB64 = (s) => key32(Buffer.from(s.trim(), "base64"));
48
+ function fileHolder(path, protection, readOnly) {
49
+ return {
50
+ protection,
51
+ read: () => {
52
+ if (!existsSync(path))
53
+ return null;
54
+ const raw = readFileSync(path);
55
+ return raw.length === 32 ? raw : fromB64(raw.toString("utf8"));
56
+ },
57
+ create: () => {
58
+ if (readOnly)
59
+ throw new Error(`the vault key ${path} is provided by ${protection} and is missing or unreadable`);
60
+ const k = randomBytes(32);
61
+ writePrivate(path, k.toString("base64") + "\n");
62
+ return k;
63
+ },
64
+ };
65
+ }
66
+ /** `keychain`: a keychain file other than the login one (the tests use a throwaway keychain). */
67
+ function keychainHolder(account, exec, keychain) {
68
+ // The account goes into a `security -i` command line: a key id only (letters, digits, -).
69
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(account))
70
+ throw new Error("the key id is not one the Keychain item may be named after");
71
+ const kc = keychain ? [keychain] : [];
72
+ return {
73
+ protection: "keychain",
74
+ read: () => {
75
+ const r = exec("/usr/bin/security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", ...kc], "");
76
+ return r.status === 0 ? fromB64(r.stdout) : null;
77
+ },
78
+ create: () => {
79
+ const k = randomBytes(32);
80
+ // `security -i` reads its commands on standard input, so the key never appears in a process listing.
81
+ const r = exec("/usr/bin/security", ["-i"], `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${account} -l "Sagentix collector vault" -w ${k.toString("base64")}${keychain ? ` "${keychain}"` : ""}\n`);
82
+ if (r.status !== 0)
83
+ throw new Error(`the Keychain refused the vault key (${r.stderr.trim() || r.status})`);
84
+ return k;
85
+ },
86
+ };
87
+ }
88
+ const DPAPI_PROTECT = "$in=[Console]::In.ReadToEnd().Trim();Add-Type -AssemblyName System.Security;"
89
+ + "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String($in),$null,'CurrentUser'))";
90
+ const DPAPI_UNPROTECT = "$in=[Console]::In.ReadToEnd().Trim();Add-Type -AssemblyName System.Security;"
91
+ + "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($in),$null,'CurrentUser'))";
92
+ const PS = ["-NoProfile", "-NonInteractive", "-Command"];
93
+ function dpapiHolder(path, exec) {
94
+ return {
95
+ protection: "dpapi",
96
+ read: () => {
97
+ if (!existsSync(path))
98
+ return null;
99
+ const r = exec("powershell.exe", [...PS, DPAPI_UNPROTECT], readFileSync(path, "utf8"));
100
+ return r.status === 0 ? fromB64(r.stdout) : null;
101
+ },
102
+ create: () => {
103
+ const k = randomBytes(32);
104
+ const r = exec("powershell.exe", [...PS, DPAPI_PROTECT], k.toString("base64"));
105
+ if (r.status !== 0 || !r.stdout.trim())
106
+ throw new Error(`DPAPI refused the vault key (${r.stderr.trim() || r.status})`);
107
+ writePrivate(path, r.stdout.trim() + "\n");
108
+ return k;
109
+ },
110
+ };
111
+ }
112
+ /** The strongest key holder this computer offers (see the top of this file). */
113
+ export function keyHolderFor(dir, keyId, o) {
114
+ const exec = o.exec ?? realExec, exists = o.exists ?? existsSync;
115
+ const credDir = o.env.CREDENTIALS_DIRECTORY;
116
+ if (credDir && exists(join(credDir, CREDENTIAL_NAME)))
117
+ return fileHolder(join(credDir, CREDENTIAL_NAME), "systemd", true);
118
+ if (exists(DOCKER_SECRET))
119
+ return fileHolder(DOCKER_SECRET, "docker", true);
120
+ if (o.runner !== "docker" && o.platform === "darwin")
121
+ return keychainHolder(keyId, exec, o.keychain);
122
+ if (o.runner !== "docker" && o.platform === "win32")
123
+ return dpapiHolder(join(dir, DPAPI_FILE), exec);
124
+ return fileHolder(join(dir, KEY_FILE), "file", false);
125
+ }
126
+ export class Vault {
127
+ path;
128
+ holder;
129
+ key = null;
130
+ constructor(dir, holder) { this.path = join(dir, VAULT_FILE); this.holder = holder; }
131
+ get protection() { return this.holder.protection; }
132
+ doc() {
133
+ if (!existsSync(this.path))
134
+ return { format: "sagentix-vault/1", entries: {} };
135
+ const d = JSON.parse(readFileSync(this.path, "utf8"));
136
+ if (d?.format !== "sagentix-vault/1" || typeof d.entries !== "object")
137
+ throw new Error(`${this.path} is not a collector vault`);
138
+ return d;
139
+ }
140
+ write(d) {
141
+ // A new file renamed over the old one, so a crash mid-write never leaves half a vault.
142
+ const tmp = `${this.path}.${randomBytes(4).toString("hex")}.tmp`;
143
+ writePrivate(tmp, JSON.stringify(d, null, 1) + "\n");
144
+ renameSync(tmp, this.path);
145
+ }
146
+ /** The key, made on the first write when there is none. Reading an empty vault needs no key. */
147
+ keyFor(create) {
148
+ if (this.key)
149
+ return this.key;
150
+ this.key = this.holder.read();
151
+ if (!this.key && create)
152
+ this.key = this.holder.create();
153
+ return this.key;
154
+ }
155
+ /** Names, dates and whether each is a secret: no value, no key needed. */
156
+ list() {
157
+ return Object.entries(this.doc().entries).map(([name, e]) => ({ name, setAt: e.setAt, kind: e.kind, ...(e.bind ? { bind: e.bind } : {}) })).sort((a, b) => a.name.localeCompare(b.name));
158
+ }
159
+ set(name, value, o) {
160
+ if (!KINDS.includes(o.kind))
161
+ throw new Error(`${o.kind} is not a kind of vault entry`);
162
+ if (!NAME.test(name))
163
+ throw new Error(`${name} is not a name the vault takes (capital letters, digits and _)`);
164
+ if (typeof value !== "string" || value.length === 0)
165
+ throw new Error("the value is empty");
166
+ if (Buffer.byteLength(value) > MAX_VALUE)
167
+ throw new Error(`the value is larger than ${MAX_VALUE / 1024} KiB`);
168
+ const key = this.keyFor(true);
169
+ const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv);
170
+ c.setAAD(Buffer.from(`sagentix-vault/1\n${name}`));
171
+ const data = Buffer.concat([c.update(value, "utf8"), c.final()]);
172
+ const d = this.doc();
173
+ d.entries[name] = { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64"), setAt: o.now.toISOString(), kind: o.kind, ...(o.bind && o.kind !== "setting" ? { bind: o.bind } : {}) };
174
+ this.write(d);
175
+ }
176
+ remove(name) {
177
+ const d = this.doc();
178
+ if (!(name in d.entries))
179
+ return false;
180
+ delete d.entries[name];
181
+ this.write(d);
182
+ return true;
183
+ }
184
+ /** One value, or undefined when it is not there. Throws when the key is missing or the value was tampered with. */
185
+ get(name) {
186
+ const e = this.doc().entries[name];
187
+ if (!e)
188
+ return undefined;
189
+ const key = this.keyFor(false);
190
+ if (!key)
191
+ throw new Error(`the vault key is missing (${this.holder.protection}); the vault cannot be read`);
192
+ const d = createDecipheriv("aes-256-gcm", key, Buffer.from(e.iv, "base64"));
193
+ d.setAAD(Buffer.from(`sagentix-vault/1\n${name}`));
194
+ d.setAuthTag(Buffer.from(e.tag, "base64"));
195
+ try {
196
+ return Buffer.concat([d.update(Buffer.from(e.data, "base64")), d.final()]).toString("utf8");
197
+ }
198
+ catch {
199
+ throw new Error(`the vault value ${name} does not open with this key (changed, or sealed with another key)`);
200
+ }
201
+ }
202
+ sealWith(key, plain, aad) {
203
+ const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv);
204
+ c.setAAD(Buffer.from(aad));
205
+ const data = Buffer.concat([c.update(plain), c.final()]);
206
+ return { iv: iv.toString("base64"), tag: c.getAuthTag().toString("base64"), data: data.toString("base64") };
207
+ }
208
+ /** This collector's sealing public key (32 bytes, base64url), made with the vault key on first use. */
209
+ sealPublicKey() {
210
+ const d = this.doc();
211
+ if (d.seal)
212
+ return d.seal.publicKey;
213
+ const key = this.keyFor(true);
214
+ const { privateKey, publicKey } = generateKeyPairSync("x25519");
215
+ const raw = publicKey.export({ format: "jwk" }).x;
216
+ d.seal = { ...this.sealWith(key, privateKey.export({ type: "pkcs8", format: "der" }), "sagentix-vault/1\nseal"), publicKey: raw };
217
+ this.write(d);
218
+ return raw;
219
+ }
220
+ /** Opens a value sealed in the browser for this collector (ECDH X25519, HKDF-SHA256, AES-256-GCM); throws when it
221
+ * was sealed for another collector, name or kind, or changed on the way. */
222
+ openSealed(keyId, v) {
223
+ const d = this.doc();
224
+ if (!d.seal)
225
+ throw new Error("this collector has no sealing key yet");
226
+ const key = this.keyFor(false);
227
+ if (!key)
228
+ throw new Error(`the vault key is missing (${this.holder.protection})`);
229
+ const dk = createDecipheriv("aes-256-gcm", key, Buffer.from(d.seal.iv, "base64"));
230
+ dk.setAAD(Buffer.from("sagentix-vault/1\nseal"));
231
+ dk.setAuthTag(Buffer.from(d.seal.tag, "base64"));
232
+ const priv = createPrivateKey({ key: Buffer.concat([dk.update(Buffer.from(d.seal.data, "base64")), dk.final()]), format: "der", type: "pkcs8" });
233
+ const epk = Buffer.from(v.epk, "base64url");
234
+ if (epk.length !== 32)
235
+ throw new Error("the sealed value's ephemeral key is not 32 bytes");
236
+ const shared = diffieHellman({ privateKey: priv, publicKey: createPublicKey({ key: { kty: "OKP", crv: "X25519", x: v.epk }, format: "jwk" }) });
237
+ const info = sealInfo(keyId, v.name, v.kind, v.bind ?? {});
238
+ const aes = Buffer.from(hkdfSync("sha256", shared, Buffer.concat([epk, Buffer.from(d.seal.publicKey, "base64url")]), info, 32));
239
+ const all = Buffer.from(v.data, "base64"), iv = Buffer.from(v.iv, "base64");
240
+ if (iv.length !== 12 || all.length < 17)
241
+ throw new Error("the sealed value is malformed");
242
+ const dc = createDecipheriv("aes-256-gcm", aes, iv);
243
+ dc.setAAD(info);
244
+ dc.setAuthTag(all.subarray(all.length - 16));
245
+ try {
246
+ return Buffer.concat([dc.update(all.subarray(0, all.length - 16)), dc.final()]).toString("utf8");
247
+ }
248
+ catch {
249
+ throw new Error(`the sealed value for ${v.name} does not open with this collector's key`);
250
+ }
251
+ }
252
+ /** Every entry with its value, for a run's environment. */
253
+ all() {
254
+ return this.list().map(({ name, kind, bind }) => ({ name, kind, value: this.get(name), ...(bind ? { bind } : {}) }));
255
+ }
256
+ }
257
+ /**
258
+ * A run's environment: the process's own, plus each vault entry it does not already set (a variable set on purpose
259
+ * wins). `settingsOf(name)`: the settings of the connector a secret belongs to (the config's credentials). A `file` entry is written to a private temporary folder beside the config, its path put in the variable, and
260
+ * the folder removed by `cleanup` when the run ends. No vault file: the environment as it is.
261
+ */
262
+ export function runEnv(dir, holder, base, settingsOf = () => []) {
263
+ const v = new Vault(dir, holder);
264
+ if (!existsSync(v.path))
265
+ return { env: base, used: [], secrets: [], warnings: [], cleanup: () => { } };
266
+ const env = { ...base }, used = [], secrets = [], warnings = [];
267
+ const all = v.all();
268
+ for (const e of all)
269
+ if (e.kind === "setting" && !env[e.name]) {
270
+ env[e.name] = e.value;
271
+ used.push(e.name);
272
+ }
273
+ let tmp;
274
+ for (const e of all) {
275
+ if (e.kind === "setting" || env[e.name])
276
+ continue;
277
+ // A secret is used only while its connector's settings are the ones it was set with: all of them, so a setting
278
+ // added since (an optional URL) counts as a change too.
279
+ if (e.bind) {
280
+ const now = {};
281
+ for (const k of settingsOf(e.name))
282
+ if (env[k])
283
+ now[k] = env[k];
284
+ if (canonicalBind(now) !== canonicalBind(e.bind)) {
285
+ const changed = [...new Set([...Object.keys(now), ...Object.keys(e.bind)])].filter((k) => now[k] !== e.bind[k]);
286
+ warnings.push(`${e.name} is not used: ${changed.join(", ")} changed since it was set; set ${e.name} again`);
287
+ continue;
288
+ }
289
+ }
290
+ if (e.kind === "file") {
291
+ tmp ??= mkdtempSync(join(dir, ".sagentix-run-"));
292
+ const p = join(tmp, e.name.toLowerCase());
293
+ writePrivate(p, e.value);
294
+ env[e.name] = p;
295
+ }
296
+ else
297
+ env[e.name] = e.value;
298
+ used.push(e.name);
299
+ secrets.push(e.value, ...e.value.split(/\r?\n/).filter((l) => l.length >= 16));
300
+ }
301
+ return { env, used, secrets, warnings, cleanup: () => { if (tmp)
302
+ rmSync(tmp, { recursive: true, force: true }); } };
303
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Customer-side evidence collector: reads systems read-only, runs checks, signs the results and pushes them outbound to sos-app.",
5
5
  "type": "module",
6
6
  "engines": {