Skip to content

Commit 26cba86

Browse files
committed
fix(039): resolve 13 QA findings in security scanner plugin system
Bundled fix for bugs surfaced by a full QA pass of Spec 039. Each finding has a one-line code reference and a regression test where applicable. Critical: * F-01 security approve now actually unquarantines the server. The scanner Service gets a new ServerUnquarantiner dependency injected from server.Server.UnquarantineServer; ApproveServer calls it after persisting the integrity baseline. Tool indexing and quarantine-bucket removal follow the existing unquarantine path. Critical-findings guard still blocks the call before anything mutates. TestServiceApproveServerCallsUnquarantiner and TestServiceApproveServerCriticalDoesNotUnquarantine pin the contract. * F-02 Source resolver no longer misreads a server's positional data-dir argument as source code. For package-runner commands (npx, uvx, pipx, pnpm dlx, bunx, yarn dlx) resolveFromPackageCache runs first; arg-scan fallback only accepts directories containing a source marker (package.json, pyproject.toml, setup.py, Cargo.toml, go.mod, or a source file). Regression tests cover the filesystem-server false-positive case and the legitimate python-script arg case. High: * F-03 macOS keychain probe no longer pops the "Keychain Not Found" modal. KeyringProvider.IsAvailable uses a read-only Get probe with a 2s timeout and a headless-environment fast path instead of keyring.Set. Public Get, Store, Delete, List, and both registry helpers are wrapped in a new runWithTimeout helper (3s hard deadline) backed by ErrKeyringTimeout. Scanner ConfigureScanner already falls back to in-config storage on Store errors. TestKeyringProvider_IsAvailable_HangingBackend proves the bail-out. * F-04 UI Approve buttons are now scanner-aware. ServerCard, ServerDetail and ScanReport call the new securityApproveServer store action. A custom modal gates force-approve on servers with no scan or with critical findings; the legacy unquarantineServer path is retained only for admin and the Scan First action. * F-05 CLI security scan no longer hangs after the job reports completed. The poll loop now terminates on completed|failed|cancelled and prints live progress lines (N run, M running, F failed). Medium: * F-06 --dry-run prints a structured plan (source method, source path, scanner images, commands, timeouts) and exits without invoking the scan endpoint. No containers start. * F-09 Scanner status vocabulary unified between table and JSON output (available | pulling | installed | configured | error). * F-10 security report text output now contains "Scanners: X run, Y failed (names) of Z" and a coverage warning when any scanner did not run, so a user cannot mistake scanner crashes for "clean". * F-11 Scan history endpoint returns the aggregated risk_score instead of always zero. UX: * F-12 Dashboard "Security Scan" chip is now a live router-link to /security with no "soon" placeholder; matches adjacent Docker / Quarantine chips. * F-14 security overview shows "Last scan: never" instead of 0001-01-01 00:00:00, and JSON emits last_scan_at: null. * F-15 security configure returns in ~3s (was 60s+), bumped client timeout from 10s to 60s as additional safety, added existence prefetch so typos return 404 fast. * F-16 scan --all redraws the progress table in place on TTY via ANSI cursor escapes; falls back to per-line output when piped; FINDINGS column now populated from findings_count. Extra: * mcpproxy security subcommands now honor --config and --data-dir global flags (newSecurityCLIClient checks the package-level configFile/dataDir before calling config.LoadFromFile, mirroring main.go). Not fixed (documented): * F-07 ramparts scanner container ships a GLIBC_2.39-linked binary that fails on arm64 macOS. Upstream scanner image issue, not mcpproxy code. * F-13 cisco-mcp-scanner stdout contains a hardcoded server_url header. Cosmetic upstream scanner output quirk. Post-fix revalidation: 6 of 7 scanners run end-to-end on arm64 macOS against the three test servers (everything, fetch-test, filesystem-test). The QA report in docs/qa/security-scanners-2026-04-10.html now contains a §11 "After-Fix Revalidation" section with per-finding verification evidence, updated code-landscape table, and reproduction instructions. Tests: internal/secret, internal/security, internal/security/scanner, cmd/mcpproxy, frontend unit tests all pass. golangci-lint: 0 issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6fbf914 commit 26cba86

16 files changed

Lines changed: 1992 additions & 148 deletions

File tree

cmd/mcpproxy/security_cmd.go

Lines changed: 591 additions & 76 deletions
Large diffs are not rendered by default.

cmd/mcpproxy/security_cmd_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
8+
)
9+
10+
// TestScannerDisplayStatus verifies F-09: scanner status vocabulary is
11+
// consistent and rich enough to distinguish "available" / "pulling" /
12+
// "installed" / "configured" / "error" in BOTH table and JSON outputs.
13+
func TestScannerDisplayStatus(t *testing.T) {
14+
cases := []struct {
15+
in string
16+
want string
17+
}{
18+
{"available", "available"},
19+
{"pulling", "pulling"},
20+
{"installed", "installed"},
21+
{"configured", "configured"},
22+
{"error", "error"},
23+
{"", "unknown"},
24+
// Future / unexpected values pass through unchanged so they don't
25+
// silently get hidden behind a hard-coded mapping.
26+
{"some-new-state", "some-new-state"},
27+
}
28+
for _, c := range cases {
29+
got := scannerDisplayStatus(c.in)
30+
if got != c.want {
31+
t.Errorf("scannerDisplayStatus(%q) = %q, want %q", c.in, got, c.want)
32+
}
33+
}
34+
}
35+
36+
// TestComputeScanHardTimeout verifies F-05: the per-scanner timeout is
37+
// extrapolated into a sensible whole-scan timeout that won't return early
38+
// nor hang for the duration of the universe.
39+
func TestComputeScanHardTimeout(t *testing.T) {
40+
// Nil config -> 15-minute fallback.
41+
if got := computeScanHardTimeout(nil, ""); got != 15*time.Minute {
42+
t.Errorf("nil cfg: got %s, want 15m", got)
43+
}
44+
45+
// Config with no security section -> fallback.
46+
cfg := &config.Config{}
47+
if got := computeScanHardTimeout(cfg, ""); got != 15*time.Minute {
48+
t.Errorf("nil security: got %s, want 15m", got)
49+
}
50+
51+
// Config with explicit per-scanner timeout, with explicit scanner list:
52+
// 60s * 3 + 30s = 3m30s, but we floor at 15m for sanity.
53+
cfg = &config.Config{
54+
Security: &config.SecurityConfig{
55+
ScanTimeoutDefault: config.Duration(60 * time.Second),
56+
},
57+
}
58+
if got := computeScanHardTimeout(cfg, "a,b,c"); got != 15*time.Minute {
59+
t.Errorf("60s*3 with floor: got %s, want 15m", got)
60+
}
61+
62+
// Per-scanner 5m, no flag (default 8 scanners): 5m*8 + 30s = 40m30s,
63+
// capped at 30m.
64+
cfg = &config.Config{
65+
Security: &config.SecurityConfig{
66+
ScanTimeoutDefault: config.Duration(5 * time.Minute),
67+
},
68+
}
69+
if got := computeScanHardTimeout(cfg, ""); got != 30*time.Minute {
70+
t.Errorf("5m*8 cap: got %s, want 30m", got)
71+
}
72+
73+
// Per-scanner 4m, 6 scanners: 4m*6 + 30s = 24m30s — within bounds.
74+
cfg = &config.Config{
75+
Security: &config.SecurityConfig{
76+
ScanTimeoutDefault: config.Duration(4 * time.Minute),
77+
},
78+
}
79+
got := computeScanHardTimeout(cfg, "s1,s2,s3,s4,s5,s6")
80+
want := 4*time.Minute*6 + 30*time.Second
81+
if got != want {
82+
t.Errorf("4m*6: got %s, want %s", got, want)
83+
}
84+
}
85+
86+
// TestNormalizeOverviewLastScan verifies F-14: Go zero-time `last_scan_at`
87+
// values are scrubbed to JSON null in both table and JSON outputs.
88+
func TestNormalizeOverviewLastScan(t *testing.T) {
89+
cases := []struct {
90+
name string
91+
in map[string]interface{}
92+
// We assert nil-ness via key presence and value.
93+
wantPresent bool
94+
wantNil bool
95+
wantValue interface{}
96+
}{
97+
{
98+
name: "missing key inserted as nil",
99+
in: map[string]interface{}{},
100+
wantPresent: true,
101+
wantNil: true,
102+
},
103+
{
104+
name: "explicit empty string -> nil",
105+
in: map[string]interface{}{"last_scan_at": ""},
106+
wantPresent: true,
107+
wantNil: true,
108+
},
109+
{
110+
name: "Go zero-time RFC3339 -> nil",
111+
in: map[string]interface{}{"last_scan_at": "0001-01-01T00:00:00Z"},
112+
wantPresent: true,
113+
wantNil: true,
114+
},
115+
{
116+
name: "real timestamp preserved",
117+
in: map[string]interface{}{"last_scan_at": "2025-01-15T10:30:00Z"},
118+
wantPresent: true,
119+
wantNil: false,
120+
wantValue: "2025-01-15T10:30:00Z",
121+
},
122+
}
123+
for _, c := range cases {
124+
t.Run(c.name, func(t *testing.T) {
125+
normalizeOverviewLastScan(c.in)
126+
v, present := c.in["last_scan_at"]
127+
if present != c.wantPresent {
128+
t.Errorf("present=%v, want %v", present, c.wantPresent)
129+
}
130+
if c.wantNil && v != nil {
131+
t.Errorf("expected nil value, got %v (%T)", v, v)
132+
}
133+
if !c.wantNil && c.wantValue != nil && v != c.wantValue {
134+
t.Errorf("value = %v, want %v", v, c.wantValue)
135+
}
136+
})
137+
}
138+
139+
// Nil map should not panic.
140+
normalizeOverviewLastScan(nil)
141+
}
142+
143+
// TestClearPreviousLines verifies F-16: passing 0 or negative values is a
144+
// safe no-op (so the first redraw cycle doesn't blow up the terminal).
145+
func TestClearPreviousLines(t *testing.T) {
146+
// We can't easily capture stdout here without restructuring; just verify
147+
// the function doesn't panic on edge cases.
148+
clearPreviousLines(0)
149+
clearPreviousLines(-1)
150+
}

frontend/src/components/ServerCard.vue

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113
<!-- Primary action button based on health.action -->
114114
<button
115115
v-if="healthAction === 'approve'"
116-
@click="unquarantine"
116+
@click="handleApproveClick"
117117
:disabled="loading"
118118
class="btn btn-sm btn-warning"
119119
>
@@ -232,6 +232,51 @@
232232
</div>
233233
</div>
234234

235+
<!-- Approve Confirmation Modal (F-04: security scanner gated) -->
236+
<div v-if="showApproveConfirmation" class="modal modal-open">
237+
<div class="modal-box">
238+
<h3 class="font-bold text-lg mb-4">
239+
{{ approveDialogMode === 'no_scan' ? 'No Security Scan Run' : 'Critical Findings Detected' }}
240+
</h3>
241+
<p v-if="approveDialogMode === 'critical'" class="mb-4">
242+
<strong>{{ server.name }}</strong> has
243+
<span class="text-error font-semibold">{{ criticalFindingCount }} critical finding{{ criticalFindingCount === 1 ? '' : 's' }}</span>
244+
in its most recent security scan. Approving this server will allow it to run despite these warnings.
245+
</p>
246+
<p v-else class="mb-4">
247+
No security scan has been run for <strong>{{ server.name }}</strong>. We strongly recommend running a scan first.
248+
</p>
249+
<p class="text-sm text-base-content/70 mb-6">
250+
The security scanner is an experimental heuristic. Force-approving a server bypasses the scanner gate and is irreversible from this dialog.
251+
</p>
252+
<div class="modal-action">
253+
<button
254+
@click="showApproveConfirmation = false"
255+
:disabled="loading"
256+
class="btn btn-outline"
257+
>
258+
Cancel
259+
</button>
260+
<router-link
261+
v-if="approveDialogMode === 'no_scan'"
262+
:to="`/servers/${server.name}?tab=security`"
263+
class="btn btn-primary"
264+
@click="showApproveConfirmation = false"
265+
>
266+
Scan First
267+
</router-link>
268+
<button
269+
@click="confirmForceApprove"
270+
:disabled="loading"
271+
class="btn btn-error"
272+
>
273+
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
274+
Force Approve
275+
</button>
276+
</div>
277+
</div>
278+
</div>
279+
235280
<!-- Delete Confirmation Modal -->
236281
<div v-if="showDeleteConfirmation" class="modal modal-open">
237282
<div class="modal-box">
@@ -282,6 +327,8 @@ const systemStore = useSystemStore()
282327
const { hasEnabledScanners } = useSecurityScannerStatus()
283328
const loading = ref(false)
284329
const showDeleteConfirmation = ref(false)
330+
const showApproveConfirmation = ref(false)
331+
const approveDialogMode = ref<'no_scan' | 'critical'>('no_scan')
285332
286333
const isHttpProtocol = computed(() => {
287334
return props.server.protocol === 'http' || props.server.protocol === 'streamable-http'
@@ -585,26 +632,67 @@ async function triggerLogout() {
585632
}
586633
}
587634
588-
async function unquarantine() {
635+
// Counts critical findings from the scan summary if available. Used to gate
636+
// the Approve button behind an extra confirmation (F-04).
637+
const criticalFindingCount = computed(() => {
638+
const scan = props.server.security_scan as any
639+
if (!scan) return 0
640+
// finding_counts.critical is populated from the latest report summary.
641+
const fc = scan.finding_counts as Record<string, number> | undefined
642+
if (fc && typeof fc.critical === 'number') return fc.critical
643+
return 0
644+
})
645+
646+
// True when a scan has actually been run (has a last_scan_at timestamp).
647+
const hasCompletedScan = computed(() => {
648+
const scan = props.server.security_scan
649+
if (!scan) return false
650+
return !!scan.last_scan_at
651+
})
652+
653+
// Primary approve click handler. Chooses the right flow based on scan state:
654+
// 1. No scan run yet → open "Scan first / Force approve" dialog
655+
// 2. Scan run with critical findings → open "Force approve?" dialog
656+
// 3. Clean scan → call securityApproveServer directly
657+
function handleApproveClick() {
658+
if (!hasCompletedScan.value) {
659+
approveDialogMode.value = 'no_scan'
660+
showApproveConfirmation.value = true
661+
return
662+
}
663+
if (criticalFindingCount.value > 0) {
664+
approveDialogMode.value = 'critical'
665+
showApproveConfirmation.value = true
666+
return
667+
}
668+
void doSecurityApprove(false)
669+
}
670+
671+
async function doSecurityApprove(force: boolean) {
589672
loading.value = true
590673
try {
591-
await serversStore.unquarantineServer(props.server.name)
674+
await serversStore.securityApproveServer(props.server.name, force)
592675
systemStore.addToast({
593676
type: 'success',
594-
title: 'Server Unquarantined',
595-
message: `${props.server.name} has been removed from quarantine`,
677+
title: 'Server Approved',
678+
message: `${props.server.name} has been approved and unquarantined`,
596679
})
680+
showApproveConfirmation.value = false
597681
} catch (error) {
598682
systemStore.addToast({
599683
type: 'error',
600-
title: 'Unquarantine Failed',
684+
title: 'Approve Failed',
601685
message: error instanceof Error ? error.message : 'Unknown error',
602686
})
603687
} finally {
604688
loading.value = false
605689
}
606690
}
607691
692+
function confirmForceApprove() {
693+
void doSecurityApprove(true)
694+
}
695+
608696
async function confirmDelete() {
609697
loading.value = true
610698
try {

frontend/src/composables/useSecurityScannerStatus.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import api from '@/services/api'
1313
const enabledCount = ref<number | null>(null)
1414
const dockerAvailable = ref<boolean>(true)
1515
const loaded = ref(false)
16+
const totalFindings = ref<number>(0)
17+
const totalScans = ref<number>(0)
1618
let inflight: Promise<void> | null = null
1719

1820
export async function refreshSecurityScannerStatus(): Promise<void> {
@@ -28,6 +30,12 @@ export async function refreshSecurityScannerStatus(): Promise<void> {
2830
const enabled = data.scanners_enabled ?? data.scanners_installed ?? 0
2931
enabledCount.value = typeof enabled === 'number' ? enabled : 0
3032
dockerAvailable.value = data.docker_available !== false
33+
// Aggregated finding count from the /security/overview endpoint used by
34+
// the Dashboard security chip (F-12). The backend returns this under
35+
// `findings_by_severity.total`.
36+
const fbs = data.findings_by_severity ?? {}
37+
totalFindings.value = typeof fbs.total === 'number' ? fbs.total : 0
38+
totalScans.value = typeof data.total_scans === 'number' ? data.total_scans : 0
3139
loaded.value = true
3240
} catch {
3341
// On error, keep current values; the UI will fall back to dockerAvailable
@@ -48,8 +56,12 @@ export function useSecurityScannerStatus() {
4856
enabledCount,
4957
dockerAvailable,
5058
loaded,
59+
totalFindings,
60+
totalScans,
5161
/** True when at least one scanner is enabled and docker is available. */
5262
hasEnabledScanners: () => (enabledCount.value ?? 0) > 0,
63+
/** True when at least one scan has completed (ever). */
64+
hasAnyScans: () => totalScans.value > 0,
5365
refresh: refreshSecurityScannerStatus,
5466
}
5567
}

frontend/src/stores/servers.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,31 @@ export const useServersStore = defineStore('servers', () => {
277277
}
278278
}
279279

280+
// Security-aware approval path (Spec 039 / F-04). Goes through
281+
// POST /api/v1/servers/{name}/security/approve which enforces the
282+
// scanner gate before unquarantining the server. Use this — not
283+
// unquarantineServer — for all user-facing "Approve" buttons.
284+
async function securityApproveServer(serverName: string, force = false) {
285+
try {
286+
const response = await api.securityApprove(serverName, force)
287+
if (response.success) {
288+
// Optimistic update: the backend will also unquarantine the server
289+
// on a successful approve, so reflect that in local state. SSE
290+
// refresh will reconcile any remaining fields shortly after.
291+
const server = servers.value.find(s => s.name === serverName)
292+
if (server) {
293+
server.quarantined = false
294+
}
295+
return true
296+
} else {
297+
throw new Error(response.error || 'Failed to approve server')
298+
}
299+
} catch (error) {
300+
console.error('Failed to approve server via security scanner:', error)
301+
throw error
302+
}
303+
}
304+
280305
async function deleteServer(serverName: string) {
281306
try {
282307
const response = await api.deleteServer(serverName)
@@ -371,10 +396,11 @@ export const useServersStore = defineStore('servers', () => {
371396
triggerOAuthLogout,
372397
quarantineServer,
373398
unquarantineServer,
399+
securityApproveServer,
374400
deleteServer,
375401
updateServerStatus,
376402
getServerByName,
377403
addServer,
378404
cleanupEventListeners,
379405
}
380-
})
406+
})

0 commit comments

Comments
 (0)