From 1e2670193042912f29f57c693a82fc03ee5313f8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 14 Jun 2026 17:16:18 +0300 Subject: [PATCH] fix(scanner): resolve uvx source from ephemeral archive cache (MCP-2400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveUvxCache only checked the persistent `uv tool install` tools dir and git checkouts, so a server launched via plain `uvx ` — whose wheel is unpacked into the content-addressed ~/.cache/uv/archive-v0 cache, keyed by an opaque hash rather than package name — always missed and fell through to a tool-definitions-only scan. Add a third local-cache strategy that scans archive-v0 entries and matches the target distribution by its wheel .dist-info (robust to dist-name vs import-name differences), supporting both the flat and venv-style layouts and picking the newest entry when several versions are cached. Also strip PEP 508 version specifiers (==, >=, [extras]) from the spec before matching. The container resolver (findContainerTargetDir) already searched archive-v0; this brings the host resolver to parity. Complements the published-source fetch in #658 (MCP-2206) by resolving locally first, avoiding a network round-trip and working in air-gapped deployments. Related #658 Related MCP-2400 --- docs/features/security-scanner-plugins.md | 6 + internal/security/scanner/source_resolver.go | 142 +++++++++++++++ .../security/scanner/source_resolver_test.go | 161 ++++++++++++++++++ 3 files changed, 309 insertions(+) diff --git a/docs/features/security-scanner-plugins.md b/docs/features/security-scanner-plugins.md index 086a5fb9b..a1c3a30db 100644 --- a/docs/features/security-scanner-plugins.md +++ b/docs/features/security-scanner-plugins.md @@ -245,6 +245,12 @@ Scanners communicate results via SARIF 2.1.0. Exit code `0` indicates "scan comp 6. Tool definitions only — last resort (HTTP / SSE / unresolvable) ``` +For `uvx` servers the package cache lookup (step 2) covers persistent `uv tool install` +locations, git checkouts, **and** the ephemeral `~/.cache/uv/archive-v0` wheel +cache that a plain `uvx ` populates — so a locally-run Python MCP server +resolves to its real source from the local cache (no network) before step 5's +published fetch is reached, and works in air-gapped deployments. + The resolved method and path are recorded on the scan job and visible via both the text and JSON report. The `source_method` field reports how source was obtained: `docker_extract`, `npx_cache`, `uvx_cache`, `working_dir`, `npm_pack`, `pip_download`, `url`, or `tool_definitions_only`. See [Security Commands → scan](/cli/security-commands#security-scan) for more. #### Published package fetch (npx / uvx) diff --git a/internal/security/scanner/source_resolver.go b/internal/security/scanner/source_resolver.go index 19e38b694..db38b38ae 100644 --- a/internal/security/scanner/source_resolver.go +++ b/internal/security/scanner/source_resolver.go @@ -1305,9 +1305,151 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro }, nil } + // Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0//). + // `uvx ` (the common case) never populates the persistent tools dir + // above — it unpacks the published wheel into a content-addressed archive + // entry keyed by an opaque hash, not by package name. So a server that HAS + // been run locally still missed both strategies above and fell through to a + // tool-definitions-only scan (or, post-MCP-2206, a redundant network fetch). + // The container resolver (findContainerTargetDir) already searches this + // cache; the host resolver now does too. Found by globbing every archive + // entry and matching the wheel's `.dist-info` (robust to dist-name vs + // import-name differences). git+URL packages live in git-v0 (Strategy 1), + // not archive-v0, so they are skipped here. + if !isGitURL { + archiveRoot := filepath.Join(homeDir, ".cache", "uv", "archive-v0") + if dir, ok := findUvxArchiveDir(archiveRoot, stripPkgVersion(cleanPkg)); ok { + r.logger.Info("Resolved source from UV archive cache", + zap.String("server", info.Name), + zap.String("package", cleanPkg), + zap.String("path", dir), + ) + return &ResolvedSource{ + SourceDir: dir, + Method: "uvx_cache", + Cleanup: func() {}, + }, nil + } + } + return nil, fmt.Errorf("package %q not found in UV cache", pkgName) } +// stripPkgVersion trims a PEP 508 version specifier and/or extras from a +// package spec, leaving the bare distribution name. Handles `pkg==1.0`, +// `pkg>=1.0`, `pkg~=1.0`, `pkg!=1.0`, `pkg[extra]`, and a trailing space/marker. +// (The `@version` form is already stripped earlier by the caller.) +func stripPkgVersion(spec string) string { + cut := len(spec) + for _, c := range []string{"==", ">=", "<=", "~=", "!=", ">", "<", "[", " ", ";"} { + if idx := strings.Index(spec, c); idx >= 0 && idx < cut { + cut = idx + } + } + return strings.TrimSpace(spec[:cut]) +} + +// normalizeDistName applies PEP 503 / wheel normalization to a Python +// distribution name: lowercase, with runs of "-", "_" and "." collapsed to a +// single "_". This matches the form uv writes for `.dist-info` directory names +// (e.g. "My.Cool-Server" → "my_cool_server"), so a config's free-form package +// spec can be matched against on-disk wheels. +func normalizeDistName(name string) string { + n := strings.ToLower(name) + n = strings.NewReplacer("-", "_", ".", "_").Replace(n) + for strings.Contains(n, "__") { + n = strings.ReplaceAll(n, "__", "_") + } + return strings.Trim(n, "_") +} + +// findUvxArchiveDir locates the unpacked wheel for distribution pkg inside the +// uv ephemeral archive cache. Each archive entry (~/.cache/uv/archive-v0/) +// holds exactly one unpacked wheel, content-addressed by an opaque hash — so the +// package is found by scanning entries and matching the wheel's `.dist-info` +// directory rather than by constructing a name-based path. Both the flat layout +// (/-.dist-info) and the venv-style layout +// (/lib/python*/site-packages/-.dist-info) are supported. When +// multiple entries match (e.g. several cached versions) the most recently +// modified entry wins, consistent with resolveNpxCache. Returns the archive +// entry root (the dir) and ok=true on a hit. +func findUvxArchiveDir(archiveRoot, pkg string) (string, bool) { + norm := normalizeDistName(pkg) + if norm == "" { + return "", false + } + entries, err := os.ReadDir(archiveRoot) + if err != nil { + return "", false + } + + var best string + var bestMod int64 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + entryPath := filepath.Join(archiveRoot, entry.Name()) + // Check the flat layout, then any venv-style site-packages dirs. + matchDirs := []string{entryPath} + if sp, _ := filepath.Glob(filepath.Join(entryPath, "lib", "python*", "site-packages")); len(sp) > 0 { + matchDirs = append(matchDirs, sp...) + } + matched := false + for _, d := range matchDirs { + if dirHasDistInfo(d, norm) { + matched = true + break + } + } + if !matched { + continue + } + modTime := int64(0) + if fi, err := entry.Info(); err == nil { + modTime = fi.ModTime().Unix() + } + if best == "" || modTime > bestMod { + best = entryPath + bestMod = modTime + } + } + if best == "" { + return "", false + } + return best, true +} + +// dirHasDistInfo reports whether dir directly contains a `-.dist-info` +// directory whose normalized distribution name equals norm. A wheel's `.dist-info` +// name is `{normalized-distribution}-{version}.dist-info`, and the normalized +// distribution name contains no "-" (those become "_"), so the FIRST "-" cleanly +// separates name from version. +func dirHasDistInfo(dir, norm string) bool { + es, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, e := range es { + if !e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".dist-info") { + continue + } + base := name[:len(name)-len(".dist-info")] + idx := strings.Index(base, "-") + if idx <= 0 { + continue + } + if normalizeDistName(base[:idx]) == norm { + return true + } + } + return false +} + // findGitCheckoutByRepo searches UV git checkouts for a directory that matches the given repo. // It walks checkouts/// directories and checks for repo-identifying markers // (pyproject.toml with matching name, or .git/config with matching URL). diff --git a/internal/security/scanner/source_resolver_test.go b/internal/security/scanner/source_resolver_test.go index bd2684598..e6cce3f75 100644 --- a/internal/security/scanner/source_resolver_test.go +++ b/internal/security/scanner/source_resolver_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "go.uber.org/zap" ) @@ -508,6 +509,166 @@ func TestResolveUvxCacheGitCheckout(t *testing.T) { } } +// writeUvArchiveWheel materializes a fake unpacked wheel inside a uv archive-v0 +// entry. uv content-addresses each wheel by an opaque hash, so the package is +// found by globbing all entries and matching the .dist-info directory — never +// by constructing a name-based path. distName is the (un-normalized) PyPI +// distribution name; it gets PEP 503 normalized (lowercased, -._ → _) for the +// .dist-info name, mirroring what uv writes to disk. +func writeUvArchiveWheel(t *testing.T, homeDir, hash, distName, version, importPkg string, venvStyle bool) string { + t.Helper() + norm := strings.ToLower(distName) + norm = strings.NewReplacer("-", "_", ".", "_").Replace(norm) + entry := filepath.Join(homeDir, ".cache", "uv", "archive-v0", hash) + base := entry + if venvStyle { + base = filepath.Join(entry, "lib", "python3.11", "site-packages") + } + pkgDir := filepath.Join(base, importPkg) + if err := os.MkdirAll(pkgDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pkgDir, "__init__.py"), []byte("# server source"), 0644); err != nil { + t.Fatal(err) + } + distInfo := filepath.Join(base, norm+"-"+version+".dist-info") + if err := os.MkdirAll(distInfo, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(distInfo, "METADATA"), []byte("Name: "+distName+"\n"), 0644); err != nil { + t.Fatal(err) + } + return entry +} + +// TestResolveUvxCacheArchiveFlat verifies the COMMON `uvx ` case: the +// package was run locally (so its wheel sits unpacked in the ephemeral +// ~/.cache/uv/archive-v0 cache) but was never `uv tool install`-ed, so the +// tools dir is empty. Before MCP-2400 this fell through to +// tool_definitions_only / a network fetch; now it resolves from local cache. +func TestResolveUvxCacheArchiveFlat(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + entry := writeUvArchiveWheel(t, homeDir, "Ab12Cd34Ef56", "mcp-server-fetch", "1.2.3", "mcp_server_fetch", false) + + r := NewSourceResolver(zap.NewNop()) + result, err := r.resolveUvxCache(ServerInfo{ + Name: "fetch", + Command: "uvx", + Args: []string{"mcp-server-fetch"}, + }) + if err != nil { + t.Fatalf("resolveUvxCache: %v", err) + } + if result.SourceDir != entry { + t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir) + } + if result.Method != "uvx_cache" { + t.Errorf("expected method 'uvx_cache', got %q", result.Method) + } +} + +// TestResolveUvxCacheArchiveVenvStyle verifies the venv-style archive layout +// (/lib/python*/site-packages/) is also discovered. +func TestResolveUvxCacheArchiveVenvStyle(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + entry := writeUvArchiveWheel(t, homeDir, "Zz99Yy88Xx77", "some-tool", "0.4.0", "some_tool", true) + + r := NewSourceResolver(zap.NewNop()) + result, err := r.resolveUvxCache(ServerInfo{ + Name: "some-tool", + Command: "uvx", + Args: []string{"--from", "some-tool", "some-tool"}, + }) + if err != nil { + t.Fatalf("resolveUvxCache: %v", err) + } + if result.SourceDir != entry { + t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir) + } +} + +// TestResolveUvxCacheArchiveNameNormalization verifies a uvx spec with a +// version pin and hyphen/case differences still matches the underscore- +// normalized .dist-info on disk. +func TestResolveUvxCacheArchiveNameNormalization(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + entry := writeUvArchiveWheel(t, homeDir, "Hash1234", "My.Cool-Server", "2.0.0", "my_cool_server", false) + + r := NewSourceResolver(zap.NewNop()) + result, err := r.resolveUvxCache(ServerInfo{ + Name: "cool", + Command: "uvx", + Args: []string{"My.Cool-Server==2.0.0"}, + }) + if err != nil { + t.Fatalf("resolveUvxCache: %v", err) + } + if result.SourceDir != entry { + t.Errorf("expected source_dir %q, got %q", entry, result.SourceDir) + } +} + +// TestResolveUvxCacheArchiveNewestWins verifies that when the same distribution +// is cached at multiple versions (multiple archive hashes), the most recently +// modified entry is chosen — consistent with resolveNpxCache. +func TestResolveUvxCacheArchiveNewestWins(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + old := writeUvArchiveWheel(t, homeDir, "old0000", "dup-pkg", "1.0.0", "dup_pkg", false) + newer := writeUvArchiveWheel(t, homeDir, "new1111", "dup-pkg", "2.0.0", "dup_pkg", false) + // Force a deterministic ordering of mod times. + past := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + + r := NewSourceResolver(zap.NewNop()) + result, err := r.resolveUvxCache(ServerInfo{ + Name: "dup", + Command: "uvx", + Args: []string{"dup-pkg"}, + }) + if err != nil { + t.Fatalf("resolveUvxCache: %v", err) + } + if result.SourceDir != newer { + t.Errorf("expected newest entry %q, got %q", newer, result.SourceDir) + } + _ = old +} + +// TestResolveUvxCacheArchiveNoMatch verifies a clean error (fall-through) when +// the package is not present in any local uv cache. +func TestResolveUvxCacheArchiveNoMatch(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + // A different package is cached; the requested one is absent. + writeUvArchiveWheel(t, homeDir, "other999", "unrelated-pkg", "1.0.0", "unrelated_pkg", false) + + r := NewSourceResolver(zap.NewNop()) + _, err := r.resolveUvxCache(ServerInfo{ + Name: "missing", + Command: "uvx", + Args: []string{"definitely-not-cached"}, + }) + if err == nil { + t.Error("expected error when package absent from uv cache") + } +} + // TestSourceResolverNpxFilesystemArgNotPicked verifies that a filesystem-style // positional argument (e.g. `/tmp/mydata` for @modelcontextprotocol/server-filesystem) // is NOT picked up as the server's source directory. Package-runner commands