unbound-cli 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Opt-out Sentry error reporting for the CLI.
3
+ *
4
+ * Design constraints (see onboarding-failure-observability spec):
5
+ * - No DSN env var (UNBOUND_CLI_SENTRY_DSN) → fully disabled. Nothing inits,
6
+ * no network, no throw. The DSN is filled in later by the maintainer.
7
+ * - UNBOUND_TELEMETRY in {0,false,no} → disabled even if a DSN is present.
8
+ * - beforeSend scrubs secrets (API/discovery keys), home-dir file paths, and
9
+ * the OS username before anything leaves the machine.
10
+ * - The CLI is short-lived, so callers must flush() before the process exits
11
+ * or queued events are dropped.
12
+ *
13
+ * Everything here is best-effort: a Sentry failure must never change the CLI's
14
+ * own behavior or exit code. All entry points swallow their own errors.
15
+ */
16
+
17
+ const os = require('os');
18
+ const { version } = require('../package.json');
19
+
20
+ let Sentry = null; // lazily required only when we actually init
21
+ let initialized = false;
22
+
23
+ // Opt-out: any of these values (case-insensitive) for UNBOUND_TELEMETRY disables
24
+ // telemetry entirely. Unset / any other value leaves it enabled (subject to DSN).
25
+ function telemetryOptedOut() {
26
+ const v = (process.env.UNBOUND_TELEMETRY || '').trim().toLowerCase();
27
+ return v === '0' || v === 'false' || v === 'no';
28
+ }
29
+
30
+ function isEnabled() {
31
+ return !!process.env.UNBOUND_CLI_SENTRY_DSN && !telemetryOptedOut();
32
+ }
33
+
34
+ // Matches secret-key-shaped tokens so a value that slips into a message/path is
35
+ // redacted even when we don't know which flag produced it. Conservative on
36
+ // length so ordinary words aren't clobbered.
37
+ const KEY_RE = /\b(?:sk|pk|ub|unb|gw|disc)[-_][A-Za-z0-9_-]{8,}\b/gi;
38
+
39
+ // Redacts known-sensitive substrings from an arbitrary string: the concrete
40
+ // key values we were handed, then key-shaped tokens, then home-dir paths and
41
+ // the OS username. Returns the input unchanged when not a string.
42
+ function scrubString(str, secrets) {
43
+ if (typeof str !== 'string' || !str) return str;
44
+ let out = str;
45
+ for (const secret of secrets) {
46
+ if (secret) out = out.split(secret).join('[redacted]');
47
+ }
48
+ out = out.replace(KEY_RE, '[redacted]');
49
+ try {
50
+ const home = os.homedir();
51
+ if (home) out = out.split(home).join('~');
52
+ } catch { /* ignore */ }
53
+ try {
54
+ const username = os.userInfo().username;
55
+ if (username && username.length >= 3) {
56
+ out = out.split(username).join('[user]');
57
+ }
58
+ } catch { /* ignore */ }
59
+ return out;
60
+ }
61
+
62
+ // Recursively scrub strings in nested event structures (messages, exception
63
+ // values, stack frame paths, breadcrumbs). Depth-bounded so a pathological
64
+ // event can't blow the stack.
65
+ function scrubDeep(value, secrets, depth) {
66
+ if (depth > 8 || value == null) return value;
67
+ if (typeof value === 'string') return scrubString(value, secrets);
68
+ if (Array.isArray(value)) return value.map((v) => scrubDeep(v, secrets, depth + 1));
69
+ if (typeof value === 'object') {
70
+ for (const k of Object.keys(value)) {
71
+ value[k] = scrubDeep(value[k], secrets, depth + 1);
72
+ }
73
+ }
74
+ return value;
75
+ }
76
+
77
+ // Collects the concrete secret values passed on the command line so beforeSend
78
+ // can redact the exact strings (not just key-shaped guesses). Mutated by
79
+ // rememberSecret(); kept tiny.
80
+ const knownSecrets = new Set();
81
+
82
+ function rememberSecret(value) {
83
+ if (typeof value === 'string' && value.length >= 6) knownSecrets.add(value);
84
+ }
85
+
86
+ function beforeSend(event) {
87
+ try {
88
+ const secrets = [...knownSecrets];
89
+ // Drop server_name (often the hostname) and the user object before scrub.
90
+ delete event.server_name;
91
+ if (event.user) delete event.user.username;
92
+ scrubDeep(event, secrets, 0);
93
+ } catch {
94
+ // Never let scrubbing throw — if we can't scrub safely, drop the event.
95
+ return null;
96
+ }
97
+ return event;
98
+ }
99
+
100
+ /**
101
+ * Initializes Sentry if (and only if) a DSN is configured and telemetry is not
102
+ * opted out. Safe to call once at startup. Never throws.
103
+ *
104
+ * @param {object} [opts]
105
+ * @param {string} [opts.runId] - shared onboard/setup/discover run id (tag).
106
+ * @param {string} [opts.flow] - flow name tag (defaults to 'onboard').
107
+ */
108
+ function init({ runId, flow = 'onboard' } = {}) {
109
+ if (initialized || !isEnabled()) return;
110
+ try {
111
+ Sentry = require('@sentry/node');
112
+ Sentry.init({
113
+ dsn: process.env.UNBOUND_CLI_SENTRY_DSN,
114
+ release: `unbound-cli@${version}`,
115
+ // We only want explicit captures, not unrelated auto-instrumentation noise.
116
+ integrations: [],
117
+ defaultIntegrations: false,
118
+ tracesSampleRate: 0,
119
+ sendDefaultPii: false,
120
+ beforeSend,
121
+ });
122
+ Sentry.setTags({
123
+ flow,
124
+ cli_version: version,
125
+ platform: process.platform,
126
+ ...(runId ? { run_id: runId } : {}),
127
+ });
128
+ initialized = true;
129
+ } catch {
130
+ // Missing module / bad DSN / anything — telemetry just stays off.
131
+ Sentry = null;
132
+ initialized = false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Captures an error if telemetry is active. No-op otherwise. Never throws and
138
+ * never changes the caller's control flow — callers rethrow/exit on their own.
139
+ */
140
+ function captureError(err, context = {}) {
141
+ if (!initialized || !Sentry) return;
142
+ try {
143
+ Sentry.captureException(err, context.tags ? { tags: context.tags } : undefined);
144
+ } catch { /* best-effort */ }
145
+ }
146
+
147
+ /**
148
+ * Flushes queued events with a short bound, then closes the client. Must be
149
+ * awaited before the process exits or short-lived runs lose their events.
150
+ * Never throws.
151
+ */
152
+ async function flush(timeoutMs = 2000) {
153
+ if (!initialized || !Sentry) return;
154
+ try {
155
+ // close() flushes queued events and then disables the client. Calling
156
+ // flush() first would only double the worst-case exit latency (2x
157
+ // timeoutMs) for a short-lived CLI, so close() alone is enough.
158
+ await Sentry.close(timeoutMs);
159
+ } catch { /* best-effort */ }
160
+ initialized = false;
161
+ }
162
+
163
+ /**
164
+ * Wraps a command action so any thrown error is captured to Sentry (before
165
+ * being rethrown) and queued events are flushed before the process exits.
166
+ * Because the CLI is short-lived, the flush is the only thing keeping events
167
+ * alive — so it runs on BOTH the success and error paths.
168
+ *
169
+ * The wrapper preserves the action's own error handling: it rethrows, so the
170
+ * existing try/catch inside the action (which sets process.exitCode and prints
171
+ * the message) still runs exactly as before. Telemetry never changes behavior.
172
+ *
173
+ * @param {string} flow - flow tag for captured events (e.g. 'setup').
174
+ * @param {(...args:any[])=>Promise<any>} action - the commander action handler.
175
+ */
176
+ function wrapAction(flow, action) {
177
+ return async (...args) => {
178
+ try {
179
+ const result = await action(...args);
180
+ await flush();
181
+ return result;
182
+ } catch (err) {
183
+ captureError(err, { tags: { flow } });
184
+ await flush();
185
+ throw err;
186
+ }
187
+ };
188
+ }
189
+
190
+ module.exports = {
191
+ init,
192
+ captureError,
193
+ flush,
194
+ wrapAction,
195
+ rememberSecret,
196
+ isEnabled,
197
+ // Exported for tests:
198
+ _beforeSend: beforeSend,
199
+ _scrubString: scrubString,
200
+ _telemetryOptedOut: telemetryOptedOut,
201
+ };
package/src/toolHealth.js CHANGED
@@ -126,6 +126,26 @@ function scriptCheck(label, file, kind = 'structural') {
126
126
  return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
127
127
  }
128
128
 
129
+ // Set-but-non-empty check + value capture, intended for per-tool API key envs.
130
+ // WEB-4949: API keys are not equality-checked against the stored
131
+ // `config.api_key` — per-tool keys can legitimately differ for the same tenant
132
+ // (different keys for Cursor vs Codex, dashboard-rotated key, etc.). The
133
+ // `keyCheck`/`envName`/`value` tags let `validateToolKeys` revisit each
134
+ // captured value and flip the check not-ok when the gateway rejects it.
135
+ function envKeyCheck(label, name, kind = 'aux') {
136
+ const base = label.replace(/ env$/, '');
137
+ const r = readEnvVar(name);
138
+ if (!r.found || !r.value) {
139
+ return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
140
+ }
141
+ return {
142
+ name: label, ok: true, kind,
143
+ detail: `${name} set (${r.source})`,
144
+ summary: `${base} set`,
145
+ keyCheck: true, envName: name, value: r.value,
146
+ };
147
+ }
148
+
129
149
  function envCheck(label, name, expected, kind = 'aux') {
130
150
  const base = label.replace(/ env$/, '');
131
151
  const r = readEnvVar(name);
@@ -217,8 +237,11 @@ function refsUnbound(obj) {
217
237
  }
218
238
 
219
239
  // One descriptor per (tool, mode). `family` groups the two-mode tools so the
220
- // collapsed view shows a single line per product.
221
- function buildVariants(gatewayUrl, apiKey, binaryPath) {
240
+ // collapsed view shows a single line per product. `apiKey` is no longer
241
+ // equality-checked (WEB-4949) per-tool keys can legitimately differ from
242
+ // the stored login key. Use `validateToolKeys` post-detection to verify each
243
+ // captured key against the gateway.
244
+ function buildVariants(gatewayUrl, binaryPath) {
222
245
  const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
223
246
  return [
224
247
  {
@@ -226,7 +249,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
226
249
  checks: () => [
227
250
  configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
228
251
  scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
229
- envCheck('API key env', 'UNBOUND_CURSOR_API_KEY', apiKey),
252
+ envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
230
253
  ],
231
254
  },
232
255
  {
@@ -234,7 +257,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
234
257
  checks: () => [
235
258
  configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
236
259
  scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
237
- envCheck('API key env', 'UNBOUND_CLAUDE_API_KEY', apiKey),
260
+ envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
238
261
  ],
239
262
  },
240
263
  {
@@ -242,7 +265,9 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
242
265
  checks: () => [
243
266
  configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
244
267
  scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
245
- envCheck('API key env', 'UNBOUND_API_KEY', apiKey),
268
+ envKeyCheck('API key env', 'UNBOUND_API_KEY'),
269
+ // Gateway URL is a real equality check — the hook must point at the
270
+ // configured backend; tenants don't generate different gateway URLs.
246
271
  envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
247
272
  ],
248
273
  },
@@ -252,14 +277,14 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
252
277
  configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
253
278
  configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
254
279
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
255
- envCheck('API key env', 'UNBOUND_CODEX_API_KEY', apiKey),
280
+ envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
256
281
  ],
257
282
  },
258
283
  {
259
284
  key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
260
285
  checks: () => [
261
286
  configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
262
- envCheck('API key env', 'OPENAI_API_KEY', apiKey),
287
+ envKeyCheck('API key env', 'OPENAI_API_KEY'),
263
288
  ],
264
289
  },
265
290
  {
@@ -276,7 +301,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
276
301
  const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
277
302
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
278
303
  else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
279
- checks.push(envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey));
304
+ checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
280
305
  return checks;
281
306
  },
282
307
  },
@@ -305,8 +330,8 @@ function detectVariant(variant) {
305
330
  // org-managed scenarios can be exercised without writing under /Library or /etc.
306
331
  // `_binaryPath` (test-only) overrides the system hook-binary path so binary-mode
307
332
  // scenarios can be exercised without writing under /opt.
308
- function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
309
- const variants = buildVariants(gatewayUrl, apiKey, _binaryPath).map(detectVariant);
333
+ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
334
+ const variants = buildVariants(gatewayUrl, _binaryPath).map(detectVariant);
310
335
  const families = [];
311
336
  const seen = new Set();
312
337
  for (const v of variants) {
@@ -337,4 +362,80 @@ function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
337
362
  return families;
338
363
  }
339
364
 
340
- module.exports = { detectTools, statusOf, readEnvVar, GATEWAY_DEFAULT };
365
+ // WEB-4949: Validate each tool's captured env-var key against the gateway and
366
+ // mutate the corresponding `keyCheck: true` entries in place. Fail-open: an
367
+ // 'unknown' verdict (network error, timeout, 5xx) leaves the check healthy
368
+ // but tags it `validationSkipped: true` so status/doctor can surface
369
+ // "gateway unreachable — N key(s) not verified" instead of silently lying.
370
+ // Unique keys are fanned out in parallel so N tools share one round-trip's
371
+ // worth of latency. `validateKey` is
372
+ // `(key) => Promise<'valid'|'invalid'|'unknown'>` — injected from
373
+ // status/doctor (which wrap `api.validateApiKey`) so tests can stub it. A
374
+ // validator that throws is coerced to 'unknown' so a regression in the api
375
+ // client can never wipe out the local-state view callers depend on.
376
+ async function validateToolKeys(tools, validateKey) {
377
+ if (!tools?.length) return tools;
378
+ const unique = new Set();
379
+ for (const t of tools) {
380
+ for (const c of t.checks || []) {
381
+ if (c.keyCheck && c.value) unique.add(c.value);
382
+ }
383
+ }
384
+ if (!unique.size) return tools;
385
+ const entries = await Promise.all(
386
+ [...unique].map(async (k) => {
387
+ try {
388
+ return [k, await validateKey(k)];
389
+ } catch {
390
+ return [k, 'unknown'];
391
+ }
392
+ })
393
+ );
394
+ const validity = new Map(entries);
395
+ for (const t of tools) {
396
+ if (!t.checks?.length) continue;
397
+ let okFlipped = false;
398
+ for (const c of t.checks) {
399
+ if (!c.keyCheck) continue;
400
+ const verdict = validity.get(c.value);
401
+ if (verdict === 'invalid') {
402
+ c.ok = false;
403
+ c.warn = true;
404
+ const base = c.name.replace(/ env$/, '');
405
+ c.summary = `${base} invalid`;
406
+ c.detail = `${c.envName} set but not valid for this tenant`;
407
+ okFlipped = true;
408
+ } else if (verdict === 'unknown') {
409
+ // Stay healthy (fail-open), but record the skip so the caller can
410
+ // surface it. Without this tag the operator can't tell an offline
411
+ // run from a fully-validated one — that's the WEB-4922 lesson.
412
+ // Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
413
+ c.validationSkipped = true;
414
+ c.detail = `${c.detail}; validation skipped — gateway unreachable`;
415
+ }
416
+ }
417
+ // ONLY re-derive when a check's `ok` actually flipped. Without this guard,
418
+ // `statusOf` overwrites `managed-by-mdm` (the sentinel `detectTools` sets
419
+ // for MDM-managed tools) back to a plain 'healthy', erasing the MDM badge
420
+ // from both human + JSON output. MDM checks have no `keyCheck` entries so
421
+ // this guard naturally excludes them — no MDM-specific carve-out needed.
422
+ if (okFlipped) t.status = statusOf(t.checks);
423
+ }
424
+ return tools;
425
+ }
426
+
427
+ // WEB-4949: Convenience accessor for status/doctor. Returns the count of
428
+ // `keyCheck` entries flagged `validationSkipped: true` across all tools.
429
+ // Used to render a one-line "N key(s) not verified" note when the validator
430
+ // failed open. Returns 0 when validation ran cleanly or no key checks exist.
431
+ function countValidationSkipped(tools) {
432
+ let n = 0;
433
+ for (const t of tools || []) {
434
+ for (const c of t.checks || []) {
435
+ if (c.keyCheck && c.validationSkipped) n += 1;
436
+ }
437
+ }
438
+ return n;
439
+ }
440
+
441
+ module.exports = { detectTools, statusOf, readEnvVar, validateToolKeys, countValidationSkipped, GATEWAY_DEFAULT };
package/src/utils.js CHANGED
@@ -1,5 +1,8 @@
1
1
  const readline = require('readline');
2
2
  const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
3
6
  const https = require('https');
4
7
 
5
8
  function formatDate(dateStr) {
@@ -31,14 +34,38 @@ function shellEscape(str) {
31
34
  }
32
35
 
33
36
  /**
34
- * Downloads a URL to a local file, following up to 3 redirects.
35
- * Forwards response-stream errors to the Promise rejection so callers
36
- * (and their finally blocks) can clean up partial downloads.
37
+ * Downloads a URL to a local file, following up to 3 redirects. This is the
38
+ * ONE secure implementation setup.js and discover.js both import it so the
39
+ * download-then-run paths (which `sudo unbound …` executes as ROOT) share a
40
+ * single hardened code path instead of drifted copies.
41
+ *
42
+ * SECURITY: the script file we are about to EXECUTE must not be attacker-
43
+ * controllable. `destPath` is expected to live inside a per-run private temp dir
44
+ * (see withSecureTempFile / mkdtempSync, mode 0700) so it can't be pre-created
45
+ * or symlinked by a local attacker. As defense in depth we ALSO open the file
46
+ * with O_EXCL|O_NOFOLLOW via createWriteStream({ flags: 'wx' }) and mode 0o600:
47
+ * an existing path or symlink at destPath yields EEXIST/ELOOP and rejects rather
48
+ * than being followed or overwritten. This closes the TOCTOU root-privesc window.
49
+ *
50
+ * Verifies BOTH a 200 status AND a non-empty body before resolving: a 200 with
51
+ * zero bytes is a failed download (the script never arrived), not a runnable
52
+ * empty script. This is what lets the download-then-run paths close the old
53
+ * `curl | bash` / `curl | python3` pipe hole, where a failed download still
54
+ * exited 0.
55
+ *
56
+ * On any transport/stream error the partner stream is destroyed and the partial
57
+ * file is unlinked before rejecting, so a one-shot CLI doesn't leak a socket or
58
+ * leave a partial temp file behind (the mkdtemp-dir cleanup also covers it).
59
+ *
60
+ * Injectable transport (tests only): pass `{ transport }` to use a custom
61
+ * https-like module exposing `.get(url, cb)`, so the SHIPPED function can be
62
+ * exercised against a local server instead of real network/TLS.
37
63
  */
38
- function downloadToFile(url, destPath) {
64
+ function downloadToFile(url, destPath, { transport } = {}) {
65
+ const client = transport || https;
39
66
  return new Promise((resolve, reject) => {
40
67
  const request = (u, remaining) => {
41
- https.get(u, (res) => {
68
+ client.get(u, (res) => {
42
69
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
43
70
  res.resume();
44
71
  return request(res.headers.location, remaining - 1);
@@ -47,17 +74,66 @@ function downloadToFile(url, destPath) {
47
74
  res.resume();
48
75
  return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
49
76
  }
50
- const file = fs.createWriteStream(destPath);
77
+ // wx = O_CREAT|O_EXCL (fail if it already exists, never follow a symlink
78
+ // into it); mode 0o600 keeps the downloaded script owner-only.
79
+ const file = fs.createWriteStream(destPath, { flags: 'wx', mode: 0o600 });
80
+ let bytes = 0;
81
+ let settled = false;
82
+ const fail = (err) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ // Tear down both ends so a one-shot CLI doesn't leak a socket.
86
+ res.destroy();
87
+ file.destroy();
88
+ // Remove the partial file we wrote — but NOT when the open itself
89
+ // failed (EEXIST from `wx` / ELOOP from a symlink): that file/symlink
90
+ // is pre-existing and attacker-controlled, so we must never delete it.
91
+ if (err && (err.code === 'EEXIST' || err.code === 'ELOOP')) {
92
+ reject(err);
93
+ return;
94
+ }
95
+ fs.unlink(destPath, () => reject(err));
96
+ };
97
+ res.on('data', (chunk) => { bytes += chunk.length; });
98
+ res.on('error', fail);
99
+ file.on('error', fail);
51
100
  res.pipe(file);
52
- res.on('error', reject);
53
- file.on('finish', () => file.close(resolve));
54
- file.on('error', reject);
101
+ file.on('finish', () => file.close(() => {
102
+ if (settled) return;
103
+ if (bytes === 0) {
104
+ settled = true;
105
+ fs.unlink(destPath, () => reject(new Error(`Failed to download ${u}: empty response body`)));
106
+ return;
107
+ }
108
+ settled = true;
109
+ resolve();
110
+ }));
55
111
  }).on('error', reject);
56
112
  };
57
113
  request(url, 3);
58
114
  });
59
115
  }
60
116
 
117
+ /**
118
+ * Runs `fn(filePath)` with `filePath` pointing inside a freshly-created PRIVATE
119
+ * temp directory (fs.mkdtempSync, mode 0700), then removes the whole directory
120
+ * (and the file) afterward — success or failure. A private dir defeats symlink/
121
+ * pre-creation attacks on the predictable old /tmp path, and the random file
122
+ * name uses crypto.randomUUID() rather than Date.now()+Math.random().
123
+ *
124
+ * @param {string} suffix file extension incl. dot, e.g. '.py' or '.sh'.
125
+ * @param {(p:string)=>Promise} fn receives the absolute temp file path.
126
+ */
127
+ async function withSecureTempFile(suffix, fn) {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-'));
129
+ const file = path.join(dir, `${crypto.randomUUID()}${suffix}`);
130
+ try {
131
+ return await fn(file);
132
+ } finally {
133
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
134
+ }
135
+ }
136
+
61
137
  const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
62
138
 
63
- module.exports = { formatDate, confirm, parseCommaSeparated, shellEscape, downloadToFile, DISCOVER_BASE_URL };
139
+ module.exports = { formatDate, confirm, parseCommaSeparated, shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL };
@@ -0,0 +1,107 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const api = require('../src/api');
5
+
6
+ // WEB-4949: validateApiKey wraps the /api/v1/users/privileges/ probe with the
7
+ // fail-open semantics status/doctor need. Stand up a real local HTTP server
8
+ // per case so we exercise the actual transport (timeout, status mapping, no
9
+ // throw on 4xx) instead of mocking the api client.
10
+ function withServer(handler, fn) {
11
+ return new Promise((resolve, reject) => {
12
+ const server = http.createServer(handler);
13
+ server.listen(0, '127.0.0.1', async () => {
14
+ const { address, port } = server.address();
15
+ const baseUrl = `http://${address}:${port}`;
16
+ try {
17
+ await fn(baseUrl);
18
+ resolve();
19
+ } catch (err) {
20
+ reject(err);
21
+ } finally {
22
+ server.close();
23
+ }
24
+ });
25
+ server.on('error', reject);
26
+ });
27
+ }
28
+
29
+ test('validateApiKey: 200 → valid', async () => {
30
+ await withServer(
31
+ (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); },
32
+ async (baseUrl) => {
33
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'valid');
34
+ }
35
+ );
36
+ });
37
+
38
+ test('validateApiKey: 401 → invalid', async () => {
39
+ await withServer(
40
+ (_req, res) => { res.writeHead(401, { 'content-type': 'application/json' }); res.end('{"error":"bad key"}'); },
41
+ async (baseUrl) => {
42
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
43
+ }
44
+ );
45
+ });
46
+
47
+ test('validateApiKey: 403 → invalid', async () => {
48
+ await withServer(
49
+ (_req, res) => { res.writeHead(403); res.end(''); },
50
+ async (baseUrl) => {
51
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
52
+ }
53
+ );
54
+ });
55
+
56
+ test('validateApiKey: 500 → unknown (fail-open)', async () => {
57
+ await withServer(
58
+ (_req, res) => { res.writeHead(500); res.end(''); },
59
+ async (baseUrl) => {
60
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'unknown');
61
+ }
62
+ );
63
+ });
64
+
65
+ test('validateApiKey: timeout → unknown (fail-open)', async () => {
66
+ // Handler never responds; short timeout forces the unknown branch.
67
+ await withServer(
68
+ (_req, _res) => { /* hang */ },
69
+ async (baseUrl) => {
70
+ assert.equal(await api.validateApiKey('k', { baseUrl, timeoutMs: 100 }), 'unknown');
71
+ }
72
+ );
73
+ });
74
+
75
+ test('validateApiKey: connection refused → unknown (fail-open)', async () => {
76
+ // Pick a localhost port nothing is listening on. 1 is reliably refused.
77
+ assert.equal(
78
+ await api.validateApiKey('k', { baseUrl: 'http://127.0.0.1:1', timeoutMs: 500 }),
79
+ 'unknown'
80
+ );
81
+ });
82
+
83
+ test('validateApiKey: missing key → invalid without a network call', async () => {
84
+ // baseUrl points at a dead port; if we made a network call the test would
85
+ // hang or take ages. A short timeout would also work but is unnecessary
86
+ // because the function should short-circuit before any IO.
87
+ assert.equal(await api.validateApiKey('', { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
88
+ assert.equal(await api.validateApiKey(null, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
89
+ assert.equal(await api.validateApiKey(undefined, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
90
+ });
91
+
92
+ test('validateApiKey: sends the key as bearer + hits /api/v1/users/privileges/', async () => {
93
+ let observedAuth = null;
94
+ let observedPath = null;
95
+ await withServer(
96
+ (req, res) => {
97
+ observedAuth = req.headers['authorization'];
98
+ observedPath = req.url;
99
+ res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
100
+ },
101
+ async (baseUrl) => {
102
+ await api.validateApiKey('sk-abc123', { baseUrl });
103
+ assert.equal(observedAuth, 'Bearer sk-abc123');
104
+ assert.ok(observedPath.startsWith('/api/v1/users/privileges/'), observedPath);
105
+ }
106
+ );
107
+ });