magector 2.15.0 → 2.15.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
@@ -136,6 +136,37 @@ flowchart LR
136
136
 
137
137
  ---
138
138
 
139
+ ## Security
140
+
141
+ Magector operates on source code indexed from potentially-untrusted `vendor/` dependencies and is driven by an LLM that may be manipulated via prompt injection in indexed comments, docblocks, or markdown. The following hardening applies as of **v2.15.1**:
142
+
143
+ ### Path traversal protection
144
+
145
+ All tools that accept a `path` argument (`magento_read`, `magento_grep`, `magento_ast_search`, `magento_find_dataobject_issues`) route the input through `safePath()` / `safeRelPath()` helpers in `src/mcp-server.js`. These:
146
+
147
+ 1. Resolve the argument against `MAGENTO_ROOT` with `path.resolve()` (normalizes `..`, symlinks are not followed during validation).
148
+ 2. Reject any resolved path that does not lie inside `MAGENTO_ROOT`.
149
+
150
+ This prevents a hostile `vendor/` comment from instructing the LLM to e.g. `magento_read` `../../home/user/.ssh/id_rsa`. Both the standalone case handlers and their `magento_batch` counterparts share the same chokepoint.
151
+
152
+ ### Shell injection hardening in auto-update
153
+
154
+ `src/update.js` fetches the `latest` field from the npm registry and re-execs itself with the new version string. Previously this was interpolated into a shell command; a tampered registry response could inject shell metacharacters. As of v2.15.1:
155
+
156
+ - The re-exec passes argv as an **array** to a no-shell spawner (no intermediate shell).
157
+ - A semver-strict `isSafeVersion()` validator rejects any version string containing metacharacters or that does not match `X.Y.Z` / `X.Y.Z-prerelease` form.
158
+ - Fails closed: the auto-update is silently skipped rather than run a malformed version.
159
+
160
+ ### Unix socket permissions
161
+
162
+ The serve-proxy Unix socket at `.magector/serve.sock` is created with `chmod 0600` immediately after `listen()`. On multi-user systems, another local account can no longer connect and query the vector index (which would leak indexed source snippets). The chmod is best-effort on platforms that don't support it (logged to `.magector/magector.log`).
163
+
164
+ ### Reporting vulnerabilities
165
+
166
+ If you find a security issue, please open an issue on the GitHub repo and mark it as security-related. Do not post reproducers that leak actual source contents from private codebases.
167
+
168
+ ---
169
+
139
170
  ## Quick Start
140
171
 
141
172
  ### Prerequisites
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magector",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "description": "Semantic code search for Magento 2 — index, search, MCP server",
5
5
  "type": "module",
6
6
  "main": "src/mcp-server.js",
@@ -33,10 +33,10 @@
33
33
  "ruvector": "^0.1.96"
34
34
  },
35
35
  "optionalDependencies": {
36
- "@magector/cli-darwin-arm64": "2.15.0",
37
- "@magector/cli-linux-x64": "2.15.0",
38
- "@magector/cli-linux-arm64": "2.15.0",
39
- "@magector/cli-win32-x64": "2.15.0"
36
+ "@magector/cli-darwin-arm64": "2.15.1",
37
+ "@magector/cli-linux-x64": "2.15.1",
38
+ "@magector/cli-linux-arm64": "2.15.1",
39
+ "@magector/cli-win32-x64": "2.15.1"
40
40
  },
41
41
  "keywords": [
42
42
  "magento",
package/src/mcp-server.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { execFileSync, spawn } from 'child_process';
18
18
  import { createInterface } from 'readline';
19
19
  import { createServer as createNetServer, createConnection } from 'net';
20
- import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync, openSync, closeSync, constants as fsConstants } from 'fs';
20
+ import { existsSync, statSync, unlinkSync, copyFileSync, renameSync, appendFileSync, writeFileSync, readFileSync, mkdirSync, openSync, closeSync, chmodSync, constants as fsConstants } from 'fs';
21
21
  import { stat } from 'fs/promises';
22
22
  import { glob } from 'glob';
23
23
  import path from 'path';
@@ -149,6 +149,38 @@ const SOCK_PATH = path.join(config.magentoRoot, '.magector', 'serve.sock');
149
149
  const FORMAT_CACHE_PATH = path.join(config.magentoRoot, '.magector', 'format-ok.json');
150
150
  const PRIMARY_LOCK_PATH = path.join(config.magentoRoot, '.magector', 'primary.lock');
151
151
 
152
+ // ─── Path Safety ────────────────────────────────────────────────
153
+ // All tool handlers accept user-supplied paths relative to MAGENTO_ROOT.
154
+ // Without validation, `../../../etc/passwd` would escape the project.
155
+ // A hostile indexed file could prompt-inject the LLM into requesting such a
156
+ // path and leak host files. These helpers are the single chokepoint.
157
+
158
+ /**
159
+ * Resolve a user-supplied path against a trusted root and verify it stays
160
+ * inside the root. Returns the absolute resolved path or null on escape.
161
+ */
162
+ function safePath(root, rel) {
163
+ if (rel === undefined || rel === null) return null;
164
+ const rootAbs = path.resolve(root);
165
+ const joined = path.resolve(rootAbs, String(rel));
166
+ if (joined !== rootAbs && !joined.startsWith(rootAbs + path.sep)) {
167
+ return null;
168
+ }
169
+ return joined;
170
+ }
171
+
172
+ /**
173
+ * Like safePath but returns the relative form (used for tools that invoke
174
+ * external processes with cwd=root and want a relative path argument).
175
+ * '.' means "the root itself".
176
+ */
177
+ function safeRelPath(root, rel) {
178
+ const abs = safePath(root, rel);
179
+ if (!abs) return null;
180
+ const r = path.relative(path.resolve(root), abs);
181
+ return r === '' ? '.' : r;
182
+ }
183
+
152
184
  /**
153
185
  * Expand brace patterns in include globs for GNU grep compatibility.
154
186
  * GNU grep --include does NOT support brace expansion (that's a shell feature).
@@ -737,7 +769,13 @@ function startSocketProxy() {
737
769
  logToFile('WARN', `Socket proxy error: ${err.message}`);
738
770
  });
739
771
  socketServer.listen(SOCK_PATH, () => {
740
- logToFile('INFO', `Socket proxy listening on ${SOCK_PATH}`);
772
+ // Restrict socket to the owning user — without this, on multi-user
773
+ // systems any local account could connect to the serve proxy and query
774
+ // the index (leaking indexed code snippets to other local users).
775
+ try { chmodSync(SOCK_PATH, 0o600); } catch (err) {
776
+ logToFile('WARN', `Failed to chmod socket to 0600: ${err.message}`);
777
+ }
778
+ logToFile('INFO', `Socket proxy listening on ${SOCK_PATH} (mode 0600)`);
741
779
  });
742
780
  }
743
781
 
@@ -3663,7 +3701,11 @@ async function astSearch(pattern, searchPath, lang, maxResults) {
3663
3701
  const root = config.magentoRoot;
3664
3702
  if (!root) throw new Error('MAGENTO_ROOT not set');
3665
3703
 
3666
- const targetPath = searchPath ? path.join(root, searchPath) : root;
3704
+ const targetPath = searchPath ? safePath(root, searchPath) : path.resolve(root);
3705
+ if (!targetPath) {
3706
+ logToFile('WARN', `ast_search: rejected path traversal attempt: "${searchPath}"`);
3707
+ throw new Error(`Path escapes project root: ${searchPath}`);
3708
+ }
3667
3709
  const semgrepLang = lang || 'php';
3668
3710
  const limit = Math.min(maxResults || 50, 200);
3669
3711
 
@@ -6466,7 +6508,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6466
6508
  break;
6467
6509
  }
6468
6510
  case 'magento_read': {
6469
- const filePath = path.join(config.magentoRoot, a.path);
6511
+ const filePath = safePath(config.magentoRoot, a.path);
6512
+ if (!filePath) {
6513
+ logToFile('WARN', `batch read: rejected path traversal attempt: "${a.path}"`);
6514
+ text = `Path escapes project root: ${a.path}`;
6515
+ break;
6516
+ }
6470
6517
  let fileContent;
6471
6518
  try { fileContent = readFileSync(filePath, 'utf-8'); } catch {
6472
6519
  text = `File not found: ${a.path}`;
@@ -6491,7 +6538,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6491
6538
  break;
6492
6539
  }
6493
6540
  case 'magento_grep': {
6494
- const searchPath = a.path || '.';
6541
+ const searchPath = safeRelPath(config.magentoRoot, a.path || '.');
6542
+ if (!searchPath) {
6543
+ logToFile('WARN', `batch grep: rejected path traversal attempt: "${a.path}"`);
6544
+ text = `Path escapes project root: ${a.path}`;
6545
+ break;
6546
+ }
6495
6547
  const include = a.include || '*.php';
6496
6548
  const maxRes = Math.min(a.maxResults || 30, 100);
6497
6549
  const batchCtx = a.context !== undefined ? a.context : 4;
@@ -6569,7 +6621,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6569
6621
  case 'magento_grep': {
6570
6622
  const root = config.magentoRoot;
6571
6623
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6572
- const searchPath = args.path || '.';
6624
+ const searchPath = safeRelPath(root, args.path || '.');
6625
+ if (!searchPath) {
6626
+ logToFile('WARN', `grep: rejected path traversal attempt: "${args.path}"`);
6627
+ return { content: [{ type: 'text', text: `Path escapes project root: ${args.path}` }], isError: true };
6628
+ }
6573
6629
  const include = args.include || '*.php';
6574
6630
  const maxResults = Math.min(args.maxResults || 50, 200);
6575
6631
  const ctxLines = args.context !== undefined ? args.context : 4;
@@ -6756,7 +6812,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6756
6812
  case 'magento_read': {
6757
6813
  const root = config.magentoRoot;
6758
6814
  if (!root) return { content: [{ type: 'text', text: 'MAGENTO_ROOT not set.' }], isError: true };
6759
- const filePath = path.join(root, args.path);
6815
+ const filePath = safePath(root, args.path);
6816
+ if (!filePath) {
6817
+ logToFile('WARN', `read: rejected path traversal attempt: "${args.path}"`);
6818
+ return { content: [{ type: 'text', text: `Path escapes project root: ${args.path}` }], isError: true };
6819
+ }
6760
6820
  let content;
6761
6821
  try { content = readFileSync(filePath, 'utf-8'); } catch (err) {
6762
6822
  logToFile('WARN', `read: file not found: ${args.path} (${err.code || err.message})`);
package/src/update.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Never blocks the CLI on failure — network errors are silently ignored.
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
- import { execSync } from 'child_process';
11
+ import { execFileSync } from 'child_process';
12
12
  import { homedir } from 'os';
13
13
  import path from 'path';
14
14
  import { fileURLToPath } from 'url';
@@ -140,14 +140,29 @@ export async function checkForUpdate(command, originalArgs) {
140
140
  }
141
141
  }
142
142
 
143
+ /**
144
+ * Validate a semver string to prevent shell injection via malicious registry
145
+ * responses. Only digits, dots, dashes and alphanumerics allowed (semver prerelease).
146
+ * Example: "1.2.3", "1.2.3-beta.1", "2.0.0-rc.9" — yes. "1; rm -rf ~" — no.
147
+ */
148
+ function isSafeVersion(v) {
149
+ return typeof v === 'string' && /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/.test(v);
150
+ }
151
+
143
152
  /**
144
153
  * Re-exec the current command with the latest version.
145
154
  */
146
155
  function reExec(current, latest, originalArgs) {
156
+ // Defensive: reject anything that doesn't look like a real semver so a
157
+ // compromised npm registry response can't inject shell metacharacters.
158
+ if (!isSafeVersion(latest)) {
159
+ return; // silently skip — never block CLI on update check
160
+ }
147
161
  console.log(`\n⬆ Updating magector: v${current} → v${latest}...\n`);
148
162
  try {
149
- const cmd = `npx -y magector@${latest} ${originalArgs.join(' ')}`;
150
- execSync(cmd, {
163
+ // execFileSync with an argv array (no shell) — originalArgs are passed as
164
+ // individual argv entries, so spaces/metachars in them can't expand.
165
+ execFileSync('npx', ['-y', `magector@${latest}`, ...originalArgs], {
151
166
  stdio: 'inherit',
152
167
  env: { ...process.env, MAGECTOR_NO_UPDATE: '1' }
153
168
  });