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.
- package/README.md +25 -0
- package/package.json +2 -1
- package/src/api.js +29 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/doctor.js +17 -4
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +109 -115
- package/src/commands/status.js +18 -3
- package/src/index.js +32 -1
- package/src/run-id.js +27 -0
- package/src/setup-report.js +119 -0
- package/src/telemetry.js +201 -0
- package/src/toolHealth.js +112 -11
- package/src/utils.js +86 -10
- package/test/api-validate-key.test.js +107 -0
- package/test/auth-server-leak.test.js +201 -0
- package/test/cli-flush.test.js +101 -0
- package/test/download-pipe-hole.test.js +221 -0
- package/test/onboard-failure-exit.test.js +125 -0
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
- package/test/tool-health.test.js +237 -11
package/src/telemetry.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
309
|
-
const variants = buildVariants(gatewayUrl,
|
|
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
|
-
|
|
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
|
-
*
|
|
36
|
-
* (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
});
|