Skip to content

Commit 9100e38

Browse files
committed
fix(scanner): route non-CVE findings out of Supply Chain Audit section
AI-scanner and other non-package findings surfaced during the Pass 2 deep rescan (full-filesystem) were rendered under "Supply Chain Audit (CVEs)" because the frontend filtered that section purely by scan_pass === 2. Introduce a `supply_chain_audit` flag set by the backend only for findings with a CVE-prefixed rule ID or a populated PackageName, and group by threat_type in the UI instead. Adds an "Other Findings" bucket so AI findings classified as `uncategorized` stay visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bbd2a47 commit 9100e38

6 files changed

Lines changed: 142 additions & 36 deletions

File tree

frontend/src/types/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface SecurityScanSummary {
4444
}
4545

4646
// Security scan finding (Spec 039)
47-
export type ThreatType = 'tool_poisoning' | 'prompt_injection' | 'rug_pull' | 'supply_chain' | 'malicious_code'
47+
export type ThreatType = 'tool_poisoning' | 'prompt_injection' | 'rug_pull' | 'supply_chain' | 'malicious_code' | 'uncategorized'
4848
export type ThreatLevel = 'dangerous' | 'warning' | 'info'
4949

5050
export interface SecurityScanFinding {
@@ -64,6 +64,7 @@ export interface SecurityScanFinding {
6464
fixed_version?: string // Version with fix
6565
scan_pass?: number // 1 = security scan, 2 = supply chain audit
6666
evidence?: string // Text/content that triggered the finding
67+
supply_chain_audit?: boolean // True for real CVE/package findings — routes to the Supply Chain (CVEs) section regardless of scan_pass
6768
}
6869

6970
export interface SecurityScanReport {

frontend/src/views/ScanReport.vue

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -251,24 +251,26 @@
251251
</div>
252252
</div>
253253

254-
<!-- Supply Chain Audit (Pass 2) -->
254+
<!-- Supply Chain Audit -->
255+
<!-- Pass 2 in-progress banner (shown independently of existing findings) -->
255256
<div v-if="report.pass2_running" class="alert alert-info">
256257
<span class="loading loading-spinner loading-sm"></span>
257258
<div>
258259
<h3 class="font-bold">Supply Chain Audit</h3>
259-
<p class="text-sm">Deep dependency analysis running in background. Results will appear here when complete.</p>
260+
<p class="text-sm">Deep dependency analysis running in background. Additional CVEs will appear here when complete.</p>
260261
</div>
261262
</div>
262-
<div v-else-if="report.pass2_complete && pass2Findings.length > 0" class="space-y-4">
263+
<!-- CVE/package findings from any pass (flagged by backend) -->
264+
<div v-if="supplyChainFindings.length > 0" class="space-y-4">
263265
<div class="collapse collapse-arrow bg-base-100 shadow-md">
264266
<input type="checkbox" />
265267
<div class="collapse-title font-medium flex items-center gap-2">
266268
<span>Supply Chain Audit (CVEs)</span>
267-
<span class="badge badge-sm" :class="pass2HasDangerous ? 'badge-error' : pass2HasWarnings ? 'badge-warning' : 'badge-info'">{{ pass2Findings.length }}</span>
269+
<span class="badge badge-sm" :class="supplyChainHasDangerous ? 'badge-error' : supplyChainHasWarnings ? 'badge-warning' : 'badge-info'">{{ supplyChainFindings.length }}</span>
268270
</div>
269271
<div class="collapse-content">
270272
<div class="space-y-2">
271-
<div v-for="(finding, idx) in pass2Findings" :key="'p2-' + idx"
273+
<div v-for="(finding, idx) in supplyChainFindings" :key="'sc-' + idx"
272274
class="collapse collapse-arrow bg-base-200 rounded-lg"
273275
>
274276
<input type="checkbox" />
@@ -344,15 +346,11 @@
344346
</div>
345347
</div>
346348
</div>
347-
<div v-else-if="report.pass2_complete && pass2Findings.length === 0"
348-
class="alert"
349-
:class="pass1SupplyChainCount > 0 ? 'alert-info' : 'alert-success'"
350-
>
349+
<div v-else-if="report.pass2_complete" class="alert alert-success">
351350
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
352351
<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" />
353352
</svg>
354-
<span v-if="pass1SupplyChainCount > 0">Supply chain audit complete. No additional CVEs beyond the {{ pass1SupplyChainCount }} already reported above.</span>
355-
<span v-else>Supply chain audit complete. No CVEs found in dependencies.</span>
353+
<span>Supply chain audit complete. No CVEs found in dependencies.</span>
356354
</div>
357355

358356
<!-- Scanner Execution Logs -->
@@ -520,19 +518,24 @@ const riskScoreClass = computed(() => {
520518
return 'badge-success'
521519
})
522520
523-
// Threat type grouping (same logic as ServerDetail.vue)
524-
const threatTypeLabels: Record<ThreatType, string> = {
521+
// Threat type grouping. Real CVE/package findings are routed to the dedicated
522+
// Supply Chain Audit section via the `supply_chain_audit` flag instead of the
523+
// `supply_chain` threat type, so they are filtered out of `groupedFindings`.
524+
// 'uncategorized' is rendered as "Other Findings" so AI-scanner output that
525+
// ClassifyThreat can't pattern-match stays visible instead of silently vanishing.
526+
const threatTypeLabels: Record<Exclude<ThreatType, 'supply_chain'>, string> = {
525527
tool_poisoning: 'Tool Poisoning',
526528
prompt_injection: 'Prompt Injection',
527529
rug_pull: 'Rug Pull Detection',
528-
supply_chain: 'Supply Chain (CVEs)',
529530
malicious_code: 'Malicious Code',
531+
uncategorized: 'Other Findings',
530532
}
531533
532-
const dangerousTypes: ThreatType[] = ['tool_poisoning', 'prompt_injection', 'rug_pull', 'malicious_code']
534+
type DisplayThreatType = Exclude<ThreatType, 'supply_chain'>
535+
const dangerousTypes: DisplayThreatType[] = ['tool_poisoning', 'prompt_injection', 'rug_pull', 'malicious_code']
533536
534537
interface FindingGroup {
535-
type: ThreatType
538+
type: DisplayThreatType
536539
label: string
537540
findings: SecurityScanFinding[]
538541
defaultOpen: boolean
@@ -542,19 +545,27 @@ interface FindingGroup {
542545
const groupedFindings = computed<FindingGroup[]>(() => {
543546
if (!report.value?.findings) return []
544547
545-
const pass1Findings = report.value.findings.filter(
546-
(f: SecurityScanFinding) => !f.scan_pass || f.scan_pass === 1
548+
// Pull out CVE/package findings; they render in the Supply Chain Audit section.
549+
// Everything else is grouped by threat_type regardless of which pass produced it,
550+
// so AI-scanner findings that only surface during the deep Pass 2 scan land in
551+
// their proper category instead of the CVE list.
552+
const nonCveFindings = report.value.findings.filter(
553+
(f: SecurityScanFinding) => !f.supply_chain_audit
547554
)
548555
549-
const groups = new Map<ThreatType, SecurityScanFinding[]>()
550-
for (const f of pass1Findings) {
551-
const type = (f.threat_type || 'supply_chain') as ThreatType
556+
const groups = new Map<DisplayThreatType, SecurityScanFinding[]>()
557+
for (const f of nonCveFindings) {
558+
const rawType = (f.threat_type || 'uncategorized') as ThreatType
559+
// Legacy data may still carry threat_type === 'supply_chain' on a non-CVE
560+
// finding. Fold it into 'uncategorized' so it stays visible instead of
561+
// being silently dropped.
562+
const type: DisplayThreatType = rawType === 'supply_chain' ? 'uncategorized' : rawType
552563
if (!groups.has(type)) groups.set(type, [])
553564
groups.get(type)!.push(f)
554565
}
555566
556567
const result: FindingGroup[] = []
557-
const typeOrder: ThreatType[] = ['tool_poisoning', 'prompt_injection', 'rug_pull', 'malicious_code', 'supply_chain']
568+
const typeOrder: DisplayThreatType[] = ['tool_poisoning', 'prompt_injection', 'rug_pull', 'malicious_code', 'uncategorized']
558569
for (const type of typeOrder) {
559570
const findings = groups.get(type)
560571
if (!findings) continue
@@ -570,24 +581,17 @@ const groupedFindings = computed<FindingGroup[]>(() => {
570581
return result
571582
})
572583
573-
const pass1SupplyChainCount = computed(() => {
574-
if (!report.value?.findings) return 0
575-
return report.value.findings.filter(
576-
(f: SecurityScanFinding) => (!f.scan_pass || f.scan_pass === 1) && f.threat_type === 'supply_chain'
577-
).length
578-
})
579-
580-
const pass2Findings = computed<SecurityScanFinding[]>(() => {
584+
const supplyChainFindings = computed<SecurityScanFinding[]>(() => {
581585
if (!report.value?.findings) return []
582-
return report.value.findings.filter((f: SecurityScanFinding) => f.scan_pass === 2)
586+
return report.value.findings.filter((f: SecurityScanFinding) => f.supply_chain_audit === true)
583587
})
584588
585-
const pass2HasDangerous = computed(() => {
586-
return pass2Findings.value.some(f => f.threat_level === 'dangerous')
589+
const supplyChainHasDangerous = computed(() => {
590+
return supplyChainFindings.value.some(f => f.threat_level === 'dangerous')
587591
})
588592
589-
const pass2HasWarnings = computed(() => {
590-
return pass2Findings.value.some(f => f.threat_level === 'warning')
593+
const supplyChainHasWarnings = computed(() => {
594+
return supplyChainFindings.value.some(f => f.threat_level === 'warning')
591595
})
592596
593597
function formatDate(dateStr: string): string {

internal/security/scanner/engine.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,13 @@ func AggregateReports(jobID, serverName string, reports []*ScanReport) *Aggregat
635635
// Classify findings that lack threat_type/threat_level (legacy data)
636636
ClassifyAllFindings(agg.Findings)
637637

638+
// Flag findings that belong in the Supply Chain Audit (CVEs) UI section.
639+
// Match only real CVE/package vulnerabilities so AI-scanner output from Pass 2
640+
// doesn't get routed into the CVE section.
641+
for i := range agg.Findings {
642+
agg.Findings[i].SupplyChainAudit = isSupplyChainAudit(&agg.Findings[i])
643+
}
644+
638645
agg.RiskScore = CalculateRiskScore(agg.Findings)
639646
agg.Summary = SummarizeFindings(agg.Findings)
640647

internal/security/scanner/engine_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,83 @@ func TestAggregateReports(t *testing.T) {
6262
}
6363
}
6464

65+
func TestAggregateReportsSupplyChainAuditFlag(t *testing.T) {
66+
reports := []*ScanReport{
67+
{
68+
ID: "r1",
69+
ScannerID: "trivy-mcp",
70+
Findings: []ScanFinding{
71+
// CVE-prefixed rule ID → should be flagged.
72+
{
73+
RuleID: "CVE-2023-12345",
74+
Severity: SeverityHigh,
75+
Title: "CVE in lodash",
76+
Description: "Prototype pollution",
77+
PackageName: "lodash",
78+
ScanPass: ScanPassSupplyChainAudit,
79+
},
80+
// PackageName populated, no CVE prefix → should be flagged.
81+
{
82+
RuleID: "GHSA-xxxx-yyyy-zzzz",
83+
Severity: SeverityMedium,
84+
Title: "Known advisory",
85+
PackageName: "express",
86+
ScanPass: ScanPassSupplyChainAudit,
87+
},
88+
},
89+
},
90+
{
91+
ID: "r2",
92+
ScannerID: "mcp-ai-scanner",
93+
Findings: []ScanFinding{
94+
// AI scanner finding promoted to Pass 2 via full-filesystem rescan.
95+
// No CVE prefix, no PackageName → must NOT be flagged as supply chain.
96+
{
97+
RuleID: "AI-01-001",
98+
Severity: SeverityHigh,
99+
Title: "get-env dumps process.env",
100+
Description: "Data exfiltration via environment dump",
101+
Location: "target/dist/tools/get-env.js",
102+
ScanPass: ScanPassSupplyChainAudit,
103+
},
104+
// Pass 1 AI finding, also not a CVE.
105+
{
106+
RuleID: "AI-02-001",
107+
Severity: SeverityMedium,
108+
Title: "SSRF in gzip-file-as-resource",
109+
Description: "Server-side request forgery",
110+
Location: "target/dist/tools/gzip-file-as-resource.js",
111+
ScanPass: ScanPassSecurityScan,
112+
},
113+
},
114+
},
115+
}
116+
117+
agg := AggregateReports("job-supply-chain", "test-server", reports)
118+
119+
if len(agg.Findings) != 4 {
120+
t.Fatalf("expected 4 findings, got %d", len(agg.Findings))
121+
}
122+
123+
byRule := map[string]ScanFinding{}
124+
for _, f := range agg.Findings {
125+
byRule[f.RuleID] = f
126+
}
127+
128+
if !byRule["CVE-2023-12345"].SupplyChainAudit {
129+
t.Error("CVE-prefixed rule should be flagged SupplyChainAudit=true")
130+
}
131+
if !byRule["GHSA-xxxx-yyyy-zzzz"].SupplyChainAudit {
132+
t.Error("finding with PackageName should be flagged SupplyChainAudit=true")
133+
}
134+
if byRule["AI-01-001"].SupplyChainAudit {
135+
t.Error("AI scanner finding (no CVE prefix, no PackageName) must not be flagged as SupplyChainAudit even when ScanPass=2")
136+
}
137+
if byRule["AI-02-001"].SupplyChainAudit {
138+
t.Error("Pass 1 AI finding must not be flagged as SupplyChainAudit")
139+
}
140+
}
141+
65142
func TestAggregateReportsEmpty(t *testing.T) {
66143
agg := AggregateReports("job-1", "server", nil)
67144
if agg.RiskScore != 0 {

internal/security/scanner/sarif.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,3 +464,14 @@ func ClassifyAllFindings(findings []ScanFinding) {
464464
ClassifyThreat(&findings[i])
465465
}
466466
}
467+
468+
// isSupplyChainAudit reports whether a finding is a real CVE/package vulnerability
469+
// that should render in the "Supply Chain Audit (CVEs)" UI section. The criteria are
470+
// intentionally narrow — broad keyword matching (e.g. description contains "vulnerability")
471+
// would miscategorize AI-scanner output as CVEs.
472+
func isSupplyChainAudit(f *ScanFinding) bool {
473+
if f.PackageName != "" {
474+
return true
475+
}
476+
return strings.HasPrefix(strings.ToLower(f.RuleID), "cve-")
477+
}

internal/security/scanner/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ type ScanFinding struct {
191191
FixedVersion string `json:"fixed_version,omitempty"` // Version with fix
192192
ScanPass int `json:"scan_pass,omitempty"` // 1 = security scan, 2 = supply chain audit
193193
Evidence string `json:"evidence,omitempty"` // The text/content that triggered the finding
194+
// SupplyChainAudit marks findings that belong in the "Supply Chain Audit (CVEs)"
195+
// UI section regardless of which pass produced them. True only for real CVE/package
196+
// vulnerabilities (CVE-prefixed rule ID or a populated PackageName). AI scanner and
197+
// other non-package findings stay false so the UI can route them to their proper
198+
// threat_type group instead of the CVE section.
199+
SupplyChainAudit bool `json:"supply_chain_audit,omitempty"`
194200
}
195201

196202
// ScanReport represents aggregated scan results for a server

0 commit comments

Comments
 (0)