Skip to content

Commit 88f28a0

Browse files
fix(scanner): reject non-registry package specs before fetch (P0 ACE)
Codex audit (MCP-2442): resolveFromPackageFetch passed the server-configured package spec to npm pack / uv pip download / pip download UNVALIDATED. For a uvx/pipx server with a local-path, git+/VCS, URL, or file: spec, the download STILL executes the package's build backend (setup.py / PEP 517 egg_info) even with --only-binary=:all: / --ignore-scripts — arbitrary code execution during a supposedly static scan. Add isBareRegistrySpec(spec, ecosystem) and gate both ecosystems in resolveFromPackageFetch: only a bare PEP 503 (python) / npm registry name (+ optional version pin) is fetched. Paths, git+/hg+/svn+/bzr+, URLs (://), file:, PEP 508 direct references (name @ url), and shell metacharacters are refused → caller falls back to tool_definitions_only (no fetch, no execution). The registry-name path (already wheel-only / --ignore-scripts) is unchanged. TDD: isBareRegistrySpec accept/reject table; resolveFromPackageFetch rejection for git+/local/URL specs; and an execution-marker test proving a local-path uvx spec never runs its setup.py (validation short-circuits before any download, independent of whether uv/pip is installed). Related #MCP-2442 Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 9f3e1ab commit 88f28a0

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

internal/security/scanner/package_fetch.go

Lines changed: 60 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,59 @@ func parsePackageSpec(spec string) (name, version string) {
7980
return spec, ""
8081
}
8182

83+
// pep503NameRe matches a bare PEP 503 Python distribution name (the only thing
84+
// we will hand to pip/uv on the scan path). No path separators, no scheme.
85+
var pep503NameRe = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`)
86+
87+
// npmNameRe matches a bare npm registry name, optionally scoped (@scope/name).
88+
// The single '/' of a scope is the ONLY slash permitted — a real filesystem
89+
// path (or "@scope/../escape") must not pass.
90+
var npmNameRe = regexp.MustCompile(`^(?:@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
91+
92+
// isBareRegistrySpec reports whether spec is a bare registry package name
93+
// (with an optional version pin) for the given ecosystem ("python" or "npm").
94+
//
95+
// MCP-2442 (CRITICAL/P0): the package-fetch scan path must REFUSE anything that
96+
// is not a bare registry name. pip/uv/npm execute the package's build backend
97+
// (setup.py / PEP 517 egg_info) when given a local path, git+/VCS, URL, file:,
98+
// or PEP 508 direct-reference spec — even with --only-binary=:all: /
99+
// --ignore-scripts — which is arbitrary code execution during a static scan.
100+
// Bare registry names are safe (wheel-only download / `npm pack --ignore-scripts`).
101+
// A rejected spec makes the fetch caller fall back to tool_definitions_only.
102+
func isBareRegistrySpec(spec, ecosystem string) bool {
103+
if spec == "" || strings.TrimSpace(spec) != spec {
104+
return false
105+
}
106+
// No whitespace (PEP 508 direct references use "name @ url").
107+
if strings.ContainsAny(spec, " \t\r\n;") {
108+
return false
109+
}
110+
// No URLs, VCS prefixes, file: refs, or direct references.
111+
lower := strings.ToLower(spec)
112+
for _, bad := range []string{"://", "git+", "hg+", "svn+", "bzr+", "file:", "@http", "@git"} {
113+
if strings.Contains(lower, bad) {
114+
return false
115+
}
116+
}
117+
// No path indicators (absolute, relative, home, backslash, parent-dir).
118+
if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") ||
119+
strings.HasPrefix(spec, "~") || strings.HasPrefix(spec, "\\") {
120+
return false
121+
}
122+
if strings.Contains(spec, "\\") || strings.Contains(spec, "..") {
123+
return false
124+
}
125+
// Validate the bare NAME (version specifier stripped) against the ecosystem.
126+
name, _ := parsePackageSpec(spec)
127+
switch ecosystem {
128+
case "python":
129+
return pep503NameRe.MatchString(name)
130+
case "npm":
131+
return npmNameRe.MatchString(name)
132+
}
133+
return false
134+
}
135+
82136
// firstPackageArg returns the package spec from a runner command's args. It
83137
// honors `--from <pkg>` (uvx) and otherwise returns the first non-flag arg.
84138
func firstPackageArg(args []string) string {
@@ -321,8 +375,14 @@ func (r *SourceResolver) resolveFromPackageFetch(ctx context.Context, info Serve
321375

322376
switch cmdBase {
323377
case "npx", "bunx", "pnpm":
378+
if !isBareRegistrySpec(spec, "npm") {
379+
return nil, fmt.Errorf("refusing to fetch non-registry npm spec %q for %s: path/URL/VCS/file specs can execute package code during a static scan (MCP-2442); falling back to tool_definitions_only", spec, info.Name)
380+
}
324381
return r.fetchNpmPackage(ctx, info, spec)
325382
case "uvx", "pipx":
383+
if !isBareRegistrySpec(spec, "python") {
384+
return nil, fmt.Errorf("refusing to fetch non-registry python spec %q for %s: path/URL/VCS/file specs execute setup.py during a static scan (MCP-2442); falling back to tool_definitions_only", spec, info.Name)
385+
}
326386
return r.fetchPythonPackage(ctx, info, spec)
327387
}
328388
return nil, fmt.Errorf("package fetch unsupported for command %q", cmdBase)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package scanner
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strconv"
8+
"strings"
9+
"testing"
10+
11+
"go.uber.org/zap"
12+
)
13+
14+
// TestIsBareRegistrySpec is the MCP-2442 (CRITICAL/P0) guard. The package-fetch
15+
// scan path must only ever hand the package manager a BARE registry name.
16+
// pip/uv/npm execute the package's build backend (setup.py / PEP 517) for
17+
// local-path, git+/VCS, URL, and file: specs even with --only-binary /
18+
// --ignore-scripts — arbitrary code execution during a supposedly static scan.
19+
func TestIsBareRegistrySpec(t *testing.T) {
20+
cases := []struct {
21+
spec string
22+
ecosystem string
23+
want bool
24+
}{
25+
// Accepted: bare registry names (+ optional version pin).
26+
{"requests", "python", true},
27+
{"requests==2.31.0", "python", true},
28+
{"Flask>=2.0", "python", true},
29+
{"mcp-server-git", "python", true},
30+
{"package_with_underscores", "python", true},
31+
{"left-pad", "npm", true},
32+
{"left-pad@1.3.0", "npm", true},
33+
{"@modelcontextprotocol/server-everything", "npm", true},
34+
{"@scope/name@1.2.3", "npm", true},
35+
36+
// Rejected (Python): paths, VCS, URLs, file:, direct refs.
37+
{"./local-pkg", "python", false},
38+
{"../evil", "python", false},
39+
{"/abs/path/pkg", "python", false},
40+
{"~user/pkg", "python", false},
41+
{"git+https://github.com/evil/repo.git", "python", false},
42+
{"git+ssh://git@github.com/evil/repo.git", "python", false},
43+
{"hg+https://example.com/repo", "python", false},
44+
{"https://evil.example.com/pkg.tar.gz", "python", false},
45+
{"file:./local", "python", false},
46+
{"file:///abs/pkg", "python", false},
47+
{"requests @ https://evil.example.com/x.whl", "python", false},
48+
{"pkg; rm -rf /", "python", false},
49+
{"a\\b", "python", false},
50+
{"", "python", false},
51+
{" spaced ", "python", false},
52+
53+
// Rejected (npm): the slash-name path must not let a real filesystem path through.
54+
{"./local-pkg", "npm", false},
55+
{"/abs/path", "npm", false},
56+
{"git+https://github.com/evil/repo.git", "npm", false},
57+
{"file:../local", "npm", false},
58+
{"https://evil.example.com/x.tgz", "npm", false},
59+
{"@scope/../escape", "npm", false},
60+
}
61+
for _, c := range cases {
62+
t.Run(c.ecosystem+"/"+c.spec, func(t *testing.T) {
63+
if got := isBareRegistrySpec(c.spec, c.ecosystem); got != c.want {
64+
t.Errorf("isBareRegistrySpec(%q, %q) = %v, want %v", c.spec, c.ecosystem, got, c.want)
65+
}
66+
})
67+
}
68+
}
69+
70+
// TestResolveFromPackageFetch_RejectsNonRegistrySpec proves the validation
71+
// short-circuits BEFORE any download command runs, for both ecosystems.
72+
func TestResolveFromPackageFetch_RejectsNonRegistrySpec(t *testing.T) {
73+
r := NewSourceResolver(zap.NewNop())
74+
cases := []ServerInfo{
75+
{Name: "py-git", Command: "uvx", Args: []string{"--from", "git+https://github.com/evil/repo.git", "evil"}},
76+
{Name: "py-local", Command: "uvx", Args: []string{"--from", "/tmp/evil-pkg", "evil"}},
77+
{Name: "npm-url", Command: "npx", Args: []string{"https://evil.example.com/x.tgz"}},
78+
}
79+
for _, info := range cases {
80+
t.Run(info.Name, func(t *testing.T) {
81+
_, err := r.resolveFromPackageFetch(context.Background(), info)
82+
if err == nil {
83+
t.Fatalf("expected rejection for non-registry spec, got nil error")
84+
}
85+
if !strings.Contains(err.Error(), "non-registry") {
86+
t.Errorf("expected a non-registry rejection, got: %v", err)
87+
}
88+
})
89+
}
90+
}
91+
92+
// TestResolveFromPackageFetch_LocalPathSpecDoesNotExecute is the execution-marker
93+
// proof requested by MCP-2442. A uvx server configured with a LOCAL PATH whose
94+
// setup.py writes a marker file must never have that marker appear — the spec is
95+
// rejected at validation, so pip/uv is never invoked. This holds regardless of
96+
// whether uv/pip is installed on the host (validation fires first).
97+
func TestResolveFromPackageFetch_LocalPathSpecDoesNotExecute(t *testing.T) {
98+
pkgDir := t.TempDir()
99+
marker := filepath.Join(t.TempDir(), "EXECUTED")
100+
// A malicious setup.py that, if built/executed, would create the marker.
101+
setupPy := "import pathlib; pathlib.Path(" + strconv.Quote(marker) + ").write_text('pwned')\n"
102+
if err := os.WriteFile(filepath.Join(pkgDir, "setup.py"), []byte(setupPy), 0o644); err != nil {
103+
t.Fatal(err)
104+
}
105+
106+
r := NewSourceResolver(zap.NewNop())
107+
info := ServerInfo{Name: "evil-local", Command: "uvx", Args: []string{"--from", pkgDir, "evil"}}
108+
109+
if _, err := r.resolveFromPackageFetch(context.Background(), info); err == nil {
110+
t.Fatalf("expected local-path spec to be rejected")
111+
}
112+
if _, err := os.Stat(marker); err == nil {
113+
t.Fatalf("setup.py was EXECUTED during scan — marker file %q exists (arbitrary code execution)", marker)
114+
}
115+
}

0 commit comments

Comments
 (0)