Skip to content

Commit acbfbcc

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 8196ea0 commit acbfbcc

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
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14+
"regexp"
1415
"strings"
1516

1617
"go.uber.org/zap"
@@ -104,6 +105,59 @@ func parsePackageSpec(spec string) (name, version string) {
104105
return spec, ""
105106
}
106107

108+
// pep503NameRe matches a bare PEP 503 Python distribution name (the only thing
109+
// we will hand to pip/uv on the scan path). No path separators, no scheme.
110+
var pep503NameRe = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`)
111+
112+
// npmNameRe matches a bare npm registry name, optionally scoped (@scope/name).
113+
// The single '/' of a scope is the ONLY slash permitted — a real filesystem
114+
// path (or "@scope/../escape") must not pass.
115+
var npmNameRe = regexp.MustCompile(`^(?:@[A-Za-z0-9][A-Za-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$`)
116+
117+
// isBareRegistrySpec reports whether spec is a bare registry package name
118+
// (with an optional version pin) for the given ecosystem ("python" or "npm").
119+
//
120+
// MCP-2442 (CRITICAL/P0): the package-fetch scan path must REFUSE anything that
121+
// is not a bare registry name. pip/uv/npm execute the package's build backend
122+
// (setup.py / PEP 517 egg_info) when given a local path, git+/VCS, URL, file:,
123+
// or PEP 508 direct-reference spec — even with --only-binary=:all: /
124+
// --ignore-scripts — which is arbitrary code execution during a static scan.
125+
// Bare registry names are safe (wheel-only download / `npm pack --ignore-scripts`).
126+
// A rejected spec makes the fetch caller fall back to tool_definitions_only.
127+
func isBareRegistrySpec(spec, ecosystem string) bool {
128+
if spec == "" || strings.TrimSpace(spec) != spec {
129+
return false
130+
}
131+
// No whitespace (PEP 508 direct references use "name @ url").
132+
if strings.ContainsAny(spec, " \t\r\n;") {
133+
return false
134+
}
135+
// No URLs, VCS prefixes, file: refs, or direct references.
136+
lower := strings.ToLower(spec)
137+
for _, bad := range []string{"://", "git+", "hg+", "svn+", "bzr+", "file:", "@http", "@git"} {
138+
if strings.Contains(lower, bad) {
139+
return false
140+
}
141+
}
142+
// No path indicators (absolute, relative, home, backslash, parent-dir).
143+
if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") ||
144+
strings.HasPrefix(spec, "~") || strings.HasPrefix(spec, "\\") {
145+
return false
146+
}
147+
if strings.Contains(spec, "\\") || strings.Contains(spec, "..") {
148+
return false
149+
}
150+
// Validate the bare NAME (version specifier stripped) against the ecosystem.
151+
name, _ := parsePackageSpec(spec)
152+
switch ecosystem {
153+
case "python":
154+
return pep503NameRe.MatchString(name)
155+
case "npm":
156+
return npmNameRe.MatchString(name)
157+
}
158+
return false
159+
}
160+
107161
// firstPackageArg returns the package spec from a runner command's args. It
108162
// honors `--from <pkg>` (uvx) and otherwise returns the first non-flag arg.
109163
func firstPackageArg(args []string) string {
@@ -392,8 +446,14 @@ func (r *SourceResolver) resolveFromPackageFetch(ctx context.Context, info Serve
392446

393447
switch cmdBase {
394448
case "npx", "bunx", "pnpm":
449+
if !isBareRegistrySpec(spec, "npm") {
450+
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)
451+
}
395452
return r.fetchNpmPackage(ctx, info, spec)
396453
case "uvx", "pipx":
454+
if !isBareRegistrySpec(spec, "python") {
455+
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)
456+
}
397457
return r.fetchPythonPackage(ctx, info, spec)
398458
}
399459
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)