@pixelbyte-software/pixcode 1.54.0 → 1.54.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.
Files changed (74) hide show
  1. package/dist/assets/{index-CEw93JQ7.js → index-CLO8YURv.js} +205 -205
  2. package/dist/assets/index-DvudRU5X.css +32 -0
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/claude-sdk.js +6 -0
  5. package/dist-server/server/claude-sdk.js.map +1 -1
  6. package/dist-server/server/index.js +240 -46
  7. package/dist-server/server/index.js.map +1 -1
  8. package/dist-server/server/middleware/account-lockout.js +101 -0
  9. package/dist-server/server/middleware/account-lockout.js.map +1 -0
  10. package/dist-server/server/middleware/auth.js +71 -9
  11. package/dist-server/server/middleware/auth.js.map +1 -1
  12. package/dist-server/server/middleware/rate-limiter.js +62 -0
  13. package/dist-server/server/middleware/rate-limiter.js.map +1 -0
  14. package/dist-server/server/routes/agent.js +3 -4
  15. package/dist-server/server/routes/agent.js.map +1 -1
  16. package/dist-server/server/routes/auth.js +75 -13
  17. package/dist-server/server/routes/auth.js.map +1 -1
  18. package/dist-server/server/routes/codex.js +1 -1
  19. package/dist-server/server/routes/codex.js.map +1 -1
  20. package/dist-server/server/routes/diagnostics.js +6 -3
  21. package/dist-server/server/routes/diagnostics.js.map +1 -1
  22. package/dist-server/server/routes/gemini.js +1 -1
  23. package/dist-server/server/routes/gemini.js.map +1 -1
  24. package/dist-server/server/routes/git.js +9 -9
  25. package/dist-server/server/routes/git.js.map +1 -1
  26. package/dist-server/server/routes/live-view.js +2 -2
  27. package/dist-server/server/routes/live-view.js.map +1 -1
  28. package/dist-server/server/routes/plugins.js +12 -12
  29. package/dist-server/server/routes/plugins.js.map +1 -1
  30. package/dist-server/server/routes/projects.js +2 -2
  31. package/dist-server/server/routes/projects.js.map +1 -1
  32. package/dist-server/server/routes/qwen.js +1 -1
  33. package/dist-server/server/routes/qwen.js.map +1 -1
  34. package/dist-server/server/routes/remote.js +1 -1
  35. package/dist-server/server/routes/remote.js.map +1 -1
  36. package/dist-server/server/routes/webhooks.js +1 -1
  37. package/dist-server/server/routes/webhooks.js.map +1 -1
  38. package/dist-server/server/services/startup-update.js +31 -6
  39. package/dist-server/server/services/startup-update.js.map +1 -1
  40. package/dist-server/server/services/telegram/bot.js +6 -4
  41. package/dist-server/server/services/telegram/bot.js.map +1 -1
  42. package/dist-server/server/setup-wizard.js +12 -6
  43. package/dist-server/server/setup-wizard.js.map +1 -1
  44. package/dist-server/server/utils/plugin-loader.js +3 -0
  45. package/dist-server/server/utils/plugin-loader.js.map +1 -1
  46. package/dist-server/server/utils/port-access.js +25 -8
  47. package/dist-server/server/utils/port-access.js.map +1 -1
  48. package/dist-server/server/utils/security-log.js +89 -0
  49. package/dist-server/server/utils/security-log.js.map +1 -0
  50. package/package.json +1 -1
  51. package/server/claude-sdk.js +7 -0
  52. package/server/index.js +249 -46
  53. package/server/middleware/account-lockout.js +112 -0
  54. package/server/middleware/auth.js +72 -9
  55. package/server/middleware/rate-limiter.js +82 -0
  56. package/server/routes/agent.js +3 -4
  57. package/server/routes/auth.js +86 -13
  58. package/server/routes/codex.js +1 -1
  59. package/server/routes/diagnostics.js +6 -3
  60. package/server/routes/gemini.js +1 -1
  61. package/server/routes/git.js +9 -9
  62. package/server/routes/live-view.js +2 -2
  63. package/server/routes/plugins.js +12 -12
  64. package/server/routes/projects.js +2 -2
  65. package/server/routes/qwen.js +1 -1
  66. package/server/routes/remote.js +1 -1
  67. package/server/routes/webhooks.js +1 -1
  68. package/server/services/startup-update.js +31 -6
  69. package/server/services/telegram/bot.js +6 -4
  70. package/server/setup-wizard.js +12 -6
  71. package/server/utils/plugin-loader.js +4 -0
  72. package/server/utils/port-access.js +26 -8
  73. package/server/utils/security-log.js +84 -0
  74. package/dist/assets/index-B6Ssy_IG.css +0 -32
@@ -887,7 +887,7 @@ router.post('/initial-commit', async (req, res) => {
887
887
  });
888
888
  }
889
889
 
890
- res.status(500).json({ error: error.message });
890
+ res.status(500).json({ error: "Internal server error" });
891
891
  }
892
892
  });
893
893
 
@@ -918,7 +918,7 @@ router.post('/commit', async (req, res) => {
918
918
  res.json({ success: true, output: stdout });
919
919
  } catch (error) {
920
920
  console.error('Git commit error:', error);
921
- res.status(500).json({ error: error.message });
921
+ res.status(500).json({ error: "Internal server error" });
922
922
  }
923
923
  });
924
924
 
@@ -965,7 +965,7 @@ router.post('/revert-local-commit', async (req, res) => {
965
965
  });
966
966
  } catch (error) {
967
967
  console.error('Git revert local commit error:', error);
968
- res.status(500).json({ error: error.message });
968
+ res.status(500).json({ error: "Internal server error" });
969
969
  }
970
970
  });
971
971
 
@@ -1031,7 +1031,7 @@ router.post('/checkout', async (req, res) => {
1031
1031
  res.json({ success: true, output: stdout });
1032
1032
  } catch (error) {
1033
1033
  console.error('Git checkout error:', error);
1034
- res.status(500).json({ error: error.message });
1034
+ res.status(500).json({ error: "Internal server error" });
1035
1035
  }
1036
1036
  });
1037
1037
 
@@ -1053,7 +1053,7 @@ router.post('/create-branch', async (req, res) => {
1053
1053
  res.json({ success: true, output: stdout });
1054
1054
  } catch (error) {
1055
1055
  console.error('Git create branch error:', error);
1056
- res.status(500).json({ error: error.message });
1056
+ res.status(500).json({ error: "Internal server error" });
1057
1057
  }
1058
1058
  });
1059
1059
 
@@ -1079,7 +1079,7 @@ router.post('/delete-branch', async (req, res) => {
1079
1079
  res.json({ success: true, output: stdout });
1080
1080
  } catch (error) {
1081
1081
  console.error('Git delete branch error:', error);
1082
- res.status(500).json({ error: error.message });
1082
+ res.status(500).json({ error: "Internal server error" });
1083
1083
  }
1084
1084
  });
1085
1085
 
@@ -1234,7 +1234,7 @@ router.post('/generate-commit-message', async (req, res) => {
1234
1234
  res.json({ message });
1235
1235
  } catch (error) {
1236
1236
  console.error('Generate commit message error:', error);
1237
- res.status(500).json({ error: error.message });
1237
+ res.status(500).json({ error: "Internal server error" });
1238
1238
  }
1239
1239
  });
1240
1240
 
@@ -1764,7 +1764,7 @@ router.post('/discard', async (req, res) => {
1764
1764
  res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
1765
1765
  } catch (error) {
1766
1766
  console.error('Git discard error:', error);
1767
- res.status(500).json({ error: error.message });
1767
+ res.status(500).json({ error: "Internal server error" });
1768
1768
  }
1769
1769
  });
1770
1770
 
@@ -1815,7 +1815,7 @@ router.post('/delete-untracked', async (req, res) => {
1815
1815
  }
1816
1816
  } catch (error) {
1817
1817
  console.error('Git delete untracked error:', error);
1818
- res.status(500).json({ error: error.message });
1818
+ res.status(500).json({ error: "Internal server error" });
1819
1819
  }
1820
1820
  });
1821
1821
 
@@ -298,7 +298,7 @@ router.post('/:projectName/restart', requireLiveViewProjectAccess('useShell'), a
298
298
  environment: attachEnvironmentRuntimeState(state.environment, urls, tunnel),
299
299
  });
300
300
  } catch (error) {
301
- res.status(500).json({ error: error.message || 'Failed to restart Live View' });
301
+ res.status(500).json({ error: 'Failed to restart Live View' });
302
302
  }
303
303
  });
304
304
 
@@ -315,7 +315,7 @@ router.post('/:projectName/stop', requireLiveViewProjectAccess('useShell'), asyn
315
315
  environment: attachEnvironmentRuntimeState(null, urls, tunnel),
316
316
  });
317
317
  } catch (error) {
318
- res.status(500).json({ error: error.message || 'Failed to stop Live View' });
318
+ res.status(500).json({ error: 'Failed to stop Live View' });
319
319
  }
320
320
  });
321
321
 
@@ -317,7 +317,7 @@ router.get('/marketplace', (req, res) => {
317
317
  categories: ['popular', 'skills', 'plugin', 'workflow', 'new'],
318
318
  });
319
319
  } catch (err) {
320
- res.status(500).json({ error: 'Failed to read marketplace', details: err.message });
320
+ res.status(500).json({ error: 'Failed to read marketplace' });
321
321
  }
322
322
  });
323
323
 
@@ -358,7 +358,7 @@ router.get('/marketplace/search', async (req, res) => {
358
358
  res.json({ entries });
359
359
  } catch (err) {
360
360
  const status = err?.name === 'AbortError' ? 504 : 502;
361
- res.status(status).json({ error: 'GitHub search failed', details: err.message });
361
+ res.status(status).json({ error: 'GitHub search failed' });
362
362
  } finally {
363
363
  clearTimeout(timeout);
364
364
  }
@@ -378,7 +378,7 @@ router.post('/marketplace/sources', (req, res) => {
378
378
  writeMarketplaceSources(sources.slice(0, 200));
379
379
  res.json({ success: true, source, installedSources: readMarketplaceSources() });
380
380
  } catch (err) {
381
- res.status(400).json({ error: 'Failed to add source', details: err.message });
381
+ res.status(400).json({ error: 'Failed to add source' });
382
382
  }
383
383
  });
384
384
 
@@ -391,7 +391,7 @@ router.delete('/marketplace/sources/:id', (req, res) => {
391
391
  writeMarketplaceSources(next);
392
392
  res.json({ success: true, installedSources: next });
393
393
  } catch (err) {
394
- res.status(400).json({ error: 'Failed to remove source', details: err.message });
394
+ res.status(400).json({ error: 'Failed to remove source' });
395
395
  }
396
396
  });
397
397
 
@@ -404,7 +404,7 @@ router.get('/', (req, res) => {
404
404
  }));
405
405
  res.json({ plugins });
406
406
  } catch (err) {
407
- res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
407
+ res.status(500).json({ error: 'Failed to scan plugins' });
408
408
  }
409
409
  });
410
410
 
@@ -421,7 +421,7 @@ router.get('/:name/manifest', (req, res) => {
421
421
  }
422
422
  res.json(plugin);
423
423
  } catch (err) {
424
- res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
424
+ res.status(500).json({ error: 'Failed to read plugin manifest' });
425
425
  }
426
426
  });
427
427
 
@@ -512,7 +512,7 @@ router.put('/:name/enable', async (req, res) => {
512
512
 
513
513
  res.json({ success: true, name: req.params.name, enabled });
514
514
  } catch (err) {
515
- res.status(500).json({ error: 'Failed to update plugin', details: err.message });
515
+ res.status(500).json({ error: 'Failed to update plugin' });
516
516
  }
517
517
  });
518
518
 
@@ -545,7 +545,7 @@ router.post('/install', async (req, res) => {
545
545
 
546
546
  res.json({ success: true, plugin: manifest });
547
547
  } catch (err) {
548
- res.status(400).json({ error: 'Failed to install plugin', details: err.message });
548
+ res.status(400).json({ error: 'Failed to install plugin' });
549
549
  }
550
550
  });
551
551
 
@@ -579,7 +579,7 @@ router.post('/:name/update', async (req, res) => {
579
579
 
580
580
  res.json({ success: true, plugin: manifest });
581
581
  } catch (err) {
582
- res.status(400).json({ error: 'Failed to update plugin', details: err.message });
582
+ res.status(400).json({ error: 'Failed to update plugin' });
583
583
  }
584
584
  });
585
585
 
@@ -610,7 +610,7 @@ router.all(/^\/([a-zA-Z0-9_-]+)\/rpc\/(.*)$/, async (req, res) => {
610
610
  try {
611
611
  port = await startPluginServer(pluginName, pluginDir, plugin.server);
612
612
  } catch (err) {
613
- return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
613
+ return res.status(503).json({ error: 'Plugin server failed to start' });
614
614
  }
615
615
  }
616
616
 
@@ -646,7 +646,7 @@ router.all(/^\/([a-zA-Z0-9_-]+)\/rpc\/(.*)$/, async (req, res) => {
646
646
 
647
647
  proxyReq.on('error', (err) => {
648
648
  if (!res.headersSent) {
649
- res.status(502).json({ error: 'Plugin server error', details: err.message });
649
+ res.status(502).json({ error: 'Plugin server error' });
650
650
  } else {
651
651
  res.end();
652
652
  }
@@ -683,7 +683,7 @@ router.delete('/:name', async (req, res) => {
683
683
  await uninstallPlugin(pluginName);
684
684
  res.json({ success: true, name: pluginName });
685
685
  } catch (err) {
686
- res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
686
+ res.status(400).json({ error: 'Failed to uninstall plugin' });
687
687
  }
688
688
  });
689
689
 
@@ -326,7 +326,7 @@ router.get('/:projectName/dir-status', requireAdmin, async (req, res) => {
326
326
  }
327
327
  } catch (error) {
328
328
  console.error(`[projects] dir-status ${projectName}:`, error);
329
- res.status(500).json({ error: error.message || 'Failed to check project directory' });
329
+ res.status(500).json({ error: 'Failed to check project directory' });
330
330
  }
331
331
  });
332
332
 
@@ -410,7 +410,7 @@ router.post('/quick-start', requireAdmin, async (req, res) => {
410
410
  res.json({ success: true, project, suggestedName: name, reused: false });
411
411
  } catch (error) {
412
412
  console.error('[projects] quick-start failed:', error);
413
- res.status(500).json({ error: error.message || 'Failed to quick-start project' });
413
+ res.status(500).json({ error: 'Failed to quick-start project' });
414
414
  }
415
415
  });
416
416
 
@@ -20,7 +20,7 @@ router.delete('/sessions/:sessionId', async (req, res) => {
20
20
  res.json({ success: true });
21
21
  } catch (error) {
22
22
  console.error(`Error deleting Qwen session ${req.params.sessionId}:`, error);
23
- res.status(500).json({ success: false, error: error.message });
23
+ res.status(500).json({ success: false, error: "Internal server error" });
24
24
  }
25
25
  });
26
26
 
@@ -42,7 +42,7 @@ router.get('/control-room', async (_req, res) => {
42
42
  controlRoom: await buildControlRoomSnapshot(),
43
43
  });
44
44
  } catch (error) {
45
- res.status(500).json({ success: false, error: error.message });
45
+ res.status(500).json({ success: false, error: "Internal server error" });
46
46
  }
47
47
  });
48
48
 
@@ -56,7 +56,7 @@ router.post('/test', async (req, res) => {
56
56
  });
57
57
  res.json({ success: true, result });
58
58
  } catch (error) {
59
- res.status(500).json({ success: false, error: error.message });
59
+ res.status(500).json({ success: false, error: "Internal server error" });
60
60
  }
61
61
  });
62
62
 
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
3
4
  import { spawn, spawnSync } from 'node:child_process';
4
5
  import { Readable } from 'node:stream';
5
6
 
@@ -68,10 +69,14 @@ async function readLatestPackageMetadata() {
68
69
  const latestVersion = metadata?.['dist-tags']?.latest;
69
70
  const latestEntry = latestVersion ? metadata?.versions?.[latestVersion] : null;
70
71
  const tarballUrl = latestEntry?.dist?.tarball;
72
+ const integrity = latestEntry?.dist?.integrity;
71
73
  if (!latestVersion || !tarballUrl) {
72
74
  throw new Error('Registry response missing latest version or tarball URL.');
73
75
  }
74
- return { latestVersion, tarballUrl };
76
+ if (!integrity) {
77
+ console.warn('[startup-update] Registry response missing integrity hash — proceeding without verification.');
78
+ }
79
+ return { latestVersion, tarballUrl, integrity };
75
80
  }
76
81
 
77
82
  async function installNpmGlobal(latestVersion, color) {
@@ -119,7 +124,7 @@ function readPackageVersion(appRoot) {
119
124
  }
120
125
  }
121
126
 
122
- async function extractRuntimeTarball({ runtimeDir, tarballUrl, latestVersion, currentVersion, color }) {
127
+ async function extractRuntimeTarball({ runtimeDir, tarballUrl, latestVersion, currentVersion, color, integrity }) {
123
128
  color?.info && console.log(`${color.info('[INFO]')} Updating runtime ${currentVersion} -> ${latestVersion} before opening the port...`);
124
129
 
125
130
  const tarballRes = await fetch(tarballUrl);
@@ -127,6 +132,27 @@ async function extractRuntimeTarball({ runtimeDir, tarballUrl, latestVersion, cu
127
132
  throw new Error(`Tarball fetch failed: HTTP ${tarballRes.status}`);
128
133
  }
129
134
 
135
+ // Download tarball to a buffer first so we can verify integrity before extracting.
136
+ const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
137
+
138
+ // Verify integrity hash (SRI format: "sha512-<base64>") if provided by the registry.
139
+ if (integrity && typeof integrity === 'string') {
140
+ const match = integrity.match(/^(sha512)-(.+)$/);
141
+ if (match) {
142
+ const algo = match[1];
143
+ const expectedHash = match[2];
144
+ const actualHash = crypto.createHash(algo).update(tarballBuffer).digest('base64');
145
+ if (actualHash !== expectedHash) {
146
+ throw new Error(`Integrity verification failed: expected ${algo}-${expectedHash.slice(0, 16)}..., got ${algo}-${actualHash.slice(0, 16)}...`);
147
+ }
148
+ color?.info && console.log(`${color.info('[INFO]')} Tarball integrity verified (${algo}).`);
149
+ } else {
150
+ console.warn('[startup-update] Unrecognized integrity format — skipping verification.');
151
+ }
152
+ } else {
153
+ console.warn('[startup-update] No integrity hash available — tarball extracted without verification.');
154
+ }
155
+
130
156
  const stagingDir = path.join(runtimeDir, '.staging');
131
157
  const backupDir = path.join(runtimeDir, '.previous');
132
158
  fs.rmSync(stagingDir, { recursive: true, force: true });
@@ -137,9 +163,7 @@ async function extractRuntimeTarball({ runtimeDir, tarballUrl, latestVersion, cu
137
163
  if (!tarExtract) throw new Error('tar extractor not available');
138
164
 
139
165
  await new Promise((resolve, reject) => {
140
- const nodeStream = typeof Readable.fromWeb === 'function' && tarballRes.body?.getReader
141
- ? Readable.fromWeb(tarballRes.body)
142
- : tarballRes.body;
166
+ const nodeStream = Readable.from(tarballBuffer);
143
167
  const extractor = tarExtract({ cwd: stagingDir, strip: 1 });
144
168
  nodeStream.pipe(extractor);
145
169
  extractor.on('finish', resolve);
@@ -199,7 +223,7 @@ export async function runStartupAutoUpdate({
199
223
  return await updateGitCheckout(appRoot, color);
200
224
  }
201
225
 
202
- const { latestVersion, tarballUrl } = await readLatestPackageMetadata();
226
+ const { latestVersion, tarballUrl, integrity } = await readLatestPackageMetadata();
203
227
  if (compareVersions(latestVersion, currentVersion) <= 0) {
204
228
  return { updated: false, latestVersion };
205
229
  }
@@ -211,6 +235,7 @@ export async function runStartupAutoUpdate({
211
235
  latestVersion,
212
236
  currentVersion,
213
237
  color,
238
+ integrity,
214
239
  });
215
240
  return { updated: true, version: latestVersion, restartMode: 'exit42' };
216
241
  }
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import crypto from 'node:crypto';
2
3
 
3
4
  import { telegramConfigDb, telegramLinksDb } from '../../database/db.js';
4
5
 
@@ -43,10 +44,11 @@ export const telegramEvents = new EventEmitter();
43
44
  const now = () => Date.now();
44
45
 
45
46
  const generate6DigitCode = () => {
46
- // Zero-padded random 6-digit code. Using rand instead of crypto is fine
47
- // here: the code is short-lived (10min), single-use, and verified against
48
- // a per-user row brute-force requires both the code AND a live pairing.
49
- const n = Math.floor(Math.random() * 1_000_000);
47
+ // Zero-padded random 6-digit code. Using crypto.randomInt prevents
48
+ // modulo bias and ensures uniform distribution across the 000000–999999
49
+ // range. The code is short-lived (10min), single-use, and verified
50
+ // against a per-user row.
51
+ const n = crypto.randomInt(0, 1_000_000);
50
52
  return n.toString().padStart(6, '0');
51
53
  };
52
54
 
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import net from 'node:net';
5
5
  import readline from 'readline';
6
- import { exec } from 'node:child_process';
6
+ import { spawn } from 'node:child_process';
7
7
 
8
8
  import { c } from './utils/colors.js';
9
9
 
@@ -108,16 +108,22 @@ async function askSelect(question, options) {
108
108
 
109
109
  function openBrowser(url) {
110
110
  const platform = process.platform;
111
- let cmd;
111
+ // Use spawn with an argument array instead of exec with a shell string
112
+ // to prevent command injection through the url value.
113
+ let bin;
114
+ let args;
112
115
  if (platform === 'darwin') {
113
- cmd = `open "${url}"`;
116
+ bin = 'open';
117
+ args = [url];
114
118
  } else if (platform === 'win32') {
115
- cmd = `start "" "${url}"`;
119
+ bin = 'cmd';
120
+ args = ['/c', 'start', '', url];
116
121
  } else {
117
- cmd = `xdg-open "${url}"`;
122
+ bin = 'xdg-open';
123
+ args = [url];
118
124
  }
119
125
  try {
120
- exec(cmd, { stdio: 'ignore', timeout: 3000 });
126
+ spawn(bin, args, { stdio: 'ignore', timeout: 3000, detached: true, shell: false }).unref();
121
127
  return true;
122
128
  } catch {
123
129
  return false;
@@ -3,6 +3,8 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { spawn } from 'child_process';
5
5
 
6
+ import { securityLog } from './security-log.js';
7
+
6
8
  const PLUGINS_DIR = path.join(os.homedir(), '.pixcode', 'plugins');
7
9
  const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.pixcode', 'plugins.json');
8
10
 
@@ -290,6 +292,7 @@ export function installPluginFromGit(url) {
290
292
  cleanupTemp();
291
293
  return reject(new Error(`Failed to move plugin into place: ${err.message}`));
292
294
  }
295
+ securityLog('plugin_installed', { reason: manifest.name });
293
296
  resolve(manifest);
294
297
  };
295
298
 
@@ -303,6 +306,7 @@ export function installPluginFromGit(url) {
303
306
  gitProcess.on('close', (code) => {
304
307
  if (code !== 0) {
305
308
  cleanupTemp();
309
+ securityLog('plugin_install_failed', { reason: `git clone failed: exit code ${code}` });
306
310
  return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
307
311
  }
308
312
 
@@ -2,7 +2,7 @@ import os from 'os';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import readline from 'readline';
5
- import { execSync } from 'child_process';
5
+ import { execSync, spawnSync } from 'child_process';
6
6
 
7
7
  import { c } from './colors.js';
8
8
 
@@ -65,7 +65,7 @@ function tryUfw(port) {
65
65
  const status = execSync('ufw status', { stdio: 'pipe', encoding: 'utf8' });
66
66
  // UFW inactive = no rule needed; the port is already reachable.
67
67
  if (!/active/i.test(status)) return 'inactive';
68
- execSync(`sudo -n ufw allow ${port}/tcp`, { stdio: 'pipe' });
68
+ execSync('sudo -n ufw allow ' + String(port) + '/tcp', { stdio: 'pipe' });
69
69
  return 'applied';
70
70
  } catch {
71
71
  return null;
@@ -77,7 +77,7 @@ function tryFirewalld(port) {
77
77
  execSync('command -v firewall-cmd', { stdio: 'pipe' });
78
78
  const state = execSync('firewall-cmd --state', { stdio: 'pipe', encoding: 'utf8' });
79
79
  if (!/running/i.test(state)) return 'inactive';
80
- execSync(`sudo -n firewall-cmd --add-port=${port}/tcp --permanent`, { stdio: 'pipe' });
80
+ execSync('sudo -n firewall-cmd --add-port=' + String(port) + '/tcp --permanent', { stdio: 'pipe' });
81
81
  execSync('sudo -n firewall-cmd --reload', { stdio: 'pipe' });
82
82
  return 'applied';
83
83
  } catch {
@@ -89,12 +89,19 @@ function tryFirewalld(port) {
89
89
 
90
90
  function applyWindowsRule(port) {
91
91
  const ruleName = `Pixcode-${port}`;
92
- const cmd = `netsh advfirewall firewall add rule name="${ruleName}" dir=in action=allow protocol=TCP localport=${port}`;
92
+ // Use spawnSync with an argument array instead of execSync with a shell
93
+ // string to prevent command injection. Port is validated as an integer
94
+ // at the entry point, but this is defense-in-depth.
93
95
  try {
94
- execSync(cmd, { stdio: 'pipe' });
95
- return { ok: true, cmd, ruleName };
96
+ spawnSync('netsh', [
97
+ 'advfirewall', 'firewall', 'add', 'rule',
98
+ `name=${ruleName}`,
99
+ 'dir=in', 'action=allow', 'protocol=TCP',
100
+ `localport=${port}`,
101
+ ], { stdio: 'pipe', shell: false });
102
+ return { ok: true, ruleName };
96
103
  } catch (error) {
97
- return { ok: false, cmd, ruleName, error: error.message };
104
+ return { ok: false, ruleName, error: error.message };
98
105
  }
99
106
  }
100
107
 
@@ -125,6 +132,16 @@ function macFirewallHint(port) {
125
132
  * without any firewall change.
126
133
  */
127
134
  export async function ensurePortOpen(port) {
135
+ // Validate port is a safe integer to prevent command injection via the
136
+ // firewall commands below. The caller passes SERVER_PORT which comes from
137
+ // env, so this is defense-in-depth against a tampered environment.
138
+ const safePort = Number.parseInt(port, 10);
139
+ if (!Number.isInteger(safePort) || safePort < 1 || safePort > 65535) {
140
+ console.log(`${c.dim('[INFO]')} Skipping firewall setup: invalid port value.`);
141
+ return;
142
+ }
143
+ port = safePort;
144
+
128
145
  const state = loadState();
129
146
  const key = `port:${port}`;
130
147
  const entry = state[key];
@@ -185,8 +202,9 @@ export async function ensurePortOpen(port) {
185
202
  console.log(`${c.ok('[OK]')} Firewall rule "${result.ruleName}" added.`);
186
203
  saveState({ ...state, [key]: { decision: 'allow', status: 'applied', via: 'windows' } });
187
204
  } else {
205
+ const manualCmd = `netsh advfirewall firewall add rule name="Pixcode-${port}" dir=in action=allow protocol=TCP localport=${port}`;
188
206
  console.log(`${c.tip('[TIP]')} Adding the rule needs Administrator. Run this in an elevated PowerShell:`);
189
- console.log(` ${c.bright(result.cmd)}`);
207
+ console.log(` ${c.bright(manualCmd)}`);
190
208
  saveState({ ...state, [key]: { decision: 'allow', status: 'manual' } });
191
209
  }
192
210
  return;
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const LOG_DIR = path.join(os.homedir(), '.pixcode', 'logs');
6
+ const SECURITY_LOG_PATH = path.join(LOG_DIR, 'security.log');
7
+ const MAX_LOG_SIZE = 10 * 1024 * 1024;
8
+ const MAX_LOG_FILES = 5;
9
+
10
+ function ensureLogDir() {
11
+ try {
12
+ if (!fs.existsSync(LOG_DIR)) {
13
+ fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
14
+ }
15
+ } catch {
16
+ // Non-fatal: if we can't create the log dir, we fall back to console
17
+ }
18
+ }
19
+
20
+ function sanitizeForLog(value) {
21
+ if (typeof value !== 'string') {
22
+ try {
23
+ value = String(value ?? '');
24
+ } catch {
25
+ return '[unserializable]';
26
+ }
27
+ }
28
+ return value
29
+ .replace(/[\r\n\t]/g, ' ')
30
+ .replace(/\x1b\[[0-9;]*m/g, '')
31
+ .slice(0, 500);
32
+ }
33
+
34
+ function rotateLogIfNeeded() {
35
+ try {
36
+ if (!fs.existsSync(SECURITY_LOG_PATH)) return;
37
+ const stats = fs.statSync(SECURITY_LOG_PATH);
38
+ if (stats.size < MAX_LOG_SIZE) return;
39
+ for (let i = MAX_LOG_FILES - 1; i >= 1; i--) {
40
+ const oldPath = `${SECURITY_LOG_PATH}.${i}`;
41
+ const newPath = `${SECURITY_LOG_PATH}.${i + 1}`;
42
+ if (fs.existsSync(oldPath)) {
43
+ try { fs.renameSync(oldPath, newPath); } catch { /* noop */ }
44
+ }
45
+ }
46
+ try { fs.renameSync(SECURITY_LOG_PATH, `${SECURITY_LOG_PATH}.1`); } catch { /* noop */ }
47
+ } catch {
48
+ // Non-fatal
49
+ }
50
+ }
51
+
52
+ export function securityLog(event, details = {}) {
53
+ const entry = {
54
+ timestamp: new Date().toISOString(),
55
+ event,
56
+ ip: sanitizeForLog(details.ip),
57
+ userId: details.userId != null ? String(details.userId) : null,
58
+ username: details.username ? sanitizeForLog(details.username) : null,
59
+ endpoint: details.endpoint ? sanitizeForLog(details.endpoint) : null,
60
+ method: details.method ? sanitizeForLog(details.method) : null,
61
+ statusCode: details.statusCode || null,
62
+ reason: details.reason ? sanitizeForLog(details.reason) : null,
63
+ userAgent: details.userAgent ? sanitizeForLog(details.userAgent) : null,
64
+ };
65
+
66
+ const line = JSON.stringify(entry);
67
+
68
+ try {
69
+ ensureLogDir();
70
+ rotateLogIfNeeded();
71
+ fs.appendFileSync(SECURITY_LOG_PATH, line + '\n', { mode: 0o600 });
72
+ } catch {
73
+ // Fall back to console if file logging fails
74
+ console.warn('[SECURITY]', line);
75
+ }
76
+ }
77
+
78
+ export function getClientIp(req) {
79
+ const forwarded = req?.headers?.['x-forwarded-for'];
80
+ if (typeof forwarded === 'string' && forwarded.trim()) {
81
+ return forwarded.split(',')[0].trim();
82
+ }
83
+ return req?.socket?.remoteAddress || req?.ip || 'unknown';
84
+ }