cli-tunnel 1.2.0-beta.8 → 1.2.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/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import http from 'node:http';
23
23
  import readline from 'node:readline';
24
24
  import { WebSocketServer, WebSocket } from 'ws';
25
25
  import os from 'node:os';
26
+ import { redactSecrets } from './redact.js';
26
27
  function askUser(question) {
27
28
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
28
29
  return new Promise((resolve) => {
@@ -113,8 +114,46 @@ function getGitInfo() {
113
114
  }
114
115
  // ─── Security: Session token for WebSocket auth ────────────
115
116
  const sessionToken = crypto.randomUUID();
116
- // ─── F-18: Session TTL (24 hours) ──────────────────────────
117
- const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
117
+ // ─── Session file registry (IPC via filesystem) ────────────
118
+ const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
119
+ fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
120
+ let sessionFilePath = null;
121
+ function writeSessionFile(tunnelId, tunnelUrl, port) {
122
+ sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
123
+ const data = JSON.stringify({
124
+ token: sessionToken, name: sessionName || command,
125
+ tunnelId, tunnelUrl, port, hubMode,
126
+ machine: os.hostname(), pid: process.pid,
127
+ createdAt: new Date().toISOString(),
128
+ });
129
+ fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
130
+ }
131
+ function removeSessionFile() {
132
+ if (sessionFilePath) {
133
+ try {
134
+ fs.unlinkSync(sessionFilePath);
135
+ }
136
+ catch { }
137
+ }
138
+ }
139
+ function readLocalSessions() {
140
+ try {
141
+ return fs.readdirSync(sessionsDir)
142
+ .filter(f => f.endsWith('.json'))
143
+ .map(f => { try {
144
+ return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
145
+ }
146
+ catch {
147
+ return null;
148
+ } })
149
+ .filter((s) => s !== null && !s.hubMode);
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ }
155
+ // ─── F-18: Session TTL (4 hours) ───────────────────────────
156
+ const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
118
157
  const sessionCreatedAt = Date.now();
119
158
  // ─── F-02: One-time ticket store for WebSocket auth ────────
120
159
  const tickets = new Map();
@@ -127,24 +166,6 @@ setInterval(() => {
127
166
  }
128
167
  }, 30000);
129
168
  // ─── Security: Redact secrets from replay events ────────────
130
- function redactSecrets(text) {
131
- return text
132
- // Generic patterns: key=value, key: value, key="value"
133
- .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
134
- // OpenAI keys
135
- .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
136
- // GitHub tokens
137
- .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
138
- // AWS keys
139
- .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
140
- // Azure connection strings
141
- .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
142
- .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
143
- // Database URLs
144
- .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
145
- // Bearer tokens in headers
146
- .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
147
- }
148
169
  // ─── Bridge server ──────────────────────────────────────────
149
170
  const acpEventLog = [];
150
171
  const connections = new Map();
@@ -157,7 +178,51 @@ setInterval(() => {
157
178
  }
158
179
  }
159
180
  }, 60000);
160
- const server = http.createServer((req, res) => {
181
+ // ─── F-8: Per-IP rate limiter ───────────────────────────────
182
+ const rateLimits = new Map();
183
+ const ticketRateLimits = new Map();
184
+ function checkRateLimit(ip, map, maxRequests) {
185
+ const now = Date.now();
186
+ const entry = map.get(ip);
187
+ if (!entry || entry.resetAt < now) {
188
+ map.set(ip, { count: 1, resetAt: now + 60000 });
189
+ return true;
190
+ }
191
+ entry.count++;
192
+ return entry.count <= maxRequests;
193
+ }
194
+ // Clean up rate limit maps every 60s
195
+ setInterval(() => {
196
+ const now = Date.now();
197
+ for (const [ip, entry] of rateLimits) {
198
+ if (entry.resetAt < now)
199
+ rateLimits.delete(ip);
200
+ }
201
+ for (const [ip, entry] of ticketRateLimits) {
202
+ if (entry.resetAt < now)
203
+ ticketRateLimits.delete(ip);
204
+ }
205
+ }, 60000);
206
+ const server = http.createServer(async (req, res) => {
207
+ const clientIp = req.socket.remoteAddress || 'unknown';
208
+ // F-8: Rate limiting for HTTP endpoints
209
+ if (req.url?.startsWith('/api/')) {
210
+ const isTicket = req.url === '/api/auth/ticket';
211
+ if (isTicket) {
212
+ if (!checkRateLimit(clientIp, ticketRateLimits, 10)) {
213
+ res.writeHead(429, { 'Content-Type': 'application/json' });
214
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
215
+ return;
216
+ }
217
+ }
218
+ else {
219
+ if (!checkRateLimit(clientIp, rateLimits, 30)) {
220
+ res.writeHead(429, { 'Content-Type': 'application/json' });
221
+ res.end(JSON.stringify({ error: 'Too Many Requests' }));
222
+ return;
223
+ }
224
+ }
225
+ }
161
226
  // F-18: Session expiry check for API routes
162
227
  if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
163
228
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -173,13 +238,14 @@ const server = http.createServer((req, res) => {
173
238
  return;
174
239
  }
175
240
  const ticket = crypto.randomUUID();
176
- tickets.set(ticket, { expires: Date.now() + 60000 });
241
+ const expiresAt = Date.now() + 60000;
242
+ tickets.set(ticket, { expires: expiresAt });
177
243
  res.writeHead(200, { 'Content-Type': 'application/json' });
178
- res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
244
+ res.end(JSON.stringify({ ticket, expires: expiresAt }));
179
245
  return;
180
246
  }
181
- // F-01: Session token check for all API routes (skip in hub mode)
182
- if (!hubMode && req.url?.startsWith('/api/')) {
247
+ // F-01: Session token check for all API routes
248
+ if (req.url?.startsWith('/api/')) {
183
249
  const reqUrl = new URL(req.url, `http://${req.headers.host}`);
184
250
  const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
185
251
  if (authToken !== sessionToken) {
@@ -188,27 +254,78 @@ const server = http.createServer((req, res) => {
188
254
  return;
189
255
  }
190
256
  }
257
+ // Hub ticket proxy — fetch ticket from local session on behalf of grid client
258
+ if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
259
+ const ticketPathMatch = req.url?.match(/^\/api\/proxy\/ticket\/(\d+)$/);
260
+ if (!ticketPathMatch) {
261
+ res.writeHead(400, { 'Content-Type': 'application/json' });
262
+ res.end(JSON.stringify({ error: 'Invalid port' }));
263
+ return;
264
+ }
265
+ const targetPort = parseInt(ticketPathMatch[1], 10);
266
+ if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
267
+ res.writeHead(400, { 'Content-Type': 'application/json' });
268
+ res.end(JSON.stringify({ error: 'Invalid port' }));
269
+ return;
270
+ }
271
+ // Find token for this port from session files
272
+ const localSessions = readLocalSessions();
273
+ const session = localSessions.find(s => s.port === targetPort);
274
+ if (!session) {
275
+ res.writeHead(404, { 'Content-Type': 'application/json' });
276
+ res.end(JSON.stringify({ error: 'Session not found' }));
277
+ return;
278
+ }
279
+ try {
280
+ const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
281
+ method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
282
+ signal: AbortSignal.timeout(3000),
283
+ });
284
+ if (!ticketResp.ok)
285
+ throw new Error('Ticket request failed');
286
+ const ticketData = await ticketResp.json();
287
+ res.writeHead(200, { 'Content-Type': 'application/json' });
288
+ res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
289
+ }
290
+ catch {
291
+ res.writeHead(502, { 'Content-Type': 'application/json' });
292
+ res.end(JSON.stringify({ error: 'Session unreachable' }));
293
+ return;
294
+ }
295
+ return;
296
+ }
191
297
  // Sessions API
192
- if (req.url === '/api/sessions' && req.method === 'GET') {
298
+ if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
193
299
  try {
194
300
  const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
195
301
  const data = JSON.parse(output);
302
+ const localMachine = os.hostname();
303
+ const localSessions = hubMode ? readLocalSessions() : [];
304
+ const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
196
305
  const sessions = (data.tunnels || []).map((t) => {
197
306
  const labels = t.labels || [];
198
307
  const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
199
308
  const cluster = t.tunnelId?.split('.').pop() || 'euw';
200
309
  const portLabel = labels.find((l) => l.startsWith('port-'));
201
310
  const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
202
- return {
311
+ const machine = labels[4] || 'unknown';
312
+ const session = {
203
313
  id, tunnelId: t.tunnelId,
204
314
  name: labels[1] || 'unnamed',
205
315
  repo: labels[2] || 'unknown',
206
316
  branch: (labels[3] || 'unknown').replace(/_/g, '/'),
207
- machine: labels[4] || 'unknown',
317
+ machine,
208
318
  online: (t.hostConnections || 0) > 0,
209
319
  port: p,
210
320
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
321
+ isLocal: machine === localMachine,
211
322
  };
323
+ // Attach token from local session files (hub mode only)
324
+ const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
325
+ const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
326
+ if (token)
327
+ session.hasToken = true;
328
+ return session;
212
329
  });
213
330
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
214
331
  res.end(JSON.stringify({ sessions }));
@@ -286,9 +403,10 @@ const server = http.createServer((req, res) => {
286
403
  'Content-Type': mimes[ext] || 'application/octet-stream',
287
404
  'X-Frame-Options': 'DENY',
288
405
  'X-Content-Type-Options': 'nosniff',
289
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
406
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
290
407
  'Referrer-Policy': 'no-referrer',
291
408
  'Cache-Control': 'no-store',
409
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
292
410
  };
293
411
  res.writeHead(200, securityHeaders);
294
412
  // #8: Handle createReadStream errors
@@ -302,24 +420,10 @@ const wss = new WebSocketServer({
302
420
  server,
303
421
  maxPayload: 1048576,
304
422
  verifyClient: (info) => {
305
- if (hubMode)
306
- return true; // Hub mode doesn't need WS auth
307
423
  // F-18: Session expiry
308
424
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
309
425
  return false;
310
- const url = new URL(info.req.url, `http://${info.req.headers.host}`);
311
- // F-02: Accept one-time ticket
312
- const ticket = url.searchParams.get('ticket');
313
- if (ticket && tickets.has(ticket)) {
314
- const t = tickets.get(ticket);
315
- tickets.delete(ticket); // Single use
316
- return t.expires > Date.now();
317
- }
318
- // Backward compat: accept token
319
- if (url.searchParams.get('token') !== sessionToken)
320
- return false;
321
- // Validate origin if present
322
- // #28: Proper origin validation using URL parsing
426
+ // F-3: Validate origin BEFORE ticket acceptance
323
427
  const origin = info.req.headers.origin;
324
428
  if (origin) {
325
429
  try {
@@ -333,7 +437,15 @@ const wss = new WebSocketServer({
333
437
  return false;
334
438
  }
335
439
  }
336
- return true;
440
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
441
+ // F-02: Accept one-time ticket (only auth method for WS)
442
+ const ticket = url.searchParams.get('ticket');
443
+ if (ticket && tickets.has(ticket)) {
444
+ const t = tickets.get(ticket);
445
+ tickets.delete(ticket); // Single use
446
+ return t.expires > Date.now();
447
+ }
448
+ return false;
337
449
  },
338
450
  });
339
451
  // ─── Security: Audit log for remote PTY input ──────────────
@@ -343,14 +455,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
343
455
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
344
456
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
345
457
  wss.on('connection', (ws, req) => {
346
- // F-10: Connection cap
458
+ // F-10: Connection cap (global + per-IP)
347
459
  if (connections.size >= 5) {
348
460
  ws.close(1013, 'Max connections reached');
349
461
  return;
350
462
  }
351
- const id = crypto.randomUUID();
352
463
  const remoteAddress = req.socket.remoteAddress || 'unknown';
464
+ let perIpCount = 0;
465
+ for (const [, c] of connections) {
466
+ if (c._remoteAddress === remoteAddress)
467
+ perIpCount++;
468
+ }
469
+ if (perIpCount >= 2) {
470
+ ws.close(1013, 'Max connections per IP reached');
471
+ return;
472
+ }
473
+ const id = crypto.randomUUID();
474
+ ws._remoteAddress = remoteAddress;
353
475
  connections.set(id, ws);
476
+ // F-10: WS ping/pong heartbeat
477
+ ws._isAlive = true;
478
+ ws.on('pong', () => { ws._isAlive = true; });
354
479
  // Replay history with secrets redacted (only if replay is enabled)
355
480
  if (hasReplay) {
356
481
  for (const event of acpEventLog) {
@@ -382,6 +507,18 @@ wss.on('connection', (ws, req) => {
382
507
  });
383
508
  ws.on('close', () => connections.delete(id));
384
509
  });
510
+ // F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
511
+ setInterval(() => {
512
+ for (const [id, ws] of connections) {
513
+ if (ws._isAlive === false) {
514
+ ws.terminate();
515
+ connections.delete(id);
516
+ continue;
517
+ }
518
+ ws._isAlive = false;
519
+ ws.ping();
520
+ }
521
+ }, 30000);
385
522
  function broadcast(data) {
386
523
  const msg = JSON.stringify({ type: 'pty', data });
387
524
  if (hasReplay) {
@@ -410,7 +547,8 @@ async function main() {
410
547
  console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
411
548
  if (hubMode) {
412
549
  console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
413
- console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
550
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
551
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
414
552
  }
415
553
  else {
416
554
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
@@ -531,19 +669,21 @@ async function main() {
531
669
  });
532
670
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
533
671
  });
534
- const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
672
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
535
673
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
674
+ // Write session file for hub discovery
675
+ writeSessionFile(tunnelId, url, actualPort);
536
676
  try {
537
677
  // @ts-ignore
538
678
  const qr = (await import('qrcode-terminal'));
539
679
  qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
540
680
  }
541
681
  catch { }
542
- process.on('SIGINT', () => { hostProc.kill(); try {
682
+ process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
543
683
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
544
684
  }
545
685
  catch { } });
546
- process.on('exit', () => { hostProc.kill(); try {
686
+ process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
547
687
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
548
688
  }
549
689
  catch { } });
@@ -582,6 +722,21 @@ async function main() {
582
722
  // Keep process alive
583
723
  await new Promise(() => { });
584
724
  }
725
+ // Wait for user to scan QR / copy URL before starting the CLI tool
726
+ if (hasTunnel) {
727
+ console.log(` ${BOLD}Press any key to start ${command}...${RESET}`);
728
+ await new Promise((resolve) => {
729
+ if (process.stdin.isTTY)
730
+ process.stdin.setRawMode(true);
731
+ process.stdin.resume();
732
+ process.stdin.once('data', () => {
733
+ if (process.stdin.isTTY)
734
+ process.stdin.setRawMode(false);
735
+ process.stdin.pause();
736
+ resolve();
737
+ });
738
+ });
739
+ }
585
740
  console.log(` ${DIM}Starting ${command}...${RESET}\n`);
586
741
  // Spawn PTY
587
742
  const nodePty = await import('node-pty');
@@ -609,8 +764,11 @@ async function main() {
609
764
  // Blocklist approach: pass everything except known dangerous vars and secrets
610
765
  const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
611
766
  'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
612
- 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES']);
613
- const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth/i;
767
+ 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
768
+ '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']);
771
+ const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
614
772
  const safeEnv = {};
615
773
  for (const [k, v] of Object.entries(process.env)) {
616
774
  if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
@@ -623,23 +781,28 @@ async function main() {
623
781
  env: safeEnv,
624
782
  });
625
783
  // Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
626
- let ptyExitedEarly = false;
784
+ let earlyExitCode = null;
627
785
  const earlyExitCheck = new Promise((resolve) => {
628
786
  ptyProcess.onExit(({ exitCode }) => {
629
- if (exitCode === 134 || exitCode === 3221226505) {
630
- ptyExitedEarly = true;
631
- resolve();
632
- }
787
+ earlyExitCode = exitCode;
788
+ resolve();
633
789
  });
634
790
  setTimeout(resolve, 2000);
635
791
  });
636
792
  await earlyExitCheck;
637
- if (ptyExitedEarly) {
638
- const nodeVer = process.version;
639
- console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
640
- console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
641
- console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
642
- process.exit(1);
793
+ if (earlyExitCode !== null) {
794
+ if (earlyExitCode === 134 || earlyExitCode === 3221226505) {
795
+ const nodeVer = process.version;
796
+ console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
797
+ console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
798
+ console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
799
+ process.exit(1);
800
+ }
801
+ else {
802
+ console.log(`\n${DIM}Process exited (code ${earlyExitCode}).${RESET}`);
803
+ server.close();
804
+ process.exit(earlyExitCode);
805
+ }
643
806
  }
644
807
  ptyProcess.onData((data) => {
645
808
  process.stdout.write(data);
@@ -0,0 +1 @@
1
+ export declare function redactSecrets(text: string): string;
package/dist/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ export function redactSecrets(text) {
2
+ return text
3
+ // Generic patterns: key=value, key: value, key="value"
4
+ .replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
5
+ // OpenAI keys
6
+ .replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
7
+ // GitHub tokens
8
+ .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
9
+ // AWS keys
10
+ .replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
11
+ // Azure connection strings
12
+ .replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
13
+ .replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
14
+ // Database URLs
15
+ .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]')
18
+ // JWT tokens
19
+ .replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
20
+ // Slack tokens
21
+ .replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
22
+ // npm tokens
23
+ .replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
24
+ // PEM private keys
25
+ .replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.8",
3
+ "version": "1.2.0",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "vitest run"
16
+ "test": "vitest run",
17
+ "test:coverage": "vitest run --coverage"
17
18
  },
18
19
  "keywords": [
19
20
  "cli",
@@ -35,13 +36,14 @@
35
36
  "node": ">=22.0.0"
36
37
  },
37
38
  "dependencies": {
38
- "node-pty": "^1.1.0",
39
- "qrcode-terminal": "^0.12.0",
40
- "ws": "^8.19.0"
39
+ "node-pty": "1.1.0",
40
+ "qrcode-terminal": "0.12.0",
41
+ "ws": "8.19.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^25.3.2",
44
45
  "@types/ws": "^8.18.1",
46
+ "@vitest/coverage-v8": "^4.0.18",
45
47
  "typescript": "^5.9.3",
46
48
  "vitest": "^4.0.18"
47
49
  }