Skip to content

Commit 3f12faf

Browse files
fix(security): scanner Docker-skip follows per-server isolation mode (MCP-34.4)
CodexReviewer correctness finding on #781: the scanner skip was wired from a single service-wide isolation mode (cfg.DockerIsolation.ResolvedMode()), but isolation resolves per-server — a per-server isolation.mode overrides the global (internal/upstream/core IsolationManager). So a server pinned to isolation.mode:docker would wrongly have its Docker scanners skipped under a global sandbox default, and vice versa, contradicting the per-server design and the served docs. Thread the scanned server's RESOLVED mode through the scan: - ScanRequest.IsolationMode carries the per-server mode; engine.StartScan uses effectiveIsolationMode(req) (per-server wins, else engine-wide default) and passes it to resolveScanners(ids, mode). - Service.SetIsolationModeResolver injects a per-server resolver; StartScan (both passes) populates req.IsolationMode via resolveIsolationMode(server). - server.go wires the resolver from core.IsolationManager.ResolveMode against the live per-server config; SetIsolationMode stays as the engine-wide default fallback. Scanner package stays decoupled from the resolver. Tests: per-server override cases (docker under global sandbox runs Docker scanners; sandbox/none under global docker skips), effectiveIsolationMode precedence, and Service.resolveIsolationMode fallback. go test -race + golangci-lint v2 green. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent a8682c6 commit 3f12faf

5 files changed

Lines changed: 199 additions & 44 deletions

File tree

internal/security/scanner/engine.go

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ type Engine struct {
3232
// docker × AppArmor transition would otherwise deny entrypoint exec.
3333
disableNoNewPrivileges bool
3434

35-
// isolationMode is the resolved global isolation mode ("docker", "sandbox",
36-
// "none", or "" == docker for back-compat). Scanner *plugins* are
37-
// Docker-based (Spec 039); under "sandbox"/"none" the host runs no Docker
38-
// for scanners, so Docker-based scanners are cleanly skipped (prefailed with
39-
// an honest, mode-specific reason) while in-process scanners still run. This
40-
// is MCP-34.4 / D3 option (b): clean, surfaced degradation rather than a
41-
// misleading "pull the image" failure. Set via Service.SetIsolationMode.
35+
// isolationMode is the engine-wide DEFAULT isolation mode ("docker",
36+
// "sandbox", "none", or "" == docker for back-compat), used only when a
37+
// ScanRequest does not carry a per-server IsolationMode. The per-server
38+
// resolved mode on the request takes precedence (MCP-34.4) so a server with
39+
// an explicit isolation.mode override gets the right scanner behaviour.
40+
// Scanner *plugins* are Docker-based (Spec 039); under "sandbox"/"none" the
41+
// host runs no Docker for scanners, so Docker-based scanners are cleanly
42+
// skipped (prefailed with an honest reason) while in-process scanners still
43+
// run — D3 option (b). Set via Service.SetIsolationMode.
4244
isolationMode string
4345

4446
// Track active scans (one per server)
@@ -67,6 +69,14 @@ type ScanRequest struct {
6769
Env map[string]string // Additional environment variables
6870
ScanContext *ScanContext // Context metadata (set by service)
6971
ScanPass int // 1 = security scan (fast), 2 = supply chain audit (background)
72+
// IsolationMode is THIS server's resolved isolation mode ("docker",
73+
// "sandbox", "none", or "" to fall back to the engine's service-wide
74+
// default). Set per-server by the Service so the Docker-scanner skip
75+
// (MCP-34.4) follows the scanned server's effective mode — a per-server
76+
// isolation.mode override beats the global mode (internal/upstream/core
77+
// IsolationManager.ResolveMode). Under "sandbox"/"none" Docker scanner
78+
// plugins are skipped; under "docker" they run.
79+
IsolationMode string
7080
// PeerTools is a cross-server snapshot (other servers' current tool
7181
// definitions, keyed by server name) used by the in-process tpa-descriptions
7282
// scanner to build a multi-server RegistryView so the deterministic
@@ -109,8 +119,11 @@ func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCa
109119
return existing, fmt.Errorf("scan already in progress for server %s (job %s)", req.ServerName, existing.ID)
110120
}
111121

112-
// Determine which scanners to use
113-
resolved, err := e.resolveScanners(req.ScannerIDs)
122+
// Determine which scanners to use. The Docker-scanner skip (MCP-34.4)
123+
// follows THIS server's resolved isolation mode (per-server override beats
124+
// the global default); fall back to the engine-wide default when the
125+
// request didn't carry one.
126+
resolved, err := e.resolveScanners(req.ScannerIDs, e.effectiveIsolationMode(req.IsolationMode))
114127
if err != nil {
115128
e.mu.Unlock()
116129
return nil, err
@@ -195,7 +208,30 @@ type resolvedScanner struct {
195208
// executeScan loop records it as a failed scanner in the job. That way the
196209
// aggregated scan report shows "X of Y scanners failed" instead of pretending
197210
// nothing is wrong.
198-
func (e *Engine) resolveScanners(requestedIDs []string) ([]resolvedScanner, error) {
211+
// effectiveIsolationMode resolves the isolation mode to use for a scan: the
212+
// per-server mode carried on the ScanRequest when present, otherwise the
213+
// engine-wide default set via Service.SetIsolationMode. This is what lets the
214+
// Docker-scanner skip (MCP-34.4) honour a per-server isolation.mode override.
215+
func (e *Engine) effectiveIsolationMode(requestMode string) string {
216+
if requestMode != "" {
217+
return requestMode
218+
}
219+
return e.isolationMode
220+
}
221+
222+
// resolveScanners determines which scanners to use.
223+
//
224+
// Unlike the old behaviour (silently dropping scanners with missing Docker
225+
// images), this now includes every enabled scanner in the result. When a
226+
// scanner's image is absent locally it is flagged with prefail so the
227+
// executeScan loop records it as a failed scanner in the job. That way the
228+
// aggregated scan report shows "X of Y scanners failed" instead of pretending
229+
// nothing is wrong.
230+
//
231+
// isolationMode is THIS server's resolved isolation mode (per-server override
232+
// already applied by the caller); under "sandbox"/"none" Docker scanner plugins
233+
// are skipped (MCP-34.4).
234+
func (e *Engine) resolveScanners(requestedIDs []string, isolationMode string) ([]resolvedScanner, error) {
199235
all := e.registry.List()
200236

201237
// Helper: check whether a scanner's image is present locally. Returns a
@@ -214,9 +250,12 @@ func (e *Engine) resolveScanners(requestedIDs []string) ([]resolvedScanner, erro
214250
// "pull the image locally" guidance below — there is nothing to pull.
215251
// They stay in the resolved set (recorded as failed), which downgrades
216252
// the scan summary to "degraded" rather than a silent all-clear.
217-
if mode := e.isolationMode; mode == "sandbox" || mode == "none" {
218-
return fmt.Sprintf("Docker-based scanner %s skipped: isolation mode %q runs no Docker containers, so Docker scanner plugins cannot run on this host. "+
219-
"In-process scanners still ran. To run Docker scanners, set isolation.mode to \"docker\" on a host with a working Docker daemon. "+
253+
// `isolationMode` is per-server resolved, so a server pinned to
254+
// isolation.mode:docker still runs Docker scanners under a global
255+
// sandbox default, and vice versa.
256+
if mode := isolationMode; mode == "sandbox" || mode == "none" {
257+
return fmt.Sprintf("Docker-based scanner %s skipped: isolation mode %q runs no Docker containers, so Docker scanner plugins cannot run for this server. "+
258+
"In-process scanners still ran. To run Docker scanners, set isolation.mode to \"docker\" for this server on a host with a working Docker daemon. "+
220259
"See docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md.", s.ID, mode)
221260
}
222261
if e.docker == nil {

internal/security/scanner/engine_test.go

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ func TestEngineResolveScanners(t *testing.T) {
433433
// Resolve all installed. The Docker scanner we just enabled plus the
434434
// always-installed in-process scanner (tpa-descriptions) should both
435435
// resolve (MCP-2082).
436-
scanners, err := engine.resolveScanners(nil)
436+
scanners, err := engine.resolveScanners(nil, "")
437437
if err != nil {
438438
t.Fatalf("resolveScanners: %v", err)
439439
}
@@ -452,7 +452,7 @@ func TestEngineResolveScanners(t *testing.T) {
452452
}
453453

454454
// Resolve specific
455-
scanners, err = engine.resolveScanners([]string{"mcp-scan"})
455+
scanners, err = engine.resolveScanners([]string{"mcp-scan"}, "")
456456
if err != nil {
457457
t.Fatalf("resolveScanners specific: %v", err)
458458
}
@@ -461,13 +461,13 @@ func TestEngineResolveScanners(t *testing.T) {
461461
}
462462

463463
// Resolve non-installed
464-
_, err = engine.resolveScanners([]string{"cisco-mcp-scanner"})
464+
_, err = engine.resolveScanners([]string{"cisco-mcp-scanner"}, "")
465465
if err == nil {
466466
t.Error("expected error for non-installed scanner")
467467
}
468468

469469
// Resolve nonexistent
470-
_, err = engine.resolveScanners([]string{"nonexistent"})
470+
_, err = engine.resolveScanners([]string{"nonexistent"}, "")
471471
if err == nil {
472472
t.Error("expected error for nonexistent scanner")
473473
}
@@ -586,7 +586,7 @@ func TestEngineInProcessScannerAlwaysAvailable(t *testing.T) {
586586
docker := NewDockerRunner(logger)
587587
engine := NewEngine(docker, registry, dir, logger)
588588

589-
resolved, err := engine.resolveScanners(nil)
589+
resolved, err := engine.resolveScanners(nil, "")
590590
if err != nil {
591591
t.Fatalf("resolveScanners: %v", err)
592592
}
@@ -618,11 +618,11 @@ func TestEngineResolveScannersSkipsDockerUnderSandbox(t *testing.T) {
618618

619619
// A non-nil docker runner with a real image present would normally
620620
// let the Docker scanner pass checkImage; the sandbox/none gate must
621-
// short-circuit before any Docker probe.
621+
// short-circuit before any Docker probe. The per-server resolved mode
622+
// is passed directly into resolveScanners (MCP-34.4).
622623
engine := NewEngine(nil, registry, dir, logger)
623-
engine.isolationMode = mode
624624

625-
resolved, err := engine.resolveScanners(nil)
625+
resolved, err := engine.resolveScanners(nil, mode)
626626
if err != nil {
627627
t.Fatalf("resolveScanners: %v", err)
628628
}
@@ -664,10 +664,12 @@ func TestEngineResolveScannersSkipsDockerUnderSandbox(t *testing.T) {
664664
}
665665
}
666666

667-
// TestEngineResolveScannersDockerModeUnaffected is the back-compat guard: with
668-
// the default isolation mode (docker / empty) the sandbox skip path is inert,
669-
// so a Docker scanner with a nil docker runner resolves without a prefail (the
670-
// historical behaviour relied on by existing tests).
667+
// TestEngineResolveScannersDockerModeUnaffected is the back-compat / per-server
668+
// guard: with mode "docker" (or "" — fall back to engine default) the sandbox
669+
// skip path is inert, so a Docker scanner with a nil docker runner resolves
670+
// without a prefail. The "docker" case is exactly the per-server override that
671+
// must keep running Docker scanners even under a global sandbox default
672+
// (MCP-34.4).
671673
func TestEngineResolveScannersDockerModeUnaffected(t *testing.T) {
672674
for _, mode := range []string{"", "docker"} {
673675
t.Run("mode="+mode, func(t *testing.T) {
@@ -677,9 +679,8 @@ func TestEngineResolveScannersDockerModeUnaffected(t *testing.T) {
677679
registry.scanners["mcp-scan"].Status = ScannerStatusInstalled
678680

679681
engine := NewEngine(nil, registry, dir, logger)
680-
engine.isolationMode = mode
681682

682-
resolved, err := engine.resolveScanners([]string{"mcp-scan"})
683+
resolved, err := engine.resolveScanners([]string{"mcp-scan"}, mode)
683684
if err != nil {
684685
t.Fatalf("resolveScanners: %v", err)
685686
}
@@ -693,6 +694,23 @@ func TestEngineResolveScannersDockerModeUnaffected(t *testing.T) {
693694
}
694695
}
695696

697+
// TestEngineEffectiveIsolationMode locks the precedence used by StartScan: the
698+
// per-request (per-server) mode wins; an empty request falls back to the
699+
// engine-wide default (MCP-34.4).
700+
func TestEngineEffectiveIsolationMode(t *testing.T) {
701+
e := &Engine{isolationMode: "sandbox"}
702+
if got := e.effectiveIsolationMode("docker"); got != "docker" {
703+
t.Errorf("per-server 'docker' must win over engine default 'sandbox'; got %q", got)
704+
}
705+
if got := e.effectiveIsolationMode(""); got != "sandbox" {
706+
t.Errorf("empty request must fall back to engine default 'sandbox'; got %q", got)
707+
}
708+
e2 := &Engine{isolationMode: ""}
709+
if got := e2.effectiveIsolationMode("none"); got != "none" {
710+
t.Errorf("per-server 'none' must win; got %q", got)
711+
}
712+
}
713+
696714
func TestEngineParseResultsSARIF(t *testing.T) {
697715
dir := t.TempDir()
698716
logger := zap.NewNop()

internal/security/scanner/service.go

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ type Service struct {
123123
pulls *pullManager
124124
logger *zap.Logger
125125

126+
// isolationModeResolver returns a server's resolved isolation mode
127+
// ("docker"/"sandbox"/"none", or "" if unknown). Injected by the wiring
128+
// layer so the scanner honours per-server isolation.mode overrides
129+
// (MCP-34.4) without the scanner package depending on the isolation
130+
// resolver. Nil ⇒ fall back to the engine-wide default.
131+
isolationModeResolver func(serverName string) string
132+
126133
// In-memory scan summary cache — avoids expensive BBolt reads per server
127134
summaryCache map[string]*ScanSummary
128135
summaryCacheMu sync.RWMutex
@@ -172,25 +179,53 @@ func (s *Service) SetScannerDisableNoNewPrivileges(disable bool) {
172179
}
173180
}
174181

175-
// SetIsolationMode records the resolved global isolation mode ("docker",
176-
// "sandbox", "none", or "" == docker) so the scan engine can decide whether
177-
// Docker-based scanner plugins can run. Under "sandbox"/"none" the host runs no
178-
// Docker for scanners, so Docker scanner plugins are cleanly skipped (the scan
179-
// summary degrades) while in-process scanners still run. This is MCP-34.4 / D3
180-
// option (b): clean, surfaced degradation on snap-docker / non-Docker hosts.
182+
// SetIsolationMode records the engine-wide DEFAULT isolation mode ("docker",
183+
// "sandbox", "none", or "" == docker), used when a scan has no per-server mode
184+
// (no resolver wired, or the resolver returns ""). Per-server resolution via
185+
// SetIsolationModeResolver takes precedence. Under "sandbox"/"none" the host
186+
// runs no Docker for scanners, so Docker scanner plugins are cleanly skipped
187+
// (the scan summary degrades) while in-process scanners still run — MCP-34.4 /
188+
// D3 option (b): clean, surfaced degradation on snap-docker / non-Docker hosts.
181189
func (s *Service) SetIsolationMode(mode string) {
182190
if s.engine == nil {
183191
return
184192
}
185193
s.engine.isolationMode = mode
186194
if mode == "sandbox" || mode == "none" {
187-
s.logger.Warn("Isolation mode runs no Docker for scanner plugins; Docker-based scanners "+
188-
"will be skipped and the security scan will report 'degraded' for affected servers. "+
189-
"In-process scanners (e.g. tpa-descriptions) still run. See docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md.",
195+
s.logger.Warn("Default isolation mode runs no Docker for scanner plugins; Docker-based scanners "+
196+
"will be skipped and the security scan will report 'degraded' for affected servers "+
197+
"(per-server isolation.mode:docker overrides). In-process scanners (e.g. tpa-descriptions) "+
198+
"still run. See docs/errors/MCPX_DOCKER_SNAP_APPARMOR.md.",
190199
zap.String("isolation_mode", mode))
191200
}
192201
}
193202

203+
// SetIsolationModeResolver injects a per-server isolation-mode resolver so the
204+
// Docker-scanner skip (MCP-34.4) follows each scanned server's RESOLVED mode —
205+
// a per-server isolation.mode override beats the global default. The resolver
206+
// returns "docker"/"sandbox"/"none", or "" to fall back to the engine-wide
207+
// default. Wired in the server layer from the upstream IsolationManager so the
208+
// scanner package stays decoupled from the resolver.
209+
func (s *Service) SetIsolationModeResolver(resolver func(serverName string) string) {
210+
s.isolationModeResolver = resolver
211+
}
212+
213+
// resolveIsolationMode returns the isolation mode to apply to a scan of
214+
// serverName: the per-server resolver result when it yields a concrete mode,
215+
// otherwise the engine-wide default. Falls back gracefully when no resolver is
216+
// wired (e.g. unit tests) so behaviour matches SetIsolationMode alone.
217+
func (s *Service) resolveIsolationMode(serverName string) string {
218+
if s.isolationModeResolver != nil {
219+
if mode := s.isolationModeResolver(serverName); mode != "" {
220+
return mode
221+
}
222+
}
223+
if s.engine != nil {
224+
return s.engine.isolationMode
225+
}
226+
return ""
227+
}
228+
194229
// SetFetchPackageSource toggles whether the source resolver may fetch the
195230
// published source of package-runner servers (npx/uvx) for scanning. See
196231
// SecurityConfig.ScannerFetchPackageSource (MCP-2206). Default is enabled.
@@ -681,11 +716,12 @@ func (a *scanCallbackAdapter) OnScanFailed(job *ScanJob, err error) {
681716
// After Pass 1 completes, Pass 2 (supply chain audit) is auto-started in the background.
682717
func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string, sourceDir string) (*ScanJob, error) {
683718
req := ScanRequest{
684-
ServerName: serverName,
685-
DryRun: dryRun,
686-
ScannerIDs: scannerIDs,
687-
SourceDir: sourceDir,
688-
ScanPass: ScanPassSecurityScan,
719+
ServerName: serverName,
720+
DryRun: dryRun,
721+
ScannerIDs: scannerIDs,
722+
SourceDir: sourceDir,
723+
ScanPass: ScanPassSecurityScan,
724+
IsolationMode: s.resolveIsolationMode(serverName),
689725
}
690726

691727
// Build scan context for transparency
@@ -892,9 +928,10 @@ func (s *Service) startPass2(serverName string, serverInfo *ServerInfo) {
892928
ctx := context.Background()
893929

894930
req := ScanRequest{
895-
ServerName: serverName,
896-
DryRun: false,
897-
ScanPass: ScanPassSupplyChainAudit,
931+
ServerName: serverName,
932+
DryRun: false,
933+
ScanPass: ScanPassSupplyChainAudit,
934+
IsolationMode: s.resolveIsolationMode(serverName),
898935
}
899936

900937
// Build scan context

internal/security/scanner/service_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,43 @@ func TestServiceSetIsolationMode(t *testing.T) {
391391
}
392392
}
393393

394+
// TestServiceResolveIsolationModePerServer verifies the per-server resolver
395+
// (MCP-34.4 review fix): a per-server resolved mode takes precedence over the
396+
// engine-wide default, so a server pinned to isolation.mode:docker keeps
397+
// running Docker scanners under a global sandbox default, and a server resolved
398+
// to sandbox/none skips them under a global docker default. A "" resolver
399+
// result (or no resolver) falls back to the engine-wide default.
400+
func TestServiceResolveIsolationModePerServer(t *testing.T) {
401+
svc, _, _ := newTestService(t)
402+
svc.SetIsolationMode("sandbox") // engine-wide default
403+
404+
// No resolver wired yet → fall back to the engine default.
405+
if got := svc.resolveIsolationMode("any"); got != "sandbox" {
406+
t.Errorf("with no resolver, expected fallback to engine default 'sandbox', got %q", got)
407+
}
408+
409+
perServer := map[string]string{
410+
"pinned-docker": "docker", // overrides the global sandbox default
411+
"pinned-none": "none",
412+
"pinned-sandbox": "sandbox",
413+
"inherits": "", // resolver yields "" → fall back to default
414+
}
415+
svc.SetIsolationModeResolver(func(serverName string) string { return perServer[serverName] })
416+
417+
cases := map[string]string{
418+
"pinned-docker": "docker",
419+
"pinned-none": "none",
420+
"pinned-sandbox": "sandbox",
421+
"inherits": "sandbox", // "" → engine default
422+
"unknown-server": "sandbox", // not in map → "" → engine default
423+
}
424+
for server, want := range cases {
425+
if got := svc.resolveIsolationMode(server); got != want {
426+
t.Errorf("resolveIsolationMode(%q) = %q, want %q", server, got, want)
427+
}
428+
}
429+
}
430+
394431
func TestServiceListScannersEmpty(t *testing.T) {
395432
svc, _, _ := newTestService(t)
396433

0 commit comments

Comments
 (0)