Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/features/security-scanner-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg>` 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)
Expand Down
142 changes: 142 additions & 0 deletions internal/security/scanner/source_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1305,9 +1305,151 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
}, nil
}

// Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
// `uvx <pkg>` (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/<hash>)
// 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
// (<hash>/<dist>-<ver>.dist-info) and the venv-style layout
// (<hash>/lib/python*/site-packages/<dist>-<ver>.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 <hash> 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 `<name>-<version>.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/<hash>/<rev>/ directories and checks for repo-identifying markers
// (pyproject.toml with matching name, or .git/config with matching URL).
Expand Down
161 changes: 161 additions & 0 deletions internal/security/scanner/source_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"go.uber.org/zap"
)
Expand Down Expand Up @@ -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 <pkg>` 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
// (<hash>/lib/python*/site-packages/<pkg>) 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
Expand Down
Loading