Skip to content

Commit 61b5896

Browse files
committed
fix(security): reject non-registry specs before package-source fetch
The scanner's published-source fetch passed the server's configured package spec to `pip download` / `uv pip download` UNVALIDATED. For a local-path, `file:`, URL, or VCS (`git+…`, ssh) spec, those commands still invoke the package's setup.py / PEP 517 build backend to resolve metadata — EVEN with `--only-binary=:all:` — executing untrusted code from the server config on the meant-to-be-static scan path. Add validatePackageSpec(ecosystem, spec): require a bare PEP 503 / npm registry name (optionally version-pinned); reject paths, `file:`, URLs, and VCS/ssh specs. resolveFromPackageFetch now gates both the npm and python fetch paths through it, falling back to tool_definitions_only (no fetch, no execution) on rejection. The already-safe registry-name path is unchanged. TDD: TestValidatePackageSpec (registry pass / non-registry reject) and TestResolveFromPackageFetch_RejectsNonRegistrySpec (setup.py execution marker stays absent for a local-path/git+ spec). Related MCP-2442
1 parent 9f3e1ab commit 61b5896

3 files changed

Lines changed: 158 additions & 0 deletions

File tree

docs/features/security-scanner-plugins.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ Package-runner servers (`npx`, `uvx`) are the **primary** quarantine/scan target
259259

260260
**The source is fetched but never executed.** A scanner must not run the untrusted code it is scanning. The fetch only ever downloads and unpacks archives:
261261

262+
- **Registry name required.** Only a server whose configured spec is a **bare registry name** (PEP 503 for PyPI, npm package name — optionally version-pinned) is fetched. Local-path, `file:`, URL, and VCS (`git+…`, `[email protected]:…`) specs are **rejected** and fall back to tool definitions only. This is mandatory: for a non-registry spec, `pip download` / `uv pip download` still invokes the package's `setup.py` / PEP 517 build backend to resolve metadata **even with `--only-binary=:all:`** — executing untrusted code on the (static) scan path. `--only-binary=:all:` alone protects only bare registry names.
262263
- **npm (`npx`)**`npm pack <pkg>@<version> --ignore-scripts` downloads the published tarball without running any lifecycle scripts (`install`/`postinstall`), then it is extracted (`source_method=npm_pack`).
263264
- **PyPI (`uvx`)**`uv pip download <pkg>==<version> --no-deps --only-binary=:all:` (falling back to `pip download`) fetches **only a prebuilt wheel**, which is unpacked without building or running `setup.py` (`source_method=pip_download`). `--only-binary=:all:` is mandatory: downloading an sdist would invoke the package's PEP 517 build backend (`setup.py egg_info`) to resolve metadata, executing the untrusted code. A package that ships **no wheel** therefore fails the fetch and falls back to tool definitions only — sdists are never built or extracted.
264265

internal/security/scanner/package_fetch.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"os/exec"
1212
"path/filepath"
13+
"regexp"
1314
"strings"
1415

1516
"go.uber.org/zap"
@@ -79,6 +80,62 @@ func parsePackageSpec(spec string) (name, version string) {
7980
return spec, ""
8081
}
8182

83+
var (
84+
// npmNameRe matches a bare npm registry package name: an optional
85+
// "@scope/" prefix followed by the package name. It deliberately excludes
86+
// any character ('/', ':', etc. beyond the single scope separator) that
87+
// would appear in a path, URL, or VCS spec.
88+
npmNameRe = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
89+
// pep503NameRe matches a PEP 503 / PEP 508 distribution name (case
90+
// insensitive). No path/URL/VCS characters are permitted.
91+
pep503NameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
92+
)
93+
94+
// validatePackageSpec rejects any package spec that is not a bare registry name
95+
// (optionally version-pinned). This is a SECURITY gate, not a convenience check:
96+
// for a local-path, file:, URL, or VCS (git+/hg+/ssh) spec, `pip download` /
97+
// `uv pip download` STILL invoke the package's setup.py / PEP 517 build backend
98+
// to resolve metadata — EVEN with --only-binary=:all: — which executes untrusted
99+
// code from the server config on the (meant-to-be-static) scan path (MCP-2442).
100+
// `npm pack` of a non-registry spec is similarly unsafe. Only a bare PEP 503
101+
// (python) / npm registry name is guaranteed to download-without-build. On
102+
// rejection the caller falls back to tool_definitions_only (no fetch, no exec).
103+
func validatePackageSpec(ecosystem, spec string) error {
104+
if spec == "" {
105+
return fmt.Errorf("empty package spec")
106+
}
107+
// Disqualify URLs (any scheme), file:, VCS (git+/hg+/...), ssh, drive
108+
// letters, and Windows paths up front. None of ':' or '\' ever appear in a
109+
// bare registry name or a PEP 508 version pin, so their mere presence means
110+
// the spec is not a registry name.
111+
if strings.ContainsAny(spec, ":\\") {
112+
return fmt.Errorf("package spec %q is not a bare registry name (contains path/URL/VCS markers)", spec)
113+
}
114+
// Disqualify filesystem paths (absolute, relative, home-relative) and any
115+
// path traversal. A scoped npm name legitimately starts with '@', which is
116+
// not matched here.
117+
if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") || strings.HasPrefix(spec, "~") {
118+
return fmt.Errorf("package spec %q looks like a filesystem path", spec)
119+
}
120+
if strings.Contains(spec, "..") {
121+
return fmt.Errorf("package spec %q contains a path traversal", spec)
122+
}
123+
name, _ := parsePackageSpec(spec)
124+
switch ecosystem {
125+
case "npm":
126+
if !npmNameRe.MatchString(name) {
127+
return fmt.Errorf("%q is not a valid npm package name", name)
128+
}
129+
case "python":
130+
if !pep503NameRe.MatchString(name) {
131+
return fmt.Errorf("%q is not a valid PEP 503 package name", name)
132+
}
133+
default:
134+
return fmt.Errorf("unknown ecosystem %q", ecosystem)
135+
}
136+
return nil
137+
}
138+
82139
// firstPackageArg returns the package spec from a runner command's args. It
83140
// honors `--from <pkg>` (uvx) and otherwise returns the first non-flag arg.
84141
func firstPackageArg(args []string) string {
@@ -321,8 +378,14 @@ func (r *SourceResolver) resolveFromPackageFetch(ctx context.Context, info Serve
321378

322379
switch cmdBase {
323380
case "npx", "bunx", "pnpm":
381+
if err := validatePackageSpec("npm", spec); err != nil {
382+
return nil, fmt.Errorf("refusing to fetch untrusted spec for %s: %w", info.Name, err)
383+
}
324384
return r.fetchNpmPackage(ctx, info, spec)
325385
case "uvx", "pipx":
386+
if err := validatePackageSpec("python", spec); err != nil {
387+
return nil, fmt.Errorf("refusing to fetch untrusted spec for %s: %w", info.Name, err)
388+
}
326389
return r.fetchPythonPackage(ctx, info, spec)
327390
}
328391
return nil, fmt.Errorf("package fetch unsupported for command %q", cmdBase)

internal/security/scanner/package_fetch_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import (
55
"archive/zip"
66
"bytes"
77
"compress/gzip"
8+
"context"
89
"os"
910
"path/filepath"
1011
"strings"
1112
"testing"
13+
14+
"go.uber.org/zap"
1215
)
1316

1417
func TestParsePackageSpec(t *testing.T) {
@@ -304,3 +307,94 @@ func TestFindDownloadedArchive(t *testing.T) {
304307
t.Errorf("wheel should be preferred: got (%q,%v)", got2, kind2)
305308
}
306309
}
310+
311+
// MCP-2442: a `pip download` / `uv pip download` of a NON-REGISTRY spec
312+
// (local path, git+, URL, file:, VCS, ssh) STILL executes the package's
313+
// setup.py / PEP 517 build backend even with --only-binary=:all: — arbitrary
314+
// code execution from untrusted server config on the (static) scan path. The
315+
// fetch must validate the spec is a bare PEP 503 / npm registry name and REFUSE
316+
// anything else, falling back to tool-definitions-only.
317+
func TestValidatePackageSpec(t *testing.T) {
318+
cases := []struct {
319+
ecosystem string
320+
spec string
321+
wantOK bool
322+
}{
323+
// Registry names (allowed) — must not regress.
324+
{"python", "flights", true},
325+
{"python", "flights==0.2.4", true},
326+
{"python", "google-flights-mcp-server==0.2.4", true},
327+
{"python", "Flask", true},
328+
{"python", "some.dotted.name", true},
329+
{"npm", "google-flights-mcp-server", true},
330+
{"npm", "google-flights-mcp-server@0.2.4", true},
331+
{"npm", "@modelcontextprotocol/server-everything", true},
332+
{"npm", "@modelcontextprotocol/server-everything@1.0.0", true},
333+
// Non-registry specs (must be REJECTED → fall back to tool-defs).
334+
{"python", "./local-pkg", false},
335+
{"python", "../evil", false},
336+
{"python", "/abs/path/pkg", false},
337+
{"python", "~/pkg", false},
338+
{"python", "file:./pkg", false},
339+
{"python", "git+https://github.com/user/repo.git", false},
340+
{"python", "git+ssh://[email protected]/user/repo.git", false},
341+
{"python", "https://example.com/pkg.tar.gz", false},
342+
{"python", "[email protected]:user/repo.git", false},
343+
{"python", "hg+https://example.com/repo", false},
344+
{"python", `C:\Users\evil\pkg`, false},
345+
{"python", "", false},
346+
{"npm", "./local-pkg", false},
347+
{"npm", "/abs/path", false},
348+
{"npm", "git+https://github.com/user/repo.git", false},
349+
{"npm", "file:../evil", false},
350+
{"npm", "https://example.com/pkg.tgz", false},
351+
{"npm", "", false},
352+
}
353+
for _, c := range cases {
354+
err := validatePackageSpec(c.ecosystem, c.spec)
355+
if c.wantOK && err != nil {
356+
t.Errorf("validatePackageSpec(%q, %q) = %v, want OK", c.ecosystem, c.spec, err)
357+
}
358+
if !c.wantOK && err == nil {
359+
t.Errorf("validatePackageSpec(%q, %q) = nil, want rejection", c.ecosystem, c.spec)
360+
}
361+
}
362+
}
363+
364+
// TestResolveFromPackageFetch_RejectsNonRegistrySpec proves the resolver refuses
365+
// a non-registry uvx spec BEFORE invoking any download command — so the untrusted
366+
// package's setup.py is never executed. We point the spec at a local directory
367+
// containing a setup.py that drops an execution marker; the resolver must return
368+
// an error (caller falls back to tool_definitions_only) and the marker must be
369+
// absent.
370+
func TestResolveFromPackageFetch_RejectsNonRegistrySpec(t *testing.T) {
371+
pkgDir := t.TempDir()
372+
marker := filepath.Join(t.TempDir(), "EXECUTED.marker")
373+
setupPy := "import pathlib\npathlib.Path(" + pyQuote(marker) + ").write_text('pwned')\n"
374+
if err := os.WriteFile(filepath.Join(pkgDir, "setup.py"), []byte(setupPy), 0o644); err != nil {
375+
t.Fatal(err)
376+
}
377+
378+
r := NewSourceResolver(zap.NewNop())
379+
cases := []ServerInfo{
380+
{Name: "evil-path", Protocol: "stdio", Command: "uvx", Args: []string{"--from", pkgDir, "evil"}},
381+
{Name: "evil-git", Protocol: "stdio", Command: "uvx", Args: []string{"--from", "git+https://example.com/repo.git", "evil"}},
382+
{Name: "evil-npm-path", Protocol: "stdio", Command: "npx", Args: []string{"-y", "./local-evil"}},
383+
}
384+
for _, info := range cases {
385+
res, err := r.resolveFromPackageFetch(context.Background(), info)
386+
if err == nil {
387+
if res != nil && res.Cleanup != nil {
388+
res.Cleanup()
389+
}
390+
t.Errorf("%s: expected error (fallback to tool-defs), got nil", info.Name)
391+
}
392+
}
393+
if _, err := os.Stat(marker); err == nil {
394+
t.Fatalf("setup.py was EXECUTED — marker present at %s", marker)
395+
}
396+
}
397+
398+
func pyQuote(s string) string {
399+
return "r'" + s + "'"
400+
}

0 commit comments

Comments
 (0)