Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions cmd/mcpproxy/security_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1823,19 +1823,28 @@ func printReportTable(serverName string, report map[string]interface{}, failedSc
scannedAt := getMapString(report, "scanned_at")
jobID := getMapString(report, "job_id")

// F-10 / MCP-2401: scanner coverage. Read counts up front so the risk-score
// line can flag low confidence when part of the fleet never ran.
scannersRun := int(getMapFloat(report, "scanners_run"))
scannersFailed := int(getMapFloat(report, "scanners_failed"))
scannersTotal := int(getMapFloat(report, "scanners_total"))

fmt.Printf("Security Report: %s\n", serverName)
if jobID != "" {
fmt.Printf("Scan ID: %s\n", jobID)
}
fmt.Printf("Risk Score: %s/100\n", riskScore)
riskLine := fmt.Sprintf("Risk Score: %s/100", riskScore)
if scannersFailed > 0 && scannersTotal > 0 {
// MCP-2401: the score was computed from an incomplete scan. Flag the
// degraded confidence on the number itself, not only in the warning
// block below, so a glance at "0/100" isn't read as an all-clear.
riskLine += fmt.Sprintf(" (degraded — %d of %d scanners did not run)", scannersFailed, scannersTotal)
}
fmt.Println(riskLine)
if scannedAt != "" {
fmt.Printf("Scanned: %s\n", formatTimestamp(scannedAt))
}

// F-10: scanner coverage line. Pull counts from the aggregated report.
scannersRun := int(getMapFloat(report, "scanners_run"))
scannersFailed := int(getMapFloat(report, "scanners_failed"))
scannersTotal := int(getMapFloat(report, "scanners_total"))
if scannersTotal > 0 {
line := fmt.Sprintf("Scanners: %d run, %d failed", scannersRun, scannersFailed)
if scannersFailed > 0 && len(failedScanners) > 0 {
Expand Down
47 changes: 47 additions & 0 deletions cmd/mcpproxy/security_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,50 @@ func TestPrintReportTableRendersScannerTimings(t *testing.T) {
}
}
}

// TestPrintReportTableFlagsDegradedRiskScore verifies MCP-2401: when coverage
// is incomplete the risk-score line itself carries a degraded marker, so a low
// "X/100" number is not read as a trustworthy all-clear.
func TestPrintReportTableFlagsDegradedRiskScore(t *testing.T) {
report := map[string]interface{}{
"job_id": "scan-foo-1",
"risk_score": float64(0),
"scanned_at": "2026-04-26T17:39:05Z",
"scanners_run": float64(3),
"scanners_failed": float64(2),
"scanners_total": float64(5),
"findings": []interface{}{},
}
out := captureStdout(t, func() {
_ = printReportTable("foo", report, nil)
})
if !strings.Contains(out, "Risk Score:") || !strings.Contains(out, "degraded") {
t.Errorf("expected degraded marker on the Risk Score line; got:\n%s", out)
}
if !strings.Contains(out, "2 of 5") {
t.Errorf("expected coverage detail '2 of 5' on the Risk Score line; got:\n%s", out)
}
}

// TestPrintReportTableFullCoverageNoDegradedMarker ensures a complete scan does
// not get a degraded marker on the risk-score line.
func TestPrintReportTableFullCoverageNoDegradedMarker(t *testing.T) {
report := map[string]interface{}{
"job_id": "scan-foo-2",
"risk_score": float64(0),
"scanned_at": "2026-04-26T17:39:05Z",
"scanners_run": float64(5),
"scanners_failed": float64(0),
"scanners_total": float64(5),
"findings": []interface{}{},
}
out := captureStdout(t, func() {
_ = printReportTable("foo", report, nil)
})
// The "Risk Score:" line must not be annotated as degraded.
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "Risk Score:") && strings.Contains(line, "degraded") {
t.Errorf("did not expect degraded marker for full coverage; got line: %q", line)
}
}
}
4 changes: 2 additions & 2 deletions docs/cli/security-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ Scan completed for "everything".

Security Report: everything
Scan ID: scan-everything-1775804891180898000
Risk Score: 0/100
Risk Score: 0/100 (degraded — 1 of 7 scanners did not run)
Scanned: 2026-04-10 10:08:19
Scanners: 6 run, 1 failed (ramparts) of 7

Expand Down Expand Up @@ -472,7 +472,7 @@ Use `-o json`, `-o yaml`, or `-o sarif` for machine-readable output.
```
Security Report: everything
Scan ID: scan-everything-1775804891180898000
Risk Score: 0/100
Risk Score: 0/100 (degraded — 1 of 7 scanners did not run)
Scanned: 2026-04-10 10:08:19
Scanners: 6 run, 1 failed (ramparts) of 7

Expand Down
8 changes: 7 additions & 1 deletion internal/contracts/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,14 @@ type DiagnosticFixStep struct {
type SecurityScanSummary struct {
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
RiskScore int `json:"risk_score"` // 0-100
Status string `json:"status"` // "clean", "warnings", "dangerous", "failed", "not_scanned", "scanning"
Status string `json:"status"` // "clean", "degraded", "warnings", "dangerous", "failed", "not_scanned", "scanning"
FindingCounts *FindingCounts `json:"finding_counts,omitempty"`
// Scanner coverage for the primary scan pass. "degraded" status means
// ScannersFailed > 0, so the risk score reflects an incomplete scan and a
// low score should not be read as a trustworthy all-clear (MCP-2401).
ScannersRun int `json:"scanners_run"`
ScannersFailed int `json:"scanners_failed"`
ScannersTotal int `json:"scanners_total"`
}

// FindingCounts groups findings by user-facing threat category.
Expand Down
40 changes: 33 additions & 7 deletions internal/security/scanner/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1599,16 +1599,21 @@ func (s *Service) GetScanSummary(ctx context.Context, serverName string) *ScanSu
return summary
}

// Check scanner statuses on primary job
// Compute scanner coverage for the primary (security) scan pass. This drives
// the "degraded" verdict below: a clean/low risk score is not trustworthy
// when some scanners never ran (MCP-2401). If no scanner completed at all the
// scan is a flat failure, not merely degraded.
if len(primaryJob.ScannerStatuses) > 0 {
allFailed := true
for _, ss := range primaryJob.ScannerStatuses {
if ss.Status == ScanJobStatusCompleted {
allFailed = false
break
summary.ScannersTotal++
switch ss.Status {
case ScanJobStatusCompleted:
summary.ScannersRun++
case ScanJobStatusFailed:
summary.ScannersFailed++
}
}
if allFailed {
if summary.ScannersRun == 0 {
summary.Status = "failed"
s.cacheScanSummary(serverName, summary)
return summary
Expand Down Expand Up @@ -1653,6 +1658,7 @@ func (s *Service) GetScanSummary(ctx context.Context, serverName string) *ScanSu
summary.Status = "failed"
}
}
summary.degradeIfIncompleteCoverage()
s.cacheScanSummary(serverName, summary)
return summary
}
Expand Down Expand Up @@ -1685,17 +1691,37 @@ func (s *Service) GetScanSummary(ctx context.Context, serverName string) *ScanSu
summary.Status = "clean" // Only informational findings
}

// Incomplete coverage downgrades a would-be "clean" verdict (MCP-2401).
summary.degradeIfIncompleteCoverage()

// Cache for fast subsequent reads
s.cacheScanSummary(serverName, summary)
return summary
}

// degradeIfIncompleteCoverage downgrades a "clean" verdict to "degraded" when
// at least one scanner failed, so a low/zero risk score is not read as a
// trustworthy all-clear while a chunk of the scanner fleet never ran. Findings-
// driven verdicts ("dangerous"/"warnings") are left intact — they already
// signal risk; coverage can only have hidden more, never less (MCP-2401).
func (sum *ScanSummary) degradeIfIncompleteCoverage() {
if sum.Status == "clean" && sum.ScannersFailed > 0 {
sum.Status = "degraded"
}
}

// ScanSummary is a compact representation of scan status for the server list.
type ScanSummary struct {
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
RiskScore int `json:"risk_score"`
Status string `json:"status"` // clean, warnings, dangerous, failed, not_scanned, scanning
Status string `json:"status"` // clean, degraded, warnings, dangerous, failed, not_scanned, scanning
FindingCounts *FindingCounts `json:"finding_counts,omitempty"`
// Scanner coverage for the primary (security) scan pass. When ScannersFailed
// > 0 the risk score is computed from incomplete data, so a "clean"/low score
// is not trustworthy — Status is reported as "degraded" instead (MCP-2401).
ScannersRun int `json:"scanners_run"`
ScannersFailed int `json:"scanners_failed"`
ScannersTotal int `json:"scanners_total"`
}

// FindingCounts groups findings by user-facing threat level.
Expand Down
55 changes: 52 additions & 3 deletions internal/security/scanner/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1176,9 +1176,53 @@ func TestServiceGetScanSummaryPartialSuccess(t *testing.T) {
if summary == nil {
t.Fatal("expected non-nil summary")
}
// At least one scanner succeeded, so status should be "clean" (no findings)
if summary.Status != "clean" {
t.Errorf("expected status 'clean', got %q", summary.Status)
// MCP-2401: a scanner failed, so coverage is incomplete. A "0/100 clean"
// verdict here is misleading — the score's confidence is degraded because
// 1 of 2 scanners never ran. Status must reflect that, not plain "clean".
if summary.Status != "degraded" {
t.Errorf("expected status 'degraded' for incomplete coverage, got %q", summary.Status)
}
if summary.ScannersTotal != 2 || summary.ScannersRun != 1 || summary.ScannersFailed != 1 {
t.Errorf("expected coverage 1 run / 1 failed / 2 total, got %d run / %d failed / %d total",
summary.ScannersRun, summary.ScannersFailed, summary.ScannersTotal)
}
}

// TestServiceGetScanSummaryDegradedWithInfoFindings covers MCP-2401 when the
// surviving scanners produced only informational findings but coverage is
// incomplete — the verdict must degrade rather than read "clean".
func TestServiceGetScanSummaryDegradedWithInfoFindings(t *testing.T) {
svc, store, _ := newTestService(t)

now := time.Now()
_ = store.SaveScanJob(&ScanJob{
ID: "j-degraded-info",
ServerName: "server-a",
Status: ScanJobStatusCompleted,
Scanners: []string{"s1", "s2"},
StartedAt: now,
ScannerStatuses: []ScannerJobStatus{
{ScannerID: "s1", Status: ScanJobStatusCompleted, FindingsCount: 1},
{ScannerID: "s2", Status: ScanJobStatusFailed, Error: "image not found"},
},
})
_ = store.SaveScanReport(&ScanReport{
ID: "r1", JobID: "j-degraded-info", ServerName: "server-a", ScannerID: "s1",
Findings: []ScanFinding{
{RuleID: "info-1", ThreatLevel: ThreatLevelInfo, Severity: "info", Title: "informational"},
},
ScannedAt: now,
})

summary := svc.GetScanSummary(context.Background(), "server-a")
if summary == nil {
t.Fatal("expected non-nil summary")
}
if summary.Status != "degraded" {
t.Errorf("expected status 'degraded' for incomplete coverage with info-only findings, got %q", summary.Status)
}
if summary.ScannersFailed != 1 {
t.Errorf("expected 1 failed scanner, got %d", summary.ScannersFailed)
}
}

Expand Down Expand Up @@ -1208,6 +1252,11 @@ func TestServiceGetScanSummaryClean(t *testing.T) {
if summary.Status != "clean" {
t.Errorf("expected status 'clean', got %q", summary.Status)
}
// MCP-2401: full coverage (no failed scanners) keeps the verdict "clean".
if summary.ScannersTotal != 1 || summary.ScannersRun != 1 || summary.ScannersFailed != 0 {
t.Errorf("expected coverage 1 run / 0 failed / 1 total, got %d run / %d failed / %d total",
summary.ScannersRun, summary.ScannersFailed, summary.ScannersTotal)
}
}

func TestServiceGetScanSummaryEmptyScan(t *testing.T) {
Expand Down
9 changes: 6 additions & 3 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2694,9 +2694,12 @@ func (a *scanSummaryEnricherAdapter) GetSecurityScanSummary(ctx context.Context,
return nil
}
out := &contracts.SecurityScanSummary{
LastScanAt: summary.LastScanAt,
RiskScore: summary.RiskScore,
Status: summary.Status,
LastScanAt: summary.LastScanAt,
RiskScore: summary.RiskScore,
Status: summary.Status,
ScannersRun: summary.ScannersRun,
ScannersFailed: summary.ScannersFailed,
ScannersTotal: summary.ScannersTotal,
}
if summary.FindingCounts != nil {
out.FindingCounts = &contracts.FindingCounts{
Expand Down
2 changes: 1 addition & 1 deletion oas/docs.go

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion oas/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1771,8 +1771,18 @@ components:
risk_score:
description: 0-100
type: integer
scanners_failed:
type: integer
scanners_run:
description: |-
Scanner coverage for the primary scan pass. "degraded" status means
ScannersFailed > 0, so the risk score reflects an incomplete scan and a
low score should not be read as a trustworthy all-clear (MCP-2401).
type: integer
scanners_total:
type: integer
status:
description: '"clean", "warnings", "dangerous", "failed", "not_scanned",
description: '"clean", "degraded", "warnings", "dangerous", "failed", "not_scanned",
"scanning"'
type: string
type: object
Expand Down
Loading