Skip to content

Commit 5ed182e

Browse files
committed
feat(039): Scan All with worker pool, queue, progress tracking
ScanQueue with 3 concurrent workers, progress tracking, cancel support. REST API: scan-all, queue, cancel-all endpoints. CLI: --all flag and cancel-all subcommand. Web UI: Scan All button with progress card. Disabled servers auto-skipped with hint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d926db4 commit 5ed182e

33 files changed

Lines changed: 1392 additions & 15 deletions

cmd/mcpproxy/security_cmd.go

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"os"
910
"strings"
1011
"time"
1112

@@ -23,6 +24,7 @@ var (
2324
secScanAsync bool
2425
secScanDryRun bool
2526
secScanners string
27+
secScanAll bool
2628

2729
// security approve flags
2830
secApproveForce bool
@@ -62,6 +64,7 @@ Examples:
6264
securityCmd.AddCommand(newSecurityRescanCmd())
6365
securityCmd.AddCommand(newSecurityOverviewCmd())
6466
securityCmd.AddCommand(newSecurityIntegrityCmd())
67+
securityCmd.AddCommand(newSecurityCancelAllCmd())
6568

6669
return securityCmd
6770
}
@@ -155,22 +158,26 @@ Examples:
155158

156159
func newSecurityScanCmd() *cobra.Command {
157160
cmd := &cobra.Command{
158-
Use: "scan <server>",
161+
Use: "scan [server]",
159162
Short: "Scan a server with security scanners",
160163
Long: `Start a security scan on an upstream MCP server.
161164
162165
By default, blocks until the scan completes and shows a summary.
163166
Use --async to start the scan and return immediately.
167+
Use --all to scan all servers at once with a progress table.
164168
165169
Examples:
166170
mcpproxy security scan github-server
167171
mcpproxy security scan github-server --async
168172
mcpproxy security scan github-server --dry-run
169-
mcpproxy security scan github-server --scanners mcp-scan,cisco-mcp-scanner`,
170-
Args: cobra.ExactArgs(1),
173+
mcpproxy security scan github-server --scanners mcp-scan,cisco-mcp-scanner
174+
mcpproxy security scan --all
175+
mcpproxy security scan --all --scanners mcp-scan`,
176+
Args: cobra.MaximumNArgs(1),
171177
RunE: runSecurityScan,
172178
}
173179

180+
cmd.Flags().BoolVar(&secScanAll, "all", false, "Scan all servers (shows progress table)")
174181
cmd.Flags().BoolVar(&secScanAsync, "async", false, "Start scan and return immediately without waiting")
175182
cmd.Flags().BoolVar(&secScanDryRun, "dry-run", false, "Simulate scan without executing")
176183
cmd.Flags().StringVar(&secScanners, "scanners", "", "Comma-separated scanner IDs to use (default: all installed)")
@@ -495,6 +502,16 @@ func runSecurityConfigure(_ *cobra.Command, args []string) error {
495502
}
496503

497504
func runSecurityScan(_ *cobra.Command, args []string) error {
505+
// Handle --all flag
506+
if secScanAll {
507+
return runSecurityScanAll()
508+
}
509+
510+
// Single server mode requires exactly one argument
511+
if len(args) < 1 {
512+
return fmt.Errorf("server name is required (or use --all to scan all servers)")
513+
}
514+
498515
client, _, err := newSecurityCLIClient()
499516
if err != nil {
500517
return err
@@ -529,6 +546,17 @@ func runSecurityScan(_ *cobra.Command, args []string) error {
529546
return fmt.Errorf("failed to read response: %w", err)
530547
}
531548

549+
// Check for disabled server (500 with specific message)
550+
if resp.StatusCode == http.StatusInternalServerError {
551+
errMsg := extractAPIErrorMsg(respBody)
552+
if strings.Contains(strings.ToLower(errMsg), "disabled") || strings.Contains(strings.ToLower(errMsg), "not enabled") {
553+
fmt.Fprintf(os.Stderr, "Error: Server %q is disabled. Enable it first or quarantine and scan:\n", serverName)
554+
fmt.Fprintf(os.Stderr, " mcpproxy upstream enable %s\n", serverName)
555+
fmt.Fprintf(os.Stderr, " mcpproxy security scan %s\n", serverName)
556+
return fmt.Errorf("server %q is disabled", serverName)
557+
}
558+
}
559+
532560
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
533561
return parseAPIError(respBody, resp.StatusCode, "start scan")
534562
}
@@ -621,6 +649,224 @@ func runSecurityScan(_ *cobra.Command, args []string) error {
621649
}
622650
}
623651

652+
// runSecurityScanAll handles the --all flag: starts a batch scan and polls for progress.
653+
func runSecurityScanAll() error {
654+
client, _, err := newSecurityCLIClient()
655+
if err != nil {
656+
return err
657+
}
658+
659+
// Build request body
660+
reqBody := map[string]interface{}{}
661+
if secScanners != "" {
662+
reqBody["scanner_ids"] = splitAndTrim(secScanners)
663+
}
664+
665+
body, err := json.Marshal(reqBody)
666+
if err != nil {
667+
return fmt.Errorf("failed to marshal request: %w", err)
668+
}
669+
670+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
671+
defer cancel()
672+
673+
resp, err := client.DoRaw(ctx, http.MethodPost, "/api/v1/security/scan-all", body)
674+
if err != nil {
675+
return fmt.Errorf("failed to start batch scan: %w", err)
676+
}
677+
defer resp.Body.Close()
678+
679+
respBody, err := io.ReadAll(resp.Body)
680+
if err != nil {
681+
return fmt.Errorf("failed to read response: %w", err)
682+
}
683+
684+
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
685+
return parseAPIError(respBody, resp.StatusCode, "start batch scan")
686+
}
687+
688+
format := ResolveOutputFormat()
689+
690+
// If --async, return immediately
691+
if secScanAsync {
692+
if format == "json" || format == "yaml" {
693+
return formatAndPrintRaw(format, respBody)
694+
}
695+
fmt.Println("Batch scan started. Use 'mcpproxy security scan --all' to check progress.")
696+
return nil
697+
}
698+
699+
// Poll for progress
700+
for {
701+
time.Sleep(3 * time.Second)
702+
703+
qResp, err := client.DoRaw(ctx, http.MethodGet, "/api/v1/security/queue", nil)
704+
if err != nil {
705+
return fmt.Errorf("failed to check queue progress: %w", err)
706+
}
707+
708+
qBody, err := io.ReadAll(qResp.Body)
709+
qResp.Body.Close()
710+
if err != nil {
711+
return fmt.Errorf("failed to read queue response: %w", err)
712+
}
713+
714+
if qResp.StatusCode != http.StatusOK {
715+
return parseAPIError(qBody, qResp.StatusCode, "check queue progress")
716+
}
717+
718+
var progress map[string]interface{}
719+
qBody, err = unwrapAPIResponse(qBody)
720+
if err != nil {
721+
return fmt.Errorf("API error: %w", err)
722+
}
723+
if err := json.Unmarshal(qBody, &progress); err != nil {
724+
return fmt.Errorf("failed to parse progress: %w", err)
725+
}
726+
727+
// Check if idle (no batch in progress)
728+
queueStatus := getMapString(progress, "status")
729+
if queueStatus == "idle" {
730+
fmt.Println("No batch scan in progress.")
731+
return nil
732+
}
733+
734+
// JSON/YAML output for final state
735+
if queueStatus == "completed" || queueStatus == "cancelled" {
736+
if format == "json" || format == "yaml" {
737+
return formatAndPrint(format, progress)
738+
}
739+
printQueueProgressTable(progress)
740+
fmt.Println()
741+
if queueStatus == "completed" {
742+
fmt.Println("Batch scan completed.")
743+
} else {
744+
fmt.Println("Batch scan was cancelled.")
745+
}
746+
return nil
747+
}
748+
749+
// Show progress table (clear screen with carriage returns for terminal)
750+
printQueueProgressTable(progress)
751+
}
752+
}
753+
754+
// printQueueProgressTable prints the progress table for a batch scan.
755+
func printQueueProgressTable(progress map[string]interface{}) {
756+
total := int(getMapFloat(progress, "total"))
757+
completed := int(getMapFloat(progress, "completed"))
758+
running := int(getMapFloat(progress, "running"))
759+
skipped := int(getMapFloat(progress, "skipped"))
760+
failed := int(getMapFloat(progress, "failed"))
761+
762+
fmt.Printf("\nScanning all servers (%d/%d completed, %d running", completed, total, running)
763+
if skipped > 0 {
764+
fmt.Printf(", %d skipped", skipped)
765+
}
766+
if failed > 0 {
767+
fmt.Printf(", %d failed", failed)
768+
}
769+
fmt.Println(")...")
770+
771+
// Table header
772+
fmt.Printf("%-24s %-12s %-10s %s\n", "SERVER", "STATUS", "FINDINGS", "ERROR")
773+
fmt.Println(strings.Repeat("-", 70))
774+
775+
// Items
776+
if items, ok := progress["items"].([]interface{}); ok {
777+
for _, item := range items {
778+
if it, ok := item.(map[string]interface{}); ok {
779+
name := getMapString(it, "server_name")
780+
status := getMapString(it, "status")
781+
errMsg := getMapString(it, "error")
782+
skipReason := getMapString(it, "skip_reason")
783+
findings := "-"
784+
785+
// Show error or skip reason
786+
msg := errMsg
787+
if skipReason != "" {
788+
msg = skipReason
789+
}
790+
if len(msg) > 30 {
791+
msg = msg[:27] + "..."
792+
}
793+
794+
fmt.Printf("%-24s %-12s %-10s %s\n",
795+
secTruncate(name, 24),
796+
status,
797+
findings,
798+
msg,
799+
)
800+
}
801+
}
802+
}
803+
}
804+
805+
func newSecurityCancelAllCmd() *cobra.Command {
806+
return &cobra.Command{
807+
Use: "cancel-all",
808+
Short: "Cancel a running batch scan",
809+
Long: `Cancel the current batch security scan in progress.
810+
Any pending server scans will be skipped. Running scans may complete.
811+
812+
Examples:
813+
mcpproxy security cancel-all`,
814+
RunE: runSecurityCancelAll,
815+
}
816+
}
817+
818+
func runSecurityCancelAll(_ *cobra.Command, _ []string) error {
819+
client, _, err := newSecurityCLIClient()
820+
if err != nil {
821+
return err
822+
}
823+
824+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
825+
defer cancel()
826+
827+
resp, err := client.DoRaw(ctx, http.MethodPost, "/api/v1/security/cancel-all", nil)
828+
if err != nil {
829+
return fmt.Errorf("failed to cancel batch scan: %w", err)
830+
}
831+
defer resp.Body.Close()
832+
833+
respBody, err := io.ReadAll(resp.Body)
834+
if err != nil {
835+
return fmt.Errorf("failed to read response: %w", err)
836+
}
837+
838+
if resp.StatusCode != http.StatusOK {
839+
return parseAPIError(respBody, resp.StatusCode, "cancel batch scan")
840+
}
841+
842+
format := ResolveOutputFormat()
843+
if format == "json" || format == "yaml" {
844+
return formatAndPrintRaw(format, respBody)
845+
}
846+
847+
fmt.Println("Batch scan cancelled.")
848+
return nil
849+
}
850+
851+
// extractAPIErrorMsg extracts the error message from an API error response body.
852+
func extractAPIErrorMsg(body []byte) string {
853+
var errResp map[string]interface{}
854+
if err := json.Unmarshal(body, &errResp); err == nil {
855+
if msg, ok := errResp["error"].(string); ok {
856+
return msg
857+
}
858+
}
859+
return string(body)
860+
}
861+
862+
// getMapFloat returns a float64 value from a map, defaulting to 0.
863+
func getMapFloat(m map[string]interface{}, key string) float64 {
864+
if v, ok := m[key].(float64); ok {
865+
return v
866+
}
867+
return 0
868+
}
869+
624870
func runSecurityStatus(_ *cobra.Command, args []string) error {
625871
client, _, err := newSecurityCLIClient()
626872
if err != nil {

frontend/src/components/ServerCard.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,23 @@
181181
Logout
182182
</button>
183183

184+
<div
185+
v-if="!server.enabled"
186+
class="tooltip tooltip-top"
187+
data-tip="Enable server first"
188+
>
189+
<button
190+
class="btn btn-sm btn-outline btn-ghost"
191+
disabled
192+
>
193+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
194+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
195+
</svg>
196+
Scan
197+
</button>
198+
</div>
184199
<router-link
200+
v-else
185201
:to="`/servers/${server.name}?tab=security`"
186202
class="btn btn-sm btn-outline btn-ghost"
187203
title="Security Scan"

frontend/src/services/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,21 @@ class APIService {
842842
return this.request<any>('/api/v1/security/overview')
843843
}
844844

845+
async scanAll(scannerIds: string[] = []): Promise<APIResponse<any>> {
846+
return this.request<any>('/api/v1/security/scan-all', {
847+
method: 'POST',
848+
body: JSON.stringify({ scanner_ids: scannerIds }),
849+
})
850+
}
851+
852+
async getQueueProgress(): Promise<APIResponse<any>> {
853+
return this.request<any>('/api/v1/security/queue')
854+
}
855+
856+
async cancelAllScans(): Promise<APIResponse<void>> {
857+
return this.request<void>('/api/v1/security/cancel-all', { method: 'POST' })
858+
}
859+
845860
// Utility methods
846861
async testConnection(): Promise<boolean> {
847862
try {

0 commit comments

Comments
 (0)