ucn 5.0.4 → 5.0.6

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/core/shared.js CHANGED
@@ -2,9 +2,37 @@
2
2
  * core/shared.js - Shared utility functions used by both CLI and MCP server
3
3
  */
4
4
 
5
+ const fs = require('fs');
6
+ const path = require('path');
5
7
  const { isTestFile } = require('./discovery');
6
8
  const { detectLanguage } = require('./parser');
7
9
 
10
+ /**
11
+ * Security containment check (fix #285): is `candidate` inside `root`?
12
+ *
13
+ * UCN only ever reads files it discovered under the project root. Any code
14
+ * path that resolves a caller-supplied path string (stack traces, handles)
15
+ * must gate the read on this before touching disk, or a crafted input like
16
+ * `File "/etc/passwd"` or `../../secret` exfiltrates arbitrary files through
17
+ * the tool surface (CLI and, more dangerously, the MCP tool agents call).
18
+ *
19
+ * Symlinks are followed via realpath so a link INSIDE the project cannot
20
+ * resolve to a target outside it. Non-existent candidates fall back to a
21
+ * lexical resolve — the caller still gates the read on existence.
22
+ * Comparison is boundary-correct: a sibling directory whose name merely
23
+ * shares the root's prefix (`/a/b` vs `/a/bee`) is not "inside".
24
+ */
25
+ function isPathInsideRoot(root, candidate) {
26
+ if (!root || !candidate) return false;
27
+ let realRoot;
28
+ try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
29
+ let realCandidate;
30
+ try { realCandidate = fs.realpathSync(candidate); } catch { realCandidate = path.resolve(candidate); }
31
+ const rel = path.relative(realRoot, realCandidate);
32
+ return rel === '' ||
33
+ (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
34
+ }
35
+
8
36
  /**
9
37
  * Code-unit string comparison (rule 11 / fix #227): output ordering is part
10
38
  * of the public contract and must be byte-identical across machines —
@@ -453,4 +481,5 @@ module.exports = {
453
481
  isOverrideMarked,
454
482
  hasTextBlindspots,
455
483
  countTextBlindspots,
484
+ isPathInsideRoot,
456
485
  };
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
+ const { isPathInsideRoot } = require('./shared');
10
11
 
11
12
  /**
12
13
  * Calculate path similarity score between two file paths
@@ -85,9 +86,14 @@ function findBestMatchingFile(index, filePath, funcName, lineNum) {
85
86
  }
86
87
 
87
88
  if (candidates.length === 0) {
88
- // Try absolute path
89
- const absPath = path.isAbsolute(filePath) ? filePath : path.join(index.root, filePath);
90
- if (fs.existsSync(absPath)) {
89
+ // A frame that matched no indexed file may still point at a real file
90
+ // inside the project the walker skipped (a build artifact, an ignored
91
+ // path). Resolve it and show it — but ONLY inside the project root.
92
+ // The frame path is caller-supplied and untrusted; without this gate
93
+ // `File "/etc/passwd"` or `../../secret` would exfiltrate arbitrary
94
+ // files through the tool surface (fix #285 — GHSA/issue #4).
95
+ const absPath = path.resolve(index.root, filePath);
96
+ if (isPathInsideRoot(index.root, absPath) && fs.existsSync(absPath)) {
91
97
  return {
92
98
  path: absPath,
93
99
  relativePath: path.relative(index.root, absPath),
@@ -160,6 +166,16 @@ function createStackFrame(index, filePath, lineNum, funcName, col, rawLine) {
160
166
  // Find the best matching file using improved algorithm
161
167
  const match = findBestMatchingFile(index, filePath, funcName, lineNum);
162
168
 
169
+ if (match && !isPathInsideRoot(index.root, match.path)) {
170
+ // Defense in depth: never read outside the project root even if a
171
+ // future matcher change returns such a path (fix #285). The frame is
172
+ // reported as located-but-unreadable rather than leaking content.
173
+ frame.matchedFile = match.relativePath;
174
+ frame.confidence = match.confidence;
175
+ frame.error = 'resolved outside project root; not read';
176
+ return frame;
177
+ }
178
+
163
179
  if (match) {
164
180
  const resolvedPath = match.path;
165
181
  frame.found = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
6
6
  "main": "index.js",