Skip to content

Commit e4fc88b

Browse files
committed
feat(039): allow Docker image override for security scanners
Add per-scanner Docker image override so users can pin a specific version, use a mirror registry, or substitute a custom build without editing the bundled registry. Backend: - ScannerPlugin.ImageOverride field (persisted via existing scanner storage) - EffectiveImage() helper returns override if set, else bundled DockerImage - Engine uses EffectiveImage() for container launch and existence checks - PUT /api/v1/security/scanners/{id}/config accepts optional docker_image alongside env map (either or both required) Frontend: - Add Docker Image field to Configure dialog (placeholder shows default) - Show Configure button for all scanner states, not just installed/configured, so users can edit settings on disabled scanners too Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 885e12c commit e4fc88b

8 files changed

Lines changed: 59 additions & 19 deletions

File tree

frontend/src/services/api.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -784,10 +784,12 @@ class APIService {
784784
})
785785
}
786786

787-
async configureScanner(id: string, env: Record<string, string>): Promise<APIResponse<void>> {
787+
async configureScanner(id: string, env: Record<string, string>, dockerImage?: string): Promise<APIResponse<void>> {
788+
const payload: any = { env }
789+
if (dockerImage) payload.docker_image = dockerImage
788790
return this.request<void>(`/api/v1/security/scanners/${encodeURIComponent(id)}/config`, {
789791
method: 'PUT',
790-
body: JSON.stringify({ env }),
792+
body: JSON.stringify(payload),
791793
})
792794
}
793795

frontend/src/views/Security.vue

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196
Set API Key
197197
</button>
198198
<button
199-
v-else-if="scanner.status === 'installed' || scanner.status === 'configured'"
199+
v-else
200200
@click="openConfigDialog(scanner)"
201201
class="btn btn-sm btn-outline"
202202
>
@@ -339,6 +339,23 @@
339339
class="input input-bordered"
340340
/>
341341
</div>
342+
<!-- Docker image override -->
343+
<div class="divider text-xs">Docker Image</div>
344+
<div class="form-control">
345+
<label class="label">
346+
<span class="label-text">Docker Image</span>
347+
<span class="badge badge-sm badge-ghost">Optional</span>
348+
</label>
349+
<input
350+
v-model="configDockerImage"
351+
type="text"
352+
:placeholder="configScanner?.image_override || configScanner?.docker_image || 'default image'"
353+
class="input input-bordered input-sm font-mono"
354+
/>
355+
<label class="label" v-if="configScanner?.docker_image">
356+
<span class="label-text-alt text-base-content/40">Default: {{ configScanner.docker_image }}</span>
357+
</label>
358+
</div>
342359
<!-- Custom env var -->
343360
<div class="divider text-xs">Add Custom Variable</div>
344361
<div class="flex gap-2">
@@ -408,6 +425,7 @@ const configScanner = ref<any>(null)
408425
const configValues = ref<Record<string, string>>({})
409426
const customEnvKey = ref('')
410427
const customEnvValue = ref('')
428+
const configDockerImage = ref('')
411429
412430
const totalFindings = computed(() => overview.value?.findings_by_severity?.total || 0)
413431
@@ -506,6 +524,7 @@ function openConfigDialog(scanner: any) {
506524
configValues.value = { ...existing }
507525
customEnvKey.value = ''
508526
customEnvValue.value = ''
527+
configDockerImage.value = scanner.image_override || ''
509528
configDialog.value?.showModal()
510529
}
511530
@@ -539,8 +558,10 @@ async function saveConfig() {
539558
toSend[k] = v
540559
}
541560
}
542-
if (Object.keys(toSend).length > 0) {
543-
await api.configureScanner(configScanner.value.id, toSend)
561+
const hasEnv = Object.keys(toSend).length > 0
562+
const hasImage = configDockerImage.value && configDockerImage.value !== (configScanner.value?.image_override || '')
563+
if (hasEnv || hasImage) {
564+
await api.configureScanner(configScanner.value.id, toSend, configDockerImage.value || undefined)
544565
}
545566
closeConfigDialog()
546567
await refresh()

internal/httpapi/security_scanner.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type SecurityController interface {
1919
ListScanners(ctx context.Context) ([]*scanner.ScannerPlugin, error)
2020
InstallScanner(ctx context.Context, id string) error
2121
RemoveScanner(ctx context.Context, id string) error
22-
ConfigureScanner(ctx context.Context, id string, env map[string]string) error
22+
ConfigureScanner(ctx context.Context, id string, env map[string]string, dockerImage string) error
2323
GetScannerStatus(ctx context.Context, id string) (*scanner.ScannerPlugin, error)
2424

2525
StartScan(ctx context.Context, serverName string, dryRun bool, scannerIDs []string, sourceDir string) (*scanner.ScanJob, error)
@@ -133,18 +133,19 @@ func (s *Server) handleConfigureScanner(w http.ResponseWriter, r *http.Request)
133133
}
134134

135135
var req struct {
136-
Env map[string]string `json:"env"`
136+
Env map[string]string `json:"env"`
137+
DockerImage string `json:"docker_image,omitempty"`
137138
}
138139
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
139140
s.writeError(w, r, http.StatusBadRequest, "invalid request body")
140141
return
141142
}
142-
if len(req.Env) == 0 {
143-
s.writeError(w, r, http.StatusBadRequest, "env map is required")
143+
if len(req.Env) == 0 && req.DockerImage == "" {
144+
s.writeError(w, r, http.StatusBadRequest, "env map or docker_image is required")
144145
return
145146
}
146147

147-
if err := s.securityController.ConfigureScanner(r.Context(), id, req.Env); err != nil {
148+
if err := s.securityController.ConfigureScanner(r.Context(), id, req.Env, req.DockerImage); err != nil {
148149
s.writeError(w, r, http.StatusInternalServerError, err.Error())
149150
return
150151
}

internal/httpapi/security_scanner_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (m *mockSecurityController) RemoveScanner(_ context.Context, id string) err
5353
return m.removeErr
5454
}
5555

56-
func (m *mockSecurityController) ConfigureScanner(_ context.Context, id string, env map[string]string) error {
56+
func (m *mockSecurityController) ConfigureScanner(_ context.Context, id string, env map[string]string, dockerImage string) error {
5757
return m.configureErr
5858
}
5959

internal/security/scanner/engine.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ func (e *Engine) resolveScanners(requestedIDs []string) ([]*ScannerPlugin, error
179179
for _, s := range all {
180180
if s.Status == ScannerStatusInstalled || s.Status == ScannerStatusConfigured {
181181
// Verify Docker image exists before adding to scan list
182-
if e.docker != nil && !e.docker.ImageExists(context.Background(), s.DockerImage) {
182+
if e.docker != nil && !e.docker.ImageExists(context.Background(), s.EffectiveImage()) {
183183
e.logger.Warn("Skipping scanner: Docker image not found locally",
184184
zap.String("scanner", s.ID),
185-
zap.String("image", s.DockerImage),
185+
zap.String("image", s.EffectiveImage()),
186186
)
187187
continue
188188
}
@@ -382,7 +382,7 @@ func (e *Engine) runSingleScanner(ctx context.Context, s *ScannerPlugin, req Sca
382382
// Run scanner container
383383
cfg := ScannerRunConfig{
384384
ContainerName: GenerateContainerName(s.ID, req.ServerName),
385-
Image: s.DockerImage,
385+
Image: s.EffectiveImage(),
386386
Command: s.Command,
387387
Env: env,
388388
SourceDir: req.SourceDir,

internal/security/scanner/service.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ func (s *Service) SetSecretStore(store SecretStore) {
276276

277277
// ConfigureScanner sets environment variables (API keys) for a scanner.
278278
// Secret values are stored in the OS keyring; only references are kept in config.
279-
func (s *Service) ConfigureScanner(ctx context.Context, id string, env map[string]string) error {
279+
func (s *Service) ConfigureScanner(ctx context.Context, id string, env map[string]string, dockerImage string) error {
280280
sc, err := s.storage.GetScanner(id)
281281
if err != nil {
282282
// If not in storage yet (just registered in registry), create from registry
@@ -306,6 +306,12 @@ func (s *Service) ConfigureScanner(ctx context.Context, id string, env map[strin
306306
sc.ConfiguredEnv[k] = v
307307
}
308308
}
309+
310+
// Apply Docker image override
311+
if dockerImage != "" {
312+
sc.ImageOverride = dockerImage
313+
}
314+
309315
sc.Status = ScannerStatusConfigured
310316

311317
if err := s.storage.SaveScanner(sc); err != nil {
@@ -314,10 +320,11 @@ func (s *Service) ConfigureScanner(ctx context.Context, id string, env map[strin
314320

315321
_ = s.registry.UpdateStatus(id, ScannerStatusConfigured)
316322

317-
// Also update the registry's ConfiguredEnv so the engine picks up
318-
// new env vars without requiring a restart
323+
// Also update the registry's ConfiguredEnv and ImageOverride so the engine
324+
// picks up changes without requiring a restart
319325
if reg, err := s.registry.Get(id); err == nil {
320326
reg.ConfiguredEnv = sc.ConfiguredEnv
327+
reg.ImageOverride = sc.ImageOverride
321328
}
322329

323330
return nil

internal/security/scanner/service_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ func TestServiceConfigureScanner(t *testing.T) {
451451
"API_KEY": "my-key",
452452
"API_SECRET": "my-secret",
453453
}
454-
err := svc.ConfigureScanner(context.Background(), "test-scanner", env)
454+
err := svc.ConfigureScanner(context.Background(), "test-scanner", env, "")
455455
if err != nil {
456456
t.Fatalf("unexpected error: %v", err)
457457
}
@@ -475,7 +475,7 @@ func TestServiceConfigureScanner(t *testing.T) {
475475
func TestServiceConfigureScannerNotInstalled(t *testing.T) {
476476
svc, _, _ := newTestService(t)
477477

478-
err := svc.ConfigureScanner(context.Background(), "nonexistent", map[string]string{"KEY": "val"})
478+
err := svc.ConfigureScanner(context.Background(), "nonexistent", map[string]string{"KEY": "val"}, "")
479479
if err == nil {
480480
t.Fatal("expected error for non-installed scanner")
481481
}

internal/security/scanner/types.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,20 @@ type ScannerPlugin struct {
5858
Status string `json:"status"` // available, installed, configured, error
5959
InstalledAt time.Time `json:"installed_at,omitempty"`
6060
ConfiguredEnv map[string]string `json:"configured_env,omitempty"` // Set env values (secrets redacted in API)
61+
ImageOverride string `json:"image_override,omitempty"` // User override for DockerImage
6162
LastUsedAt time.Time `json:"last_used_at,omitempty"`
6263
ErrorMsg string `json:"error_message,omitempty"`
6364
Custom bool `json:"custom,omitempty"` // User-added (not from registry)
6465
}
6566

67+
// EffectiveImage returns ImageOverride if set, otherwise DockerImage.
68+
func (s *ScannerPlugin) EffectiveImage() string {
69+
if s.ImageOverride != "" {
70+
return s.ImageOverride
71+
}
72+
return s.DockerImage
73+
}
74+
6675
// Scan pass constants
6776
const (
6877
ScanPassSecurityScan = 1 // Fast pass: source code + lockfile scanning

0 commit comments

Comments
 (0)