Skip to content

Commit dba217f

Browse files
committed
fix(039): wire SecurityService into HTTP server, E2E fixes
Critical fixes discovered during E2E testing: - Wire scanner.Service as SecurityController in server setup - Register security routes unconditionally (nil-guard in handlers) - Use background context for scan goroutines (prevent request context cancellation) - Sync registry from storage on startup (scanner state survives restart) - Add source_dir parameter to scan API and service - Fix Semgrep: mount source at /src (Docker image requirement), enable network - Disable read-only for scanner containers (they need cache writes) - Fix Trivy image name: aquasecurity/trivy (not aquasec/trivy) - Add GetSecurityOverview alias on Service for interface compliance Verified E2E: - Semgrep scan found subprocess shell=True (HIGH) and eval() (MEDIUM) - Full approve/reject/report CLI workflow tested against running server Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 54ffbc2 commit dba217f

7 files changed

Lines changed: 110 additions & 15 deletions

File tree

internal/httpapi/security_scanner.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type SecurityController interface {
1818
ConfigureScanner(ctx context.Context, id string, env map[string]string) error
1919
GetScannerStatus(ctx context.Context, id string) (*scanner.ScannerPlugin, error)
2020

21-
StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string) (*scanner.ScanJob, error)
21+
StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string, sourceDir string) (*scanner.ScanJob, error)
2222
GetScanStatus(ctx context.Context, serverName string) (*scanner.ScanJob, error)
2323
GetScanReport(ctx context.Context, serverName string) (*scanner.AggregatedReport, error)
2424
CancelScan(ctx context.Context, serverName string) error
@@ -37,9 +37,21 @@ func (s *Server) SetSecurityController(ctrl SecurityController) {
3737
s.securityController = ctrl
3838
}
3939

40+
// requireSecurity returns true if the security controller is available, writing a 501 error if not.
41+
func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request) bool {
42+
if s.securityController == nil {
43+
s.writeError(w, r, http.StatusNotImplemented, "security scanner feature is not configured")
44+
return false
45+
}
46+
return true
47+
}
48+
4049
// --- Scanner management handlers ---
4150

4251
func (s *Server) handleListScanners(w http.ResponseWriter, r *http.Request) {
52+
if !s.requireSecurity(w, r) {
53+
return
54+
}
4355
scanners, err := s.securityController.ListScanners(r.Context())
4456
if err != nil {
4557
s.writeError(w, r, http.StatusInternalServerError, err.Error())
@@ -49,6 +61,9 @@ func (s *Server) handleListScanners(w http.ResponseWriter, r *http.Request) {
4961
}
5062

5163
func (s *Server) handleInstallScanner(w http.ResponseWriter, r *http.Request) {
64+
if !s.requireSecurity(w, r) {
65+
return
66+
}
5267
var req struct {
5368
ID string `json:"id"`
5469
}
@@ -69,6 +84,9 @@ func (s *Server) handleInstallScanner(w http.ResponseWriter, r *http.Request) {
6984
}
7085

7186
func (s *Server) handleRemoveScanner(w http.ResponseWriter, r *http.Request) {
87+
if !s.requireSecurity(w, r) {
88+
return
89+
}
7290
id := chi.URLParam(r, "id")
7391
if id == "" {
7492
s.writeError(w, r, http.StatusBadRequest, "scanner id is required")
@@ -83,6 +101,9 @@ func (s *Server) handleRemoveScanner(w http.ResponseWriter, r *http.Request) {
83101
}
84102

85103
func (s *Server) handleConfigureScanner(w http.ResponseWriter, r *http.Request) {
104+
if !s.requireSecurity(w, r) {
105+
return
106+
}
86107
id := chi.URLParam(r, "id")
87108
if id == "" {
88109
s.writeError(w, r, http.StatusBadRequest, "scanner id is required")
@@ -109,6 +130,9 @@ func (s *Server) handleConfigureScanner(w http.ResponseWriter, r *http.Request)
109130
}
110131

111132
func (s *Server) handleGetScannerStatus(w http.ResponseWriter, r *http.Request) {
133+
if !s.requireSecurity(w, r) {
134+
return
135+
}
112136
id := chi.URLParam(r, "id")
113137
if id == "" {
114138
s.writeError(w, r, http.StatusBadRequest, "scanner id is required")
@@ -126,6 +150,9 @@ func (s *Server) handleGetScannerStatus(w http.ResponseWriter, r *http.Request)
126150
// --- Scan operation handlers ---
127151

128152
func (s *Server) handleStartScan(w http.ResponseWriter, r *http.Request) {
153+
if !s.requireSecurity(w, r) {
154+
return
155+
}
129156
name := chi.URLParam(r, "id")
130157
if name == "" {
131158
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -135,11 +162,12 @@ func (s *Server) handleStartScan(w http.ResponseWriter, r *http.Request) {
135162
var req struct {
136163
DryRun bool `json:"dry_run"`
137164
ScannerIDs []string `json:"scanner_ids"`
165+
SourceDir string `json:"source_dir"`
138166
}
139167
// Body is optional for simple scans
140168
_ = json.NewDecoder(r.Body).Decode(&req)
141169

142-
job, err := s.securityController.StartScan(r.Context(), name, req.DryRun, req.ScannerIDs)
170+
job, err := s.securityController.StartScan(r.Context(), name, req.DryRun, req.ScannerIDs, req.SourceDir)
143171
if err != nil {
144172
s.writeError(w, r, http.StatusInternalServerError, err.Error())
145173
return
@@ -148,6 +176,9 @@ func (s *Server) handleStartScan(w http.ResponseWriter, r *http.Request) {
148176
}
149177

150178
func (s *Server) handleGetScanStatus(w http.ResponseWriter, r *http.Request) {
179+
if !s.requireSecurity(w, r) {
180+
return
181+
}
151182
name := chi.URLParam(r, "id")
152183
if name == "" {
153184
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -163,6 +194,9 @@ func (s *Server) handleGetScanStatus(w http.ResponseWriter, r *http.Request) {
163194
}
164195

165196
func (s *Server) handleGetScanReport(w http.ResponseWriter, r *http.Request) {
197+
if !s.requireSecurity(w, r) {
198+
return
199+
}
166200
name := chi.URLParam(r, "id")
167201
if name == "" {
168202
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -178,6 +212,9 @@ func (s *Server) handleGetScanReport(w http.ResponseWriter, r *http.Request) {
178212
}
179213

180214
func (s *Server) handleCancelScan(w http.ResponseWriter, r *http.Request) {
215+
if !s.requireSecurity(w, r) {
216+
return
217+
}
181218
name := chi.URLParam(r, "id")
182219
if name == "" {
183220
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -194,6 +231,9 @@ func (s *Server) handleCancelScan(w http.ResponseWriter, r *http.Request) {
194231
// --- Approval handlers ---
195232

196233
func (s *Server) handleSecurityApprove(w http.ResponseWriter, r *http.Request) {
234+
if !s.requireSecurity(w, r) {
235+
return
236+
}
197237
name := chi.URLParam(r, "id")
198238
if name == "" {
199239
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -214,6 +254,9 @@ func (s *Server) handleSecurityApprove(w http.ResponseWriter, r *http.Request) {
214254
}
215255

216256
func (s *Server) handleSecurityReject(w http.ResponseWriter, r *http.Request) {
257+
if !s.requireSecurity(w, r) {
258+
return
259+
}
217260
name := chi.URLParam(r, "id")
218261
if name == "" {
219262
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -228,6 +271,9 @@ func (s *Server) handleSecurityReject(w http.ResponseWriter, r *http.Request) {
228271
}
229272

230273
func (s *Server) handleCheckIntegrity(w http.ResponseWriter, r *http.Request) {
274+
if !s.requireSecurity(w, r) {
275+
return
276+
}
231277
name := chi.URLParam(r, "id")
232278
if name == "" {
233279
s.writeError(w, r, http.StatusBadRequest, "server name is required")
@@ -245,6 +291,9 @@ func (s *Server) handleCheckIntegrity(w http.ResponseWriter, r *http.Request) {
245291
// --- Overview handler ---
246292

247293
func (s *Server) handleSecurityOverview(w http.ResponseWriter, r *http.Request) {
294+
if !s.requireSecurity(w, r) {
295+
return
296+
}
248297
overview, err := s.securityController.GetSecurityOverview(r.Context())
249298
if err != nil {
250299
s.writeError(w, r, http.StatusInternalServerError, err.Error())

internal/httpapi/security_scanner_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (m *mockSecurityController) GetScannerStatus(_ context.Context, id string)
6262
return nil, fmt.Errorf("scanner not found: %s", id)
6363
}
6464

65-
func (m *mockSecurityController) StartScan(_ context.Context, serverName string, dryRun bool, scannerIDs []string) (*scanner.ScanJob, error) {
65+
func (m *mockSecurityController) StartScan(_ context.Context, serverName string, dryRun bool, scannerIDs []string, sourceDir string) (*scanner.ScanJob, error) {
6666
if m.startScanErr != nil {
6767
return nil, m.startScanErr
6868
}
@@ -532,7 +532,7 @@ func TestSecurityHandlerOverview(t *testing.T) {
532532
assert.Equal(t, 1, overview.FindingsBySeverity.Critical)
533533
}
534534

535-
func TestSecurityRoutesNotRegisteredWithoutController(t *testing.T) {
535+
func TestSecurityRoutesReturnNotImplementedWithoutController(t *testing.T) {
536536
logger := zap.NewNop().Sugar()
537537
ctrl := &secTestController{}
538538
srv := NewServer(ctrl, logger, nil)
@@ -542,6 +542,6 @@ func TestSecurityRoutesNotRegisteredWithoutController(t *testing.T) {
542542
w := httptest.NewRecorder()
543543
srv.ServeHTTP(w, req)
544544

545-
// Should be 404 since routes are not registered without security controller
546-
assert.Equal(t, http.StatusNotFound, w.Code)
545+
// Should be 501 since security controller is not configured
546+
assert.Equal(t, http.StatusNotImplemented, w.Code)
547547
}

internal/security/scanner/docker.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ func (d *DockerRunner) RunScanner(ctx context.Context, cfg ScannerRunConfig) (st
110110
// Mount source directory read-only
111111
if cfg.SourceDir != "" {
112112
args = append(args, "-v", cfg.SourceDir+":/scan/source:ro")
113+
// Also mount at /src for scanners that expect it (e.g., Semgrep Docker image)
114+
args = append(args, "-v", cfg.SourceDir+":/src:ro")
113115
}
114116

115117
// Mount report directory writable

internal/security/scanner/engine.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCa
110110

111111
callback.OnScanStarted(job)
112112

113-
// Run scanners in background
114-
go e.executeScan(ctx, job, scanners, req, callback)
113+
// Run scanners in background with detached context
114+
// (the HTTP request context may be cancelled after the response is sent)
115+
go e.executeScan(context.Background(), job, scanners, req, callback)
115116

116117
return job, nil
117118
}
@@ -288,7 +289,7 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
288289
ReportDir: reportDir,
289290
NetworkMode: networkMode,
290291
Timeout: timeout,
291-
ReadOnly: true,
292+
ReadOnly: false, // Scanner containers need to write cache/temp files
292293
MemoryLimit: "512m",
293294
}
294295

internal/security/scanner/registry_bundled.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ var bundledScanners = []*ScannerPlugin{
5353
OptionalEnv: []EnvRequirement{
5454
{Key: "SEMGREP_APP_TOKEN", Label: "Semgrep Cloud Token", Secret: true},
5555
},
56-
Command: []string{"semgrep", "scan", "--sarif", "--output", "/scan/report/results.sarif"},
57-
Timeout: "120s",
58-
NetworkReq: false,
56+
Command: []string{"semgrep", "scan", "--sarif", "--output", "/scan/report/results.sarif", "/scan/source"},
57+
Timeout: "300s",
58+
NetworkReq: true, // Downloads rules from registry
5959
},
6060
{
6161
ID: "trivy-mcp",
@@ -64,7 +64,7 @@ var bundledScanners = []*ScannerPlugin{
6464
Description: "Comprehensive vulnerability scanner for filesystem, dependencies, and container images. Detects known CVEs and misconfigurations.",
6565
License: "Apache-2.0",
6666
Homepage: "https://trivy.dev",
67-
DockerImage: "aquasec/trivy:latest",
67+
DockerImage: "aquasecurity/trivy:latest",
6868
Inputs: []string{"source", "container_image"},
6969
Outputs: []string{"sarif"},
7070
RequiredEnv: nil,

internal/security/scanner/service.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,21 +66,44 @@ type Service struct {
6666
// NewService creates a new SecurityService
6767
func NewService(storage Storage, registry *Registry, docker *DockerRunner, dataDir string, logger *zap.Logger) *Service {
6868
engine := NewEngine(docker, registry, dataDir, logger)
69-
return &Service{
69+
svc := &Service{
7070
storage: storage,
7171
engine: engine,
7272
registry: registry,
7373
docker: docker,
7474
emitter: &NoopEmitter{},
7575
logger: logger,
7676
}
77+
// Restore installed scanner state from storage (survives restart)
78+
svc.syncRegistryFromStorage()
79+
return svc
7780
}
7881

7982
// SetEmitter sets the event emitter for the service
8083
func (s *Service) SetEmitter(emitter EventEmitter) {
8184
s.emitter = emitter
8285
}
8386

87+
// syncRegistryFromStorage updates the in-memory registry status from
88+
// persistent storage. This is needed after restart so the engine knows
89+
// which scanners are installed/configured.
90+
func (s *Service) syncRegistryFromStorage() {
91+
installed, err := s.storage.ListScanners()
92+
if err != nil || len(installed) == 0 {
93+
return
94+
}
95+
for _, inst := range installed {
96+
_ = s.registry.UpdateStatus(inst.ID, inst.Status)
97+
// Also update configured env so the engine can pass it to containers
98+
if inst.ConfiguredEnv != nil {
99+
if reg, err := s.registry.Get(inst.ID); err == nil {
100+
reg.ConfiguredEnv = inst.ConfiguredEnv
101+
}
102+
}
103+
}
104+
s.logger.Info("Synced scanner registry from storage", zap.Int("count", len(installed)))
105+
}
106+
84107
// --- Scanner Management ---
85108

86109
// ListScanners returns all scanners from registry merged with installed state from storage
@@ -282,11 +305,12 @@ func (a *scanCallbackAdapter) OnScanFailed(job *ScanJob, err error) {
282305
}
283306

284307
// StartScan triggers a security scan for a server
285-
func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string) (*ScanJob, error) {
308+
func (s *Service) StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string, sourceDir string) (*ScanJob, error) {
286309
req := ScanRequest{
287310
ServerName: serverName,
288311
DryRun: dryRun,
289312
ScannerIDs: scannerIDs,
313+
SourceDir: sourceDir,
290314
}
291315

292316
callback := &scanCallbackAdapter{service: s}
@@ -436,6 +460,12 @@ func (s *Service) CheckIntegrity(ctx context.Context, serverName string) (*Integ
436460

437461
// --- Overview ---
438462

463+
// GetSecurityOverview returns aggregated security statistics.
464+
// Satisfies the httpapi.SecurityController interface.
465+
func (s *Service) GetSecurityOverview(ctx context.Context) (*SecurityOverview, error) {
466+
return s.GetOverview(ctx)
467+
}
468+
439469
// GetOverview returns aggregated security statistics
440470
func (s *Service) GetOverview(ctx context.Context) (*SecurityOverview, error) {
441471
overview := &SecurityOverview{}

internal/server/server.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/smart-mcp-proxy/mcpproxy-go/internal/observability"
3030
"github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime"
3131
"github.com/smart-mcp-proxy/mcpproxy-go/internal/secret"
32+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security/scanner"
3233
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
3334
"github.com/smart-mcp-proxy/mcpproxy-go/internal/tlslocal"
3435
"github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
@@ -1647,6 +1648,18 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se
16471648
connectSvc := connect.NewService(cfg.Listen, cfg.APIKey)
16481649
httpAPIServer.SetConnectService(connectSvc)
16491650
}
1651+
// Wire security scanner service (Spec 039)
1652+
if sm := s.runtime.StorageManager(); sm != nil {
1653+
cfg := s.runtime.Config()
1654+
dataDir := ""
1655+
if cfg != nil {
1656+
dataDir = cfg.DataDir
1657+
}
1658+
secRegistry := scanner.NewRegistry(dataDir, s.logger)
1659+
secDocker := scanner.NewDockerRunner(s.logger)
1660+
secService := scanner.NewService(sm, secRegistry, secDocker, dataDir, s.logger)
1661+
httpAPIServer.SetSecurityController(secService)
1662+
}
16501663
// Wire teams multi-user OAuth (no-op in personal edition)
16511664
wireTeamsOAuth(s, httpAPIServer)
16521665
mux.Handle("/api/", httpAPIServer)

0 commit comments

Comments
 (0)