@miraj181/ipingyou 2.1.6 → 2.1.15

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.15",
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,18 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "chalk": "^5.3.0",
48
- "commander": "^14.0.3",
48
+ "commander": "^15.0.0",
49
+ "express": "^4.21.2",
50
+ "express-rate-limit": "^7.5.0",
49
51
  "execa": "^9.5.2",
52
+ "helmet": "^8.0.0",
50
53
  "inquirer": "^12.11.1",
51
54
  "nanoid": "^5.0.9",
52
55
  "open": "^11.0.0",
53
56
  "ora": "^9.4.0",
57
+ "shell-quote": "^1.8.4",
54
58
  "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"
59
+ "ws": "^8.20.1"
59
60
  },
60
61
  "devDependencies": {
61
62
  "nodemon": "^3.1.4"
@@ -64,4 +65,4 @@
64
65
  "node": ">=18.0.0"
65
66
  },
66
67
  "type": "module"
67
- }
68
+ }
package/src/cli.js CHANGED
@@ -23,6 +23,7 @@ import inquirer from 'inquirer';
23
23
  import chalk from 'chalk';
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
+ import readline from 'node:readline';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
28
29
  import { detectOS, checkDependencies } from './lib/platform.js';
@@ -348,22 +349,22 @@ program
348
349
  console.log(chalk.bold.cyan(' 👻 Background Service Manager'));
349
350
  console.log(chalk.dim(' ──────────────────────────────────────'));
350
351
 
351
- const { execaCommand } = await import('execa');
352
+ const { execa } = await import('execa');
352
353
 
353
354
  if (action === 'install') {
354
355
  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' });
356
+ await execa('npm', ['install', '-g', 'pm2'], { stdio: 'inherit' });
357
+ await execa('pm2', ['start', 'ipingyou', '--name', 'ipingyou-host', '--', 'host'], { stdio: 'inherit' });
358
+ await execa('pm2', ['save'], { stdio: 'inherit' });
359
+ await execa('pm2', ['startup'], { stdio: 'inherit' });
359
360
  console.log(chalk.green('\n ✅ Service installed and running in the background.'));
360
361
  } 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' });
362
+ await execa('pm2', ['stop', 'ipingyou-host'], { stdio: 'inherit' });
363
+ await execa('pm2', ['delete', 'ipingyou-host'], { stdio: 'inherit' });
364
+ await execa('pm2', ['save'], { stdio: 'inherit' });
364
365
  console.log(chalk.green('\n ✅ Service stopped and removed.'));
365
366
  } else if (action === 'status') {
366
- await execaCommand('pm2 status ipingyou-host', { stdio: 'inherit' });
367
+ await execa('pm2', ['status', 'ipingyou-host'], { stdio: 'inherit' });
367
368
  } else {
368
369
  console.log(chalk.red(` ❌ Unknown action: ${action}. Use install, stop, or status.`));
369
370
  }
@@ -474,26 +475,28 @@ program
474
475
  return;
475
476
  }
476
477
 
477
- const raw = fs.readFileSync(logFile, 'utf8').trim();
478
- if (!raw) {
479
- console.log(chalk.dim(' Log file is empty.'));
480
- return;
481
- }
482
-
483
- let events = raw.split('\n').map(line => {
484
- try { return JSON.parse(line); } catch { return null; }
485
- }).filter(Boolean);
486
-
487
- // Filter by type if specified
488
- if (commandOptions.type) {
489
- const filter = commandOptions.type.toLowerCase();
490
- events = events.filter(e => (e.type || '').toLowerCase().includes(filter));
478
+ const parsedCount = Number.parseInt(commandOptions.lines, 10);
479
+ const count = Number.isFinite(parsedCount) ? Math.max(1, Math.min(parsedCount, 10000)) : 25;
480
+ const filter = commandOptions.type?.toLowerCase();
481
+ const events = [];
482
+ let totalEvents = 0;
483
+ const lines = readline.createInterface({
484
+ input: fs.createReadStream(logFile, { encoding: 'utf8' }),
485
+ crlfDelay: Infinity,
486
+ });
487
+ for await (const line of lines) {
488
+ if (!line.trim()) continue;
489
+ totalEvents += 1;
490
+ try {
491
+ const event = JSON.parse(line);
492
+ if (filter && !(event.type || '').toLowerCase().includes(filter)) continue;
493
+ events.push(event);
494
+ if (events.length > count) events.shift();
495
+ } catch {
496
+ // Ignore incomplete or invalid log lines.
497
+ }
491
498
  }
492
499
 
493
- // Take last N events
494
- const count = parseInt(commandOptions.lines) || 25;
495
- events = events.slice(-count);
496
-
497
500
  if (events.length === 0) {
498
501
  console.log(chalk.dim(' No matching events found.'));
499
502
  return;
@@ -526,7 +529,7 @@ program
526
529
 
527
530
  console.log('');
528
531
  console.log(chalk.dim(` Log file: ${logFile}`));
529
- console.log(chalk.dim(` Total events in file: ${raw.split('\n').length}`));
532
+ console.log(chalk.dim(` Total events in file: ${totalEvents}`));
530
533
  } catch (err) {
531
534
  fatal('history', err);
532
535
  }
package/src/lib/broker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import os from 'node:os';
3
3
  import crypto from 'node:crypto';
4
- import { decrypt, encrypt } from './crypto.js';
4
+ import { decryptAsync, encryptAsync } from './crypto.js';
5
5
  import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
6
6
  import { logSessionEvent } from './session-log.js';
7
7
 
@@ -51,7 +51,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
51
51
  try {
52
52
  await new Promise(r => setTimeout(r, 600));
53
53
  const payload = JSON.stringify({ url: tunnelUrl, ...serviceConfig });
54
- const encrypted = encrypt(payload, password);
54
+ const encrypted = await encryptAsync(payload, password);
55
55
  const localHostToken = crypto.randomBytes(32).toString('hex');
56
56
 
57
57
  spinner.text = 'Registering with broker...';
@@ -92,7 +92,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
92
92
  }
93
93
 
94
94
  export async function requestHostApproval(brokerUrl, uid, password, details) {
95
- const encrypted = encrypt(JSON.stringify(details), password);
95
+ const encrypted = await encryptAsync(JSON.stringify(details), password);
96
96
  const res = await fetchWithLog('approval_request', `${brokerUrl}/approval-request/${uid}`, {
97
97
  method: 'POST',
98
98
  headers: { 'Content-Type': 'application/json' },
@@ -209,7 +209,7 @@ export async function resolveUID(brokerUrl, uid, password, silent = false, reque
209
209
 
210
210
  let decryptedPayload;
211
211
  try {
212
- decryptedPayload = decrypt(data.iv, data.ciphertext, password, data.salt);
212
+ decryptedPayload = await decryptAsync(data.iv, data.ciphertext, password, data.salt);
213
213
  } catch {
214
214
  if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
215
215
  if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
@@ -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
- const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
272
+ const { iv, ciphertext, salt } = await encryptAsync(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>
@@ -167,7 +168,13 @@ const HTML_CONTENT = `
167
168
  div.textContent = msg.text;
168
169
  } else {
169
170
  div.className = 'message ' + (msg.sender === username ? 'self' : 'other');
170
- div.innerHTML = '<div class="message-header">' + msg.sender + ' • ' + msg.time + '</div><div>' + msg.text + '</div>';
171
+ const header = document.createElement('div');
172
+ header.className = 'message-header';
173
+ header.textContent = String(msg.sender || 'Unknown') + ' • ' + String(msg.time || '');
174
+ const body = document.createElement('div');
175
+ body.textContent = String(msg.text || '');
176
+ div.appendChild(header);
177
+ div.appendChild(body);
171
178
  }
172
179
  msgs.appendChild(div);
173
180
  msgs.scrollTop = msgs.scrollHeight;
@@ -322,6 +329,6 @@ export async function openLocalChatUI(port, password) {
322
329
  const chatUrl = `http://localhost:${port}#${password}`;
323
330
  await open(chatUrl);
324
331
  } catch {
325
- console.log(chalk.dim(` Unable to auto-open browser. Visit http://localhost:${port}#${password}`));
332
+ console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
326
333
  }
327
334
  }
@@ -1,12 +1,32 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
+ import { canUseWorkers, runWorkerTask } from './worker-runtime.js';
3
4
 
4
- export async function calculateChecksum(filePath) {
5
+ const WORKER_CHECKSUM_THRESHOLD_BYTES = 2 * 1024 * 1024;
6
+
7
+ async function calculateChecksumStream(filePath) {
5
8
  return new Promise((resolve) => {
6
9
  const hash = crypto.createHash('sha256');
7
10
  const stream = fs.createReadStream(filePath);
8
11
  stream.on('error', () => resolve(null));
9
- stream.on('data', chunk => hash.update(chunk));
12
+ stream.on('data', (chunk) => hash.update(chunk));
10
13
  stream.on('end', () => resolve(hash.digest('hex')));
11
14
  });
12
15
  }
16
+
17
+ export async function calculateChecksum(filePath) {
18
+ const stat = await fs.promises.stat(filePath).catch(() => null);
19
+ if (!stat || !stat.isFile()) return null;
20
+
21
+ if (canUseWorkers() && stat.size >= WORKER_CHECKSUM_THRESHOLD_BYTES) {
22
+ try {
23
+ const result = await runWorkerTask('checksum', { filePath });
24
+ return result.digest || null;
25
+ } catch (err) {
26
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
27
+ return calculateChecksumStream(filePath);
28
+ }
29
+ }
30
+
31
+ return calculateChecksumStream(filePath);
32
+ }
@@ -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...'));
package/src/lib/crypto.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import crypto from 'node:crypto';
11
+ import { canUseWorkers, runWorkerTask } from './worker-runtime.js';
11
12
 
12
13
  /**
13
14
  * Derive a 256-bit encryption key from a password and salt using PBKDF2.
@@ -42,6 +43,21 @@ export function encrypt(plaintext, password) {
42
43
  };
43
44
  }
44
45
 
46
+ export async function encryptAsync(plaintext, password) {
47
+ if (!canUseWorkers()) return encrypt(plaintext, password);
48
+ try {
49
+ const result = await runWorkerTask('encrypt', { plaintext, password });
50
+ return {
51
+ iv: result.iv,
52
+ ciphertext: result.ciphertext,
53
+ salt: result.salt,
54
+ };
55
+ } catch (err) {
56
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
57
+ return encrypt(plaintext, password);
58
+ }
59
+ }
60
+
45
61
  /**
46
62
  * Decrypt a ciphertext with AES-256-CBC using a password and salt.
47
63
  * @param {string} ivHex — 32-char hex IV
@@ -60,3 +76,14 @@ export function decrypt(ivHex, cipherBase64, password, saltHex) {
60
76
  dec += decipher.final('utf8');
61
77
  return dec;
62
78
  }
79
+
80
+ export async function decryptAsync(ivHex, cipherBase64, password, saltHex) {
81
+ if (!canUseWorkers()) return decrypt(ivHex, cipherBase64, password, saltHex);
82
+ try {
83
+ const result = await runWorkerTask('decrypt', { ivHex, cipherBase64, password, saltHex });
84
+ return result.plaintext;
85
+ } catch (err) {
86
+ if (err?.code === 'WORKER_QUEUE_FULL') throw err;
87
+ return decrypt(ivHex, cipherBase64, password, saltHex);
88
+ }
89
+ }
@@ -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,86 @@
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
+ let lastMaskedFingerprint = null;
22
+ let lastMaskedResult = null;
23
+
24
+ /**
25
+ * Verify the current process is being run by the same OS user interactively.
26
+ * Returns true if the caller is the legitimate host user on an interactive terminal.
27
+ */
28
+ function isVerifiedHostUser() {
29
+ // Must be a real interactive TTY (not piped/redirected)
30
+ if (!process.stdout.isTTY) return false;
31
+
32
+ // On Unix, verify process UID matches the logged-in user
33
+ if (typeof process.getuid === 'function') {
34
+ try {
35
+ const processUid = process.getuid();
36
+ const userUid = os.userInfo().uid;
37
+ if (processUid !== userUid) return false;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Mask a sensitive value for log-safe output.
48
+ * Returns a PBKDF2-derived hash prefix so the value can be correlated without revealing it.
49
+ */
50
+ function maskSensitive(value) {
51
+ const normalized = String(value);
52
+ const fingerprint = crypto.createHash('sha256').update(normalized).digest('hex');
53
+ if (fingerprint === lastMaskedFingerprint) return lastMaskedResult;
54
+ const hash = crypto
55
+ .pbkdf2Sync(MASKING_KEY, normalized, 210000, 32, 'sha256')
56
+ .toString('hex');
57
+ lastMaskedFingerprint = fingerprint;
58
+ lastMaskedResult = `[pbkdf2:${hash.slice(0, 12)}…]`;
59
+ return lastMaskedResult;
60
+ }
61
+
62
+ /**
63
+ * Render a sensitive value for display.
64
+ * - Interactive TTY + verified host user → show plaintext
65
+ * - Otherwise → show masked hash
66
+ *
67
+ * @param {string} value — the sensitive value (e.g. password)
68
+ * @returns {string}
69
+ */
70
+ export function secureSensitive(value) {
71
+ if (isVerifiedHostUser()) return String(value);
72
+ return maskSensitive(value);
73
+ }
74
+
75
+ /**
76
+ * Build a log-safe URL by masking the password fragment.
77
+ * e.g. http://localhost:3000#mypass → http://localhost:3000#[sha256:a1b2...]
78
+ *
79
+ * @param {string} baseUrl — URL without the fragment
80
+ * @param {string} password — the secret to place after #
81
+ * @returns {string}
82
+ */
83
+ export function secureSensitiveUrl(baseUrl, password) {
84
+ if (isVerifiedHostUser()) return `${baseUrl}#${password}`;
85
+ return `${baseUrl}#${maskSensitive(password)}`;
86
+ }
@@ -6,13 +6,47 @@ import { redactSensitive } from './ai/safety.js';
6
6
  const LOG_DIR = path.join(os.homedir(), '.ipingyou', 'logs');
7
7
  const LOG_FILE = path.join(LOG_DIR, 'session-events.jsonl');
8
8
  const SESSION_LOG_DIR = path.join(os.tmpdir(), 'ipingyou-session-logs');
9
+ const MAX_HISTORY_LOG_BYTES = 5 * 1024 * 1024;
10
+ const MAX_SESSION_LOG_BYTES = 2 * 1024 * 1024;
11
+ const MAX_LOG_STRING_LENGTH = 16 * 1024;
12
+ const SESSION_LOG_FLUSH_BYTES = 64 * 1024;
13
+ const SESSION_LOG_FLUSH_MS = 250;
9
14
 
10
15
  let sessionLogPath = null;
11
16
  let sessionLogDisabled = false;
12
17
  let cleanupRegistered = false;
18
+ let sessionLogBytes = 0;
19
+ let historyLogBytes = null;
20
+ let sessionLogBuffer = '';
21
+ let sessionLogFlushTimer = null;
22
+
23
+ function flushSessionLog() {
24
+ if (sessionLogFlushTimer) clearTimeout(sessionLogFlushTimer);
25
+ sessionLogFlushTimer = null;
26
+ if (!sessionLogPath || !sessionLogBuffer) return;
27
+ const buffered = sessionLogBuffer;
28
+ sessionLogBuffer = '';
29
+ try {
30
+ fs.appendFileSync(sessionLogPath, buffered, { mode: 0o600 });
31
+ } catch (err) {
32
+ sessionLogDisabled = true;
33
+ console.error(`Session log write failed: ${err.message}`);
34
+ }
35
+ }
36
+
37
+ function scheduleSessionLogFlush() {
38
+ if (sessionLogFlushTimer) return;
39
+ sessionLogFlushTimer = setTimeout(flushSessionLog, SESSION_LOG_FLUSH_MS);
40
+ sessionLogFlushTimer.unref?.();
41
+ }
13
42
 
14
43
  function sanitize(value) {
15
- if (typeof value === 'string') return redactSensitive(value);
44
+ if (typeof value === 'string') {
45
+ const redacted = redactSensitive(value);
46
+ return redacted.length > MAX_LOG_STRING_LENGTH
47
+ ? `${redacted.slice(0, MAX_LOG_STRING_LENGTH)}…[truncated]`
48
+ : redacted;
49
+ }
16
50
  if (Array.isArray(value)) return value.map(sanitize);
17
51
  if (value && typeof value === 'object') {
18
52
  return Object.fromEntries(
@@ -28,11 +62,23 @@ export function initSessionLog(scope = 'session') {
28
62
  if (sessionLogPath || sessionLogDisabled) return sessionLogPath;
29
63
  try {
30
64
  fs.mkdirSync(SESSION_LOG_DIR, { recursive: true, mode: 0o700 });
65
+ const staleBefore = Date.now() - (24 * 60 * 60 * 1000);
66
+ for (const name of fs.readdirSync(SESSION_LOG_DIR)) {
67
+ if (!name.startsWith('ipingyou-')) continue;
68
+ const candidate = path.join(SESSION_LOG_DIR, name);
69
+ try {
70
+ if (fs.statSync(candidate).mtimeMs < staleBefore) fs.unlinkSync(candidate);
71
+ } catch {
72
+ // Best-effort cleanup; another process may own or remove the file.
73
+ }
74
+ }
31
75
  sessionLogPath = path.join(
32
76
  SESSION_LOG_DIR,
33
77
  `ipingyou-${scope}-${Date.now()}-${process.pid}.log`
34
78
  );
35
79
  fs.writeFileSync(sessionLogPath, '', { mode: 0o600 });
80
+ sessionLogBytes = 0;
81
+ sessionLogBuffer = '';
36
82
  if (!cleanupRegistered) {
37
83
  process.on('exit', () => cleanupSessionLog());
38
84
  cleanupRegistered = true;
@@ -59,7 +105,19 @@ export function logSessionEvent(type, details = {}, level = 'info') {
59
105
  details: sanitize(details),
60
106
  };
61
107
  try {
62
- fs.appendFileSync(sessionLogPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
108
+ const line = `${JSON.stringify(entry)}\n`;
109
+ const lineBytes = Buffer.byteLength(line);
110
+ if (sessionLogBytes + lineBytes > MAX_SESSION_LOG_BYTES) {
111
+ sessionLogDisabled = true;
112
+ return;
113
+ }
114
+ sessionLogBuffer += line;
115
+ sessionLogBytes += lineBytes;
116
+ if (Buffer.byteLength(sessionLogBuffer) >= SESSION_LOG_FLUSH_BYTES) {
117
+ flushSessionLog();
118
+ } else {
119
+ scheduleSessionLogFlush();
120
+ }
63
121
  } catch (err) {
64
122
  sessionLogDisabled = true;
65
123
  console.error(`Session log write failed: ${err.message}`);
@@ -68,6 +126,7 @@ export function logSessionEvent(type, details = {}, level = 'info') {
68
126
 
69
127
  export function cleanupSessionLog() {
70
128
  if (!sessionLogPath) return;
129
+ flushSessionLog();
71
130
  const target = sessionLogPath;
72
131
  sessionLogPath = null;
73
132
  try {
@@ -85,7 +144,23 @@ export function recordEvent(type, details = {}) {
85
144
  type,
86
145
  details: sanitize(details),
87
146
  };
88
- fs.appendFileSync(LOG_FILE, `${JSON.stringify(event)}\n`, { mode: 0o600 });
147
+ const line = `${JSON.stringify(event)}\n`;
148
+ const lineBytes = Buffer.byteLength(line);
149
+ if (historyLogBytes === null) {
150
+ historyLogBytes = fs.existsSync(LOG_FILE) ? fs.statSync(LOG_FILE).size : 0;
151
+ }
152
+ if (historyLogBytes + lineBytes > MAX_HISTORY_LOG_BYTES) {
153
+ const previousLog = `${LOG_FILE}.1`;
154
+ try {
155
+ if (fs.existsSync(previousLog)) fs.unlinkSync(previousLog);
156
+ if (fs.existsSync(LOG_FILE)) fs.renameSync(LOG_FILE, previousLog);
157
+ } catch {
158
+ fs.writeFileSync(LOG_FILE, '', { mode: 0o600 });
159
+ }
160
+ historyLogBytes = 0;
161
+ }
162
+ fs.appendFileSync(LOG_FILE, line, { mode: 0o600 });
163
+ historyLogBytes += lineBytes;
89
164
  logSessionEvent(type, details);
90
165
  } catch {
91
166
  // Session recording is best-effort.