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 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 Enable replay buffer (off by default)
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
- // Attach token from local session files (hub mode only)
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
- try {
430
- const originUrl = new URL(origin);
431
- const host = originUrl.hostname;
432
- if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
433
- return false;
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
- auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
492
- ptyProcess.write(msg.data);
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
- if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
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
- const msg = JSON.stringify({ type: 'pty', data });
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
- acpEventLog.push(msg);
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\n`);
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}\n`);
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
- return text
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 in headers
17
- .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.3.0",
3
+ "version": "1.3.1-beta.1",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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]) {
@@ -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">&gt;</span>