cli-tunnel 1.3.0 → 1.3.1-beta.0
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 +108 -23
- 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();
|
|
@@ -255,6 +275,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
255
275
|
}
|
|
256
276
|
}
|
|
257
277
|
// Hub ticket proxy — fetch ticket from local session on behalf of grid client
|
|
278
|
+
// F-03: Only hub mode sessions can use this endpoint (hub token already validated above)
|
|
258
279
|
if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
|
|
259
280
|
const ticketPathMatch = req.url?.match(/^\/api\/proxy\/ticket\/(\d+)$/);
|
|
260
281
|
if (!ticketPathMatch) {
|
|
@@ -320,7 +341,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
320
341
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
321
342
|
isLocal: machine === localMachine,
|
|
322
343
|
};
|
|
323
|
-
//
|
|
344
|
+
// F-05: Never expose raw tokens in API responses — only indicate availability
|
|
324
345
|
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
325
346
|
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
326
347
|
if (token)
|
|
@@ -337,6 +358,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
337
358
|
return;
|
|
338
359
|
}
|
|
339
360
|
// Delete session
|
|
361
|
+
// F-05: Only allow deleting tunnels owned by this machine
|
|
340
362
|
if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
|
|
341
363
|
const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
|
|
342
364
|
if (!/^[a-zA-Z0-9._-]+$/.test(tunnelId)) {
|
|
@@ -344,6 +366,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
344
366
|
res.end(JSON.stringify({ error: 'Invalid tunnel ID' }));
|
|
345
367
|
return;
|
|
346
368
|
}
|
|
369
|
+
// Verify the tunnel belongs to this machine before allowing delete
|
|
370
|
+
try {
|
|
371
|
+
const verifyOut = execFileSync('devtunnel', ['show', tunnelId, '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
372
|
+
const verifyData = JSON.parse(verifyOut);
|
|
373
|
+
const labels = verifyData.tunnel?.labels || [];
|
|
374
|
+
const tunnelMachine = labels[4] || '';
|
|
375
|
+
if (tunnelMachine !== os.hostname()) {
|
|
376
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
377
|
+
res.end(JSON.stringify({ error: 'Cannot delete tunnels from other machines' }));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
// If we can't verify ownership, deny the delete
|
|
383
|
+
res.writeHead(403, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
384
|
+
res.end(JSON.stringify({ error: 'Cannot verify tunnel ownership' }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
347
387
|
try {
|
|
348
388
|
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
349
389
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
@@ -424,19 +464,21 @@ const wss = new WebSocketServer({
|
|
|
424
464
|
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
425
465
|
return false;
|
|
426
466
|
// F-3: Validate origin BEFORE ticket acceptance
|
|
467
|
+
// F-06: Require Origin header — reject non-browser clients without Origin
|
|
427
468
|
const origin = info.req.headers.origin;
|
|
428
|
-
if (origin) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
catch {
|
|
469
|
+
if (!origin) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const originUrl = new URL(origin);
|
|
474
|
+
const host = originUrl.hostname;
|
|
475
|
+
if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
|
|
437
476
|
return false;
|
|
438
477
|
}
|
|
439
478
|
}
|
|
479
|
+
catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
440
482
|
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
441
483
|
// F-02: Accept one-time ticket (only auth method for WS)
|
|
442
484
|
const ticket = url.searchParams.get('ticket');
|
|
@@ -454,6 +496,10 @@ fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
|
|
|
454
496
|
const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
455
497
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
456
498
|
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
499
|
+
// R-01: WebSocketServer error handler — prevents process crash on WSS-level errors
|
|
500
|
+
wss.on('error', (err) => {
|
|
501
|
+
console.error('[wss] WebSocketServer error:', err.message);
|
|
502
|
+
});
|
|
457
503
|
wss.on('connection', (ws, req) => {
|
|
458
504
|
// F-10: Connection cap (global + per-IP)
|
|
459
505
|
if (connections.size >= 5) {
|
|
@@ -473,6 +519,9 @@ wss.on('connection', (ws, req) => {
|
|
|
473
519
|
const id = crypto.randomUUID();
|
|
474
520
|
ws._remoteAddress = remoteAddress;
|
|
475
521
|
connections.set(id, ws);
|
|
522
|
+
// F-13: Per-connection WS message rate limiter (100 msg/sec)
|
|
523
|
+
let wsMessageCount = 0;
|
|
524
|
+
let wsMessageResetAt = Date.now() + 1000;
|
|
476
525
|
// F-10: WS ping/pong heartbeat
|
|
477
526
|
ws._isAlive = true;
|
|
478
527
|
ws.on('pong', () => { ws._isAlive = true; });
|
|
@@ -484,12 +533,29 @@ wss.on('connection', (ws, req) => {
|
|
|
484
533
|
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
485
534
|
}
|
|
486
535
|
ws.on('message', (data) => {
|
|
536
|
+
// F-13: Enforce WS message rate limit (100 msg/sec)
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
if (now > wsMessageResetAt) {
|
|
539
|
+
wsMessageCount = 0;
|
|
540
|
+
wsMessageResetAt = now + 1000;
|
|
541
|
+
}
|
|
542
|
+
wsMessageCount++;
|
|
543
|
+
if (wsMessageCount > 100) {
|
|
544
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'ws-rate-limit' }) + '\n');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
487
547
|
const raw = data.toString();
|
|
488
548
|
try {
|
|
489
549
|
const msg = JSON.parse(raw);
|
|
490
550
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
491
|
-
|
|
492
|
-
|
|
551
|
+
// R-03: Validate msg.data is a string before writing to PTY
|
|
552
|
+
if (typeof msg.data !== 'string') {
|
|
553
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'rejected', reason: 'invalid-data-type', dataType: typeof msg.data }) + '\n');
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(msg.data) }) + '\n');
|
|
557
|
+
ptyProcess.write(msg.data);
|
|
558
|
+
}
|
|
493
559
|
}
|
|
494
560
|
// #7: NaN guard on pty_resize
|
|
495
561
|
if (msg.type === 'pty_resize') {
|
|
@@ -519,10 +585,17 @@ setInterval(() => {
|
|
|
519
585
|
ws.ping();
|
|
520
586
|
}
|
|
521
587
|
}, 30000);
|
|
588
|
+
// F-12: Cap per-entry replay buffer size (64KB)
|
|
589
|
+
const MAX_REPLAY_ENTRY_SIZE = 65536;
|
|
522
590
|
function broadcast(data) {
|
|
523
|
-
|
|
591
|
+
// F-01: Redact secrets from live broadcast (not just replay)
|
|
592
|
+
const redacted = redactSecrets(data);
|
|
593
|
+
const msg = JSON.stringify({ type: 'pty', data: redacted });
|
|
524
594
|
if (hasReplay) {
|
|
525
|
-
|
|
595
|
+
// F-12: Cap per-entry size to prevent memory exhaustion
|
|
596
|
+
if (msg.length <= MAX_REPLAY_ENTRY_SIZE) {
|
|
597
|
+
acpEventLog.push(msg);
|
|
598
|
+
}
|
|
526
599
|
if (acpEventLog.length > 2000)
|
|
527
600
|
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
528
601
|
}
|
|
@@ -548,7 +621,8 @@ async function main() {
|
|
|
548
621
|
if (hubMode) {
|
|
549
622
|
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
550
623
|
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
551
|
-
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1
|
|
624
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1`);
|
|
625
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}\n`);
|
|
552
626
|
}
|
|
553
627
|
else {
|
|
554
628
|
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
@@ -556,6 +630,7 @@ async function main() {
|
|
|
556
630
|
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
557
631
|
console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
|
|
558
632
|
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
|
|
633
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share this URL in screen recordings or public channels${RESET}`);
|
|
559
634
|
console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
|
|
560
635
|
}
|
|
561
636
|
// Tunnel
|
|
@@ -655,7 +730,7 @@ async function main() {
|
|
|
655
730
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
656
731
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
657
732
|
execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
|
|
658
|
-
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
733
|
+
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false, env: getSubprocessEnv() });
|
|
659
734
|
const url = await new Promise((resolve, reject) => {
|
|
660
735
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
661
736
|
let out = '';
|
|
@@ -670,7 +745,8 @@ async function main() {
|
|
|
670
745
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
671
746
|
});
|
|
672
747
|
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
673
|
-
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}
|
|
748
|
+
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}`);
|
|
749
|
+
console.log(` ${YELLOW}⚠ Token in URL — do not share in screen recordings or public channels${RESET}\n`);
|
|
674
750
|
// Write session file for hub discovery
|
|
675
751
|
writeSessionFile(tunnelId, url, actualPort);
|
|
676
752
|
try {
|
|
@@ -766,8 +842,17 @@ async function main() {
|
|
|
766
842
|
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
767
843
|
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
768
844
|
'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'
|
|
845
|
+
'PYTHONPATH', 'PYTHONSTARTUP', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS',
|
|
846
|
+
'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT',
|
|
847
|
+
// F-04: Additional dangerous vars missed by original blocklist
|
|
848
|
+
'DATABASE_URL', 'REDIS_URL', 'MONGODB_URI', 'MONGO_URL',
|
|
849
|
+
'SLACK_WEBHOOK_URL', 'SLACK_TOKEN', 'SLACK_BOT_TOKEN',
|
|
850
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
|
|
851
|
+
'HISTFILE', 'HISTFILESIZE', 'LESSHISTFILE',
|
|
852
|
+
'GCP_SERVICE_ACCOUNT', 'GOOGLE_APPLICATION_CREDENTIALS',
|
|
853
|
+
'AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
|
|
854
|
+
'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN', 'STRIPE_SECRET_KEY',
|
|
855
|
+
'AWS_SESSION_TOKEN', 'AWS_SECURITY_TOKEN']);
|
|
771
856
|
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
772
857
|
const safeEnv = {};
|
|
773
858
|
for (const [k, v] of Object.entries(process.env)) {
|
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>
|