Skip to content

Commit f2b321a

Browse files
committed
feat(039): two-pass scanning -- fast security + background supply chain
Pass 1: Semgrep on source + Trivy on lockfile (fast, immediate results) Pass 2: Trivy full filesystem (background, auto-starts after Pass 1) Results merged in single report with pass1/pass2 completion tracking. 7 new unit tests. Frontend shows both passes with progress indicator. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b64860f commit f2b321a

31 files changed

Lines changed: 1028 additions & 125 deletions

cmd/mcpproxy/security_cmd.go

Lines changed: 95 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,7 @@ func printScanSummary(client *cliclient.Client, ctx context.Context, serverName
12631263
return printReportTable(serverName, report)
12641264
}
12651265

1266-
// printReportTable prints a human-readable report.
1266+
// printReportTable prints a human-readable report with two-pass scan support.
12671267
func printReportTable(serverName string, report map[string]interface{}) error {
12681268
riskScore := "?"
12691269
if rs, ok := report["risk_score"].(float64); ok {
@@ -1279,72 +1279,112 @@ func printReportTable(serverName string, report map[string]interface{}) error {
12791279
}
12801280
fmt.Println()
12811281

1282-
// Summary table
1283-
if summary, ok := report["summary"].(map[string]interface{}); ok {
1284-
fmt.Printf("%-12s %s\n", "SEVERITY", "COUNT")
1285-
fmt.Println(strings.Repeat("-", 24))
1286-
fmt.Printf("%-12s %s\n", "Critical", secFormatInt(summary, "critical"))
1287-
fmt.Printf("%-12s %s\n", "High", secFormatInt(summary, "high"))
1288-
fmt.Printf("%-12s %s\n", "Medium", secFormatInt(summary, "medium"))
1289-
fmt.Printf("%-12s %s\n", "Low", secFormatInt(summary, "low"))
1290-
fmt.Printf("%-12s %s\n", "Info", secFormatInt(summary, "info"))
1291-
}
1292-
1293-
// Individual findings
1294-
if findings, ok := report["findings"].([]interface{}); ok && len(findings) > 0 {
1295-
fmt.Println()
1296-
fmt.Println("FINDINGS:")
1282+
// Separate findings by scan pass
1283+
var pass1Findings, pass2Findings []interface{}
1284+
if findings, ok := report["findings"].([]interface{}); ok {
12971285
for _, f := range findings {
12981286
if finding, ok := f.(map[string]interface{}); ok {
1299-
severity := strings.ToUpper(getMapString(finding, "severity"))
1300-
ruleID := getMapString(finding, "rule_id")
1301-
title := getMapString(finding, "title")
1302-
location := getMapString(finding, "location")
1303-
scannerName := getMapString(finding, "scanner")
1304-
helpURI := getMapString(finding, "help_uri")
1305-
pkg := getMapString(finding, "package_name")
1306-
installed := getMapString(finding, "installed_version")
1307-
fixed := getMapString(finding, "fixed_version")
1308-
1309-
// Main line: [SEVERITY] CVE-ID: title (scanner)
1310-
label := title
1311-
if ruleID != "" && ruleID != title {
1312-
label = ruleID
1287+
scanPass := int(getMapFloat(finding, "scan_pass"))
1288+
if scanPass == 2 {
1289+
pass2Findings = append(pass2Findings, f)
1290+
} else {
1291+
pass1Findings = append(pass1Findings, f)
13131292
}
1314-
line := fmt.Sprintf(" [%s] %s", severity, label)
1315-
if scannerName != "" {
1316-
line += " (" + scannerName + ")"
1317-
}
1318-
fmt.Println(line)
1293+
}
1294+
}
1295+
}
13191296

1320-
// Package info
1321-
if pkg != "" {
1322-
pkgLine := " Package: " + pkg
1323-
if installed != "" {
1324-
pkgLine += " v" + installed
1325-
}
1326-
if fixed != "" {
1327-
pkgLine += " -> fix: " + fixed
1328-
}
1329-
fmt.Println(pkgLine)
1330-
}
1297+
// === Security Scan (Pass 1) ===
1298+
fmt.Println("=== Security Scan (Pass 1) ===")
1299+
if len(pass1Findings) == 0 {
1300+
fmt.Println(" 0 findings")
1301+
} else {
1302+
fmt.Printf(" %d finding(s)\n", len(pass1Findings))
1303+
fmt.Println()
1304+
printFindingsList(pass1Findings)
1305+
}
13311306

1332-
// Location
1333-
if location != "" {
1334-
fmt.Println(" Location: " + location)
1335-
}
1307+
// === Supply Chain Audit (Pass 2) ===
1308+
pass2Running := false
1309+
if v, ok := report["pass2_running"].(bool); ok {
1310+
pass2Running = v
1311+
}
1312+
pass2Complete := false
1313+
if v, ok := report["pass2_complete"].(bool); ok {
1314+
pass2Complete = v
1315+
}
13361316

1337-
// Link to advisory
1338-
if helpURI != "" {
1339-
fmt.Println(" Details: " + helpURI)
1340-
}
1341-
}
1317+
fmt.Println()
1318+
fmt.Println("=== Supply Chain Audit (Pass 2) ===")
1319+
if pass2Running {
1320+
fmt.Println(" Running in background...")
1321+
} else if pass2Complete {
1322+
if len(pass2Findings) == 0 {
1323+
fmt.Println(" 0 findings")
1324+
} else {
1325+
fmt.Printf(" %d finding(s)\n", len(pass2Findings))
1326+
fmt.Println()
1327+
printFindingsList(pass2Findings)
13421328
}
1329+
} else {
1330+
fmt.Println(" Not started")
13431331
}
13441332

13451333
return nil
13461334
}
13471335

1336+
// printFindingsList prints a list of findings in the CLI report format.
1337+
func printFindingsList(findings []interface{}) {
1338+
for _, f := range findings {
1339+
finding, ok := f.(map[string]interface{})
1340+
if !ok {
1341+
continue
1342+
}
1343+
severity := strings.ToUpper(getMapString(finding, "severity"))
1344+
ruleID := getMapString(finding, "rule_id")
1345+
title := getMapString(finding, "title")
1346+
location := getMapString(finding, "location")
1347+
scannerName := getMapString(finding, "scanner")
1348+
helpURI := getMapString(finding, "help_uri")
1349+
pkg := getMapString(finding, "package_name")
1350+
installed := getMapString(finding, "installed_version")
1351+
fixed := getMapString(finding, "fixed_version")
1352+
1353+
// Main line: [SEVERITY] CVE-ID: title (scanner)
1354+
label := title
1355+
if ruleID != "" && ruleID != title {
1356+
label = ruleID
1357+
}
1358+
line := fmt.Sprintf(" [%s] %s", severity, label)
1359+
if scannerName != "" {
1360+
line += " (" + scannerName + ")"
1361+
}
1362+
fmt.Println(line)
1363+
1364+
// Package info
1365+
if pkg != "" {
1366+
pkgLine := " Package: " + pkg
1367+
if installed != "" {
1368+
pkgLine += " v" + installed
1369+
}
1370+
if fixed != "" {
1371+
pkgLine += " -> fix: " + fixed
1372+
}
1373+
fmt.Println(pkgLine)
1374+
}
1375+
1376+
// Location
1377+
if location != "" {
1378+
fmt.Println(" Location: " + location)
1379+
}
1380+
1381+
// Link to advisory
1382+
if helpURI != "" {
1383+
fmt.Println(" Details: " + helpURI)
1384+
}
1385+
}
1386+
}
1387+
13481388
// printSarifOutput extracts and prints raw SARIF data from individual scanner reports.
13491389
func printSarifOutput(report map[string]interface{}) error {
13501390
// Try to extract SARIF from individual scanner reports

frontend/src/types/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface SecurityScanFinding {
6262
package_name?: string
6363
installed_version?: string
6464
fixed_version?: string // Version with fix
65+
scan_pass?: number // 1 = security scan, 2 = supply chain audit
6566
}
6667

6768
export interface SecurityScanReport {
@@ -79,6 +80,10 @@ export interface SecurityScanReport {
7980
scanners_failed?: number // How many scanners failed
8081
scanners_total?: number // Total scanners attempted
8182
scan_complete?: boolean // True only if at least one scanner succeeded
83+
// Two-pass scan tracking
84+
pass1_complete?: boolean // Security scan (fast) done
85+
pass2_complete?: boolean // Supply chain audit done
86+
pass2_running?: boolean // Supply chain audit in progress
8287
}
8388

8489
// Summary from the aggregated report API (matches Go ReportSummary)

frontend/src/views/ServerDetail.vue

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,102 @@
771771
</div>
772772
</div>
773773

774+
<!-- Supply Chain Audit (Pass 2) -->
775+
<div v-if="scanReport.pass2_running" class="alert alert-info mt-4">
776+
<span class="loading loading-spinner loading-sm"></span>
777+
<div>
778+
<h3 class="font-bold">Supply Chain Audit</h3>
779+
<p class="text-sm">Deep dependency analysis running in background. Results will appear here when complete.</p>
780+
</div>
781+
</div>
782+
<div v-else-if="scanReport.pass2_complete && pass2Findings.length > 0" class="mt-4">
783+
<div class="collapse collapse-arrow bg-base-100 shadow-md">
784+
<input type="checkbox" />
785+
<div class="collapse-title font-medium flex items-center gap-2">
786+
<span>Supply Chain Audit (CVEs)</span>
787+
<span class="badge badge-sm" :class="pass2HasDangerous ? 'badge-error' : pass2HasWarnings ? 'badge-warning' : 'badge-info'">{{ pass2Findings.length }}</span>
788+
</div>
789+
<div class="collapse-content">
790+
<div class="space-y-2">
791+
<div v-for="(finding, idx) in pass2Findings" :key="'p2-' + idx"
792+
class="collapse collapse-arrow bg-base-200 rounded-lg"
793+
>
794+
<input type="checkbox" />
795+
<div class="collapse-title py-2 px-4 min-h-0 flex items-center gap-3">
796+
<span
797+
class="badge badge-sm flex-shrink-0"
798+
:class="{
799+
'badge-error': finding.threat_level === 'dangerous',
800+
'badge-warning': finding.threat_level === 'warning',
801+
'badge-info': finding.threat_level === 'info',
802+
}"
803+
>
804+
{{ finding.threat_level }}
805+
</span>
806+
<span class="font-medium text-sm flex-1">
807+
{{ finding.rule_id || finding.title }}
808+
</span>
809+
<span v-if="finding.package_name" class="font-mono text-xs text-base-content/50">
810+
{{ finding.package_name }}
811+
</span>
812+
<span v-if="finding.fixed_version" class="badge badge-xs badge-success badge-outline">
813+
fix: {{ finding.fixed_version }}
814+
</span>
815+
</div>
816+
<div class="collapse-content px-4 pb-3">
817+
<div class="space-y-2 text-sm">
818+
<p class="text-base-content/80">{{ finding.description }}</p>
819+
<div class="grid grid-cols-2 gap-2 text-xs">
820+
<div v-if="finding.rule_id">
821+
<span class="text-base-content/50">Rule:</span>
822+
<code class="ml-1 bg-base-300 px-1 rounded">{{ finding.rule_id }}</code>
823+
</div>
824+
<div v-if="finding.severity">
825+
<span class="text-base-content/50">CVSS Severity:</span>
826+
<span class="ml-1 font-medium">{{ finding.severity }}</span>
827+
<span v-if="finding.cvss_score" class="ml-1">({{ finding.cvss_score }})</span>
828+
</div>
829+
<div v-if="finding.package_name">
830+
<span class="text-base-content/50">Package:</span>
831+
<span class="ml-1 font-mono">{{ finding.package_name }}</span>
832+
<span v-if="finding.installed_version" class="ml-1 text-base-content/50">v{{ finding.installed_version }}</span>
833+
</div>
834+
<div v-if="finding.fixed_version">
835+
<span class="text-base-content/50">Fixed in:</span>
836+
<span class="ml-1 font-mono text-success">{{ finding.fixed_version }}</span>
837+
</div>
838+
<div v-if="finding.location">
839+
<span class="text-base-content/50">Location:</span>
840+
<code class="ml-1 bg-base-300 px-1 rounded">{{ finding.location }}</code>
841+
</div>
842+
<div v-if="finding.scanner">
843+
<span class="text-base-content/50">Scanner:</span>
844+
<span class="ml-1">{{ finding.scanner }}</span>
845+
</div>
846+
</div>
847+
<a
848+
v-if="finding.help_uri"
849+
:href="finding.help_uri"
850+
target="_blank"
851+
rel="noopener noreferrer"
852+
class="link link-primary text-xs inline-flex items-center gap-1"
853+
>
854+
View Advisory Details &rarr;
855+
</a>
856+
</div>
857+
</div>
858+
</div>
859+
</div>
860+
</div>
861+
</div>
862+
</div>
863+
<div v-else-if="scanReport.pass2_complete && pass2Findings.length === 0" class="alert alert-success mt-4">
864+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
865+
<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" />
866+
</svg>
867+
<span>Supply chain audit complete. No additional CVEs found in dependencies.</span>
868+
</div>
869+
774870
<!-- Approve / Reject actions -->
775871
<div v-if="scanReport.findings && scanReport.findings.length > 0" class="flex gap-3 pt-2">
776872
<button
@@ -1036,8 +1132,11 @@ const dangerousTypes: ThreatType[] = ['tool_poisoning', 'prompt_injection', 'rug
10361132
const groupedFindings = computed<FindingGroup[]>(() => {
10371133
if (!scanReport.value?.findings) return []
10381134
1135+
// Only include Pass 1 findings (or findings without a pass for backward compat)
1136+
const pass1Findings = scanReport.value.findings.filter(f => !f.scan_pass || f.scan_pass === 1)
1137+
10391138
const groups = new Map<ThreatType, SecurityScanFinding[]>()
1040-
for (const f of scanReport.value.findings) {
1139+
for (const f of pass1Findings) {
10411140
const type = f.threat_type || 'supply_chain'
10421141
if (!groups.has(type)) groups.set(type, [])
10431142
groups.get(type)!.push(f)
@@ -1061,6 +1160,20 @@ const groupedFindings = computed<FindingGroup[]>(() => {
10611160
return result
10621161
})
10631162
1163+
// Pass 2 (supply chain audit) findings
1164+
const pass2Findings = computed<SecurityScanFinding[]>(() => {
1165+
if (!scanReport.value?.findings) return []
1166+
return scanReport.value.findings.filter(f => f.scan_pass === 2)
1167+
})
1168+
1169+
const pass2HasDangerous = computed(() => {
1170+
return pass2Findings.value.some(f => f.threat_level === 'dangerous')
1171+
})
1172+
1173+
const pass2HasWarnings = computed(() => {
1174+
return pass2Findings.value.some(f => f.threat_level === 'warning')
1175+
})
1176+
10641177
const filteredTools = computed(() => {
10651178
if (!toolSearch.value) return serverTools.value
10661179

internal/security/scanner/engine.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type ScanRequest struct {
4848
ScannerIDs []string // Specific scanners to use (empty = all installed)
4949
Env map[string]string // Additional environment variables
5050
ScanContext *ScanContext // Context metadata (set by service)
51+
ScanPass int // 1 = security scan (fast), 2 = supply chain audit (background)
5152
}
5253

5354
// ScanCallback receives scan lifecycle events
@@ -97,10 +98,15 @@ func (e *Engine) StartScan(ctx context.Context, req ScanRequest, callback ScanCa
9798
}
9899

99100
// Create job
101+
scanPass := req.ScanPass
102+
if scanPass == 0 {
103+
scanPass = ScanPassSecurityScan // Default to pass 1
104+
}
100105
job := &ScanJob{
101106
ID: fmt.Sprintf("scan-%s-%d", req.ServerName, time.Now().UnixNano()),
102107
ServerName: req.ServerName,
103108
Status: ScanJobStatusRunning,
109+
ScanPass: scanPass,
104110
Scanners: make([]string, len(scanners)),
105111
StartedAt: time.Now(),
106112
DryRun: req.DryRun,

0 commit comments

Comments
 (0)