@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 +8 -7
- package/src/cli.js +31 -28
- package/src/lib/broker.js +18 -9
- package/src/lib/chat.js +9 -2
- package/src/lib/checksum.js +22 -2
- package/src/lib/cleanup.js +18 -8
- package/src/lib/crypto.js +27 -0
- package/src/lib/platform.js +33 -10
- package/src/lib/secure-print.js +86 -0
- package/src/lib/session-log.js +78 -3
- package/src/lib/ssh.js +25 -5
- package/src/lib/tunnel.js +1 -0
- package/src/lib/worker-runtime.js +81 -0
- package/src/lib/workers/crypto-checksum-worker.js +70 -0
- package/src/modes/ai.js +11 -8
- package/src/modes/client.js +27 -15
- package/src/modes/doctor.js +11 -7
- package/src/modes/host.js +254 -99
- package/src/server.js +95 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraj181/ipingyou",
|
|
3
|
-
"version": "2.1.
|
|
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": "^
|
|
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 {
|
|
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
|
|
356
|
-
await
|
|
357
|
-
await
|
|
358
|
-
await
|
|
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
|
|
362
|
-
await
|
|
363
|
-
await
|
|
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
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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: ${
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
258
|
-
|
|
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().
|
|
269
|
+
time: new Date().toISOString()
|
|
261
270
|
};
|
|
262
271
|
|
|
263
|
-
const { iv, ciphertext, salt } =
|
|
272
|
+
const { iv, ciphertext, salt } = await encryptAsync(JSON.stringify(telemetry), password);
|
|
264
273
|
|
|
265
|
-
await fetchWithLog('telemetry', `${
|
|
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
|
-
|
|
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}
|
|
332
|
+
console.log(chalk.dim(` Unable to auto-open browser. Visit ${secureSensitiveUrl(`http://localhost:${port}`, password)}`));
|
|
326
333
|
}
|
|
327
334
|
}
|
package/src/lib/checksum.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/cleanup.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
await
|
|
195
|
-
await
|
|
196
|
-
|
|
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
|
|
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
|
+
}
|
package/src/lib/platform.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
* ============================================================
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
-
|
|
46
|
-
const
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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:
|
|
202
|
-
wget:
|
|
203
|
-
powershell:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/session-log.js
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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
|
-
|
|
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.
|