Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions docs/cli/security-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,12 @@ mcpproxy security scan my-new-server --scanners nova-proximity,trivy-mcp

Before running any scanner, mcpproxy determines *what to scan*. The resolver order is:

1. **Docker-isolated servers** → extract `/app` (or the server's `WorkingDir`) from the running container.
2. **Package-runner commands** (`npx`, `uvx`, `pipx`, `bunx`) → resolve from the local package cache (`~/.npm/_npx/…`, `~/.cache/uv/…`, etc.). This path is tried **first** for package runners.
3. **Working directory** from the server config.
4. **Arg-scan fallback** — iterate positional command args, accept the first one that exists as a directory AND contains a source marker (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `go.mod`, etc.).
5. **Tool definitions only** — export the server's tool schemas to a temp dir and scan those (used for HTTP/SSE servers and as a last-resort fallback).
1. **Docker-image servers** (`command: docker`, `args: [run, …, mcp/fetch]` — also `podman` / `docker container run`) → `source_method=container_image`. The scan target is the image reference itself. Image-capable scanners (Trivy) run in image mode (`trivy image mcp/fetch`) instead of scanning an empty source tree. Trivy resolves the image via the local daemon/containerd/podman, falling back to pulling it from the remote registry, so no Docker socket mount is needed.
2. **Docker-isolated servers** → extract `/app` (or the server's `WorkingDir`) from the running container.
3. **Package-runner commands** (`npx`, `uvx`, `pipx`, `bunx`) → resolve from the local package cache (`~/.npm/_npx/…`, `~/.cache/uv/…`, etc.). This path is tried **first** for package runners.
4. **Working directory** from the server config.
5. **Arg-scan fallback** — iterate positional command args, accept the first one that exists as a directory AND contains a source marker (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `go.mod`, etc.).
6. **Tool definitions only** — export the server's tool schemas to a temp dir and scan those (used for HTTP/SSE servers and as a last-resort fallback).

The `source_method` and `source_path` are recorded on the scan job and shown in both the text and JSON report. This is how you verify a scanner is examining the right directory.

Expand Down
43 changes: 35 additions & 8 deletions internal/security/scanner/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,14 @@ func NewEngine(docker *DockerRunner, registry *Registry, dataDir string, logger

// ScanRequest describes a scan to execute
type ScanRequest struct {
ServerName string
SourceDir string // Path to server source files (for "source" input)
DryRun bool // If true, don't affect quarantine state
ScannerIDs []string // Specific scanners to use (empty = all installed)
Env map[string]string // Additional environment variables
ScanContext *ScanContext // Context metadata (set by service)
ScanPass int // 1 = security scan (fast), 2 = supply chain audit (background)
ServerName string
SourceDir string // Path to server source files (for "source" input)
ContainerImage string // Docker image reference (for "container_image" input)
DryRun bool // If true, don't affect quarantine state
ScannerIDs []string // Specific scanners to use (empty = all installed)
Env map[string]string // Additional environment variables
ScanContext *ScanContext // Context metadata (set by service)
ScanPass int // 1 = security scan (fast), 2 = supply chain audit (background)
}

// ScanCallback receives scan lifecycle events
Expand Down Expand Up @@ -349,6 +350,32 @@ func (e *Engine) executeScan(ctx context.Context, job *ScanJob, scanners []resol
}

// runSingleScanner executes one scanner and returns its report plus execution logs
// scannerSupportsInput reports whether the scanner declares the given input type.
func scannerSupportsInput(s *ScannerPlugin, input string) bool {
for _, in := range s.Inputs {
if in == input {
return true
}
}
return false
}

// effectiveScannerCommand returns the command to run for a scanner. When the
// scan target is a Docker image (req.ContainerImage set) and the scanner both
// declares the "container_image" input and provides an ImageCommand template,
// the image-mode command is returned with "{{IMAGE}}" substituted for the image
// reference. Otherwise the scanner's default (source-mode) Command is returned.
func effectiveScannerCommand(s *ScannerPlugin, req ScanRequest) []string {
if req.ContainerImage == "" || len(s.ImageCommand) == 0 || !scannerSupportsInput(s, "container_image") {
return s.Command
}
out := make([]string, len(s.ImageCommand))
for i, tok := range s.ImageCommand {
out[i] = strings.ReplaceAll(tok, "{{IMAGE}}", req.ContainerImage)
}
return out
}

func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req ScanRequest) (*ScanReport, scannerLogs, error) {
// In-process scanners run directly in Go — no Docker container, no source
// files required. They analyze the tool definitions exported to
Expand Down Expand Up @@ -465,7 +492,7 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
cfg := ScannerRunConfig{
ContainerName: GenerateContainerName(s.ID, req.ServerName),
Image: s.EffectiveImage(),
Command: s.Command,
Command: effectiveScannerCommand(s, req),
Env: env,
SourceDir: req.SourceDir,
ReportDir: reportDir,
Expand Down
147 changes: 147 additions & 0 deletions internal/security/scanner/image_scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package scanner

import (
"context"
"testing"

"go.uber.org/zap"
)

func TestDockerImageFromCommand(t *testing.T) {
cases := []struct {
name string
info ServerInfo
want string
}{
{
name: "simple docker run",
info: ServerInfo{Command: "docker", Args: []string{"run", "-i", "--rm", "mcp/fetch"}},
want: "mcp/fetch",
},
{
name: "value flags before image, command after",
info: ServerInfo{Command: "docker", Args: []string{"run", "--rm", "-i", "-e", "FOO=bar", "ghcr.io/x/y:tag", "serve"}},
want: "ghcr.io/x/y:tag",
},
{
name: "podman with volume",
info: ServerInfo{Command: "podman", Args: []string{"run", "-v", "/tmp:/data", "mcp/time"}},
want: "mcp/time",
},
{
name: "name and attached long flag",
info: ServerInfo{Command: "docker", Args: []string{"run", "--name", "x", "--network=host", "mcp/git"}},
want: "mcp/git",
},
{
name: "absolute docker path",
info: ServerInfo{Command: "/usr/local/bin/docker", Args: []string{"run", "alpine"}},
want: "alpine",
},
{
name: "attached short env flag with equals",
info: ServerInfo{Command: "docker", Args: []string{"run", "-e", "A=1", "-eB=2", "mcp/x"}},
want: "mcp/x",
},
{
name: "combined boolean short flags",
info: ServerInfo{Command: "docker", Args: []string{"run", "-it", "mcp/x"}},
want: "mcp/x",
},
{
name: "docker container run subcommand",
info: ServerInfo{Command: "docker", Args: []string{"container", "run", "mcp/x"}},
want: "mcp/x",
},
{
name: "not a docker command",
info: ServerInfo{Command: "uvx", Args: []string{"mcp-server"}},
want: "",
},
{
name: "docker but not run",
info: ServerInfo{Command: "docker", Args: []string{"ps"}},
want: "",
},
{
name: "no image present",
info: ServerInfo{Command: "docker", Args: []string{"run", "--rm"}},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := dockerImageFromCommand(tc.info); got != tc.want {
t.Errorf("dockerImageFromCommand() = %q, want %q", got, tc.want)
}
})
}
}

func TestResolveDockerRunImage(t *testing.T) {
r := NewSourceResolver(zap.NewNop())
resolved, err := r.Resolve(context.Background(), ServerInfo{
Name: "fetch",
Protocol: "stdio",
Command: "docker",
Args: []string{"run", "-i", "--rm", "mcp/fetch"},
})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
defer resolved.Cleanup()
if resolved.Method != "container_image" {
t.Errorf("Method = %q, want container_image", resolved.Method)
}
if resolved.ContainerImage != "mcp/fetch" {
t.Errorf("ContainerImage = %q, want mcp/fetch", resolved.ContainerImage)
}
if resolved.SourceDir != "" {
t.Errorf("SourceDir = %q, want empty", resolved.SourceDir)
}
}

func TestScannerCommandForImage(t *testing.T) {
trivy := &ScannerPlugin{
ID: "trivy-mcp",
Inputs: []string{"source", "container_image"},
Command: []string{"fs", "--format", "sarif", "/scan/source"},
ImageCommand: []string{"image", "--format", "sarif", "{{IMAGE}}"},
}

// Image present + scanner supports it → image-mode command with substitution.
got := effectiveScannerCommand(trivy, ScanRequest{ContainerImage: "mcp/fetch"})
want := []string{"image", "--format", "sarif", "mcp/fetch"}
if !equalSlice(got, want) {
t.Errorf("image-mode command = %v, want %v", got, want)
}

// No image → fall back to default source-mode command.
got = effectiveScannerCommand(trivy, ScanRequest{})
if !equalSlice(got, trivy.Command) {
t.Errorf("no-image command = %v, want %v", got, trivy.Command)
}

// Scanner without container_image support → keep its command even if an image is present.
semgrep := &ScannerPlugin{
ID: "semgrep-mcp",
Inputs: []string{"source"},
Command: []string{"semgrep", "scan", "/scan/source"},
}
got = effectiveScannerCommand(semgrep, ScanRequest{ContainerImage: "mcp/fetch"})
if !equalSlice(got, semgrep.Command) {
t.Errorf("non-image scanner command = %v, want %v", got, semgrep.Command)
}
}

func equalSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
9 changes: 7 additions & 2 deletions internal/security/scanner/registry_bundled.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ var bundledScanners = []*ScannerPlugin{
RequiredEnv: nil,
OptionalEnv: nil,
Command: []string{"fs", "--format", "sarif", "/scan/source"},
Timeout: "300s", // First run downloads vuln DB (~90MB)
NetworkReq: true, // Needs to download vulnerability database
// For Docker-image servers (`docker run mcp/fetch`) scan the image itself
// instead of an empty source dir. Trivy resolves the image via the local
// daemon/containerd/podman and falls back to pulling from the remote
// registry (network is enabled), so no docker socket mount is required.
ImageCommand: []string{"image", "--format", "sarif", "{{IMAGE}}"},
Timeout: "300s", // First run downloads vuln DB (~90MB)
NetworkReq: true, // Needs to download vulnerability database
},
}
31 changes: 26 additions & 5 deletions internal/security/scanner/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,17 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
scanCtx.SourcePath = resolved.ServerURL
}
scanCtx.ContainerID = resolved.ContainerID
// Docker-image servers (`docker run mcp/fetch`): the scan target is the
// image itself, not a source dir. Carry the reference so image-capable
// scanners (Trivy) run in image mode, and surface it in the context.
if resolved.ContainerImage != "" {
req.ContainerImage = resolved.ContainerImage
scanCtx.ContainerImage = resolved.ContainerImage
scanCtx.DockerIsolation = true
if scanCtx.SourcePath == "" {
scanCtx.SourcePath = resolved.ContainerImage
}
}
// Determine Docker isolation status
if resolved.Method == "docker_extract" {
scanCtx.DockerIsolation = true
Expand Down Expand Up @@ -687,9 +698,10 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
tempDir, err := os.MkdirTemp("", "mcpproxy-scan-tools-")
if err == nil {
req.SourceDir = tempDir
// For HTTP/URL servers, preserve the "url" source method and path
// (the temp dir is only for tool definitions, not the real source)
if scanCtx.SourceMethod != "url" {
// For HTTP/URL and Docker-image servers, preserve the real source
// method and path — the temp dir is only for tool definitions, not
// the real scan target (the URL / the image).
if scanCtx.SourceMethod != "url" && scanCtx.SourceMethod != "container_image" {
scanCtx.SourceMethod = "tool_definitions_only"
scanCtx.SourcePath = tempDir
}
Expand Down Expand Up @@ -820,6 +832,14 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo) {
scanCtx.SourcePath = resolved.ServerURL
}
scanCtx.ContainerID = resolved.ContainerID
// Docker-image servers: scan the image (Trivy image mode reports OS-package
// and bundled-dependency CVEs). No source dir to enrich or export tools into.
if resolved.ContainerImage != "" {
req.ContainerImage = resolved.ContainerImage
scanCtx.ContainerImage = resolved.ContainerImage
scanCtx.SourcePath = resolved.ContainerImage
scanCtx.DockerIsolation = true
}
if resolved.Method == "docker_extract" {
scanCtx.DockerIsolation = true
}
Expand All @@ -828,8 +848,9 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo) {
scanCtx.TotalFiles = resolved.TotalFiles
scanCtx.TotalSizeBytes = resolved.TotalSize

// Export tool definitions for Cisco scanner
if s.serverInfo != nil {
// Export tool definitions for Cisco scanner (only when there is a real
// source dir to write tools.json into — image-only servers have none).
if s.serverInfo != nil && req.SourceDir != "" {
s.exportToolDefinitions(serverName, req.SourceDir)
}
} else {
Expand Down
Loading
Loading