@miraj181/ipingyou 2.1.6 → 2.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miraj181/ipingyou",
3
- "version": "2.1.6",
3
+ "version": "2.1.9",
4
4
  "description": "SecureLink-CLI — Secure peer-to-peer remote access via SSH & Cloudflare Tunnels",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -45,17 +45,15 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "chalk": "^5.3.0",
48
- "commander": "^14.0.3",
48
+ "commander": "^15.0.0",
49
49
  "execa": "^9.5.2",
50
50
  "inquirer": "^12.11.1",
51
51
  "nanoid": "^5.0.9",
52
52
  "open": "^11.0.0",
53
53
  "ora": "^9.4.0",
54
+ "shell-quote": "^1.8.4",
54
55
  "tree-kill": "^1.2.2",
55
- "ws": "^8.20.1",
56
- "express": "^5.2.1",
57
- "express-rate-limit": "^8.5.2",
58
- "helmet": "^8.2.0"
56
+ "ws": "^8.20.1"
59
57
  },
60
58
  "devDependencies": {
61
59
  "nodemon": "^3.1.4"
@@ -64,4 +62,4 @@
64
62
  "node": ">=18.0.0"
65
63
  },
66
64
  "type": "module"
67
- }
65
+ }
package/src/cli.js CHANGED
@@ -348,22 +348,22 @@ program
348
348
  console.log(chalk.bold.cyan(' 👻 Background Service Manager'));
349
349
  console.log(chalk.dim(' ──────────────────────────────────────'));
350
350
 
351
- const { execaCommand } = await import('execa');
351
+ const { execa } = await import('execa');
352
352
 
353
353
  if (action === 'install') {
354
354
  console.log(chalk.dim(' Installing PM2 globally and starting host...'));
355
- await execaCommand('npm install -g pm2', { stdio: 'inherit' });
356
- await execaCommand('pm2 start ipingyou --name "ipingyou-host" -- host', { stdio: 'inherit' });
357
- await execaCommand('pm2 save', { stdio: 'inherit' });
358
- await execaCommand('pm2 startup', { stdio: 'inherit' });
355
+ await execa('npm', ['install', '-g', 'pm2'], { stdio: 'inherit' });
356
+ await execa('pm2', ['start', 'ipingyou', '--name', 'ipingyou-host', '--', 'host'], { stdio: 'inherit' });
357
+ await execa('pm2', ['save'], { stdio: 'inherit' });
358
+ await execa('pm2', ['startup'], { stdio: 'inherit' });
359
359
  console.log(chalk.green('\n ✅ Service installed and running in the background.'));
360
360
  } else if (action === 'stop') {
361
- await execaCommand('pm2 stop ipingyou-host', { stdio: 'inherit' });
362
- await execaCommand('pm2 delete ipingyou-host', { stdio: 'inherit' });
363
- await execaCommand('pm2 save', { stdio: 'inherit' });
361
+ await execa('pm2', ['stop', 'ipingyou-host'], { stdio: 'inherit' });
362
+ await execa('pm2', ['delete', 'ipingyou-host'], { stdio: 'inherit' });
363
+ await execa('pm2', ['save'], { stdio: 'inherit' });
364
364
  console.log(chalk.green('\n ✅ Service stopped and removed.'));
365
365
  } else if (action === 'status') {
366
- await execaCommand('pm2 status ipingyou-host', { stdio: 'inherit' });
366
+ await execa('pm2', ['status', 'ipingyou-host'], { stdio: 'inherit' });
367
367
  } else {
368
368
  console.log(chalk.red(` ❌ Unknown action: ${action}. Use install, stop, or status.`));
369
369
  }
package/src/lib/broker.js CHANGED
@@ -244,7 +244,15 @@ export async function resolveUID(brokerUrl, uid, password, silent = false, reque
244
244
  }
245
245
 
246
246
  export async function pushTelemetry(brokerUrl, uid, password, username, action = 'connected') {
247
+ // Telemetry can be disabled via env var for privacy or testing
248
+ if (process.env.IPINGYOU_DISABLE_TELEMETRY === '1' || process.env.NODE_ENV === 'test') return;
249
+
247
250
  try {
251
+ // Validate brokerUrl: only send telemetry to HTTPS brokers
252
+ let parsed;
253
+ try { parsed = new URL(brokerUrl); } catch { parsed = null; }
254
+ if (!parsed || parsed.protocol !== 'https:') return;
255
+
248
256
  let publicIp = 'Unknown';
249
257
  try {
250
258
  publicIp = await fetch('https://api.ipify.org').then(r => r.text());
@@ -254,15 +262,16 @@ export async function pushTelemetry(brokerUrl, uid, password, username, action =
254
262
  username,
255
263
  ip: publicIp,
256
264
  os: `${os.type()} ${os.release()} (${os.arch()})`,
257
- cpu: os.cpus()[0]?.model || 'Unknown CPU',
258
- ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
265
+ // Limit fingerprint detail to reduce privacy surface
266
+ cpu: os.cpus()[0]?.model ? os.cpus()[0].model.replace(/\s{2,}/g, ' ').trim() : 'Unknown',
267
+ ramGB: Math.round(os.totalmem() / 1024 / 1024 / 1024),
259
268
  action,
260
- time: new Date().toLocaleTimeString()
269
+ time: new Date().toISOString()
261
270
  };
262
271
 
263
272
  const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
264
273
 
265
- await fetchWithLog('telemetry', `${brokerUrl}/client-info/${uid}`, {
274
+ await fetchWithLog('telemetry', `${parsed.origin}/client-info/${encodeURIComponent(uid)}`, {
266
275
  method: 'POST',
267
276
  headers: { 'Content-Type': 'application/json' },
268
277
  body: JSON.stringify({ iv, ciphertext, salt }),
package/src/lib/chat.js CHANGED
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import { WebSocketServer } from 'ws';
3
3
  import open from 'open';
4
4
  import chalk from 'chalk';
5
+ import { secureSensitiveUrl } from './secure-print.js';
5
6
 
6
7
  const HTML_CONTENT = `
7
8
  <!DOCTYPE html>
@@ -322,6 +323,6 @@ export async function openLocalChatUI(port, password) {
322
323
  const chatUrl = `http://localhost:${port}#${password}`;
323
324
  await open(chatUrl);
324
325
  } catch {
325
- console.log(chalk.dim(` Unable to auto-open browser. Visit http://localhost:${port}#${password}`));
326
+ console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
326
327
  }
327
328
  }
@@ -13,7 +13,7 @@ import chalk from 'chalk';
13
13
  import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
- import { execaCommand } from 'execa';
16
+ import { execa } from 'execa';
17
17
  import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from './tmux.js';
18
18
 
19
19
  /** @type {Set<number>} — Active child PIDs we manage */
@@ -188,21 +188,31 @@ export async function executePanicMode() {
188
188
  console.log(chalk.dim(' [1/4] Terminating all tunnel and host processes...'));
189
189
  try {
190
190
  if (process.platform === 'win32') {
191
- await execaCommand('taskkill /F /IM cloudflared.exe', { reject: false });
191
+ // taskkill expects a command string on Windows; pass args safely
192
+ await execa('taskkill', ['/F', '/IM', 'cloudflared.exe'], { reject: false });
192
193
  } else {
193
- await execaCommand('pkill -9 -f cloudflared', { reject: false });
194
- await execaCommand('pkill -9 -f "sshd:.*@"', { reject: false });
195
- await execaCommand(`tmux ${tmuxSocketArgs().join(' ')} kill-server`, { reject: false });
196
- const { stdout } = await execaCommand('tmux list-sessions -F "#{session_name}"', { reject: false });
194
+ // Use argument arrays to avoid shell interpolation
195
+ await execa('pkill', ['-9', '-f', 'cloudflared'], { reject: false });
196
+ await execa('pkill', ['-9', '-f', 'sshd:.*@'], { reject: false });
197
+
198
+ const socketArgs = Array.isArray(tmuxSocketArgs) ? tmuxSocketArgs() : [];
199
+ // Ensure socketArgs are strings and safe-ish before passing to execa
200
+ const safeSocketArgs = (socketArgs || []).map(a => String(a));
201
+ await execa('tmux', [...safeSocketArgs, 'kill-server'], { reject: false });
202
+
203
+ const { stdout } = await execa('tmux', ['list-sessions', '-F', '#{session_name}'], { reject: false });
197
204
  const legacyNames = stdout
198
205
  .split(/\r?\n/)
199
206
  .filter(Boolean)
200
207
  .filter(name => name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX));
201
208
  for (const name of legacyNames) {
202
- await execaCommand(`tmux kill-session -t ${name}`, { reject: false });
209
+ await execa('tmux', ['kill-session', '-t', name], { reject: false });
203
210
  }
204
211
  }
205
- } catch {}
212
+ } catch (err) {
213
+ // Best-effort cleanup; log debug info without exposing stack in normal flow
214
+ // (keep behavior unchanged otherwise)
215
+ }
206
216
 
207
217
  // 2. Delete configuration and aliases
208
218
  console.log(chalk.dim(' [2/4] Wiping configuration files...'));
@@ -8,10 +8,11 @@
8
8
  * ============================================================
9
9
  */
10
10
 
11
- import { execaCommand } from 'execa';
11
+ import { execa } from 'execa';
12
12
  import chalk from 'chalk';
13
13
  import ora from 'ora';
14
14
  import os from 'node:os';
15
+ import fs from 'node:fs';
15
16
  import { createWriteStream } from 'node:fs';
16
17
  import { pipeline } from 'node:stream/promises';
17
18
  import { chmod, mkdir, stat, rename } from 'node:fs/promises';
@@ -42,8 +43,9 @@ export function detectOS() {
42
43
  */
43
44
  export async function detectLinuxDistro() {
44
45
  try {
45
- const { stdout } = await execaCommand('cat /etc/os-release', { reject: false });
46
- const lower = stdout.toLowerCase();
46
+ // Read the os-release file directly instead of spawning a process
47
+ const data = await fs.promises.readFile('/etc/os-release', 'utf8');
48
+ const lower = data.toLowerCase();
47
49
  if (lower.includes('ubuntu') || lower.includes('debian') || lower.includes('kali') || lower.includes('mint')) {
48
50
  return 'debian';
49
51
  }
@@ -66,8 +68,12 @@ export async function detectLinuxDistro() {
66
68
  */
67
69
  export async function commandExists(cmd) {
68
70
  try {
69
- const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
70
- await execaCommand(checkCmd, { reject: true });
71
+ // Prefer direct exec of the check command to avoid shell parsing
72
+ if (process.platform === 'win32') {
73
+ await execa('where', [cmd], { reject: true });
74
+ } else {
75
+ await execa('which', [cmd], { reject: true });
76
+ }
71
77
  return true;
72
78
  } catch {
73
79
  return false;
@@ -93,7 +99,20 @@ export async function hasSudo() {
93
99
  async function runWithSpinner(label, cmd, opts = {}) {
94
100
  const spinner = ora(label).start();
95
101
  try {
96
- await execaCommand(cmd, { stdio: 'pipe', reject: true, ...opts });
102
+ // Support both string shell commands and argument arrays.
103
+ if (Array.isArray(cmd)) {
104
+ const [c, ...args] = cmd;
105
+ await execa(c, args, { stdio: 'pipe', reject: true, ...opts });
106
+ } else if (typeof cmd === 'string') {
107
+ if (process.platform === 'win32') {
108
+ await execa('cmd', ['/c', cmd], { stdio: 'pipe', reject: true, ...opts });
109
+ } else {
110
+ await execa('sh', ['-c', cmd], { stdio: 'pipe', reject: true, ...opts });
111
+ }
112
+ } else {
113
+ throw new Error('Unsupported command type');
114
+ }
115
+
97
116
  spinner.succeed(label.replace('...', '') + chalk.green(' ✓'));
98
117
  return true;
99
118
  } catch (err) {
@@ -198,14 +217,18 @@ async function ensureDownloader() {
198
217
  */
199
218
  async function downloadFile(url, destPath, downloader) {
200
219
  const cmds = {
201
- curl: `curl -fsSL -o "${destPath}" "${url}"`,
202
- wget: `wget -q -O "${destPath}" "${url}"`,
203
- powershell: `powershell -Command "Invoke-WebRequest -Uri '${url}' -OutFile '${destPath}'"`,
220
+ curl: ['curl', ['-fsSL', '-o', destPath, url]],
221
+ wget: ['wget', ['-q', '-O', destPath, url]],
222
+ powershell: ['powershell', ["-Command", `Invoke-WebRequest -Uri '${url}' -OutFile '${destPath}'`]],
204
223
  };
205
224
 
225
+ const entry = cmds[downloader];
226
+ if (!entry) return false;
227
+ const [bin, args] = entry;
228
+
206
229
  return runWithSpinner(
207
230
  ` Downloading ${chalk.cyan(url.split('/').pop())}...`,
208
- cmds[downloader]
231
+ [bin, ...args]
209
232
  );
210
233
  }
211
234
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ============================================================
3
+ * Secure Print — Log-safe sensitive value display
4
+ * ============================================================
5
+ * Passwords and secrets are shown in plaintext ONLY when:
6
+ * 1. stdout is an interactive TTY (not piped/redirected)
7
+ * 2. The OS user running the process matches the session owner
8
+ *
9
+ * In all other contexts (CI, PM2, piped output, log files),
10
+ * values are replaced with a SHA-256 hash prefix so they can
11
+ * be correlated without exposing the secret.
12
+ * ============================================================
13
+ */
14
+
15
+ import crypto from 'node:crypto';
16
+ import os from 'node:os';
17
+
18
+ // Keyed secret for deterministic, non-reversible log masking tokens.
19
+ // Can be overridden for stable cross-process correlation if needed.
20
+ const MASKING_KEY = process.env.SECURE_PRINT_MASK_KEY || crypto.randomBytes(32).toString('hex');
21
+
22
+ /**
23
+ * Verify the current process is being run by the same OS user interactively.
24
+ * Returns true if the caller is the legitimate host user on an interactive terminal.
25
+ */
26
+ function isVerifiedHostUser() {
27
+ // Must be a real interactive TTY (not piped/redirected)
28
+ if (!process.stdout.isTTY) return false;
29
+
30
+ // On Unix, verify process UID matches the logged-in user
31
+ if (typeof process.getuid === 'function') {
32
+ try {
33
+ const processUid = process.getuid();
34
+ const userUid = os.userInfo().uid;
35
+ if (processUid !== userUid) return false;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ return true;
42
+ }
43
+
44
+ /**
45
+ * Mask a sensitive value for log-safe output.
46
+ * Returns a PBKDF2-derived hash prefix so the value can be correlated without revealing it.
47
+ */
48
+ function maskSensitive(value) {
49
+ const normalized = String(value);
50
+ const hash = crypto
51
+ .pbkdf2Sync(MASKING_KEY, normalized, 210000, 32, 'sha256')
52
+ .toString('hex');
53
+ return `[pbkdf2:${hash.slice(0, 12)}…]`;
54
+ }
55
+
56
+ /**
57
+ * Render a sensitive value for display.
58
+ * - Interactive TTY + verified host user → show plaintext
59
+ * - Otherwise → show masked hash
60
+ *
61
+ * @param {string} value — the sensitive value (e.g. password)
62
+ * @returns {string}
63
+ */
64
+ export function secureSensitive(value) {
65
+ if (isVerifiedHostUser()) return String(value);
66
+ return maskSensitive(value);
67
+ }
68
+
69
+ /**
70
+ * Build a log-safe URL by masking the password fragment.
71
+ * e.g. http://localhost:3000#mypass → http://localhost:3000#[sha256:a1b2...]
72
+ *
73
+ * @param {string} baseUrl — URL without the fragment
74
+ * @param {string} password — the secret to place after #
75
+ * @returns {string}
76
+ */
77
+ export function secureSensitiveUrl(baseUrl, password) {
78
+ if (isVerifiedHostUser()) return `${baseUrl}#${password}`;
79
+ return `${baseUrl}#${maskSensitive(password)}`;
80
+ }
package/src/lib/ssh.js CHANGED
@@ -26,7 +26,7 @@ export function formatScpRemotePath(remotePath) {
26
26
 
27
27
  export function getSshControlOptions(hostname) {
28
28
  if (process.platform === 'win32') return [];
29
- const hash = crypto.createHash('sha1').update(hostname).digest('hex').slice(0, 10);
29
+ const hash = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 10);
30
30
  return [
31
31
  '-o', 'ControlMaster=auto',
32
32
  '-o', 'ControlPersist=5m',
package/src/modes/ai.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * AI Mode — privacy-first local/remote task assistant powered by Groq.
3
3
  */
4
4
 
5
- import { execa, execaCommand } from 'execa';
5
+ import { execa } from 'execa';
6
+ import { parse as shellParse } from 'shell-quote';
6
7
  import chalk from 'chalk';
7
8
  import inquirer from 'inquirer';
8
9
  import fs from 'node:fs';
@@ -210,8 +211,13 @@ function showRateLimitWarnings(rateLimit) {
210
211
  }
211
212
 
212
213
  async function runLocalCommand(command) {
213
- const result = await execaCommand(command, {
214
- shell: true,
214
+ const parsed = shellParse(command);
215
+ // Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
216
+ const args = parsed.filter(token => typeof token === 'string');
217
+ if (args.length === 0) {
218
+ return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
219
+ }
220
+ const result = await execa(args[0], args.slice(1), {
215
221
  reject: false,
216
222
  timeout: 30000,
217
223
  maxBuffer: 1024 * 1024,
@@ -25,8 +25,9 @@ import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForAppro
25
25
  import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
27
  import { buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
- import { buildTmuxSessionName, tmuxSocketCommand } from '../lib/tmux.js';
28
+ import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
29
29
  import open from 'open';
30
+ import { secureSensitiveUrl } from '../lib/secure-print.js';
30
31
  import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
31
32
 
32
33
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -89,9 +90,13 @@ async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts
89
90
 
90
91
  sshArgs.push(`${username}@${hostname}`);
91
92
  const tmuxSession = buildTmuxSessionName(username);
92
- const tmuxPrepare = `${tmuxSocketCommand()} has-session -t ${tmuxSession} 2>/dev/null || ${tmuxSocketCommand()} new-session -d -s ${tmuxSession}`;
93
- const tmuxAttach = `${tmuxSocketCommand()} attach -t ${tmuxSession}`;
94
- sshArgs.push('-t', `${tmuxPrepare} && ${tmuxAttach} || exec $SHELL -l`);
93
+ const quotedSocket = quoteRemoteShell(TMUX_SOCKET_PATH);
94
+ const quotedSession = quoteRemoteShell(tmuxSession);
95
+ const tmuxSocketCmdStr = `tmux -S ${quotedSocket}`;
96
+ const tmuxPrepare = `${tmuxSocketCmdStr} has-session -t ${quotedSession} 2>/dev/null || ${tmuxSocketCmdStr} new-session -d -s ${quotedSession}`;
97
+ const tmuxAttach = `${tmuxSocketCmdStr} attach -t ${quotedSession}`;
98
+ const tmuxCommand = `if command -v tmux >/dev/null 2>&1; then (${tmuxPrepare} && ${tmuxAttach}) || exec $SHELL -l; else exec $SHELL -l; fi`;
99
+ sshArgs.push('-t', tmuxCommand);
95
100
 
96
101
  const child = execa('ssh', sshArgs, {
97
102
  stdio: 'inherit',
@@ -701,7 +706,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
701
706
  const fullUrl = `${chatUrl}#${password}`;
702
707
  await open(fullUrl);
703
708
  } catch {
704
- console.log(chalk.cyan(` 👉 Please open: ${chatUrl}#${password}`));
709
+ console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
705
710
  }
706
711
  } else {
707
712
  spinner.warn('The host has not started a chat room yet.');
@@ -2,7 +2,7 @@
2
2
  * Doctor Mode — non-invasive diagnostics for iPingYou.
3
3
  */
4
4
 
5
- import { execa, execaCommand } from 'execa';
5
+ import { execa } from 'execa';
6
6
  import chalk from 'chalk';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
@@ -66,7 +66,7 @@ async function commandFound(command) {
66
66
  async function checkSshService() {
67
67
  const osInfo = detectOS();
68
68
  if (osInfo.isMac) {
69
- const launchctl = await execaCommand('launchctl print-disabled system', { reject: false, timeout: 5000 });
69
+ const launchctl = await execa('launchctl', ['print-disabled', 'system'], { reject: false, timeout: 5000 });
70
70
  const launchctlOutput = `${launchctl.stdout || ''}\n${launchctl.stderr || ''}`.toLowerCase();
71
71
  const launchctlMatch = launchctlOutput.match(/"com\.openssh\.sshd"\s*=>\s*(enabled|disabled)/);
72
72
  if (launchctlMatch) {
@@ -78,7 +78,7 @@ async function checkSshService() {
78
78
  };
79
79
  }
80
80
 
81
- const result = await execaCommand('systemsetup -getremotelogin', { reject: false, timeout: 5000 });
81
+ const result = await execa('systemsetup', ['-getremotelogin'], { reject: false, timeout: 5000 });
82
82
  const output = result.stdout || result.stderr || '';
83
83
  if (/on/i.test(output)) return { status: 'pass', detail: 'Remote Login is on' };
84
84
  if (/off/i.test(output)) {
@@ -96,8 +96,8 @@ async function checkSshService() {
96
96
  }
97
97
 
98
98
  if (osInfo.isLinux) {
99
- const ssh = await execaCommand('systemctl is-active ssh', { reject: false, timeout: 5000 });
100
- const sshd = ssh.exitCode === 0 ? ssh : await execaCommand('systemctl is-active sshd', { reject: false, timeout: 5000 });
99
+ const ssh = await execa('systemctl', ['is-active', 'ssh'], { reject: false, timeout: 5000 });
100
+ const sshd = ssh.exitCode === 0 ? ssh : await execa('systemctl', ['is-active', 'sshd'], { reject: false, timeout: 5000 });
101
101
  if (sshd.exitCode === 0) return { status: 'pass', detail: 'SSH service is active' };
102
102
  return {
103
103
  status: 'warn',
@@ -107,7 +107,7 @@ async function checkSshService() {
107
107
  }
108
108
 
109
109
  if (osInfo.isWindows) {
110
- const result = await execaCommand('sc query sshd', { reject: false, timeout: 5000 });
110
+ const result = await execa('sc', ['query', 'sshd'], { reject: false, timeout: 5000 });
111
111
  if (/RUNNING/i.test(result.stdout)) return { status: 'pass', detail: 'OpenSSH Server is running' };
112
112
  return {
113
113
  status: 'warn',
@@ -196,7 +196,11 @@ function checkAiSafety() {
196
196
  }
197
197
 
198
198
  async function runProjectSelfTest(label, command) {
199
- const result = await execaCommand(command, {
199
+ // Prefer splitting simple commands into args to avoid shell interpolation
200
+ const parts = String(command).split(' ').filter(Boolean);
201
+ const cmd = parts.shift();
202
+ const args = parts;
203
+ const result = await execa(cmd, args, {
200
204
  reject: false,
201
205
  timeout: 30000,
202
206
  maxBuffer: 1024 * 1024,
package/src/modes/host.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * ============================================================
14
14
  */
15
15
 
16
- import { execa, execaCommand } from 'execa';
16
+ import { execa } from 'execa';
17
17
  import chalk from 'chalk';
18
18
  import inquirer from 'inquirer';
19
19
  import path from 'node:path';
@@ -26,6 +26,7 @@ import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, add
26
26
  import { detectOS } from '../lib/platform.js';
27
27
  import { createSpinner, networkSpinner, typeText } from '../lib/animations.js';
28
28
  import { startChatServer, openLocalChatUI } from '../lib/chat.js';
29
+ import { secureSensitive } from '../lib/secure-print.js';
29
30
  import { spawnTunnelSupervised } from '../lib/tunnel.js';
30
31
  import { decideApprovalRequest, fetchApprovalRequests, pingBroker, registerWithBroker, revokeUID } from '../lib/broker.js';
31
32
  import { cleanupSessionLog, getSessionLogPath, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
@@ -55,24 +56,24 @@ async function ensureSSHRunning() {
55
56
  try {
56
57
  if (osInfo.isLinux) {
57
58
  try {
58
- await execaCommand('systemctl is-active ssh', { reject: true });
59
+ await execa('systemctl', ['is-active', 'ssh'], { reject: true });
59
60
  spinner.succeed('SSH service is active');
60
61
  } catch {
61
62
  spinner.text = 'Starting SSH service...';
62
63
  try {
63
- await execaCommand('sudo systemctl start ssh', { stdio: 'inherit' });
64
+ await execa('sudo', ['systemctl', 'start', 'ssh'], { stdio: 'inherit' });
64
65
  spinner.succeed('SSH service started');
65
66
  } catch {
66
- await execaCommand('sudo systemctl start sshd', { stdio: 'inherit' });
67
+ await execa('sudo', ['systemctl', 'start', 'sshd'], { stdio: 'inherit' });
67
68
  spinner.succeed('SSH service started (sshd)');
68
69
  }
69
70
  }
70
71
  } else if (osInfo.isMac) {
71
72
  try {
72
- const { stdout } = await execaCommand('sudo systemsetup -getremotelogin', { reject: false });
73
+ const { stdout } = await execa('sudo', ['systemsetup', '-getremotelogin'], { reject: false });
73
74
  if (stdout.toLowerCase().includes('off')) {
74
75
  spinner.text = 'Enabling Remote Login...';
75
- await execaCommand('sudo systemsetup -setremotelogin on', { stdio: 'inherit' });
76
+ await execa('sudo', ['systemsetup', '-setremotelogin', 'on'], { stdio: 'inherit' });
76
77
  spinner.succeed('Remote Login enabled');
77
78
  } else {
78
79
  spinner.succeed('SSH (Remote Login) is active');
@@ -82,10 +83,10 @@ async function ensureSSHRunning() {
82
83
  }
83
84
  } else if (osInfo.isWindows) {
84
85
  try {
85
- const { stdout } = await execaCommand('sc query sshd', { reject: false });
86
+ const { stdout } = await execa('sc', ['query', 'sshd'], { reject: false });
86
87
  if (stdout.includes('STOPPED')) {
87
88
  spinner.text = 'Starting OpenSSH Server...';
88
- await execaCommand('net start sshd', { stdio: 'inherit' });
89
+ await execa('net', ['start', 'sshd'], { stdio: 'inherit' });
89
90
  spinner.succeed('OpenSSH Server started');
90
91
  } else if (stdout.includes('RUNNING')) {
91
92
  spinner.succeed('OpenSSH Server is running');
@@ -112,64 +113,34 @@ async function ensureTmuxInstalled() {
112
113
  const spinner = createSpinner('Checking tmux installation...', networkSpinner).start();
113
114
  try {
114
115
  try {
115
- await execaCommand('tmux -V', { reject: true });
116
+ await execa('tmux', ['-V'], { reject: true });
116
117
  spinner.succeed('tmux is installed (Terminal Mirroring available)');
117
118
  } catch {
118
119
  spinner.text = 'tmux not found. Attempting to install...';
119
120
  if (osInfo.isLinux) {
120
121
  if (fs.existsSync('/usr/bin/apt') || fs.existsSync('/usr/bin/apt-get')) {
121
- await execaCommand('sudo apt-get update && sudo apt-get install -y tmux', { shell: true, stdio: 'inherit' });
122
+ await execa('sudo', ['apt-get', 'update', '-qq'], { stdio: 'inherit' });
123
+ await execa('sudo', ['apt-get', 'install', '-y', 'tmux'], { stdio: 'inherit' });
122
124
  } else if (fs.existsSync('/usr/bin/dnf')) {
123
- await execaCommand('sudo dnf install -y tmux', { shell: true, stdio: 'inherit' });
125
+ await execa('sudo', ['dnf', 'install', '-y', 'tmux'], { stdio: 'inherit' });
124
126
  } else if (fs.existsSync('/usr/bin/yum')) {
125
- await execaCommand('sudo yum install -y tmux', { shell: true, stdio: 'inherit' });
127
+ await execa('sudo', ['yum', 'install', '-y', 'tmux'], { stdio: 'inherit' });
126
128
  } else if (fs.existsSync('/usr/bin/pacman')) {
127
- await execaCommand('sudo pacman -S --noconfirm tmux', { shell: true, stdio: 'inherit' });
129
+ await execa('sudo', ['pacman', '-S', '--noconfirm', 'tmux'], { stdio: 'inherit' });
128
130
  } else if (fs.existsSync('/sbin/apk')) {
129
- await execaCommand('sudo apk add tmux', { shell: true, stdio: 'inherit' });
131
+ await execa('sudo', ['apk', 'add', 'tmux'], { stdio: 'inherit' });
130
132
  } else {
131
133
  throw new Error('Unsupported Linux package manager');
132
134
  }
133
135
  spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
134
136
  } else if (osInfo.isMac) {
135
137
  try {
136
- await execaCommand('brew install tmux', { shell: true, stdio: 'inherit' });
138
+ await execa('brew', ['install', 'tmux'], { stdio: 'inherit' });
137
139
  spinner.succeed('tmux installed successfully (Terminal Mirroring available)');
138
140
  } catch {
139
141
  throw new Error('Homebrew is required to install tmux on macOS');
140
142
  }
141
143
  }
142
-
143
- function isSecureLinkSession(name) {
144
- return name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX);
145
- }
146
-
147
- async function listTmuxSessions(socketArgs = []) {
148
- const result = await execa('tmux', [...socketArgs, 'list-sessions', '-F', '#{session_name}|#{session_created}'], { reject: false });
149
- if (result.exitCode !== 0) return [];
150
- return result.stdout
151
- .split(/\r?\n/)
152
- .filter(Boolean)
153
- .map(line => {
154
- const [name, createdAt] = line.split('|');
155
- return { name, createdAt: Number(createdAt) || null };
156
- });
157
- }
158
-
159
- async function getMirrorableSessions() {
160
- const sessions = [];
161
- const customSessions = await listTmuxSessions(tmuxSocketArgs());
162
- customSessions
163
- .filter(s => isSecureLinkSession(s.name))
164
- .forEach(s => sessions.push({ ...s, socketArgs: tmuxSocketArgs(), source: 'custom' }));
165
-
166
- const legacySessions = await listTmuxSessions();
167
- legacySessions
168
- .filter(s => isSecureLinkSession(s.name))
169
- .forEach(s => sessions.push({ ...s, socketArgs: [], source: 'legacy' }));
170
-
171
- return sessions;
172
- }
173
144
  }
174
145
  } catch (err) {
175
146
  spinner.fail(`tmux check/install failed: ${err.message}`);
@@ -177,6 +148,37 @@ async function ensureTmuxInstalled() {
177
148
  }
178
149
  }
179
150
 
151
+ function isSecureLinkSession(name) {
152
+ return name === TMUX_SESSION_NAME || name.startsWith(TMUX_SESSION_PREFIX);
153
+ }
154
+
155
+ async function listTmuxSessions(socketArgs = []) {
156
+ const result = await execa('tmux', [...socketArgs, 'list-sessions', '-F', '#{session_name}|#{session_created}'], { reject: false });
157
+ if (result.exitCode !== 0) return [];
158
+ return result.stdout
159
+ .split(/\r?\n/)
160
+ .filter(Boolean)
161
+ .map(line => {
162
+ const [name, createdAt] = line.split('|');
163
+ return { name, createdAt: Number(createdAt) || null };
164
+ });
165
+ }
166
+
167
+ async function getMirrorableSessions() {
168
+ const sessions = [];
169
+ const customSessions = await listTmuxSessions(tmuxSocketArgs());
170
+ customSessions
171
+ .filter(s => isSecureLinkSession(s.name))
172
+ .forEach(s => sessions.push({ ...s, socketArgs: tmuxSocketArgs(), source: 'custom' }));
173
+
174
+ const legacySessions = await listTmuxSessions();
175
+ legacySessions
176
+ .filter(s => isSecureLinkSession(s.name))
177
+ .forEach(s => sessions.push({ ...s, socketArgs: [], source: 'legacy' }));
178
+
179
+ return sessions;
180
+ }
181
+
180
182
  // ─── Ephemeral SSH Key Management ────────────────────────────
181
183
  async function generateEphemeralKey() {
182
184
  const tmpDir = os.tmpdir() || process.env.TMPDIR || process.env.TEMP || process.env.TMP;
@@ -674,7 +676,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
674
676
  console.log(chalk.bold(' ║ 🛡️ SecureLink — HOST MODE ACTIVE ║'));
675
677
  console.log(chalk.bold(' ╠════════════════════════════════════════════════════╣'));
676
678
  console.log(` ║ ${chalk.cyan('UID:')} ${chalk.bold.white(uid.padEnd(30))}║`);
677
- console.log(` ║ ${chalk.cyan('Password:')} ${chalk.bold.white(password.padEnd(30))}║`);
679
+ console.log(` ║ ${chalk.cyan('Password:')} ${chalk.bold.white(secureSensitive(password).padEnd(30))}║`);
678
680
  console.log(` ║ ${chalk.cyan('Service:')} ${chalk.dim(serviceConfig.type.toUpperCase() + ' (Port ' + serviceConfig.port + ')').padEnd(30)}║`);
679
681
  console.log(` ║ ${chalk.cyan('Tunnel:')} ${chalk.dim(sessionState.tunnelUrl.substring(0, 40))} ║`);
680
682
  if (serviceConfig.chatUrl) {
@@ -841,7 +843,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
841
843
  console.log('');
842
844
 
843
845
  try {
844
- await execaCommand('tmux -V', { reject: true });
846
+ await execa('tmux', ['-V'], { reject: true });
845
847
  const sessions = await getMirrorableSessions();
846
848
  if (sessions.length === 0) {
847
849
  console.log(chalk.yellow(' ⚠️ No mirrored terminal session is active yet.'));
@@ -928,9 +930,9 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
928
930
  const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
929
931
  try {
930
932
  if (process.platform === 'win32') {
931
- await execaCommand('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name = \'sshd.exe\'\\" | Where-Object { $_.CommandLine -match \'sshd:.*@\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"', { reject: false });
933
+ await execa('powershell', ['-NoProfile', '-Command', "Get-CimInstance Win32_Process -Filter \"name = 'sshd.exe'\" | Where-Object { $_.CommandLine -match 'sshd:.*@' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], { reject: false });
932
934
  } else {
933
- await execaCommand("pkill -f 'sshd:.*@'", { shell: true, reject: false });
935
+ await execa('pkill', ['-f', 'sshd:.*@'], { reject: false });
934
936
  await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
935
937
  const legacySessions = await listTmuxSessions();
936
938
  for (const session of legacySessions) {
@@ -994,7 +996,7 @@ export async function startHostMode() {
994
996
  },
995
997
  ]);
996
998
  const password = pwdInput.trim() || generateUID();
997
- console.log(` ${chalk.green('✓')} Password: ${chalk.bold.white(password)}`);
999
+ console.log(` ${chalk.green('✓')} Password: ${chalk.bold.white(secureSensitive(password))}`);
998
1000
  console.log('');
999
1001
 
1000
1002
  // ─── Broker Selection ───