@sagentix-inc/evidence-collector 0.3.2 → 0.3.4
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 +21 -0
- package/collector/package.json +1 -1
- package/collector/src/agent.js +38 -1
- package/collector/src/channel.js +23 -2
- package/collector/src/cli.js +52 -6
- package/collector/src/config.js +18 -0
- package/collector/src/deliveries.js +270 -0
- package/collector/src/refs.js +228 -0
- package/collector/src/report.js +3 -1
- package/collector/src/vault.js +318 -0
- package/package.json +1 -1
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.
|
package/collector/package.json
CHANGED
package/collector/src/agent.js
CHANGED
|
@@ -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` : ""}`);
|
package/collector/src/channel.js
CHANGED
|
@@ -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 };
|
package/collector/src/cli.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Exit codes: 0 sent (or dry run), 1 error (bad key, network, a source that failed), 2 usage, 3 nothing to send,
|
|
18
18
|
// 4 sos-app refused the push (its message is printed); `agent` only: 75 a signed update was written, restart me.
|
|
19
|
-
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
-
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { parseArgs } from "node:util";
|
|
23
23
|
import { DEFAULT_CATALOG_DIR, loadCatalog } from "./catalog.js";
|
|
@@ -31,8 +31,13 @@ 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, Vault, keyHolderFor, runEnv } from "./vault.js";
|
|
35
|
+
import { applyRefs } from "./refs.js";
|
|
36
|
+
import { credentialsIn, settingsOf, vaultBridge } from "./deliveries.js";
|
|
34
37
|
export const COLLECT_PATH = "/api/collect";
|
|
35
38
|
export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
|
|
39
|
+
/** Set by the agent on a connector test asked from sos-app (a dry run of one connector). */
|
|
40
|
+
export const TEST_ENV = "SAGENTIX_CONNECTOR_TEST";
|
|
36
41
|
/** `update` (75) is the agent's only: it wrote a signed update and exits so it is restarted on it. */
|
|
37
42
|
export const EXIT = { ok: 0, error: 1, usage: 2, nothing: 3, refused: 4, update: EXIT_UPDATE };
|
|
38
43
|
export function defaultDeps() {
|
|
@@ -215,10 +220,45 @@ export async function run(argv, deps) {
|
|
|
215
220
|
const channel = pem && keyId
|
|
216
221
|
? { origin: new URL(url).origin, keyId, pem, fetch: deps.channel ?? deps.fetch, dir, runner, mode, env: deps.env, now: deps.now }
|
|
217
222
|
: undefined;
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
// A connector test asked from sos-app runs that connector dry: no check-in, and no run report, so the last real run's
|
|
224
|
+
// report beside the config stays what sos-app shows.
|
|
225
|
+
const connectorTest = dry && deps.env[TEST_ENV] === "1";
|
|
226
|
+
if (connectorTest) { /* neither */ }
|
|
227
|
+
else if (channel) {
|
|
228
|
+
const vault = configPath && keyId ? vaultBridge({ configPath: resolve(configPath), keyId, now: deps.now, log: deps.err,
|
|
229
|
+
holder: deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner }) }) : undefined;
|
|
230
|
+
rec.note("checkin", (await checkin(channel, { write: !dry, log: deps.err, vault })).warnings);
|
|
231
|
+
}
|
|
220
232
|
else
|
|
221
233
|
deps.err("check-in skipped: a dry run without a key cannot sign one.");
|
|
234
|
+
// The connectors' secrets and settings, set in sos-app and sealed for this collector (src/vault.ts), when the run has a config and a key id.
|
|
235
|
+
// A vault that cannot be opened is a warning: the sources that need its values fail on their own, the run goes on.
|
|
236
|
+
let env = deps.env, cleanup = () => { }, vaultSecrets = [];
|
|
237
|
+
if (configPath && keyId) {
|
|
238
|
+
try {
|
|
239
|
+
const creds = credentialsIn(resolve(configPath));
|
|
240
|
+
const holder = deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner });
|
|
241
|
+
const r = runEnv(dir, holder, deps.env, (name) => settingsOf(creds, name));
|
|
242
|
+
// Connectors read from the client's own vault (src/refs.ts) take all their names from there, after the local vault.
|
|
243
|
+
const refs = existsSync(join(dir, VAULT_FILE)) ? new Vault(dir, holder).refs() : {};
|
|
244
|
+
const f = Object.keys(refs).length ? await applyRefs(dir, refs, creds, r.env, deps.channel ?? deps.fetch) : null;
|
|
245
|
+
env = f?.env ?? r.env;
|
|
246
|
+
cleanup = () => { r.cleanup(); f?.cleanup(); };
|
|
247
|
+
vaultSecrets = [...r.secrets, ...(f?.secrets ?? [])];
|
|
248
|
+
const used = r.used.length + (f?.used.length ?? 0), warnings = [...r.warnings, ...(f?.warnings ?? [])];
|
|
249
|
+
if (used)
|
|
250
|
+
deps.err(`vault: ${used} value(s) from ${VAULT_FILE}${f ? " and your own vault" : ""}`);
|
|
251
|
+
for (const w of warnings)
|
|
252
|
+
deps.err(`warning: vault: ${w}`);
|
|
253
|
+
if (warnings.length)
|
|
254
|
+
rec.note("vault", warnings);
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
|
|
258
|
+
deps.err(w);
|
|
259
|
+
rec.note("vault", [w]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
222
262
|
let code;
|
|
223
263
|
try {
|
|
224
264
|
code = await collect();
|
|
@@ -227,7 +267,13 @@ export async function run(argv, deps) {
|
|
|
227
267
|
deps.err(`collector error: ${String(e?.message ?? e)}`);
|
|
228
268
|
code = EXIT.error;
|
|
229
269
|
}
|
|
230
|
-
|
|
270
|
+
finally {
|
|
271
|
+
cleanup();
|
|
272
|
+
}
|
|
273
|
+
// The report is scrubbed of every value in the run's environment, the vault's included.
|
|
274
|
+
if (connectorTest)
|
|
275
|
+
return code;
|
|
276
|
+
await deliverReport(buildReport(rec, { now: deps.now(), exitCode: code, runner, mode, env, secrets: vaultSecrets }), { dry, channel, dir, log: deps.err });
|
|
231
277
|
return code;
|
|
232
278
|
async function collect() {
|
|
233
279
|
// Run every chosen source inside a guard, then keep only what the server will accept. A source that throws gives
|
|
@@ -236,7 +282,7 @@ export async function run(argv, deps) {
|
|
|
236
282
|
const raw = [];
|
|
237
283
|
const failed = [];
|
|
238
284
|
for (const s of chosen) {
|
|
239
|
-
const { value, error, report } = await rec.source(s.name, () => s.run({ opts: values, env
|
|
285
|
+
const { value, error, report } = await rec.source(s.name, () => s.run({ opts: values, env, clients: deps.clients, now, warn: deps.err }));
|
|
240
286
|
let got;
|
|
241
287
|
if (error !== undefined || !Array.isArray(value)) {
|
|
242
288
|
const message = error !== undefined ? String(error?.message ?? error) : "the source returned no list of results";
|
package/collector/src/config.js
CHANGED
|
@@ -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,270 @@
|
|
|
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
|
+
// set-ref read one connector from the client's own vault (src/refs.ts): a reference, never a value
|
|
10
|
+
// remove-ref read that connector from this vault again
|
|
11
|
+
//
|
|
12
|
+
// A delivery for a name the config does not list, or with another kind, is refused and reported, never applied: what
|
|
13
|
+
// sos-app says cannot widen what this collector reads. No delivery can name a file, a command or a source option.
|
|
14
|
+
// The outcome of each goes back in a signed POST /api/collect/vault/ack, with the vault's new summary.
|
|
15
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
16
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
17
|
+
import { signedPost, writePrivate } from "./channel.js";
|
|
18
|
+
import { scrubText } from "./report.js";
|
|
19
|
+
import { refProblem } from "./refs.js";
|
|
20
|
+
import { KINDS, MAX_VALUE, NAME, Vault, canonicalBind } from "./vault.js";
|
|
21
|
+
export const ACK_PATH = "/api/collect/vault/ack";
|
|
22
|
+
export const ENV_FILE = "sagentix-collector.env";
|
|
23
|
+
export const MAX_DELIVERIES = 50;
|
|
24
|
+
const ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
25
|
+
/** Each name a connector reads, with its kind: the only names a delivery may touch. */
|
|
26
|
+
export function knownNames(creds) {
|
|
27
|
+
const m = new Map();
|
|
28
|
+
for (const c of Object.values(creds)) {
|
|
29
|
+
for (const n of c.secrets ?? [])
|
|
30
|
+
m.set(n, "secret");
|
|
31
|
+
for (const n of c.files ?? [])
|
|
32
|
+
m.set(n, "file");
|
|
33
|
+
for (const n of Object.values(c.instead ?? {}))
|
|
34
|
+
if (!m.has(n))
|
|
35
|
+
m.set(n, n.endsWith("_FILE") ? "file" : "secret");
|
|
36
|
+
for (const n of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
|
|
37
|
+
if (!m.has(n))
|
|
38
|
+
m.set(n, "setting");
|
|
39
|
+
}
|
|
40
|
+
return m;
|
|
41
|
+
}
|
|
42
|
+
/** The settings of the connector a secret belongs to, as the vault holds them now: what a sealed value binds to. */
|
|
43
|
+
export function bindFor(creds, name, v) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const k of settingsOf(creds, name)) {
|
|
46
|
+
const val = v.get(k);
|
|
47
|
+
if (val !== undefined)
|
|
48
|
+
out[k] = val;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** The settings (required and optional) of every connector that reads this secret or file. */
|
|
53
|
+
export function settingsOf(creds, name) {
|
|
54
|
+
const out = new Set();
|
|
55
|
+
for (const c of Object.values(creds)) {
|
|
56
|
+
if (![...(c.secrets ?? []), ...(c.files ?? []), ...Object.values(c.instead ?? {})].includes(name))
|
|
57
|
+
continue;
|
|
58
|
+
for (const k of [...(c.settings ?? []), ...Object.keys(c.optional ?? {})])
|
|
59
|
+
out.add(k);
|
|
60
|
+
}
|
|
61
|
+
return [...out];
|
|
62
|
+
}
|
|
63
|
+
/** The config's credentials, or none when the file cannot be read. */
|
|
64
|
+
export function credentialsIn(configPath) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(configPath, "utf8").replace(/^\uFEFF/, "")).credentials ?? {};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** A file's text when it exists and is at most MAX_VALUE bytes; one open, so the size checked is the file read. */
|
|
73
|
+
function readSmall(path) {
|
|
74
|
+
let fd;
|
|
75
|
+
try {
|
|
76
|
+
fd = openSync(path, "r");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (fstatSync(fd).size > MAX_VALUE)
|
|
83
|
+
return null;
|
|
84
|
+
const buf = Buffer.alloc(MAX_VALUE + 1);
|
|
85
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
86
|
+
return n > MAX_VALUE ? null : buf.subarray(0, n).toString("utf8");
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The vault beside a config, as the check-in sees it. `test`: the agent's dry run of some sources (absent in `run`). */
|
|
93
|
+
export function vaultBridge(o) {
|
|
94
|
+
const dir = dirname(o.configPath), envPath = join(dir, ENV_FILE);
|
|
95
|
+
let vault;
|
|
96
|
+
const v = () => (vault ??= new Vault(dir, o.holder));
|
|
97
|
+
const creds = () => {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(readFileSync(o.configPath, "utf8").replace(/^/, "")).credentials ?? {};
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const envLines = (known) => !existsSync(envPath) ? [] : readFileSync(envPath, "utf8").split(/\r?\n/)
|
|
106
|
+
.map((l) => /^(?:export\s+)?([A-Z][A-Z0-9_]*)=(.*)$/.exec(l.trim()))
|
|
107
|
+
.filter((m) => !!m && known.has(m[1]) && m[2].length > 0)
|
|
108
|
+
.map((m) => ({ name: m[1], value: m[2].replace(/^(['"])(.*)\1$/, "$2") }));
|
|
109
|
+
function summary() {
|
|
110
|
+
const c = creds(), known = knownNames(c);
|
|
111
|
+
let sealKey = null, entries = [], error = null, protection = null;
|
|
112
|
+
let refs = {};
|
|
113
|
+
try {
|
|
114
|
+
protection = v().protection;
|
|
115
|
+
// A setting's value goes along (a URL, a tenant id), so sos-app shows it and the browser binds a secret to it.
|
|
116
|
+
entries = v().list().map((e) => e.kind === "setting" ? { ...e, value: v().get(e.name) } : e);
|
|
117
|
+
refs = v().refs();
|
|
118
|
+
sealKey = Object.keys(c).length ? v().sealPublicKey() : null;
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
error = String(e?.message ?? e).slice(0, 300);
|
|
122
|
+
}
|
|
123
|
+
return { sealKey, protection, error, entries, refs, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
|
|
124
|
+
}
|
|
125
|
+
function importEnv(known) {
|
|
126
|
+
const moved = [], leftFiles = [], problems = [], c = creds();
|
|
127
|
+
// Settings first, so each secret binds to the settings it came with.
|
|
128
|
+
for (const l of envLines(known).sort((x, y) => Number(known.get(x.name) !== "setting") - Number(known.get(y.name) !== "setting"))) {
|
|
129
|
+
const kind = known.get(l.name);
|
|
130
|
+
try {
|
|
131
|
+
if (kind === "file") {
|
|
132
|
+
const path = isAbsolute(l.value) ? l.value : join(dir, l.value);
|
|
133
|
+
const text = readSmall(path);
|
|
134
|
+
if (text === null) {
|
|
135
|
+
problems.push(`${l.name}: the file it names is missing or too large`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
v().set(l.name, text, { kind, now: o.now(), bind: bindFor(c, l.name, v()) });
|
|
139
|
+
leftFiles.push(l.value);
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
v().set(l.name, l.value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: bindFor(c, l.name, v()) } : {}) });
|
|
143
|
+
moved.push(l.name);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
problems.push(`${l.name}: ${String(e?.message ?? e)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (moved.length) {
|
|
150
|
+
const keep = readFileSync(envPath, "utf8").split(/\r?\n/).filter((l) => !moved.includes(/^(?:export\s+)?([A-Z][A-Z0-9_]*)=/.exec(l.trim())?.[1] ?? ""));
|
|
151
|
+
writePrivate(envPath, keep.join("\n").replace(/\n*$/, "\n"));
|
|
152
|
+
}
|
|
153
|
+
return { moved, leftFiles, ...(problems.length ? { error: problems.join("; ") } : {}) };
|
|
154
|
+
}
|
|
155
|
+
async function apply(raw, a) {
|
|
156
|
+
if (!Array.isArray(raw))
|
|
157
|
+
return [];
|
|
158
|
+
const c = creds(), known = knownNames(c), out = [];
|
|
159
|
+
for (const d of raw.slice(0, MAX_DELIVERIES)) {
|
|
160
|
+
if (typeof d !== "object" || d === null || typeof d.id !== "string" || !ID.test(d.id))
|
|
161
|
+
continue;
|
|
162
|
+
const refuse = (error) => { out.push({ id: d.id, ok: false, error }); o.log(`vault: refused delivery ${d.id}: ${error}`); };
|
|
163
|
+
try {
|
|
164
|
+
if (d.op === "set" || d.op === "remove") {
|
|
165
|
+
const name = String(d.name ?? ""), kind = known.get(name);
|
|
166
|
+
if (!NAME.test(name) || !kind) {
|
|
167
|
+
refuse(`${name.slice(0, 100)} is not a name this collector's connectors read`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (d.op === "remove") {
|
|
171
|
+
v().remove(name);
|
|
172
|
+
o.log(`vault: ${name} removed`);
|
|
173
|
+
out.push({ id: d.id, ok: true });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (d.kind !== kind || !KINDS.includes(d.kind)) {
|
|
177
|
+
refuse(`${name} is a ${kind}, not a ${String(d.kind)}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
let value;
|
|
181
|
+
if (kind === "setting") {
|
|
182
|
+
if (typeof d.value !== "string" || !d.value.trim()) {
|
|
183
|
+
refuse(`${name}: no value`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
value = d.value.trim();
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (!d.sealed || d.sealed.name !== name || d.sealed.kind !== kind) {
|
|
190
|
+
refuse(`${name}: a ${kind} must come sealed, for this name and kind`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// The connector's settings as they stand now must be the ones the person saw when sealing.
|
|
194
|
+
const bind = d.sealed.bind ?? {}, want = bindFor(c, name, v());
|
|
195
|
+
const missing = Object.values(c).filter((x) => [...(x.secrets ?? []), ...(x.files ?? []), ...Object.values(x.instead ?? {})].includes(name))
|
|
196
|
+
.flatMap((x) => x.settings ?? []).filter((k) => want[k] === undefined);
|
|
197
|
+
if (missing.length) {
|
|
198
|
+
refuse(`${name}: set ${missing.join(", ")} first, so the secret is bound to where it goes`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (canonicalBind(bind) !== canonicalBind(want)) {
|
|
202
|
+
refuse(`${name}: the connector's settings changed since it was sealed; enter it again`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
value = v().openSealed(o.keyId, d.sealed);
|
|
206
|
+
}
|
|
207
|
+
v().set(name, value, { kind, now: o.now(), ...(kind !== "setting" ? { bind: d.sealed.bind ?? {} } : {}) });
|
|
208
|
+
o.log(`vault: ${name} set`);
|
|
209
|
+
out.push({ id: d.id, ok: true });
|
|
210
|
+
}
|
|
211
|
+
else if (d.op === "set-ref" || d.op === "remove-ref") {
|
|
212
|
+
const id = String(d.connector ?? "");
|
|
213
|
+
if (!c[id]) {
|
|
214
|
+
refuse(`${id.slice(0, 40)} is not a connector of this collector`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (d.op === "remove-ref") {
|
|
218
|
+
v().removeRef(id);
|
|
219
|
+
o.log(`vault: ${id} reads its own settings again`);
|
|
220
|
+
out.push({ id: d.id, ok: true });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const problem = refProblem(d.ref);
|
|
224
|
+
if (problem) {
|
|
225
|
+
refuse(`${id}: ${problem}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
v().setRef(id, d.ref, o.now());
|
|
229
|
+
o.log(`vault: ${id} reads from your own vault (${d.ref.provider})`);
|
|
230
|
+
out.push({ id: d.id, ok: true });
|
|
231
|
+
}
|
|
232
|
+
else if (d.op === "import-env") {
|
|
233
|
+
const r = importEnv(known);
|
|
234
|
+
o.log(`vault: ${r.moved?.length ?? 0} value(s) moved from ${ENV_FILE}`);
|
|
235
|
+
out.push({ id: d.id, ok: !r.error, ...r });
|
|
236
|
+
}
|
|
237
|
+
else if (d.op === "test") {
|
|
238
|
+
if (!a.agent || !o.test)
|
|
239
|
+
continue; // left for the resident agent, which can run it now
|
|
240
|
+
const id = String(d.connector ?? ""), cred = c[id];
|
|
241
|
+
if (!cred) {
|
|
242
|
+
refuse(`${id.slice(0, 40)} is not a connector of this collector`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const t = await o.test(cred.sources);
|
|
246
|
+
const secrets = v().all().filter((e) => e.kind !== "setting").map((e) => e.value).filter((x) => x.length >= 6);
|
|
247
|
+
out.push({ id: d.id, ok: true, test: { ...t, errors: t.errors.slice(0, 10).map((e) => scrubText(e, secrets).slice(0, 500)) } });
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
refuse(`unknown operation ${String(d.op).slice(0, 20)}`);
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
refuse(String(e?.message ?? e).slice(0, 300));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
return { summary, apply };
|
|
259
|
+
}
|
|
260
|
+
/** Reports what the deliveries did, with the vault's summary after them. Never throws. */
|
|
261
|
+
export async function ack(c, results, s, log) {
|
|
262
|
+
try {
|
|
263
|
+
const r = await signedPost(c, ACK_PATH, new TextEncoder().encode(JSON.stringify({ format: "sagentix-vault-ack/1", results, vault: s })));
|
|
264
|
+
if (r.status !== 200)
|
|
265
|
+
log(`warning: vault ack: HTTP ${r.status}`);
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
log(`warning: vault ack failed: ${String(e?.message ?? e)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// A connector read from the client's own vault (apps/sos-app/docs/COLLECTOR_VAULT.md, section 5). sos-app stores a
|
|
2
|
+
// reference, never a value: one secret in the client's vault, a JSON object holding every name the connector reads
|
|
3
|
+
// (its settings and its secrets together). The collector reads it at run time with the identity the computer already has.
|
|
4
|
+
//
|
|
5
|
+
// aws AWS Secrets Manager: arn:aws:secretsmanager:<region>:<account>:secret:<name>, GetSecretValue, signed with
|
|
6
|
+
// the AWS credentials in the environment (keys, or a role through a web identity token)
|
|
7
|
+
// azure Azure Key Vault: https://<vault>.vault.azure.net/secrets/<name>, with a token for https://vault.azure.net
|
|
8
|
+
// from AZURE_TENANT_ID + AZURE_CLIENT_ID (+ secret or federated token), else the managed identity
|
|
9
|
+
// gcp Google Cloud Secret Manager: projects/<project>/secrets/<name> (latest version), with GCP_SA_KEY_FILE,
|
|
10
|
+
// else the metadata server's service account
|
|
11
|
+
// hashicorp HashiCorp Vault, key/value version 2: <mount>/<path>, at VAULT_ADDR with VAULT_TOKEN or an AppRole
|
|
12
|
+
// (VAULT_ROLE_ID, VAULT_SECRET_ID); VAULT_NAMESPACE when set
|
|
13
|
+
//
|
|
14
|
+
// Why the whole connector, settings included: the destination then comes from the client's vault too. sos-app (or anyone
|
|
15
|
+
// who changed its data) can only point a connector at another secret; it can never pair a real secret with an address of
|
|
16
|
+
// its choosing. For the same reason a connector read by reference takes ALL its names from that one object and nothing
|
|
17
|
+
// else (not the local vault, not the environment), and is refused when the object lacks one it needs. The addresses a
|
|
18
|
+
// token goes to are fixed here or come from this computer's own environment (VAULT_ADDR), never from sos-app.
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { writePrivate } from "./channel.js";
|
|
22
|
+
import { envCredentials, fetchAwsTransport } from "./sources/aws/api.js";
|
|
23
|
+
import { gcpKeyToken } from "./sources/gcp/api.js";
|
|
24
|
+
export const PROVIDERS = ["aws", "azure", "gcp", "hashicorp"];
|
|
25
|
+
const AWS_ARN = /^arn:aws(-[a-z]+)*:secretsmanager:([a-z]{2}(-[a-z]+)+-\d):(\d{12}):secret:[A-Za-z0-9/_+=.@-]{1,512}$/;
|
|
26
|
+
const AZURE_ID = /^https:\/\/([a-z0-9-]{3,24})\.vault\.azure\.net\/secrets\/([A-Za-z0-9-]{1,127})(\/[0-9a-f]{32})?$/;
|
|
27
|
+
const GCP_ID = /^projects\/([a-z][a-z0-9-]{4,28}[a-z0-9]|\d{1,20})\/secrets\/([A-Za-z0-9_-]{1,255})$/;
|
|
28
|
+
const VAULT_PATH = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_./-]{1,512}$/;
|
|
29
|
+
/** Why a reference is not usable, or null. Checked on both sides: sos-app before queueing, the collector before reading. */
|
|
30
|
+
export function refProblem(r) {
|
|
31
|
+
if (typeof r !== "object" || r === null)
|
|
32
|
+
return "a reference is an object";
|
|
33
|
+
const { provider, id } = r;
|
|
34
|
+
if (!PROVIDERS.includes(provider))
|
|
35
|
+
return `provider must be one of ${PROVIDERS.join(", ")}`;
|
|
36
|
+
if (typeof id !== "string")
|
|
37
|
+
return "id must be text";
|
|
38
|
+
const ok = provider === "aws" ? AWS_ARN.test(id) : provider === "azure" ? AZURE_ID.test(id) : provider === "gcp" ? GCP_ID.test(id)
|
|
39
|
+
: VAULT_PATH.test(id) && !id.split("/").includes("..");
|
|
40
|
+
return ok ? null : { aws: "an AWS Secrets Manager ARN (arn:aws:secretsmanager:<region>:<account>:secret:<name>)",
|
|
41
|
+
azure: "an Azure Key Vault secret URL (https://<vault>.vault.azure.net/secrets/<name>)",
|
|
42
|
+
gcp: "a Secret Manager name (projects/<project>/secrets/<name>)",
|
|
43
|
+
hashicorp: "a key/value path (<mount>/<path>)" }[provider];
|
|
44
|
+
}
|
|
45
|
+
const json = async (res, what) => {
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw new Error(`${what}: HTTP ${res.status}`);
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`${what}: the answer is not JSON`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
async function aws(id, env, f) {
|
|
57
|
+
const region = AWS_ARN.exec(id)[2];
|
|
58
|
+
const creds = await envCredentials(env, region, f);
|
|
59
|
+
if (!creds)
|
|
60
|
+
throw new Error("no AWS credentials in the environment (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE)");
|
|
61
|
+
if ("error" in creds)
|
|
62
|
+
throw new Error(creds.error);
|
|
63
|
+
const host = `secretsmanager.${region}.amazonaws.com`;
|
|
64
|
+
const r = await fetchAwsTransport(creds, f).send({ service: "secretsmanager", region, method: "POST", host, path: "/", query: {}, action: "GetSecretValue",
|
|
65
|
+
headers: { host, "content-type": "application/x-amz-json-1.1", "x-amz-target": "secretsmanager.GetSecretValue" }, body: JSON.stringify({ SecretId: id }) });
|
|
66
|
+
if (r.status !== 200)
|
|
67
|
+
throw new Error(`AWS Secrets Manager GetSecretValue: HTTP ${r.status}`);
|
|
68
|
+
const b = JSON.parse(r.body);
|
|
69
|
+
if (typeof b.SecretString === "string")
|
|
70
|
+
return b.SecretString;
|
|
71
|
+
if (typeof b.SecretBinary === "string")
|
|
72
|
+
return Buffer.from(b.SecretBinary, "base64").toString("utf8");
|
|
73
|
+
throw new Error("AWS Secrets Manager returned no secret");
|
|
74
|
+
}
|
|
75
|
+
async function azureToken(env, f) {
|
|
76
|
+
const scope = "https://vault.azure.net/.default";
|
|
77
|
+
if (env.AZURE_TENANT_ID && env.AZURE_CLIENT_ID && (env.AZURE_CLIENT_SECRET || env.AZURE_FEDERATED_TOKEN_FILE)) {
|
|
78
|
+
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: env.AZURE_CLIENT_ID, scope });
|
|
79
|
+
if (env.AZURE_FEDERATED_TOKEN_FILE) {
|
|
80
|
+
const { readFileSync } = await import("node:fs");
|
|
81
|
+
form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
82
|
+
form.set("client_assertion", readFileSync(env.AZURE_FEDERATED_TOKEN_FILE, "utf8").trim());
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
form.set("client_secret", env.AZURE_CLIENT_SECRET);
|
|
86
|
+
const b = await json(await f(`https://login.microsoftonline.com/${encodeURIComponent(env.AZURE_TENANT_ID)}/oauth2/v2.0/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form.toString() }), "Azure token");
|
|
87
|
+
if (typeof b.access_token !== "string")
|
|
88
|
+
throw new Error("Azure token: no access_token");
|
|
89
|
+
return b.access_token;
|
|
90
|
+
}
|
|
91
|
+
// The managed identity of the virtual machine or container the collector runs on.
|
|
92
|
+
const b = await json(await f("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net", { headers: { Metadata: "true" } }), "Azure managed identity");
|
|
93
|
+
if (typeof b.access_token !== "string")
|
|
94
|
+
throw new Error("Azure managed identity: no access_token");
|
|
95
|
+
return b.access_token;
|
|
96
|
+
}
|
|
97
|
+
async function azure(id, env, f) {
|
|
98
|
+
const token = await azureToken(env, f);
|
|
99
|
+
// AZURE_ID allows only https://<vault>.vault.azure.net, so the token goes to Key Vault and nowhere else.
|
|
100
|
+
const b = await json(await f(`${id}?api-version=7.4`, { headers: { authorization: `Bearer ${token}` } }), "Azure Key Vault");
|
|
101
|
+
if (typeof b.value !== "string")
|
|
102
|
+
throw new Error("Azure Key Vault returned no value");
|
|
103
|
+
return b.value;
|
|
104
|
+
}
|
|
105
|
+
async function gcp(id, env, f) {
|
|
106
|
+
let token;
|
|
107
|
+
const key = gcpKeyToken(env, f);
|
|
108
|
+
if (typeof key === "string")
|
|
109
|
+
throw new Error(key);
|
|
110
|
+
if (key)
|
|
111
|
+
token = await key();
|
|
112
|
+
else {
|
|
113
|
+
const b = await json(await f("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", { headers: { "Metadata-Flavor": "Google" } }), "Google metadata server");
|
|
114
|
+
if (typeof b.access_token !== "string")
|
|
115
|
+
throw new Error("Google metadata server: no access_token");
|
|
116
|
+
token = b.access_token;
|
|
117
|
+
}
|
|
118
|
+
const b = await json(await f(`https://secretmanager.googleapis.com/v1/${id}/versions/latest:access`, { headers: { authorization: `Bearer ${token}` } }), "Secret Manager");
|
|
119
|
+
const data = b.payload?.data;
|
|
120
|
+
if (typeof data !== "string")
|
|
121
|
+
throw new Error("Secret Manager returned no payload");
|
|
122
|
+
return Buffer.from(data, "base64").toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
async function hashicorp(id, env, f) {
|
|
125
|
+
const addr = env.VAULT_ADDR;
|
|
126
|
+
if (!addr || !/^https:\/\/[^/]+/.test(addr))
|
|
127
|
+
throw new Error("VAULT_ADDR must be set on this computer, over https");
|
|
128
|
+
const base = addr.replace(/\/+$/, "");
|
|
129
|
+
const ns = env.VAULT_NAMESPACE ? { "X-Vault-Namespace": env.VAULT_NAMESPACE } : {};
|
|
130
|
+
let token = env.VAULT_TOKEN;
|
|
131
|
+
if (!token && env.VAULT_ROLE_ID && env.VAULT_SECRET_ID) {
|
|
132
|
+
const b = await json(await f(`${base}/v1/auth/approle/login`, { method: "POST", headers: { "content-type": "application/json", ...ns },
|
|
133
|
+
body: JSON.stringify({ role_id: env.VAULT_ROLE_ID, secret_id: env.VAULT_SECRET_ID }) }), "Vault AppRole login");
|
|
134
|
+
token = b.auth?.client_token;
|
|
135
|
+
}
|
|
136
|
+
if (!token)
|
|
137
|
+
throw new Error("no Vault token (VAULT_TOKEN, or VAULT_ROLE_ID and VAULT_SECRET_ID)");
|
|
138
|
+
const [mount, ...rest] = id.split("/");
|
|
139
|
+
const b = await json(await f(`${base}/v1/${encodeURIComponent(mount)}/data/${rest.map(encodeURIComponent).join("/")}`, { headers: { "X-Vault-Token": token, ...ns } }), "Vault read");
|
|
140
|
+
const data = b.data?.data;
|
|
141
|
+
if (typeof data !== "object" || data === null)
|
|
142
|
+
throw new Error("Vault returned no key/value data");
|
|
143
|
+
return JSON.stringify(data);
|
|
144
|
+
}
|
|
145
|
+
/** The object the reference names, as name to value. */
|
|
146
|
+
export async function readRef(r, env, f = fetch) {
|
|
147
|
+
const p = refProblem(r);
|
|
148
|
+
if (p)
|
|
149
|
+
throw new Error(`the reference is not usable: ${p}`);
|
|
150
|
+
const raw = await ({ aws, azure, gcp, hashicorp }[r.provider])(r.id, env, f);
|
|
151
|
+
let o;
|
|
152
|
+
try {
|
|
153
|
+
o = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new Error("the secret is not a JSON object of names and values");
|
|
157
|
+
}
|
|
158
|
+
if (typeof o !== "object" || o === null || Array.isArray(o))
|
|
159
|
+
throw new Error("the secret is not a JSON object of names and values");
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const [k, v] of Object.entries(o))
|
|
162
|
+
if (typeof v === "string")
|
|
163
|
+
out[k] = v;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** The names a connector must find in its object: its settings, and each secret or its alternative. */
|
|
167
|
+
export function required(c) {
|
|
168
|
+
const instead = c.instead ?? {};
|
|
169
|
+
const all = [...(c.secrets ?? []), ...(c.files ?? []), ...(c.settings ?? []), ...Object.keys(c.optional ?? {}), ...Object.values(instead)];
|
|
170
|
+
return {
|
|
171
|
+
all: [...new Set(all)],
|
|
172
|
+
missing: (have) => [
|
|
173
|
+
...(c.settings ?? []).filter((k) => !have[k]),
|
|
174
|
+
...(c.secrets ?? []).filter((k) => !have[k] && !(instead[k] && have[instead[k]])),
|
|
175
|
+
...(c.files ?? []).filter((k) => !have[k] && !Object.values(instead).includes(k)),
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A run's environment after its references: for each connector read from the client's vault, every name it reads comes
|
|
181
|
+
* from that object and nothing else (a file name's value written to a private temporary file); a connector whose
|
|
182
|
+
* object cannot be read, or lacks a name it needs, gets none of its names, so its source fails on its own and says why.
|
|
183
|
+
*/
|
|
184
|
+
export async function applyRefs(dir, refs, creds, base, f = fetch) {
|
|
185
|
+
const env = { ...base }, secrets = [], warnings = [], used = [];
|
|
186
|
+
let tmp;
|
|
187
|
+
for (const [connector, ref] of Object.entries(refs)) {
|
|
188
|
+
const c = creds[connector];
|
|
189
|
+
if (!c)
|
|
190
|
+
continue;
|
|
191
|
+
const need = required(c);
|
|
192
|
+
for (const k of need.all)
|
|
193
|
+
delete env[k];
|
|
194
|
+
let got;
|
|
195
|
+
try {
|
|
196
|
+
got = await readRef(ref, env, f);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
warnings.push(`${connector}: its secret in your vault could not be read: ${String(e?.message ?? e).slice(0, 300)}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const missing = need.missing(got);
|
|
203
|
+
if (missing.length) {
|
|
204
|
+
warnings.push(`${connector}: its secret in your vault lacks ${missing.join(", ")}; none of its names are used`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const files = new Set([...(c.files ?? []), ...Object.values(c.instead ?? {}).filter((n) => n.endsWith("_FILE"))]);
|
|
208
|
+
const settings = new Set([...(c.settings ?? []), ...Object.keys(c.optional ?? {})]);
|
|
209
|
+
for (const k of need.all) {
|
|
210
|
+
const v = got[k];
|
|
211
|
+
if (v === undefined || v === "")
|
|
212
|
+
continue;
|
|
213
|
+
if (files.has(k)) {
|
|
214
|
+
tmp ??= mkdtempSync(join(dir, ".sagentix-run-"));
|
|
215
|
+
const p = join(tmp, k.toLowerCase());
|
|
216
|
+
writePrivate(p, v);
|
|
217
|
+
env[k] = p;
|
|
218
|
+
}
|
|
219
|
+
else
|
|
220
|
+
env[k] = v;
|
|
221
|
+
if (!settings.has(k))
|
|
222
|
+
secrets.push(v, ...v.split(/\r?\n/).filter((l) => l.length >= 16));
|
|
223
|
+
used.push(k);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { env, secrets, warnings, used, cleanup: () => { if (tmp)
|
|
227
|
+
rmSync(tmp, { recursive: true, force: true }); } };
|
|
228
|
+
}
|
package/collector/src/report.js
CHANGED
|
@@ -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
|
-
|
|
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,318 @@
|
|
|
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
|
+
/** The connectors read from the client's own vault. */
|
|
253
|
+
refs() { return { ...(this.doc().refs ?? {}) }; }
|
|
254
|
+
setRef(connector, ref, now) {
|
|
255
|
+
const d = this.doc();
|
|
256
|
+
d.refs = { ...(d.refs ?? {}), [connector]: { provider: ref.provider, id: ref.id, setAt: now.toISOString() } };
|
|
257
|
+
this.write(d);
|
|
258
|
+
}
|
|
259
|
+
removeRef(connector) {
|
|
260
|
+
const d = this.doc();
|
|
261
|
+
if (!d.refs?.[connector])
|
|
262
|
+
return false;
|
|
263
|
+
delete d.refs[connector];
|
|
264
|
+
this.write(d);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
/** Every entry with its value, for a run's environment. */
|
|
268
|
+
all() {
|
|
269
|
+
return this.list().map(({ name, kind, bind }) => ({ name, kind, value: this.get(name), ...(bind ? { bind } : {}) }));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A run's environment: the process's own, plus each vault entry it does not already set (a variable set on purpose
|
|
274
|
+
* 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
|
|
275
|
+
* the folder removed by `cleanup` when the run ends. No vault file: the environment as it is.
|
|
276
|
+
*/
|
|
277
|
+
export function runEnv(dir, holder, base, settingsOf = () => []) {
|
|
278
|
+
const v = new Vault(dir, holder);
|
|
279
|
+
if (!existsSync(v.path))
|
|
280
|
+
return { env: base, used: [], secrets: [], warnings: [], cleanup: () => { } };
|
|
281
|
+
const env = { ...base }, used = [], secrets = [], warnings = [];
|
|
282
|
+
const all = v.all();
|
|
283
|
+
for (const e of all)
|
|
284
|
+
if (e.kind === "setting" && !env[e.name]) {
|
|
285
|
+
env[e.name] = e.value;
|
|
286
|
+
used.push(e.name);
|
|
287
|
+
}
|
|
288
|
+
let tmp;
|
|
289
|
+
for (const e of all) {
|
|
290
|
+
if (e.kind === "setting" || env[e.name])
|
|
291
|
+
continue;
|
|
292
|
+
// A secret is used only while its connector's settings are the ones it was set with: all of them, so a setting
|
|
293
|
+
// added since (an optional URL) counts as a change too.
|
|
294
|
+
if (e.bind) {
|
|
295
|
+
const now = {};
|
|
296
|
+
for (const k of settingsOf(e.name))
|
|
297
|
+
if (env[k])
|
|
298
|
+
now[k] = env[k];
|
|
299
|
+
if (canonicalBind(now) !== canonicalBind(e.bind)) {
|
|
300
|
+
const changed = [...new Set([...Object.keys(now), ...Object.keys(e.bind)])].filter((k) => now[k] !== e.bind[k]);
|
|
301
|
+
warnings.push(`${e.name} is not used: ${changed.join(", ")} changed since it was set; set ${e.name} again`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (e.kind === "file") {
|
|
306
|
+
tmp ??= mkdtempSync(join(dir, ".sagentix-run-"));
|
|
307
|
+
const p = join(tmp, e.name.toLowerCase());
|
|
308
|
+
writePrivate(p, e.value);
|
|
309
|
+
env[e.name] = p;
|
|
310
|
+
}
|
|
311
|
+
else
|
|
312
|
+
env[e.name] = e.value;
|
|
313
|
+
used.push(e.name);
|
|
314
|
+
secrets.push(e.value, ...e.value.split(/\r?\n/).filter((l) => l.length >= 16));
|
|
315
|
+
}
|
|
316
|
+
return { env, used, secrets, warnings, cleanup: () => { if (tmp)
|
|
317
|
+
rmSync(tmp, { recursive: true, force: true }); } };
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sagentix-inc/evidence-collector",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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": {
|