cli-tunnel 1.3.0 → 1.3.1-beta.1
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 +14 -0
- package/dist/index.js +115 -26
- package/dist/redact.js +23 -3
- package/package.json +1 -1
- package/remote-ui/app.js +32 -0
- package/remote-ui/index.html +1 -0
package/README.md
CHANGED
|
@@ -115,9 +115,20 @@ All modes share the same WebSocket connections — switching is instant, no reco
|
|
|
115
115
|
|
|
116
116
|
- **Full terminal** rendered by xterm.js — exact same output as your local terminal
|
|
117
117
|
- **Key bar** with ↑ ↓ → ← Tab Enter Esc Ctrl+C for mobile navigation
|
|
118
|
+
- **⏺ Record button** — record the terminal as a .webm video, tap again to stop and download
|
|
118
119
|
- **Sessions button** — switch between terminal and sessions dashboard
|
|
119
120
|
- **QR code** — scan from your phone to connect instantly
|
|
120
121
|
|
|
122
|
+
## Terminal Recording
|
|
123
|
+
|
|
124
|
+
Tap the **⏺** button in the key bar to start recording your terminal session as a video. The button turns red and shows elapsed time (⏹ 2:35). Tap again to stop — a `.webm` video file downloads automatically.
|
|
125
|
+
|
|
126
|
+
- Records at 30fps using the browser's MediaRecorder API
|
|
127
|
+
- Auto-stops after 10 minutes to prevent memory issues on mobile
|
|
128
|
+
- Auto-stops when you switch views or disconnect
|
|
129
|
+
- Works on both phone and desktop browsers
|
|
130
|
+
- No server-side tools needed — recording happens entirely in the browser
|
|
131
|
+
|
|
121
132
|
## Prerequisites
|
|
122
133
|
|
|
123
134
|
- [Node.js](https://nodejs.org/) 22+ (Node 20 works too; Node 23 may need the latest beta)
|
|
@@ -182,6 +193,9 @@ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http:/
|
|
|
182
193
|
**What's hub mode?**
|
|
183
194
|
Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions. Tap any session to connect, or use Grid view to monitor all sessions simultaneously.
|
|
184
195
|
|
|
196
|
+
**How does recording work?**
|
|
197
|
+
The record button captures the xterm.js canvas at 30fps using the browser's MediaRecorder API. The recording is a `.webm` video file that downloads to your device when you stop. It records exactly what you see on screen — perfect for demos and presentations. Max duration is 10 minutes.
|
|
198
|
+
|
|
185
199
|
**How does the Grid view connect to sessions?**
|
|
186
200
|
The hub reads session tokens from `~/.cli-tunnel/sessions/` (files with owner-only permissions). It proxies ticket requests to each session's local port — no tokens are exposed to the browser client.
|
|
187
201
|
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,15 @@ import readline from 'node:readline';
|
|
|
24
24
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import { redactSecrets } from './redact.js';
|
|
27
|
+
// F-15: Global error handlers to prevent unclean crashes
|
|
28
|
+
process.on('uncaughtException', (err) => {
|
|
29
|
+
console.error('[fatal] Uncaught exception:', err.message);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
process.on('unhandledRejection', (reason) => {
|
|
33
|
+
console.error('[fatal] Unhandled rejection:', reason);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
27
36
|
function askUser(question) {
|
|
28
37
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
29
38
|
return new Promise((resolve) => {
|
|
@@ -49,7 +58,7 @@ ${BOLD}Options:${RESET}
|
|
|
49
58
|
--local Disable devtunnel (localhost only)
|
|
50
59
|
--port <n> Bridge port (default: random)
|
|
51
60
|
--name <name> Session name (shown in dashboard)
|
|
52
|
-
--replay
|
|
61
|
+
--no-replay Disable replay buffer (on by default)
|
|
53
62
|
--help, -h Show this help
|
|
54
63
|
|
|
55
64
|
${BOLD}Examples:${RESET}
|
|
@@ -69,13 +78,13 @@ pass through to the underlying app. cli-tunnel's own flags
|
|
|
69
78
|
}
|
|
70
79
|
const hasLocal = args.includes('--local');
|
|
71
80
|
const hasTunnel = !hasLocal;
|
|
72
|
-
const hasReplay = args.includes('--replay');
|
|
81
|
+
const hasReplay = !args.includes('--no-replay');
|
|
73
82
|
const portIdx = args.indexOf('--port');
|
|
74
83
|
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
|
|
75
84
|
const nameIdx = args.indexOf('--name');
|
|
76
85
|
const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
|
|
77
86
|
// Everything that's not our flags is the command
|
|
78
|
-
const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
|
|
87
|
+
const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--no-replay']);
|
|
79
88
|
const cmdArgs = [];
|
|
80
89
|
let skip = false;
|
|
81
90
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -87,7 +96,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
87
96
|
skip = true;
|
|
88
97
|
continue;
|
|
89
98
|
}
|
|
90
|
-
if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
|
|
99
|
+
if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--no-replay')
|
|
91
100
|
continue;
|
|
92
101
|
cmdArgs.push(args[i]);
|
|
93
102
|
}
|
|
@@ -101,6 +110,17 @@ function sanitizeLabel(l) {
|
|
|
101
110
|
const clean = l.replace(/[^a-zA-Z0-9_\-=]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 50);
|
|
102
111
|
return clean || 'unknown';
|
|
103
112
|
}
|
|
113
|
+
// F-07: Minimal env for subprocess calls (git, devtunnel) — only PATH and essentials
|
|
114
|
+
function getSubprocessEnv() {
|
|
115
|
+
const env = {};
|
|
116
|
+
const SUBPROCESS_ALLOWLIST = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'LANG', 'LC_ALL', 'TERM', 'COMSPEC', 'SHELL']);
|
|
117
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
118
|
+
if (v !== undefined && SUBPROCESS_ALLOWLIST.has(k)) {
|
|
119
|
+
env[k] = v;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return env;
|
|
123
|
+
}
|
|
104
124
|
function getGitInfo() {
|
|
105
125
|
try {
|
|
106
126
|
const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
@@ -169,6 +189,7 @@ setInterval(() => {
|
|
|
169
189
|
// ─── Bridge server ──────────────────────────────────────────
|
|
170
190
|
const acpEventLog = [];
|
|
171
191
|
const connections = new Map();
|
|
192
|
+
let localResizeAt = 0; // Timestamp of last local terminal resize
|
|
172
193
|
// #10: Session TTL enforcement — periodically close expired connections
|
|
173
194
|
setInterval(() => {
|
|
174
195
|
if (Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
@@ -255,6 +276,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
255
276
|
}
|
|
256
277
|
}
|
|
257
278
|
// Hub ticket proxy — fetch ticket from local session on behalf of grid client
|
|
279
|
+
// F-03: Only hub mode sessions can use this endpoint (hub token already validated above)
|
|
258
280
|
if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
|
|
259
281
|
const ticketPathMatch = req.url?.match(/^\/api\/proxy\/ticket\/(\d+)$/);
|
|
260
282
|
if (!ticketPathMatch) {
|
|
@@ -320,7 +342,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
320
342
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
321
343
|
isLocal: machine === localMachine,
|
|
322
344
|
};
|
|
323
|
-
//
|
|
345
|
+
// F-05: Never expose raw tokens in API responses — only indicate availability
|
|
324
346
|
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
325
347
|
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
326
348
|
if (token)
|
|
@@ -337,6 +359,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
337
359
|
return;
|
|
338
360
|
}
|
|
339
361
|
// Delete session
|
|
362
|
+
// F-05: Only allow deleting tunnels owned by this machine
|
|
340
363
|
if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
|
|
341
364
|
const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
|
|
342
365
|
if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
|
|
@@ -344,6 +367,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
344
367
|
res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
|
|
345
368
|
return;
|
|
346
369
|
}
|
|
370
|
+
// Verify the tunnel belongs to this machine before allowing delete
|
|
371
|
+
try {
|
|
372
|
+
const verifyOut = execFileSync('devtunnel', ['show', tunnelId, '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
373
|
+
const verifyData = JSON.parse(verifyOut);
|
|
374
|
+
const labels = verifyData.tunnel?.labels || [];
|
|
375
|
+
const tunnelMachine = labels[4] || '';
|
|
376
|
+
if (tunnelMachine !== os.hostname()) {
|
|
377
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
378
|
+
res.end(JSON.stringify({ error: 'Cannot delete tunnels from other machines' }));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// If we can't verify ownership, deny the delete
|
|
384
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
385
|
+
res.end(JSON.stringify({ error: 'Cannot verify tunnel ownership' }));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
347
388
|
try {
|
|
348
389
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
349
390
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
@@ -424,19 +465,21 @@ const wss = new WebSocketServer({
|
|
|
424
465
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
425
466
|
return false;
|
|
426
467
|
// F-3: Validate origin BEFORE ticket acceptance
|
|
468
|
+
// F-06: Require Origin header — reject non-browser clients without Origin
|
|
427
469
|
const origin = info.req.headers.origin;
|
|
428
|
-
if (origin) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
catch {
|
|
470
|
+
if (!origin) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const originUrl = new URL(origin);
|
|
475
|
+
const host = originUrl.hostname;
|
|
476
|
+
if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
|
|
437
477
|
return false;
|
|
438
478
|
}
|
|
439
479
|
}
|
|
480
|
+
catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
440
483
|
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
441
484
|
// F-02: Accept one-time ticket (only auth method for WS)
|
|
442
485
|
const ticket = url.searchParams.get('ticket');
|
|
@@ -454,6 +497,10 @@ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
|
|
|
454
497
|
const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
455
498
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
456
499
|
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
500
|
+
// R-01: WebSocketServer error handler — prevents process crash on WSS-level errors
|
|
501
|
+
wss.on('error', (err) => {
|
|
502
|
+
console.error('[wss] WebSocketServer error:', err.message);
|
|
503
|
+
});
|
|
457
504
|
wss.on('connection', (ws, req) => {
|
|
458
505
|
// F-10: Connection cap (global + per-IP)
|
|
459
506
|
if (connections.size >= 5) {
|
|
@@ -473,6 +520,9 @@ wss.on('connection', (ws, req) => {
|
|
|
473
520
|
const id = crypto.randomUUID();
|
|
474
521
|
ws._remoteAddress = remoteAddress;
|
|
475
522
|
connections.set(id, ws);
|
|
523
|
+
// F-13: Per-connection WS message rate limiter (100 msg/sec)
|
|
524
|
+
let wsMessageCount = 0;
|
|
525
|
+
let wsMessageResetAt = Date.now() + 1000;
|
|
476
526
|
// F-10: WS ping/pong heartbeat
|
|
477
527
|
ws._isAlive = true;
|
|
478
528
|
ws.on('pong', () => { ws._isAlive = true; });
|
|
@@ -484,18 +534,36 @@ wss.on('connection', (ws, req) => {
|
|
|
484
534
|
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
485
535
|
}
|
|
486
536
|
ws.on('message', (data) => {
|
|
537
|
+
// F-13: Enforce WS message rate limit (100 msg/sec)
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
if (now > wsMessageResetAt) {
|
|
540
|
+
wsMessageCount = 0;
|
|
541
|
+
wsMessageResetAt = now + 1000;
|
|
542
|
+
}
|
|
543
|
+
wsMessageCount++;
|
|
544
|
+
if (wsMessageCount > 100) {
|
|
545
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'ws-rate-limit' }) + '\n');
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
487
548
|
const raw = data.toString();
|
|
488
549
|
try {
|
|
489
550
|
const msg = JSON.parse(raw);
|
|
490
551
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
491
|
-
|
|
492
|
-
|
|
552
|
+
// R-03: Validate msg.data is a string before writing to PTY
|
|
553
|
+
if (typeof msg.data !== 'string') {
|
|
554
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'invalid-data-type', dataType: typeof msg.data }) + '\n');
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(msg.data) }) + '\n');
|
|
558
|
+
ptyProcess.write(msg.data);
|
|
559
|
+
}
|
|
493
560
|
}
|
|
494
|
-
// #7: NaN guard on pty_resize
|
|
561
|
+
// #7: NaN guard on pty_resize — remote resize only if local hasn't resized recently
|
|
495
562
|
if (msg.type === 'pty_resize') {
|
|
496
563
|
const cols = Number(msg.cols);
|
|
497
564
|
const rows = Number(msg.rows);
|
|
498
|
-
|
|
565
|
+
const localRecentlyResized = Date.now() - localResizeAt < 2000;
|
|
566
|
+
if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess && !localRecentlyResized) {
|
|
499
567
|
ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
|
|
500
568
|
}
|
|
501
569
|
}
|
|
@@ -519,10 +587,17 @@ setInterval(() => {
|
|
|
519
587
|
ws.ping();
|
|
520
588
|
}
|
|
521
589
|
}, 30000);
|
|
590
|
+
// F-12: Cap per-entry replay buffer size (64KB)
|
|
591
|
+
const MAX_REPLAY_ENTRY_SIZE = 65536;
|
|
522
592
|
function broadcast(data) {
|
|
523
|
-
|
|
593
|
+
// F-01: Redact secrets from live broadcast (not just replay)
|
|
594
|
+
const redacted = redactSecrets(data);
|
|
595
|
+
const msg = JSON.stringify({ type: 'pty', data: redacted });
|
|
524
596
|
if (hasReplay) {
|
|
525
|
-
|
|
597
|
+
// F-12: Cap per-entry size to prevent memory exhaustion
|
|
598
|
+
if (msg.length <= MAX_REPLAY_ENTRY_SIZE) {
|
|
599
|
+
acpEventLog.push(msg);
|
|
600
|
+
}
|
|
526
601
|
if (acpEventLog.length > 2000)
|
|
527
602
|
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
528
603
|
}
|
|
@@ -548,7 +623,8 @@ async function main() {
|
|
|
548
623
|
if (hubMode) {
|
|
549
624
|
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
550
625
|
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
551
|
-
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1
|
|
626
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1`);
|
|
627
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}\n`);
|
|
552
628
|
}
|
|
553
629
|
else {
|
|
554
630
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
@@ -556,6 +632,7 @@ async function main() {
|
|
|
556
632
|
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
557
633
|
console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
|
|
558
634
|
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
|
|
635
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}`);
|
|
559
636
|
console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
|
|
560
637
|
}
|
|
561
638
|
// Tunnel
|
|
@@ -655,7 +732,7 @@ async function main() {
|
|
|
655
732
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
656
733
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
657
734
|
execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
|
|
658
|
-
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
735
|
+
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false, env: getSubprocessEnv() });
|
|
659
736
|
const url = await new Promise((resolve, reject) => {
|
|
660
737
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
661
738
|
let out = '';
|
|
@@ -670,7 +747,8 @@ async function main() {
|
|
|
670
747
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
671
748
|
});
|
|
672
749
|
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
673
|
-
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}
|
|
750
|
+
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}`);
|
|
751
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share in screen recordings or public channels${RESET}\n`);
|
|
674
752
|
// Write session file for hub discovery
|
|
675
753
|
writeSessionFile(tunnelId, url, actualPort);
|
|
676
754
|
try {
|
|
@@ -738,6 +816,8 @@ async function main() {
|
|
|
738
816
|
});
|
|
739
817
|
}
|
|
740
818
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
819
|
+
// Clear screen before PTY takes over — prevents overlap with banner/QR output
|
|
820
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
741
821
|
// Spawn PTY
|
|
742
822
|
const nodePty = await import('node-pty');
|
|
743
823
|
const cols = process.stdout.columns || 120;
|
|
@@ -766,8 +846,17 @@ async function main() {
|
|
|
766
846
|
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
767
847
|
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
768
848
|
'SSH_AUTH_SOCK', 'GPG_TTY',
|
|
769
|
-
'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
|
|
770
|
-
'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT'
|
|
849
|
+
'PYTHONPATH', 'PYTHONSTARTUP', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
|
|
850
|
+
'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT',
|
|
851
|
+
// F-04: Additional dangerous vars missed by original blocklist
|
|
852
|
+
'DATABASE_URL', 'REDIS_URL', 'MONGODB_URI', 'MONGO_URL',
|
|
853
|
+
'SLACK_WEBHOOK_URL', 'SLACK_TOKEN', 'SLACK_BOT_TOKEN',
|
|
854
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
855
|
+
'HISTFILE', 'HISTFILESIZE', 'LESSHISTFILE',
|
|
856
|
+
'GCP_SERVICE_ACCOUNT', 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
857
|
+
'AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
|
|
858
|
+
'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN', 'STRIPE_SECRET_KEY',
|
|
859
|
+
'AWS_SESSION_TOKEN', 'AWS_SECURITY_TOKEN']);
|
|
771
860
|
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
772
861
|
const safeEnv = {};
|
|
773
862
|
for (const [k, v] of Object.entries(process.env)) {
|
|
@@ -817,6 +906,6 @@ async function main() {
|
|
|
817
906
|
process.stdin.setRawMode(true);
|
|
818
907
|
process.stdin.resume();
|
|
819
908
|
process.stdin.on('data', (data) => ptyProcess.write(data.toString()));
|
|
820
|
-
process.stdout.on('resize', () => ptyProcess.resize(process.stdout.columns || 120, process.stdout.rows || 30));
|
|
909
|
+
process.stdout.on('resize', () => { localResizeAt = Date.now(); ptyProcess.resize(process.stdout.columns || 120, process.stdout.rows || 30); });
|
|
821
910
|
}
|
|
822
911
|
main().catch((err) => { console.error(err); process.exit(1); });
|
package/dist/redact.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// F-08: Strip Unicode zero-width characters that can bypass regex patterns
|
|
2
|
+
function stripZeroWidth(text) {
|
|
3
|
+
return text.replace(/[\u200B\u200C\u200D\uFEFF\u00AD\u2060\u180E]/g, '');
|
|
4
|
+
}
|
|
1
5
|
export function redactSecrets(text) {
|
|
2
|
-
|
|
6
|
+
const cleaned = stripZeroWidth(text);
|
|
7
|
+
return cleaned
|
|
3
8
|
// Generic patterns: key=value, key: value, key="value"
|
|
4
9
|
.replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
|
|
5
10
|
// OpenAI keys
|
|
@@ -11,16 +16,31 @@ export function redactSecrets(text) {
|
|
|
11
16
|
// Azure connection strings
|
|
12
17
|
.replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
|
|
13
18
|
.replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
|
|
19
|
+
// F-09: Azure SAS tokens
|
|
20
|
+
.replace(/[?&]sig=[a-zA-Z0-9%/+=]{20,}/gi, '?sig=[REDACTED]')
|
|
21
|
+
.replace(/SharedAccessSignature\s+[^\s"']{20,}/gi, 'SharedAccessSignature [REDACTED]')
|
|
14
22
|
// Database URLs
|
|
15
23
|
.replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
|
|
16
|
-
// Bearer tokens
|
|
17
|
-
.replace(/Bearer\s+[a-zA-Z0-9._
|
|
24
|
+
// F-17: Bearer tokens (relaxed min length to catch shorter tokens)
|
|
25
|
+
.replace(/Bearer\s+[a-zA-Z0-9._\-/+=]{8,}/gi, 'Bearer [REDACTED]')
|
|
26
|
+
// F-17: Basic auth headers
|
|
27
|
+
.replace(/Basic\s+[a-zA-Z0-9+/=]{8,}/gi, 'Basic [REDACTED]')
|
|
18
28
|
// JWT tokens
|
|
19
29
|
.replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
|
|
20
30
|
// Slack tokens
|
|
21
31
|
.replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
|
|
22
32
|
// npm tokens
|
|
23
33
|
.replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
|
|
34
|
+
// F-09: Google API keys
|
|
35
|
+
.replace(/AIzaSy[a-zA-Z0-9_-]{33}/g, '[REDACTED]')
|
|
36
|
+
// F-09: Stripe keys
|
|
37
|
+
.replace(/(?:sk|pk|rk)_(?:live|test)_[a-zA-Z0-9]{10,}/g, '[REDACTED]')
|
|
38
|
+
// F-09: SendGrid keys
|
|
39
|
+
.replace(/SG\.[a-zA-Z0-9_-]{22,}\.[a-zA-Z0-9_-]{22,}/g, '[REDACTED]')
|
|
40
|
+
// F-09: Twilio keys
|
|
41
|
+
.replace(/SK[a-f0-9]{32}/g, '[REDACTED]')
|
|
42
|
+
// F-09: Webhook secrets
|
|
43
|
+
.replace(/whsec_[a-zA-Z0-9+/=]{20,}/g, '[REDACTED]')
|
|
24
44
|
// PEM private keys
|
|
25
45
|
.replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
|
|
26
46
|
}
|
package/package.json
CHANGED
package/remote-ui/app.js
CHANGED
|
@@ -135,6 +135,34 @@
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function takeScreenshot() {
|
|
139
|
+
var canvas = null;
|
|
140
|
+
if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
|
|
141
|
+
canvas = gridTerminals[focusedIndex].panel.querySelector('canvas');
|
|
142
|
+
} else {
|
|
143
|
+
canvas = termContainer.querySelector('canvas');
|
|
144
|
+
}
|
|
145
|
+
if (!canvas) {
|
|
146
|
+
if (statusText) { var prev = statusText.textContent; statusText.textContent = 'No terminal to capture'; setTimeout(function() { statusText.textContent = prev; }, 2000); }
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
var dataUrl = canvas.toDataURL('image/png');
|
|
151
|
+
var a = document.createElement('a');
|
|
152
|
+
var timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
153
|
+
a.href = dataUrl;
|
|
154
|
+
a.download = 'cli-tunnel-' + timestamp + '.png';
|
|
155
|
+
document.body.appendChild(a);
|
|
156
|
+
a.click();
|
|
157
|
+
document.body.removeChild(a);
|
|
158
|
+
// Flash effect
|
|
159
|
+
canvas.style.opacity = '0.5';
|
|
160
|
+
setTimeout(function() { canvas.style.opacity = '1'; }, 150);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (statusText) { var prev2 = statusText.textContent; statusText.textContent = 'Screenshot failed'; setTimeout(function() { statusText.textContent = prev2; }, 2000); }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
// ─── xterm.js Terminal ───────────────────────────────────
|
|
139
167
|
let xterm = null;
|
|
140
168
|
let fitAddon = null;
|
|
@@ -1097,6 +1125,10 @@
|
|
|
1097
1125
|
toggleRecording();
|
|
1098
1126
|
return;
|
|
1099
1127
|
}
|
|
1128
|
+
if (btn && btn.tagName === 'BUTTON' && btn.dataset.action === 'take-screenshot') {
|
|
1129
|
+
takeScreenshot();
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1100
1132
|
if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
|
|
1101
1133
|
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
1102
1134
|
if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
|
package/remote-ui/index.html
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
<button data-key=" ">Space</button>
|
|
44
44
|
<button data-key="\x7f">⌫</button>
|
|
45
45
|
<button id="btn-record" data-action="toggle-record" title="Record terminal" aria-label="Record terminal">⏺</button>
|
|
46
|
+
<button id="btn-screenshot" data-action="take-screenshot" title="Screenshot terminal" aria-label="Screenshot terminal">📷</button>
|
|
46
47
|
</div>
|
|
47
48
|
<form id="input-form">
|
|
48
49
|
<span class="prompt">></span>
|