@sagentix-inc/evidence-collector 0.3.3 → 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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module"
5
5
  }
@@ -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,7 +31,8 @@ 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";
34
+ import { VAULT_FILE, Vault, keyHolderFor, runEnv } from "./vault.js";
35
+ import { applyRefs } from "./refs.js";
35
36
  import { credentialsIn, settingsOf, vaultBridge } from "./deliveries.js";
36
37
  export const COLLECT_PATH = "/api/collect";
37
38
  export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
@@ -236,15 +237,21 @@ export async function run(argv, deps) {
236
237
  if (configPath && keyId) {
237
238
  try {
238
239
  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)
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)
245
252
  deps.err(`warning: vault: ${w}`);
246
- if (r.warnings.length)
247
- rec.note("vault", r.warnings);
253
+ if (warnings.length)
254
+ rec.note("vault", warnings);
248
255
  }
249
256
  catch (e) {
250
257
  const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
@@ -6,6 +6,8 @@
6
6
  // remove the vault's value for such a name
7
7
  // import-env move such names' values from sagentix-collector.env into the vault, then drop those lines
8
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
9
11
  //
10
12
  // A delivery for a name the config does not list, or with another kind, is refused and reported, never applied: what
11
13
  // sos-app says cannot widen what this collector reads. No delivery can name a file, a command or a source option.
@@ -14,6 +16,7 @@ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } fr
14
16
  import { dirname, isAbsolute, join } from "node:path";
15
17
  import { signedPost, writePrivate } from "./channel.js";
16
18
  import { scrubText } from "./report.js";
19
+ import { refProblem } from "./refs.js";
17
20
  import { KINDS, MAX_VALUE, NAME, Vault, canonicalBind } from "./vault.js";
18
21
  export const ACK_PATH = "/api/collect/vault/ack";
19
22
  export const ENV_FILE = "sagentix-collector.env";
@@ -106,16 +109,18 @@ export function vaultBridge(o) {
106
109
  function summary() {
107
110
  const c = creds(), known = knownNames(c);
108
111
  let sealKey = null, entries = [], error = null, protection = null;
112
+ let refs = {};
109
113
  try {
110
114
  protection = v().protection;
111
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.
112
116
  entries = v().list().map((e) => e.kind === "setting" ? { ...e, value: v().get(e.name) } : e);
117
+ refs = v().refs();
113
118
  sealKey = Object.keys(c).length ? v().sealPublicKey() : null;
114
119
  }
115
120
  catch (e) {
116
121
  error = String(e?.message ?? e).slice(0, 300);
117
122
  }
118
- return { sealKey, protection, error, entries, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
123
+ return { sealKey, protection, error, entries, refs, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
119
124
  }
120
125
  function importEnv(known) {
121
126
  const moved = [], leftFiles = [], problems = [], c = creds();
@@ -203,6 +208,27 @@ export function vaultBridge(o) {
203
208
  o.log(`vault: ${name} set`);
204
209
  out.push({ id: d.id, ok: true });
205
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
+ }
206
232
  else if (d.op === "import-env") {
207
233
  const r = importEnv(known);
208
234
  o.log(`vault: ${r.moved?.length ?? 0} value(s) moved from ${ENV_FILE}`);
@@ -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
+ }
@@ -249,6 +249,21 @@ export class Vault {
249
249
  throw new Error(`the sealed value for ${v.name} does not open with this collector's key`);
250
250
  }
251
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
+ }
252
267
  /** Every entry with its value, for a run's environment. */
253
268
  all() {
254
269
  return this.list().map(({ name, kind, bind }) => ({ name, kind, value: this.get(name), ...(bind ? { bind } : {}) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentix-inc/evidence-collector",
3
- "version": "0.3.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": {