unbound-cli 1.6.3 → 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/scripts/verify-nuke-ubuntu.sh +92 -0
- 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 +204 -117
- 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-args.test.js +178 -1
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort reporting of onboarding/setup step failures to the backend's
|
|
3
|
+
* setup-failure ledger (POST /api/v1/setup/failed/).
|
|
4
|
+
*
|
|
5
|
+
* This is the client half of "no onboarding step fails silently": when a setup
|
|
6
|
+
* step fails (script exits non-zero, throws, or its download fails) the CLI
|
|
7
|
+
* records the failure against the device so it shows up server-side instead of
|
|
8
|
+
* vanishing.
|
|
9
|
+
*
|
|
10
|
+
* CONTRACT (do not change here — backend owns it):
|
|
11
|
+
* POST {backendUrl}/api/v1/setup/failed/
|
|
12
|
+
* body: { serial_number: string (<=255, required),
|
|
13
|
+
* step: string (<=64, required),
|
|
14
|
+
* exit_code: integer }
|
|
15
|
+
* auth: same as /setup/complete/ — gateway API key in Authorization header,
|
|
16
|
+
* User-Agent "UnboundCLI/<version>". install_mode/managed are forced
|
|
17
|
+
* server-side, so we never send them.
|
|
18
|
+
*
|
|
19
|
+
* BEST-EFFORT, ALWAYS: a telemetry POST failure (offline, 429, 4xx, timeout)
|
|
20
|
+
* must NEVER mask or replace the real error shown to the user, and must NOT
|
|
21
|
+
* change the CLI's exit code. Every path here swallows its own errors.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const api = require('./api');
|
|
26
|
+
const config = require('./config');
|
|
27
|
+
|
|
28
|
+
// The failure report is best-effort telemetry that runs while the user is
|
|
29
|
+
// already waiting on a real setup failure. Bound it tightly (well under the
|
|
30
|
+
// default 30s API timeout) so a hung/slow backend can't delay surfacing the
|
|
31
|
+
// user's error. On timeout the POST rejects and the catch below swallows it.
|
|
32
|
+
const REPORT_TIMEOUT_MS = 3000;
|
|
33
|
+
|
|
34
|
+
// JS-thrown error (no process exit code available).
|
|
35
|
+
const EXIT_THROWN = 1;
|
|
36
|
+
// Download/transport failure before any process ran.
|
|
37
|
+
const EXIT_DOWNLOAD_FAILED = -1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Best-effort device identifier for the failure ledger. The Node CLI can't read
|
|
41
|
+
* the hardware serial reliably, so we use os.hostname() — the backend only uses
|
|
42
|
+
* serial_number as an org-scoped upsert key, not as a true hardware serial.
|
|
43
|
+
* Truncated to the backend's 255-char limit; falls back to "unknown-device".
|
|
44
|
+
*/
|
|
45
|
+
function deviceIdentifier() {
|
|
46
|
+
let name;
|
|
47
|
+
try { name = os.hostname(); } catch { name = null; }
|
|
48
|
+
if (!name || typeof name !== 'string' || !name.trim()) return 'unknown-device';
|
|
49
|
+
return name.trim().slice(0, 255);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Normalize a step name to the backend's 64-char limit so an over-long stage
|
|
53
|
+
// label can't cause a 400 that we'd then (silently) drop.
|
|
54
|
+
function normalizeStep(step) {
|
|
55
|
+
const s = (step == null ? '' : String(step)).trim() || 'unknown';
|
|
56
|
+
return s.slice(0, 64);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Coerce to an integer exit code; default to the thrown-error sentinel.
|
|
60
|
+
function normalizeExitCode(code) {
|
|
61
|
+
const n = Number(code);
|
|
62
|
+
return Number.isInteger(n) ? n : EXIT_THROWN;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Maps a caught error to the exit code we report to the ledger: a download
|
|
66
|
+
// failure uses the download sentinel, a script exit uses its real code, and
|
|
67
|
+
// anything else falls back to the thrown sentinel.
|
|
68
|
+
function exitCodeForError(err) {
|
|
69
|
+
if (err && err.downloadFailed) return EXIT_DOWNLOAD_FAILED;
|
|
70
|
+
if (err && Number.isInteger(err.exitCode)) return err.exitCode;
|
|
71
|
+
return EXIT_THROWN;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reports a single failed setup step. Resolves to true if the POST succeeded,
|
|
76
|
+
* false otherwise — but NEVER rejects, so callers can `await` it inline in an
|
|
77
|
+
* error path without risk of masking the original failure.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} args
|
|
80
|
+
* @param {string} args.step - the tool/stage that failed (<=64 chars).
|
|
81
|
+
* @param {number} args.exitCode - the process exit code (or a sentinel).
|
|
82
|
+
* @param {string} [args.apiKey] - gateway API key (defaults to stored key).
|
|
83
|
+
* @param {string} [args.backendUrl] - explicit backend URL override.
|
|
84
|
+
*/
|
|
85
|
+
async function reportSetupFailure({ step, exitCode, apiKey, backendUrl } = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const key = apiKey || config.getApiKey();
|
|
88
|
+
// No key → nothing to authenticate with; the ledger upserts by org, so a
|
|
89
|
+
// keyless report can't be attributed. Skip quietly.
|
|
90
|
+
if (!key) return false;
|
|
91
|
+
|
|
92
|
+
await api.post('/api/v1/setup/failed/', {
|
|
93
|
+
apiKey: key,
|
|
94
|
+
baseUrl: backendUrl,
|
|
95
|
+
timeoutMs: REPORT_TIMEOUT_MS,
|
|
96
|
+
body: {
|
|
97
|
+
serial_number: deviceIdentifier(),
|
|
98
|
+
step: normalizeStep(step),
|
|
99
|
+
exit_code: normalizeExitCode(exitCode),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
// Offline / 429 / 4xx / timeout — all swallowed. Telemetry must never
|
|
105
|
+
// surface over the real error or change the exit code.
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
reportSetupFailure,
|
|
112
|
+
deviceIdentifier,
|
|
113
|
+
exitCodeForError,
|
|
114
|
+
EXIT_THROWN,
|
|
115
|
+
EXIT_DOWNLOAD_FAILED,
|
|
116
|
+
// Exported for tests:
|
|
117
|
+
_normalizeStep: normalizeStep,
|
|
118
|
+
_normalizeExitCode: normalizeExitCode,
|
|
119
|
+
};
|
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
|
+
});
|