Skip to content

Commit d9a371d

Browse files
committed
fix(039): extract UV git checkouts for uvx-from-git servers
For servers installed via uvx --from pkg@git+URL, the actual source code lives at /root/.cache/uv/git-v0/checkouts/<hash>/<rev>/. Previously this was extracted as /root/.cache (too broad) or filtered out entirely. Changes: - extractAppRoot recognizes UV git checkout paths specifically - isSystemPath excludes UV archive-v0 (dependencies) but keeps git-v0 - npm node_modules exclusion now allows npx cache paths - Tests updated for UV git checkout extraction Note: Scanner requires the server container to be fully connected. If uvx is still downloading from GitHub when the scan runs, the source won't be available yet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3605ed4 commit d9a371d

2 files changed

Lines changed: 48 additions & 24 deletions

File tree

internal/security/scanner/source_resolver.go

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -264,26 +264,40 @@ func (r *SourceResolver) isSystemPath(path string) bool {
264264
}
265265
// Skip dependency directories (too large, not user code)
266266
if strings.Contains(path, "/site-packages/") ||
267-
strings.Contains(path, "/dist-packages/") ||
268-
strings.Contains(path, "/node_modules/") {
267+
strings.Contains(path, "/dist-packages/") {
268+
return true
269+
}
270+
// Skip standalone node_modules (but NOT inside npx cache which is the server itself)
271+
if strings.Contains(path, "/node_modules/") && !strings.Contains(path, "/_npx/") {
272+
return true
273+
}
274+
// Skip UV/pip dependency archives (keep git checkouts which are actual source)
275+
if strings.Contains(path, "/.cache/uv/archive-v0/") ||
276+
strings.Contains(path, "/.cache/pip/") {
269277
return true
270278
}
271279
return false
272280
}
273281

274282
// extractAppRoot extracts the top-level application directory from a path.
275-
// For npm: /root/.npm/_npx/*/node_modules/@scope/pkg → /root/.npm/_npx/*/node_modules
276-
// For pip: /usr/local/lib/python*/site-packages/pkg → /usr/local/lib/python*/site-packages
277-
// For generic: /app/src/file.py → /app
283+
// Identifies actual server source vs dependency code for various package managers.
278284
func (r *SourceResolver) extractAppRoot(path string) string {
279-
// npm packages (npx installs)
280-
if idx := strings.Index(path, "/node_modules/"); idx != -1 {
281-
return path[:idx+len("/node_modules")]
285+
// UV git checkouts: /root/.cache/uv/git-v0/checkouts/<hash>/<rev>/ → extract that specific checkout
286+
// This is the ACTUAL source code of a git-installed package (e.g., uvx --from pkg@git+URL)
287+
if strings.Contains(path, "/.cache/uv/git-v0/checkouts/") {
288+
// Extract: /root/.cache/uv/git-v0/checkouts/<hash>/<rev>
289+
parts := strings.Split(path, "/")
290+
for i, p := range parts {
291+
if p == "checkouts" && i+2 < len(parts) {
292+
return strings.Join(parts[:i+3], "/")
293+
}
294+
}
282295
}
283296

284-
// pip packages
285-
if idx := strings.Index(path, "/site-packages/"); idx != -1 {
286-
return path[:idx+len("/site-packages")]
297+
// npm npx cache: /root/.npm/_npx/<hash>/node_modules/<pkg> → extract the specific package
298+
if strings.Contains(path, "/.npm/_npx/") && strings.Contains(path, "/node_modules/") {
299+
idx := strings.Index(path, "/node_modules/")
300+
return path[:idx+len("/node_modules")]
287301
}
288302

289303
// Common app directories
@@ -294,8 +308,8 @@ func (r *SourceResolver) extractAppRoot(path string) string {
294308
}
295309
}
296310

297-
// Root-level user files (e.g., /root/.npm/_npx/...)
298-
if strings.HasPrefix(path, "/root/") {
311+
// Root-level user files (but NOT .cache — too broad)
312+
if strings.HasPrefix(path, "/root/") && !strings.HasPrefix(path, "/root/.cache/") {
299313
parts := strings.SplitN(path[6:], "/", 2) // after "/root/"
300314
if len(parts) > 0 {
301315
return "/root/" + parts[0]

internal/security/scanner/source_resolver_test.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,35 +121,42 @@ func TestFindAppDirectories(t *testing.T) {
121121
r := NewSourceResolver(zap.NewNop())
122122

123123
diffOutput := `C /root
124-
A /root/.npm
125124
A /root/.npm/_npx/abc123/node_modules/@mcp/server/index.js
126125
A /root/.npm/_npx/abc123/node_modules/@mcp/server/package.json
127126
C /tmp
128127
A /tmp/some-cache
129128
A /app/server.py
130129
A /app/tools/search.py
131130
C /etc/hostname
132-
A /usr/local/lib/python3.11/site-packages/mcp_server/main.py`
131+
A /usr/local/lib/python3.11/site-packages/mcp_server/main.py
132+
A /root/.cache/uv/git-v0/checkouts/abc123/def456/gcore_mcp_server/server.py
133+
A /root/.cache/uv/archive-v0/xxx/click/__init__.py`
133134

134135
dirs := r.findAppDirectories(diffOutput)
135136

136-
// Should find: /root/.npm and /app
137-
// node_modules and site-packages are now excluded (dependency code, not user code)
138137
found := make(map[string]bool)
139138
for _, d := range dirs {
140139
found[d] = true
141140
}
142141

143-
if !found["/root/.npm"] {
144-
t.Errorf("expected /root/.npm dir, got %v", dirs)
142+
// Should find: npm node_modules, /app, and UV git checkout
143+
if !found["/root/.npm/_npx/abc123/node_modules"] {
144+
t.Errorf("expected npm node_modules dir, got %v", dirs)
145145
}
146146
if !found["/app"] {
147147
t.Errorf("expected /app dir, got %v", dirs)
148148
}
149-
// site-packages and node_modules should be filtered out
149+
if !found["/root/.cache/uv/git-v0/checkouts/abc123/def456"] {
150+
t.Errorf("expected UV git checkout dir, got %v", dirs)
151+
}
152+
153+
// Should NOT find: site-packages, UV archive (dependencies)
150154
for _, d := range dirs {
151-
if strings.Contains(d, "site-packages") || strings.Contains(d, "node_modules") {
152-
t.Errorf("dependency dir should be excluded: %s", d)
155+
if strings.Contains(d, "site-packages") {
156+
t.Errorf("site-packages should be excluded: %s", d)
157+
}
158+
if strings.Contains(d, "archive-v0") {
159+
t.Errorf("UV archive should be excluded: %s", d)
153160
}
154161
}
155162
}
@@ -187,11 +194,14 @@ func TestExtractAppRoot(t *testing.T) {
187194
want string
188195
}{
189196
{"/root/.npm/_npx/abc/node_modules/@mcp/server/index.js", "/root/.npm/_npx/abc/node_modules"},
190-
{"/usr/local/lib/python3.11/site-packages/mcp_server/main.py", "/usr/local/lib/python3.11/site-packages"},
191197
{"/app/server.py", "/app"},
192198
{"/src/main.go", "/src"},
193-
{"/root/.cache/data", "/root/.cache"},
194199
{"/opt/app/config.yaml", "/opt/app"},
200+
// UV git checkouts — actual server source
201+
{"/root/.cache/uv/git-v0/checkouts/abc123/def456/server.py", "/root/.cache/uv/git-v0/checkouts/abc123/def456"},
202+
{"/root/.cache/uv/git-v0/checkouts/abc123/def456/pkg/main.py", "/root/.cache/uv/git-v0/checkouts/abc123/def456"},
203+
// /root non-cache files
204+
{"/root/script.py", "/root/script.py"},
195205
}
196206

197207
for _, tt := range tests {

0 commit comments

Comments
 (0)