From 6b702d6dabe2f72fb1fbb3d07dd6e3d004b2a89d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 18:17:30 -0400 Subject: [PATCH] fix(scanner): classify Windows npx.cmd/uvx.exe as package runners The source resolver matched runner commands by exact base name, so a Windows server launched with npx.cmd, uvx.exe or pnpm.cmd was not a package runner. The package-cache-first lookup was skipped, letting arg scanning pick a data-dir argument as server source, and the published package fetch never ran. Strip a .exe, .cmd or .bat suffix (and lowercase) wherever the runner name is derived. --- internal/security/scanner/package_fetch.go | 2 +- .../scanner/runner_windows_ext_test.go | 26 +++++++++++++++++++ internal/security/scanner/source_resolver.go | 23 +++++++++++----- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 internal/security/scanner/runner_windows_ext_test.go diff --git a/internal/security/scanner/package_fetch.go b/internal/security/scanner/package_fetch.go index 251d89ef4..6abe9a017 100644 --- a/internal/security/scanner/package_fetch.go +++ b/internal/security/scanner/package_fetch.go @@ -575,7 +575,7 @@ func writeArchiveFile(target string, src io.Reader, limit int64) (int64, error) // the toolchain is missing, the host is offline, or the fetch/extract fails — // guaranteeing no regression versus today's behavior. func (r *SourceResolver) resolveFromPackageFetch(ctx context.Context, info ServerInfo) (*ResolvedSource, error) { - cmdBase := strings.ToLower(filepath.Base(info.Command)) + cmdBase := runnerBase(info.Command) spec := runnerPackageSpec(cmdBase, info.Args) if spec == "" { return nil, fmt.Errorf("no package spec in args for %s", info.Name) diff --git a/internal/security/scanner/runner_windows_ext_test.go b/internal/security/scanner/runner_windows_ext_test.go new file mode 100644 index 000000000..7283c8a94 --- /dev/null +++ b/internal/security/scanner/runner_windows_ext_test.go @@ -0,0 +1,26 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Windows configs name package runners with their executable suffix (npx.cmd, +// uvx.exe). Missing them skips the package-cache-first path, so a data-dir +// argument can be scanned as server source, and the published-package fetch +// never runs. +func TestPackageRunner_WindowsExecutableSuffix(t *testing.T) { + fs := []string{"-y", "@modelcontextprotocol/server-filesystem", "/data"} + + assert.True(t, isPackageRunnerCommand("npx.cmd", fs)) + assert.True(t, isPackageRunnerCommand("NPX.CMD", fs)) + assert.True(t, isPackageRunnerCommand("uvx.exe", []string{"mcp-server-fetch"})) + assert.True(t, isPackageRunnerCommand("pnpm.cmd", []string{"dlx", "some-mcp"})) + assert.False(t, isPackageRunnerCommand("node.exe", []string{"server.js"})) + + assert.Equal(t, "@modelcontextprotocol/server-filesystem", + npxTargetPackage(ServerInfo{Command: "npx.cmd", Args: fs})) + assert.Equal(t, "mcp-server-fetch", + uvxTargetPackage(ServerInfo{Command: "uvx.exe", Args: []string{"mcp-server-fetch"}})) +} diff --git a/internal/security/scanner/source_resolver.go b/internal/security/scanner/source_resolver.go index a5e810f83..732a6ec76 100644 --- a/internal/security/scanner/source_resolver.go +++ b/internal/security/scanner/source_resolver.go @@ -342,8 +342,19 @@ func (r *SourceResolver) Resolve(ctx context.Context, info ServerInfo) (*Resolve // `bun server.ts` as a runner by name alone would route a local invocation into // the package cache/fetch path and scan the wrong token. We decide this up front // rather than relying on a later parser failure. -func isPackageRunnerCommand(command string, args []string) bool { +// runnerBase returns a command's lowercased base name without a Windows +// executable suffix, so npx.cmd and uvx.exe classify as npx and uvx. +func runnerBase(command string) string { base := strings.ToLower(filepath.Base(command)) + switch filepath.Ext(base) { + case ".exe", ".cmd", ".bat": + base = strings.TrimSuffix(base, filepath.Ext(base)) + } + return base +} + +func isPackageRunnerCommand(command string, args []string) bool { + base := runnerBase(command) switch base { case "npx", "uvx", "bunx": return true @@ -853,7 +864,7 @@ func extractNpxPackageDir(path, targetPkg string) string { // stripped) that an npx-based server launches. Returns "" if the command is // not npx or no package name can be determined. func npxTargetPackage(info ServerInfo) string { - if info.Command == "" || filepath.Base(info.Command) != "npx" { + if info.Command == "" || runnerBase(info.Command) != "npx" { return "" } name, _ := parsePackageSpec(runnerPackageSpec("npx", info.Args)) @@ -865,7 +876,7 @@ func npxTargetPackage(info ServerInfo) string { // Git URLs (git+https://...) are reduced to the repo name. Returns "" if the // command is not uvx or no package name can be determined. func uvxTargetPackage(info ServerInfo) string { - if info.Command == "" || filepath.Base(info.Command) != "uvx" { + if info.Command == "" || runnerBase(info.Command) != "uvx" { return "" } // runnerPackageSpec skips `--with ` / `-p ` values so the target @@ -1364,7 +1375,7 @@ func (r *SourceResolver) EnrichWithFileList(resolved *ResolvedSource) { // resolveFromPackageCache attempts to find server source in local package manager caches. // Supports npx (npm), uvx (uv/pip), and pipx. func (r *SourceResolver) resolveFromPackageCache(ctx context.Context, info ServerInfo) (*ResolvedSource, error) { - cmdBase := filepath.Base(info.Command) + cmdBase := runnerBase(info.Command) switch cmdBase { case "npx": @@ -1380,7 +1391,7 @@ func (r *SourceResolver) resolveFromPackageCache(ctx context.Context, info Serve func (r *SourceResolver) resolveNpxCache(info ServerInfo) (*ResolvedSource, error) { // Extract the target package spec (handles `npx -p `, `pnpm dlx `, // version pins, etc.) then split name from any exact version pin. - spec := runnerPackageSpec(strings.ToLower(filepath.Base(info.Command)), info.Args) + spec := runnerPackageSpec(runnerBase(info.Command), info.Args) if spec == "" { return nil, fmt.Errorf("no package name found in npx args") } @@ -1563,7 +1574,7 @@ func (r *SourceResolver) resolveUvxCache(info ServerInfo) (*ResolvedSource, erro // `uvx --from `, `uvx git+`, and crucially skips `--with ` // / `-p ` values so the additional dependency or python version is not // mistaken for the target (MCP-2445). - pkgName := runnerPackageSpec(strings.ToLower(filepath.Base(info.Command)), info.Args) + pkgName := runnerPackageSpec(runnerBase(info.Command), info.Args) if pkgName == "" { return nil, fmt.Errorf("no package name found in uvx args") }