Skip to content

Commit 41e9a59

Browse files
committed
fix(scanner): close 3 spec-parsing holes from Codex round-2 re-review
1. Flag-bypass: runnerPackageSpec parsed package flags (-p/--package/--from) BEFORE enforcing the dlx/x/run subcommand, so `pnpm -p start` / `bun -p server.ts` still produced a spec. Now the required subcommand keyword is enforced FIRST (skipping only leading global flags); flag parsing happens only on the args that follow it. 2. Argument-aware classification: isPackageRunnerCommand classified bare pnpm/yarn/bun/pipx as runners by name alone. It now takes args and treats them as runners only when the ephemeral-run keyword is actually present (npx/uvx/bunx remain runners by name). Decided up front, not via a later parser failure. 3. Version-pin miss never substitutes: when an exact pin is requested but absent from a local cache, resolveNpxCache, findUvxArchiveDir, the uv tools dir (Strategy 2), and the container npx locate script all now resolve NOTHING instead of falling back to a mismatched cached version (false coverage). The published-source fetch then gets the pinned version. Also fixed cleanPkg to strip PEP 440 specifiers (pkg==1.0), not just @ver. TDD: pnpm -p start / bun -p server.ts -> no spec; isPackageRunnerCommand arg-aware table; npx + uvx-archive + uv-tools-dir pin-miss -> not resolved; container locate pin-miss -> empty; exact-pin hits + unpinned still resolve. go test ./internal/security/... -race green; golangci v2 0 issues. Related MCP-2445
1 parent 8bade8b commit 41e9a59

4 files changed

Lines changed: 254 additions & 67 deletions

File tree

internal/security/scanner/package_fetch.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,35 @@ func runnerFlagTakesValue(npm bool, flag string) bool {
257257
// The first remaining positional token is the package. Returns "" when no package
258258
// can be determined.
259259
func runnerPackageSpec(commandBase string, args []string) string {
260-
sub := runnerSubcommand(commandBase)
261-
subSkipped := sub == ""
262260
npm := isNpmRunner(commandBase)
261+
262+
// Subcommand-style runners (pnpm/yarn `dlx`, `bun x`, `pipx run`) REQUIRE
263+
// their keyword before anything is treated as a package. The keyword check
264+
// runs BEFORE any flag parsing, so a package flag cannot bypass it: a bare
265+
// `pnpm start`, `bun server.ts`, `pipx install foo`, or `pnpm -p start` has
266+
// no keyword and resolves nothing (a local invocation, not a remote fetch —
267+
// fetching the wrong token would mean false coverage + typosquat risk;
268+
// MCP-2445). Leading global flags before the keyword (e.g. `pnpm --silent
269+
// dlx`) are skipped. npx/uvx/bunx have no subcommand and parse from the start.
270+
if sub := runnerSubcommand(commandBase); sub != "" {
271+
i := 0
272+
for i < len(args) {
273+
arg := args[i]
274+
if strings.HasPrefix(arg, "-") {
275+
if i+1 < len(args) && runnerFlagTakesValue(npm, arg) {
276+
i++
277+
}
278+
i++
279+
continue
280+
}
281+
break // first positional token
282+
}
283+
if i >= len(args) || args[i] != sub {
284+
return "" // first positional is not the required keyword → no package
285+
}
286+
args = args[i+1:] // parse only what follows the keyword
287+
}
288+
263289
for i := 0; i < len(args); i++ {
264290
arg := args[i]
265291
hasNext := i+1 < len(args)
@@ -278,20 +304,6 @@ func runnerPackageSpec(commandBase string, args []string) string {
278304
}
279305
continue
280306
}
281-
// First positional token. Subcommand-style runners (pnpm/yarn/bun/pipx)
282-
// REQUIRE their keyword (dlx/x/run) before the package token: a bare
283-
// `pnpm start`, `bun server.ts`, or `pipx install foo` is a LOCAL
284-
// invocation, NOT a remote package fetch, so it must not resolve a spec —
285-
// fetching the wrong token would mean false coverage and typosquat risk
286-
// (MCP-2445 re-review). Once the keyword is consumed the next positional
287-
// is the package.
288-
if !subSkipped {
289-
if arg == sub {
290-
subSkipped = true
291-
continue
292-
}
293-
return ""
294-
}
295307
return arg
296308
}
297309
return ""

internal/security/scanner/package_fetch_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ func TestRunnerPackageSpec(t *testing.T) {
7373
{"bun run dev (local script)", "bun", []string{"run", "dev"}, ""},
7474
{"yarn build (local script)", "yarn", []string{"build"}, ""},
7575
{"pipx install foo (not ephemeral run)", "pipx", []string{"install", "foo"}, ""},
76+
// The subcommand keyword is enforced BEFORE flag parsing, so a package
77+
// flag cannot bypass it (MCP-2445 round-2 hole #1).
78+
{"pnpm -p start (flag bypass)", "pnpm", []string{"-p", "start"}, ""},
79+
{"bun -p server.ts (flag bypass)", "bun", []string{"-p", "server.ts"}, ""},
80+
{"pnpm --package x (no dlx)", "pnpm", []string{"--package", "x", "start"}, ""},
81+
// Leading global flags before the keyword are fine; keyword still required.
82+
{"pnpm --silent dlx pkg", "pnpm", []string{"--silent", "dlx", "pkg"}, "pkg"},
7683
// Degenerate inputs.
7784
{"npx flag only", "npx", []string{"-y"}, ""},
7885
{"nil", "npx", nil, ""},

internal/security/scanner/source_resolver.go

Lines changed: 106 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
174174
// a server like `npx @modelcontextprotocol/server-filesystem /tmp/data`
175175
// from picking up the user's data dir (`/tmp/data`) as the server
176176
// source — the arg is the filesystem server's allowed root, not code.
177-
if info.Command != "" && isPackageRunnerCommand(info.Command) {
177+
if info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
178178
if resolved, err := r.resolveFromPackageCache(ctx, info); err == nil {
179179
return resolved, nil
180180
} else {
@@ -256,7 +256,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
256256

257257
// Last resort: try the package cache for any other command (e.g. the user
258258
// has an absolute path to node_modules that we didn't match above).
259-
if info.Command != "" && !isPackageRunnerCommand(info.Command) {
259+
if info.Command != "" && !isPackageRunnerCommand(info.Command, info.Args) {
260260
if resolved, err := r.resolveFromPackageCache(ctx, info); err == nil {
261261
return resolved, nil
262262
}
@@ -267,7 +267,7 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
267267
// a quarantined-on-add server is never run locally, so the local cache above
268268
// always misses (MCP-2206). Fetching real source lets the AI + supply-chain
269269
// scanners run instead of degrading to tool_definitions_only.
270-
if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command) {
270+
if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
271271
if resolved, err := r.resolveFromPackageFetch(ctx, info); err == nil {
272272
return resolved, nil
273273
} else {
@@ -286,15 +286,22 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve
286286
// a remote registry rather than running local source code. For these, the
287287
// server source lives in the package manager's cache, not in any positional
288288
// argument.
289-
func isPackageRunnerCommand(command string) bool {
289+
//
290+
// Classification is ARGUMENT-AWARE for subcommand-style runners (MCP-2445):
291+
// npx/uvx/bunx always run a remote package by name, but pnpm/yarn/bun/pipx are
292+
// general multi-command tools — they are runners ONLY when their ephemeral-run
293+
// keyword (`dlx`/`x`/`run`) is actually present. Classifying `pnpm start` or
294+
// `bun server.ts` as a runner by name alone would route a local invocation into
295+
// the package cache/fetch path and scan the wrong token. We decide this up front
296+
// rather than relying on a later parser failure.
297+
func isPackageRunnerCommand(command string, args []string) bool {
290298
base := strings.ToLower(filepath.Base(command))
291299
switch base {
292-
// pnpm/yarn are runners only in their `dlx` form; that is the only way an MCP
293-
// server is launched from them, and resolveFromPackageFetch dispatches pnpm to
294-
// the npm fetch path. Including them here lets that fetch fallback actually run
295-
// (previously pnpm was dispatched but never gated true here — a dead branch).
296-
case "npx", "uvx", "pipx", "bunx", "bun", "pnpm", "yarn":
300+
case "npx", "uvx", "bunx":
297301
return true
302+
case "pipx", "pnpm", "yarn", "bun":
303+
// Runner only when the ephemeral-run keyword yields a resolvable package.
304+
return runnerPackageSpec(base, args) != ""
298305
}
299306
return false
300307
}
@@ -792,19 +799,20 @@ func uvxTargetPackage(info ServerInfo) string {
792799
// embedding. Factored out so the shell logic is unit-testable on the host.
793800
func npxContainerLocateScript(npxGlobRoot, pkg, version string) string {
794801
pkgEsc := strings.ReplaceAll(pkg, "'", `'\''`)
795-
verClause := ""
796802
if version != "" {
797-
verEsc := strings.ReplaceAll(version, "'", `'\''`)
798-
// The .dist-info-free npm case: match the version inside package.json. The
803+
// Pinned: return ONLY a bucket whose package.json declares that exact
804+
// version. A pin-miss returns nothing — never a mismatched version, which
805+
// would scan the wrong code and report false coverage (MCP-2445). The
799806
// pattern tolerates arbitrary JSON spacing around the colon.
800-
verClause = "if grep -Eq '\"version\"[[:space:]]*:[[:space:]]*\"" + verEsc + "\"' \"$d/package.json\" 2>/dev/null; then printf '%s\\n' \"$d\"; exit 0; fi; "
801-
}
802-
return "first=''; for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
803-
"[ -d \"$d\" ] || continue; " +
804-
"[ -z \"$first\" ] && first=\"$d\"; " +
805-
verClause +
806-
"done; " +
807-
"[ -n \"$first\" ] && printf '%s\\n' \"$first\""
807+
verEsc := strings.ReplaceAll(version, "'", `'\''`)
808+
return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
809+
"[ -d \"$d\" ] || continue; " +
810+
"if grep -Eq '\"version\"[[:space:]]*:[[:space:]]*\"" + verEsc + "\"' \"$d/package.json\" 2>/dev/null; then printf '%s\\n' \"$d\"; exit 0; fi; " +
811+
"done"
812+
}
813+
// Unpinned: first matching bucket.
814+
return "for d in " + npxGlobRoot + "/*/node_modules/'" + pkgEsc + "'; do " +
815+
"[ -d \"$d\" ] || continue; printf '%s\\n' \"$d\"; exit 0; done"
808816
}
809817

810818
// findContainerTargetDir locates the target package's directory inside a
@@ -951,7 +959,7 @@ func (r *SourceResolver) ResolveFullSource(ctx context.Context, info ServerInfo)
951959
// source without executing it (MCP-2206). Same rationale as Pass 1 — without
952960
// a local container or cache, Pass 2 supply-chain scanning would otherwise
953961
// have nothing to analyze for npx/uvx servers.
954-
if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command) {
962+
if r.fetchPackageSource && info.Command != "" && isPackageRunnerCommand(info.Command, info.Args) {
955963
if resolved, err := r.resolveFromPackageFetch(ctx, info); err == nil {
956964
return resolved, nil
957965
}
@@ -1347,6 +1355,19 @@ func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, erro
13471355
return nil, fmt.Errorf("package %q not found in npx cache (%s)", pkgName, npxCacheDir)
13481356
}
13491357

1358+
if wantVersion != "" && !bestVerMatch {
1359+
// An exact version was pinned but no cached candidate declares it. Do NOT
1360+
// substitute a different cached version — scanning the wrong code reports
1361+
// false coverage (MCP-2445). Defer to the published-source fetch fallback,
1362+
// which fetches the PINNED version, or degrade to tool_definitions_only.
1363+
r.logger.Warn("npx cache has the package but not the pinned version; deferring to published-source fetch",
1364+
zap.String("server", info.Name),
1365+
zap.String("package", pkgName),
1366+
zap.String("pinned_version", wantVersion),
1367+
)
1368+
return nil, fmt.Errorf("npx cache for %q has no entry matching pinned version %q (avoiding mismatched-version scan)", pkgName, wantVersion)
1369+
}
1370+
13501371
if !bestReal {
13511372
// Every candidate was a bare stub (e.g. a lone tools.json mcpproxy wrote
13521373
// into the cache) — no real installed source exists locally. Returning the
@@ -1443,6 +1464,13 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
14431464
// Check if it's a git URL: git+https://github.com/...
14441465
isGitURL := strings.HasPrefix(pkgName, "git+")
14451466

1467+
// Exact version pin (if any). Only meaningful for registry packages — git
1468+
// specs pin via the URL ref, not a PEP 440 version.
1469+
wantVersion := ""
1470+
if !isGitURL {
1471+
_, wantVersion = parsePackageSpec(pkgName)
1472+
}
1473+
14461474
homeDir, err := os.UserHomeDir()
14471475
if err != nil {
14481476
return nil, fmt.Errorf("cannot determine home directory: %w", err)
@@ -1474,33 +1502,48 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
14741502
}
14751503
}
14761504

1477-
// Strategy 2: UV tools directory (for regular packages)
1478-
// Strip version: package@version → package
1505+
// Strategy 2: UV tools directory (for regular packages). Reduce the spec to a
1506+
// bare distribution name: strip a git+ URL to its repo, otherwise strip the
1507+
// `@version` form AND any PEP 440 specifier (`==`, `>=`, extras …) — the latter
1508+
// was previously missed, so a `pkg==1.0` spec produced a bogus `tools/pkg==1.0`
1509+
// path that never matched.
14791510
cleanPkg := pkgName
1480-
if idx := strings.LastIndex(cleanPkg, "@"); idx > 0 {
1481-
cleanPkg = cleanPkg[:idx]
1482-
}
1483-
// Also strip git+ prefix and URL
14841511
if strings.HasPrefix(cleanPkg, "git+") {
14851512
// Extract package name from URL: git+https://github.com/org/repo → repo
14861513
parts := strings.Split(cleanPkg, "/")
14871514
if len(parts) > 0 {
14881515
cleanPkg = parts[len(parts)-1]
14891516
}
1517+
} else {
1518+
if idx := strings.LastIndex(cleanPkg, "@"); idx > 0 {
1519+
cleanPkg = cleanPkg[:idx]
1520+
}
1521+
cleanPkg = stripPkgVersion(cleanPkg)
14901522
}
14911523

14921524
toolsDir := filepath.Join(homeDir, ".local", "share", "uv", "tools", cleanPkg)
14931525
if stat, err := os.Stat(toolsDir); err == nil && stat.IsDir() {
1494-
r.logger.Info("Resolved source from UV tools directory",
1526+
// When a version is pinned, only accept the tools dir if it actually holds
1527+
// that version — otherwise fall through so the published-source fetch gets
1528+
// the PINNED version instead of scanning whatever was `uv tool install`-ed
1529+
// (MCP-2445: never substitute a mismatched version).
1530+
if wantVersion == "" || uvDirHasVersion(toolsDir, stripPkgVersion(cleanPkg), wantVersion) {
1531+
r.logger.Info("Resolved source from UV tools directory",
1532+
zap.String("server", info.Name),
1533+
zap.String("package", cleanPkg),
1534+
zap.String("path", toolsDir),
1535+
)
1536+
return &ResolvedSource{
1537+
SourceDir: toolsDir,
1538+
Method: "uvx_cache",
1539+
Cleanup: func() {},
1540+
}, nil
1541+
}
1542+
r.logger.Warn("UV tools dir present but not the pinned version; deferring to archive/published-source fetch",
14951543
zap.String("server", info.Name),
14961544
zap.String("package", cleanPkg),
1497-
zap.String("path", toolsDir),
1545+
zap.String("pinned_version", wantVersion),
14981546
)
1499-
return &ResolvedSource{
1500-
SourceDir: toolsDir,
1501-
Method: "uvx_cache",
1502-
Cleanup: func() {},
1503-
}, nil
15041547
}
15051548

15061549
// Strategy 3: ephemeral uvx archive cache (~/.cache/uv/archive-v0/<hash>/).
@@ -1516,9 +1559,8 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro
15161559
// not archive-v0, so they are skipped here.
15171560
if !isGitURL {
15181561
archiveRoot := filepath.Join(homeDir, ".cache", "uv", "archive-v0")
1519-
// Honor an exact version pin: prefer the archive entry whose .dist-info
1520-
// declares the pinned version over a newer-but-mismatched one (MCP-2445).
1521-
_, wantVersion := parsePackageSpec(pkgName)
1562+
// Honor an exact version pin: select the archive entry whose .dist-info
1563+
// declares the pinned version; a pin-miss resolves nothing (MCP-2445).
15221564
if dir, ok := findUvxArchiveDir(archiveRoot, stripPkgVersion(cleanPkg), wantVersion); ok {
15231565
r.logger.Info("Resolved source from UV archive cache",
15241566
zap.String("server", info.Name),
@@ -1635,9 +1677,36 @@ func findUvxArchiveDir(archiveRoot, pkg, wantVersion string) (string, bool) {
16351677
if best == "" {
16361678
return "", false
16371679
}
1680+
if wantVersion != "" && !bestVerMatch {
1681+
// Pinned version requested but no archive entry declares it. Do not
1682+
// substitute a different cached version (false coverage) — report not found
1683+
// so the caller fetches the pinned version or degrades (MCP-2445).
1684+
return "", false
1685+
}
16381686
return best, true
16391687
}
16401688

1689+
// uvDirHasVersion reports whether a uv venv-style directory (a `uv tool install`
1690+
// tools dir) holds distribution pkg at exactly version. It looks for a matching
1691+
// `<dist>-<version>.dist-info` under any `lib/python*/site-packages`. Used to keep
1692+
// a version pin honest against the persistent tools dir.
1693+
func uvDirHasVersion(root, pkg, version string) bool {
1694+
if version == "" {
1695+
return false
1696+
}
1697+
norm := normalizeDistName(pkg)
1698+
if norm == "" {
1699+
return false
1700+
}
1701+
sps, _ := filepath.Glob(filepath.Join(root, "lib", "python*", "site-packages"))
1702+
for _, d := range sps {
1703+
if _, vm := distInfoMatch(d, norm, version); vm {
1704+
return true
1705+
}
1706+
}
1707+
return false
1708+
}
1709+
16411710
// distInfoMatch reports whether dir directly contains a `<name>-<version>.dist-info`
16421711
// directory whose normalized distribution name equals norm (nameMatch), and — when
16431712
// wantVersion is non-empty — whether that directory's version equals wantVersion

0 commit comments

Comments
 (0)