Skip to content

Commit f6135c9

Browse files
fix(security): validate package-spec @-tail, close direct-reference bypass
Codex re-review of #672 found validatePackageSpec only validated the parsed NAME, not the version/@-tail. A PEP 508 / npm direct reference ("pkg@./local", "pkg@/abs/path", "pkg@git+https://…") therefore passed — the name "pkg" is a valid registry name — while the full untrusted spec still reached `pip download` / `uv pip download` / `npm pack` and executed setup.py. The P0 was not actually closed. Validate the WHOLE spec: - New bareVersionRe: the "name@<tail>" / "name==<tail>" tail must be a bare version / range / npm dist-tag — must start alphanumeric and contain no '/' or ':' — so a path/URL/VCS tail is rejected. - Python: any '@' is a PEP 508 direct reference (pins use '=='), so it is refused outright. - Bare version pins / dist-tags (pkg@1.2.3, pkg@latest, @scope/pkg@2.0.0) stay valid — no regression. TDD: TestValidatePackageSpec gains npm+python "pkg@<path/url/vcs>" reject cases and bare-version positive cases; TestResolveFromPackageFetch_ RejectsNonRegistrySpec gains uvx/npx direct-reference cases asserting the setup.py execution marker stays absent. Related MCP-2442 Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent c842e18 commit f6135c9

3 files changed

Lines changed: 47 additions & 2 deletions

File tree

docs/features/security-scanner-plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +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.
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. Validation covers the **whole spec including the `@`-tail**: a PEP 508 / npm direct reference such as `pkg@./local`, `pkg@/abs/path`, or `pkg@git+https://…` is rejected (the `name@<ref>` tail must be a bare version specifier, not a path/URL/VCS); for PyPI, any `@` is treated as a direct reference and refused.
263263
- **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`).
264264
- **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.
265265

internal/security/scanner/package_fetch.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ var (
114114
// pep503NameRe matches a PEP 503 / PEP 508 distribution name (case
115115
// insensitive). No path/URL/VCS characters are permitted.
116116
pep503NameRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
117+
// bareVersionRe matches a plain version pin, range, or npm dist-tag. It must
118+
// START with an alphanumeric (so it rejects "./x", "/abs", "~/x", "../x") and
119+
// contains NO '/' or ':' (so it rejects local paths and direct-reference
120+
// URLs). This validates the 'name@<tail>' / 'name==<tail>' tail so a PEP 508 /
121+
// npm direct reference (e.g. "pkg@./local", "pkg@/abs") cannot smuggle a
122+
// path/URL/VCS past the name check and on to pip/npm (MCP-2442 re-review).
123+
bareVersionRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_~^*<>=!,-]*$`)
117124
)
118125

119126
// validatePackageSpec rejects any package spec that is not a bare registry name
@@ -145,19 +152,33 @@ func validatePackageSpec(ecosystem, spec string) error {
145152
if strings.Contains(spec, "..") {
146153
return fmt.Errorf("package spec %q contains a path traversal", spec)
147154
}
148-
name, _ := parsePackageSpec(spec)
155+
name, version := parsePackageSpec(spec)
149156
switch ecosystem {
150157
case "npm":
151158
if !npmNameRe.MatchString(name) {
152159
return fmt.Errorf("%q is not a valid npm package name", name)
153160
}
154161
case "python":
162+
// Python version pins use "==" / ">=" etc., never "@". The ONLY use of
163+
// "@" in a PEP 508 spec is a direct reference ("name @ url"), which is
164+
// always a path/URL/VCS — never a registry fetch. Reject any "@" outright.
165+
if strings.Contains(spec, "@") {
166+
return fmt.Errorf("python package spec %q is a PEP 508 direct reference (@), not a registry name", spec)
167+
}
155168
if !pep503NameRe.MatchString(name) {
156169
return fmt.Errorf("%q is not a valid PEP 503 package name", name)
157170
}
158171
default:
159172
return fmt.Errorf("unknown ecosystem %q", ecosystem)
160173
}
174+
// Validate the version / tail too. parsePackageSpec splits "name@tail" on the
175+
// last '@'; without this check a direct-reference tail (e.g. "./local",
176+
// "/abs", "~/x") would pass because only the NAME was validated, and the full
177+
// untrusted spec would still reach pip/npm and execute setup.py (MCP-2442
178+
// re-review). The tail must be a bare version specifier / dist-tag.
179+
if version != "" && !bareVersionRe.MatchString(version) {
180+
return fmt.Errorf("package spec %q has a non-version reference tail %q (path/URL/VCS not allowed)", spec, version)
181+
}
161182
return nil
162183
}
163184

internal/security/scanner/package_fetch_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,27 @@ func TestValidatePackageSpec(t *testing.T) {
510510
{"npm", "file:../evil", false},
511511
{"npm", "https://example.com/pkg.tgz", false},
512512
{"npm", "", false},
513+
// MCP-2442 re-review: PEP 508 / npm direct-reference '@'-tail bypass — the
514+
// NAME parses as a bare registry name but the tail is a path/URL/VCS, which
515+
// reaches pip/npm verbatim and STILL executes setup.py. The WHOLE spec
516+
// (including the tail after '@') must be validated.
517+
{"npm", "pkg@./local", false},
518+
{"npm", "pkg@/abs/path", false},
519+
{"npm", "pkg@~/evil", false},
520+
{"npm", "pkg@git+https://github.com/user/repo.git", false},
521+
{"npm", "pkg@file:./evil", false},
522+
{"npm", "pkg@https://example.com/pkg.tgz", false},
523+
{"npm", "@scope/pkg@./local", false},
524+
{"python", "pkg@./local", false},
525+
{"python", "pkg@/abs/path", false},
526+
{"python", "pkg@git+https://github.com/user/repo.git", false},
527+
{"python", "pkg@~/evil", false},
528+
{"python", "flights @ git+https://example.com/repo.git", false},
529+
// Bare version pins / dist-tags after '@' stay valid (no regression).
530+
{"npm", "pkg@1.2.3", true},
531+
{"npm", "pkg@1.2.3-beta.1", true},
532+
{"npm", "pkg@latest", true},
533+
{"npm", "@scope/pkg@2.0.0", true},
513534
}
514535
for _, c := range cases {
515536
err := validatePackageSpec(c.ecosystem, c.spec)
@@ -541,6 +562,9 @@ func TestResolveFromPackageFetch_RejectsNonRegistrySpec(t *testing.T) {
541562
{Name: "evil-path", Protocol: "stdio", Command: "uvx", Args: []string{"--from", pkgDir, "evil"}},
542563
{Name: "evil-git", Protocol: "stdio", Command: "uvx", Args: []string{"--from", "git+https://example.com/repo.git", "evil"}},
543564
{Name: "evil-npm-path", Protocol: "stdio", Command: "npx", Args: []string{"-y", "./local-evil"}},
565+
// MCP-2442 re-review: PEP 508 / npm direct-reference '@'-tail bypass.
566+
{Name: "evil-uvx-directref", Protocol: "stdio", Command: "uvx", Args: []string{"--from", "evilpkg@" + pkgDir, "evil"}},
567+
{Name: "evil-npm-directref", Protocol: "stdio", Command: "npx", Args: []string{"-y", "evilpkg@./local-evil"}},
544568
}
545569
for _, info := range cases {
546570
res, err := r.resolveFromPackageFetch(context.Background(), info)

0 commit comments

Comments
 (0)