unbound-cli 1.6.4 → 1.6.5
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 +9 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +109 -115
- 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/utils.js +86 -10
- 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/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/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,201 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const { URL } = require('url');
|
|
5
|
+
|
|
6
|
+
// WEB-4941, root cause #1: loginWithBrowser's local callback server leaked a
|
|
7
|
+
// keep-alive socket. server.close() stops accepting NEW connections but does NOT
|
|
8
|
+
// destroy the socket the browser holds open via HTTP/1.1 keep-alive, so the
|
|
9
|
+
// process stayed alive ~60s. The fix sets `Connection: close` on every response
|
|
10
|
+
// and force-drops lingering sockets via closeAllConnections (shutdownServer).
|
|
11
|
+
//
|
|
12
|
+
// These tests drive the REAL loginWithBrowser through its injected `open` seam,
|
|
13
|
+
// reproducing the exact leaking condition with a keepAlive agent, and assert
|
|
14
|
+
// (a) the success result, (b) every response carries `connection: close`, and
|
|
15
|
+
// (c) the server actually stopped listening (fresh connect → ECONNREFUSED).
|
|
16
|
+
// No real ~/.unbound write, no real network beyond loopback, no real browser.
|
|
17
|
+
|
|
18
|
+
const auth = require('../src/auth');
|
|
19
|
+
const config = require('../src/config');
|
|
20
|
+
|
|
21
|
+
// Issues a single GET over a keepAlive agent and resolves with status, headers,
|
|
22
|
+
// and the underlying client socket. keepAlive is the load-bearing detail: it
|
|
23
|
+
// reproduces the exact socket the browser holds open, so this test FAILS against
|
|
24
|
+
// the pre-fix code (which left that socket — and the event loop — open).
|
|
25
|
+
function getKeepAlive(agent, url) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const req = http.get(url, { agent }, (res) => {
|
|
28
|
+
res.resume(); // drain the body so the response completes
|
|
29
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, socket: req.socket }));
|
|
30
|
+
});
|
|
31
|
+
req.on('error', reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The actual leak-proof: post-fix the server force-closes the answered socket
|
|
36
|
+
// (Connection: close + closeAllConnections), so the client socket emits 'close'
|
|
37
|
+
// promptly. Against the pre-fix code the socket stayed in keep-alive limbo and
|
|
38
|
+
// this would time out — so THIS assertion (with the Connection: close header) is
|
|
39
|
+
// what distinguishes the fix. expectConnectionRefused below only proves the
|
|
40
|
+
// server stopped listening, which a bare server.close() already did pre-fix.
|
|
41
|
+
function expectSocketClosed(socket, ms) {
|
|
42
|
+
if (socket.destroyed) return Promise.resolve();
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timer = setTimeout(
|
|
45
|
+
() => reject(new Error(`socket was not closed within ${ms}ms — keep-alive leak`)),
|
|
46
|
+
ms,
|
|
47
|
+
);
|
|
48
|
+
socket.once('close', () => { clearTimeout(timer); resolve(); });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Confirms shutdownServer ran: a fresh connection to the port is refused.
|
|
53
|
+
function expectConnectionRefused(port) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const req = http.get(`http://localhost:${port}/callback`, (res) => {
|
|
56
|
+
res.resume();
|
|
57
|
+
reject(new Error(`expected ECONNREFUSED but got HTTP ${res.statusCode}`));
|
|
58
|
+
});
|
|
59
|
+
req.on('error', (err) => {
|
|
60
|
+
if (err.code === 'ECONNREFUSED') resolve();
|
|
61
|
+
else reject(err);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Pulls the callback_url the server told the browser to redirect to, then
|
|
67
|
+
// appends the success params the /callback handler reads (api_key/email/org).
|
|
68
|
+
function buildSuccessCallback(authUrl) {
|
|
69
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
70
|
+
return `${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Monkeypatch config so no real ~/.unbound write happens. Restored in finally.
|
|
74
|
+
function withStubbedConfig(fn) {
|
|
75
|
+
const saved = {
|
|
76
|
+
setApiKey: config.setApiKey,
|
|
77
|
+
writeConfig: config.writeConfig,
|
|
78
|
+
readConfig: config.readConfig,
|
|
79
|
+
};
|
|
80
|
+
config.setApiKey = () => {};
|
|
81
|
+
config.writeConfig = () => {};
|
|
82
|
+
config.readConfig = () => ({});
|
|
83
|
+
return Promise.resolve()
|
|
84
|
+
.then(fn)
|
|
85
|
+
.finally(() => {
|
|
86
|
+
config.setApiKey = saved.setApiKey;
|
|
87
|
+
config.writeConfig = saved.writeConfig;
|
|
88
|
+
config.readConfig = saved.readConfig;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Hard safety net so a regression that reintroduces the leak can't hang CI.
|
|
93
|
+
function withTimeout(promise, ms, label) {
|
|
94
|
+
let timer;
|
|
95
|
+
const guard = new Promise((_, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
97
|
+
});
|
|
98
|
+
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('loginWithBrowser: success path resolves, sets Connection: close, and stops listening', async () => {
|
|
102
|
+
await withStubbedConfig(async () => {
|
|
103
|
+
const agent = new http.Agent({ keepAlive: true });
|
|
104
|
+
let port;
|
|
105
|
+
let successHeaders;
|
|
106
|
+
let successSocket;
|
|
107
|
+
|
|
108
|
+
const fakeOpen = async (authUrl) => {
|
|
109
|
+
port = Number(new URL(new URL(authUrl).searchParams.get('callback_url')).port);
|
|
110
|
+
const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
|
|
111
|
+
successHeaders = res.headers;
|
|
112
|
+
successSocket = res.socket;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const result = await withTimeout(
|
|
117
|
+
auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
118
|
+
10_000,
|
|
119
|
+
'loginWithBrowser success path',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
123
|
+
assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
|
|
124
|
+
|
|
125
|
+
// The leak-proof: the held keep-alive socket is actually closed by the
|
|
126
|
+
// server. This times out (fails) against the pre-fix code.
|
|
127
|
+
await expectSocketClosed(successSocket, 5_000);
|
|
128
|
+
|
|
129
|
+
// Secondary: the server also stopped listening (fresh connect refused).
|
|
130
|
+
await withTimeout(expectConnectionRefused(port), 5_000, 'fresh connect after shutdown');
|
|
131
|
+
} finally {
|
|
132
|
+
agent.destroy();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('loginWithBrowser: 404 path also sets Connection: close', async () => {
|
|
138
|
+
await withStubbedConfig(async () => {
|
|
139
|
+
const agent = new http.Agent({ keepAlive: true });
|
|
140
|
+
let notFoundHeaders;
|
|
141
|
+
|
|
142
|
+
const fakeOpen = async (authUrl) => {
|
|
143
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
144
|
+
const origin = new URL(callbackUrl).origin;
|
|
145
|
+
// 404 first (non-/callback path) — the server is still up; then the real
|
|
146
|
+
// /callback to let loginWithBrowser resolve and shut the server down.
|
|
147
|
+
const notFound = await getKeepAlive(agent, `${origin}/nope`);
|
|
148
|
+
notFoundHeaders = notFound.headers;
|
|
149
|
+
await getKeepAlive(agent, buildSuccessCallback(authUrl));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await withTimeout(
|
|
154
|
+
auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
155
|
+
10_000,
|
|
156
|
+
'loginWithBrowser 404-then-success',
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
assert.equal(notFoundHeaders.connection, 'close', '404 response must set Connection: close');
|
|
160
|
+
} finally {
|
|
161
|
+
agent.destroy();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('loginWithBrowser: missing-api-key path rejects, sets Connection: close, and tears down', async () => {
|
|
167
|
+
await withStubbedConfig(async () => {
|
|
168
|
+
const agent = new http.Agent({ keepAlive: true });
|
|
169
|
+
let resolveCallback;
|
|
170
|
+
let rejectCallback;
|
|
171
|
+
const callbackDone = new Promise((res, rej) => { resolveCallback = res; rejectCallback = rej; });
|
|
172
|
+
|
|
173
|
+
// Mirror the real `open`: it returns after launching the browser, and the
|
|
174
|
+
// /callback request arrives over HTTP a tick LATER — after loginWithBrowser
|
|
175
|
+
// is already awaiting callbackPromise. Firing it inline (before that await)
|
|
176
|
+
// would reject an unhandled promise. Hitting /callback with no api_key drives
|
|
177
|
+
// the 400 reject + shutdown path. Forward a client error to callbackDone so a
|
|
178
|
+
// failed request surfaces as a test failure rather than a hang.
|
|
179
|
+
const fakeOpen = async (authUrl) => {
|
|
180
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
181
|
+
setImmediate(() => { getKeepAlive(agent, callbackUrl).then(resolveCallback, rejectCallback); });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await withTimeout(
|
|
186
|
+
assert.rejects(
|
|
187
|
+
() => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
188
|
+
/No API key received/,
|
|
189
|
+
),
|
|
190
|
+
10_000,
|
|
191
|
+
'loginWithBrowser missing-api-key path',
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const res = await withTimeout(callbackDone, 5_000, 'missing-api-key callback');
|
|
195
|
+
assert.equal(res.headers.connection, 'close', '400 response must set Connection: close');
|
|
196
|
+
await expectSocketClosed(res.socket, 5_000);
|
|
197
|
+
} finally {
|
|
198
|
+
agent.destroy();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
|
|
5
|
+
// WEB-4941, root cause #2: telemetry.init() runs for every command at startup,
|
|
6
|
+
// but flush()/Sentry.close() only happened inside wrapAction (setup/onboard/
|
|
7
|
+
// discover). Every other command left Sentry's background timers holding the
|
|
8
|
+
// event loop ~1min when a DSN was set. The fix wraps program.parseAsync in an
|
|
9
|
+
// IIFE whose finally always calls telemetry.flush().
|
|
10
|
+
//
|
|
11
|
+
// These tests prove the two invariants that IIFE relies on:
|
|
12
|
+
// A) flush() is a no-op + idempotent when telemetry is disabled (no DSN), so
|
|
13
|
+
// the common path adds zero latency and a double-flush can't throw.
|
|
14
|
+
// B) the try/parseAsync/finally/flush pattern preserves the action's exit code.
|
|
15
|
+
// No real Sentry, no network.
|
|
16
|
+
|
|
17
|
+
function freshTelemetry() {
|
|
18
|
+
delete require.cache[require.resolve('../src/telemetry')];
|
|
19
|
+
return require('../src/telemetry');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withEnv(vars, fn) {
|
|
23
|
+
const saved = {};
|
|
24
|
+
for (const k of Object.keys(vars)) {
|
|
25
|
+
saved[k] = process.env[k];
|
|
26
|
+
if (vars[k] === undefined) delete process.env[k];
|
|
27
|
+
else process.env[k] = vars[k];
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
} finally {
|
|
32
|
+
for (const k of Object.keys(vars)) {
|
|
33
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
34
|
+
else process.env[k] = saved[k];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('cli flush: telemetry.flush() is a no-op and idempotent when no DSN is set', async () => {
|
|
40
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined, UNBOUND_TELEMETRY: undefined }, async () => {
|
|
41
|
+
const t = freshTelemetry();
|
|
42
|
+
// Both calls resolve without throwing — the no-op + idempotency the IIFE
|
|
43
|
+
// (and a possible double-flush with wrapAction) rely on.
|
|
44
|
+
await t.flush();
|
|
45
|
+
await t.flush();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('cli flush: the entry pattern preserves a command-set exit code', async () => {
|
|
50
|
+
const savedExitCode = process.exitCode;
|
|
51
|
+
process.exitCode = 0;
|
|
52
|
+
try {
|
|
53
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
54
|
+
const t = freshTelemetry();
|
|
55
|
+
const p = new Command();
|
|
56
|
+
p.exitOverride();
|
|
57
|
+
p.command('x').action(async () => { process.exitCode = 7; });
|
|
58
|
+
|
|
59
|
+
// The exact shape index.js uses around program.parseAsync.
|
|
60
|
+
try {
|
|
61
|
+
await p.parseAsync(['node', 'cli', 'x']);
|
|
62
|
+
} finally {
|
|
63
|
+
await t.flush();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assert.equal(process.exitCode, 7, 'flush in finally must not clobber the command exit code');
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
process.exitCode = savedExitCode;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('cli flush: an action that throws past its own handler exits non-zero, not an unhandled crash', async () => {
|
|
74
|
+
const savedExitCode = process.exitCode;
|
|
75
|
+
process.exitCode = 0;
|
|
76
|
+
let caught = false;
|
|
77
|
+
try {
|
|
78
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
79
|
+
const t = freshTelemetry();
|
|
80
|
+
const p = new Command();
|
|
81
|
+
p.exitOverride();
|
|
82
|
+
p.command('boom').action(async () => { throw new Error('escaped'); });
|
|
83
|
+
|
|
84
|
+
// The exact try/catch/finally shape index.js uses — a rejection must be
|
|
85
|
+
// turned into a non-zero exit + flush, never an UnhandledPromiseRejection.
|
|
86
|
+
try {
|
|
87
|
+
await p.parseAsync(['node', 'cli', 'boom']);
|
|
88
|
+
} catch {
|
|
89
|
+
caught = true;
|
|
90
|
+
process.exitCode = process.exitCode || 1;
|
|
91
|
+
} finally {
|
|
92
|
+
await t.flush();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assert.equal(caught, true, 'the rejection must be caught, not left unhandled');
|
|
96
|
+
assert.equal(process.exitCode, 1, 'a thrown action must yield a non-zero exit code');
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
process.exitCode = savedExitCode;
|
|
100
|
+
}
|
|
101
|
+
});
|