Skip to content

Commit 35d2f35

Browse files
authored
fix(scanner): scan Docker-image servers in Trivy image mode (MCP-2398) (#666)
Servers wired as `docker run <image>` (e.g. `command: docker`, `args: [run, -i, --rm, mcp/fetch]`) were not mcpproxy-managed containers, so the source resolver found no `mcpproxy-<name>-*` container and fell through to source_method=tool_definitions_only. Trivy then ran `fs /scan/source` on an empty temp dir (0 results) — Docker-image servers got effectively zero scanning. Detect the image reference from a `docker`/`podman run` command and resolve it as a new `container_image` source method. Scanners that declare the `container_image` input and provide an `ImageCommand` template (Trivy) now run in image mode (`trivy image <ref>`) with `{{IMAGE}}` substituted at runtime. Trivy resolves the image via the local daemon/containerd/podman and falls back to pulling from the remote registry, so no Docker socket mount is required. - source_resolver: ResolvedSource.ContainerImage + dockerImageFromCommand, wired into both Resolve (Pass 1) and ResolveFullSource (Pass 2) - types/registry: ScannerPlugin.ImageCommand; Trivy image-mode template - engine: ScanRequest.ContainerImage + effectiveScannerCommand selection - service: carry the image, preserve the container_image method past the tool-definitions temp dir, and don't abort image scans with 0 tools Related MCP-2398
1 parent 28b4143 commit 35d2f35

7 files changed

Lines changed: 347 additions & 30 deletions

File tree

docs/cli/security-commands.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,12 @@ mcpproxy security scan my-new-server --scanners nova-proximity,trivy-mcp
374374

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

377-
1. **Docker-isolated servers** → extract `/app` (or the server's `WorkingDir`) from the running container.
378-
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.
379-
3. **Working directory** from the server config.
380-
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.).
381-
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).
377+
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.
378+
2. **Docker-isolated servers** → extract `/app` (or the server's `WorkingDir`) from the running container.
379+
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.
380+
4. **Working directory** from the server config.
381+
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.).
382+
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).
382383

383384
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.
384385

internal/security/scanner/engine.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,14 @@ func NewEngine(docker *DockerRunner, registry *Registry, dataDir string, logger
5050

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

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

351352
// runSingleScanner executes one scanner and returns its report plus execution logs
353+
// scannerSupportsInput reports whether the scanner declares the given input type.
354+
func scannerSupportsInput(s *ScannerPlugin, input string) bool {
355+
for _, in := range s.Inputs {
356+
if in == input {
357+
return true
358+
}
359+
}
360+
return false
361+
}
362+
363+
// effectiveScannerCommand returns the command to run for a scanner. When the
364+
// scan target is a Docker image (req.ContainerImage set) and the scanner both
365+
// declares the "container_image" input and provides an ImageCommand template,
366+
// the image-mode command is returned with "{{IMAGE}}" substituted for the image
367+
// reference. Otherwise the scanner's default (source-mode) Command is returned.
368+
func effectiveScannerCommand(s *ScannerPlugin, req ScanRequest) []string {
369+
if req.ContainerImage == "" || len(s.ImageCommand) == 0 || !scannerSupportsInput(s, "container_image") {
370+
return s.Command
371+
}
372+
out := make([]string, len(s.ImageCommand))
373+
for i, tok := range s.ImageCommand {
374+
out[i] = strings.ReplaceAll(tok, "{{IMAGE}}", req.ContainerImage)
375+
}
376+
return out
377+
}
378+
352379
func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req ScanRequest) (*ScanReport, scannerLogs, error) {
353380
// In-process scanners run directly in Go — no Docker container, no source
354381
// files required. They analyze the tool definitions exported to
@@ -465,7 +492,7 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
465492
cfg := ScannerRunConfig{
466493
ContainerName: GenerateContainerName(s.ID, req.ServerName),
467494
Image: s.EffectiveImage(),
468-
Command: s.Command,
495+
Command: effectiveScannerCommand(s, req),
469496
Env: env,
470497
SourceDir: req.SourceDir,
471498
ReportDir: reportDir,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package scanner
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"go.uber.org/zap"
8+
)
9+
10+
func TestDockerImageFromCommand(t *testing.T) {
11+
cases := []struct {
12+
name string
13+
info ServerInfo
14+
want string
15+
}{
16+
{
17+
name: "simple docker run",
18+
info: ServerInfo{Command: "docker", Args: []string{"run", "-i", "--rm", "mcp/fetch"}},
19+
want: "mcp/fetch",
20+
},
21+
{
22+
name: "value flags before image, command after",
23+
info: ServerInfo{Command: "docker", Args: []string{"run", "--rm", "-i", "-e", "FOO=bar", "ghcr.io/x/y:tag", "serve"}},
24+
want: "ghcr.io/x/y:tag",
25+
},
26+
{
27+
name: "podman with volume",
28+
info: ServerInfo{Command: "podman", Args: []string{"run", "-v", "/tmp:/data", "mcp/time"}},
29+
want: "mcp/time",
30+
},
31+
{
32+
name: "name and attached long flag",
33+
info: ServerInfo{Command: "docker", Args: []string{"run", "--name", "x", "--network=host", "mcp/git"}},
34+
want: "mcp/git",
35+
},
36+
{
37+
name: "absolute docker path",
38+
info: ServerInfo{Command: "/usr/local/bin/docker", Args: []string{"run", "alpine"}},
39+
want: "alpine",
40+
},
41+
{
42+
name: "attached short env flag with equals",
43+
info: ServerInfo{Command: "docker", Args: []string{"run", "-e", "A=1", "-eB=2", "mcp/x"}},
44+
want: "mcp/x",
45+
},
46+
{
47+
name: "combined boolean short flags",
48+
info: ServerInfo{Command: "docker", Args: []string{"run", "-it", "mcp/x"}},
49+
want: "mcp/x",
50+
},
51+
{
52+
name: "docker container run subcommand",
53+
info: ServerInfo{Command: "docker", Args: []string{"container", "run", "mcp/x"}},
54+
want: "mcp/x",
55+
},
56+
{
57+
name: "not a docker command",
58+
info: ServerInfo{Command: "uvx", Args: []string{"mcp-server"}},
59+
want: "",
60+
},
61+
{
62+
name: "docker but not run",
63+
info: ServerInfo{Command: "docker", Args: []string{"ps"}},
64+
want: "",
65+
},
66+
{
67+
name: "no image present",
68+
info: ServerInfo{Command: "docker", Args: []string{"run", "--rm"}},
69+
want: "",
70+
},
71+
}
72+
for _, tc := range cases {
73+
t.Run(tc.name, func(t *testing.T) {
74+
if got := dockerImageFromCommand(tc.info); got != tc.want {
75+
t.Errorf("dockerImageFromCommand() = %q, want %q", got, tc.want)
76+
}
77+
})
78+
}
79+
}
80+
81+
func TestResolveDockerRunImage(t *testing.T) {
82+
r := NewSourceResolver(zap.NewNop())
83+
resolved, err := r.Resolve(context.Background(), ServerInfo{
84+
Name: "fetch",
85+
Protocol: "stdio",
86+
Command: "docker",
87+
Args: []string{"run", "-i", "--rm", "mcp/fetch"},
88+
})
89+
if err != nil {
90+
t.Fatalf("Resolve: %v", err)
91+
}
92+
defer resolved.Cleanup()
93+
if resolved.Method != "container_image" {
94+
t.Errorf("Method = %q, want container_image", resolved.Method)
95+
}
96+
if resolved.ContainerImage != "mcp/fetch" {
97+
t.Errorf("ContainerImage = %q, want mcp/fetch", resolved.ContainerImage)
98+
}
99+
if resolved.SourceDir != "" {
100+
t.Errorf("SourceDir = %q, want empty", resolved.SourceDir)
101+
}
102+
}
103+
104+
func TestScannerCommandForImage(t *testing.T) {
105+
trivy := &ScannerPlugin{
106+
ID: "trivy-mcp",
107+
Inputs: []string{"source", "container_image"},
108+
Command: []string{"fs", "--format", "sarif", "/scan/source"},
109+
ImageCommand: []string{"image", "--format", "sarif", "{{IMAGE}}"},
110+
}
111+
112+
// Image present + scanner supports it → image-mode command with substitution.
113+
got := effectiveScannerCommand(trivy, ScanRequest{ContainerImage: "mcp/fetch"})
114+
want := []string{"image", "--format", "sarif", "mcp/fetch"}
115+
if !equalSlice(got, want) {
116+
t.Errorf("image-mode command = %v, want %v", got, want)
117+
}
118+
119+
// No image → fall back to default source-mode command.
120+
got = effectiveScannerCommand(trivy, ScanRequest{})
121+
if !equalSlice(got, trivy.Command) {
122+
t.Errorf("no-image command = %v, want %v", got, trivy.Command)
123+
}
124+
125+
// Scanner without container_image support → keep its command even if an image is present.
126+
semgrep := &ScannerPlugin{
127+
ID: "semgrep-mcp",
128+
Inputs: []string{"source"},
129+
Command: []string{"semgrep", "scan", "/scan/source"},
130+
}
131+
got = effectiveScannerCommand(semgrep, ScanRequest{ContainerImage: "mcp/fetch"})
132+
if !equalSlice(got, semgrep.Command) {
133+
t.Errorf("non-image scanner command = %v, want %v", got, semgrep.Command)
134+
}
135+
}
136+
137+
func equalSlice(a, b []string) bool {
138+
if len(a) != len(b) {
139+
return false
140+
}
141+
for i := range a {
142+
if a[i] != b[i] {
143+
return false
144+
}
145+
}
146+
return true
147+
}

internal/security/scanner/registry_bundled.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,12 @@ var bundledScanners = []*ScannerPlugin{
152152
RequiredEnv: nil,
153153
OptionalEnv: nil,
154154
Command: []string{"fs", "--format", "sarif", "/scan/source"},
155-
Timeout: "300s", // First run downloads vuln DB (~90MB)
156-
NetworkReq: true, // Needs to download vulnerability database
155+
// For Docker-image servers (`docker run mcp/fetch`) scan the image itself
156+
// instead of an empty source dir. Trivy resolves the image via the local
157+
// daemon/containerd/podman and falls back to pulling from the remote
158+
// registry (network is enabled), so no docker socket mount is required.
159+
ImageCommand: []string{"image", "--format", "sarif", "{{IMAGE}}"},
160+
Timeout: "300s", // First run downloads vuln DB (~90MB)
161+
NetworkReq: true, // Needs to download vulnerability database
157162
},
158163
}

internal/security/scanner/service.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,17 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
692692
scanCtx.SourcePath = resolved.ServerURL
693693
}
694694
scanCtx.ContainerID = resolved.ContainerID
695+
// Docker-image servers (`docker run mcp/fetch`): the scan target is the
696+
// image itself, not a source dir. Carry the reference so image-capable
697+
// scanners (Trivy) run in image mode, and surface it in the context.
698+
if resolved.ContainerImage != "" {
699+
req.ContainerImage = resolved.ContainerImage
700+
scanCtx.ContainerImage = resolved.ContainerImage
701+
scanCtx.DockerIsolation = true
702+
if scanCtx.SourcePath == "" {
703+
scanCtx.SourcePath = resolved.ContainerImage
704+
}
705+
}
695706
// Determine Docker isolation status
696707
if resolved.Method == "docker_extract" {
697708
scanCtx.DockerIsolation = true
@@ -734,9 +745,10 @@ func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool,
734745
tempDir, err := os.MkdirTemp("", "mcpproxy-scan-tools-")
735746
if err == nil {
736747
req.SourceDir = tempDir
737-
// For HTTP/URL servers, preserve the "url" source method and path
738-
// (the temp dir is only for tool definitions, not the real source)
739-
if scanCtx.SourceMethod != "url" {
748+
// For HTTP/URL and Docker-image servers, preserve the real source
749+
// method and path — the temp dir is only for tool definitions, not
750+
// the real scan target (the URL / the image).
751+
if scanCtx.SourceMethod != "url" && scanCtx.SourceMethod != "container_image" {
740752
scanCtx.SourceMethod = "tool_definitions_only"
741753
scanCtx.SourcePath = tempDir
742754
}
@@ -867,6 +879,14 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo) {
867879
scanCtx.SourcePath = resolved.ServerURL
868880
}
869881
scanCtx.ContainerID = resolved.ContainerID
882+
// Docker-image servers: scan the image (Trivy image mode reports OS-package
883+
// and bundled-dependency CVEs). No source dir to enrich or export tools into.
884+
if resolved.ContainerImage != "" {
885+
req.ContainerImage = resolved.ContainerImage
886+
scanCtx.ContainerImage = resolved.ContainerImage
887+
scanCtx.SourcePath = resolved.ContainerImage
888+
scanCtx.DockerIsolation = true
889+
}
870890
if resolved.Method == "docker_extract" {
871891
scanCtx.DockerIsolation = true
872892
}
@@ -875,8 +895,9 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo) {
875895
scanCtx.TotalFiles = resolved.TotalFiles
876896
scanCtx.TotalSizeBytes = resolved.TotalSize
877897

878-
// Export tool definitions for Cisco scanner
879-
if s.serverInfo != nil {
898+
// Export tool definitions for Cisco scanner (only when there is a real
899+
// source dir to write tools.json into — image-only servers have none).
900+
if s.serverInfo != nil && req.SourceDir != "" {
880901
s.exportToolDefinitions(serverName, req.SourceDir)
881902
}
882903
} else {

0 commit comments

Comments
 (0)