diff --git a/packages/scanner-core/src/result-aggregator.test.ts b/packages/scanner-core/src/result-aggregator.test.ts new file mode 100644 index 0000000..ca2c906 --- /dev/null +++ b/packages/scanner-core/src/result-aggregator.test.ts @@ -0,0 +1,268 @@ +import type { FindingResult } from '@veridion/scanner-types'; +import { FindingSeverity } from '@veridion/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { ResultAggregator } from './result-aggregator'; + +function createMockFinding(overrides: Partial & { category?: string } = {}): FindingResult { + return { + pluginId: 'reentrancy', + title: 'Potential Vulnerability', + description: 'A mock vulnerability description', + severity: FindingSeverity.MEDIUM, + filePath: 'contracts/Token.sol', + lineStart: 10, + lineEnd: 15, + codeSnippet: 'msg.sender.call{value: amount}("");', + recommendation: 'Fix the vulnerability', + confidence: 0.8, + references: ['https://swcregistry.io/docs/SWC-107'], + ...overrides, + }; +} + +describe('ResultAggregator', () => { + let aggregator: ResultAggregator; + + beforeEach(() => { + aggregator = new ResultAggregator(); + }); + + describe('deduplicateFindings', () => { + it('should return an empty array when given no findings', () => { + expect(aggregator.deduplicateFindings([])).toEqual([]); + }); + + it('should return the single finding when only one is provided', () => { + const finding = createMockFinding(); + expect(aggregator.deduplicateFindings([finding])).toEqual([finding]); + }); + + it('should deduplicate findings at the same file, line range, and category with same severity', () => { + const finding1 = createMockFinding({ + pluginId: 'plugin-a', + confidence: 0.8, + description: 'First report', + }); + const finding2 = createMockFinding({ + pluginId: 'plugin-b', + confidence: 0.8, + description: 'Second report', + }); + + const result = aggregator.deduplicateFindings([ + { ...finding1, category: 'REENTRANCY' }, + { ...finding2, category: 'REENTRANCY' }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.description).toBe('First report'); + }); + + it('should keep higher confidence finding when severities are identical', () => { + const lowerConfidence = createMockFinding({ + pluginId: 'plugin-a', + confidence: 0.6, + description: 'Lower confidence', + }); + const higherConfidence = createMockFinding({ + pluginId: 'plugin-b', + confidence: 0.95, + description: 'Higher confidence', + }); + + const result = aggregator.deduplicateFindings([ + { ...lowerConfidence, category: 'REENTRANCY' }, + { ...higherConfidence, category: 'REENTRANCY' }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.confidence).toBe(0.95); + expect(result[0]?.description).toBe('Higher confidence'); + }); + + it('should keep higher-severity finding when duplicates exist with different severities', () => { + const lowFinding = createMockFinding({ + pluginId: 'plugin-low', + severity: FindingSeverity.LOW, + description: 'Low severity detection', + }); + const criticalFinding = createMockFinding({ + pluginId: 'plugin-crit', + severity: FindingSeverity.CRITICAL, + description: 'Critical severity detection', + }); + + const result1 = aggregator.deduplicateFindings([ + { ...lowFinding, category: 'REENTRANCY' }, + { ...criticalFinding, category: 'REENTRANCY' }, + ]); + + expect(result1).toHaveLength(1); + expect(result1[0]?.severity).toBe(FindingSeverity.CRITICAL); + expect(result1[0]?.description).toBe('Critical severity detection'); + + const result2 = aggregator.deduplicateFindings([ + { ...criticalFinding, category: 'REENTRANCY' }, + { ...lowFinding, category: 'REENTRANCY' }, + ]); + + expect(result2).toHaveLength(1); + expect(result2[0]?.severity).toBe(FindingSeverity.CRITICAL); + }); + + it('should correctly prioritize across all severity levels', () => { + const severities = [ + FindingSeverity.INFORMATIONAL, + FindingSeverity.GAS, + FindingSeverity.LOW, + FindingSeverity.MEDIUM, + FindingSeverity.HIGH, + FindingSeverity.CRITICAL, + ]; + + for (let i = 0; i < severities.length - 1; i++) { + const lower = severities[i]; + const higher = severities[i + 1]; + if (!lower || !higher) continue; + + const fLower = createMockFinding({ severity: lower }); + const fHigher = createMockFinding({ severity: higher }); + + const result = aggregator.deduplicateFindings([ + { ...fLower, category: 'CUSTOM' }, + { ...fHigher, category: 'CUSTOM' }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.severity).toBe(higher); + } + }); + + it('should not deduplicate findings with different file paths', () => { + const findingA = createMockFinding({ filePath: 'contracts/A.sol' }); + const findingB = createMockFinding({ filePath: 'contracts/B.sol' }); + + const result = aggregator.deduplicateFindings([findingA, findingB]); + expect(result).toHaveLength(2); + }); + + it('should not deduplicate findings with different line ranges', () => { + const findingA = createMockFinding({ lineStart: 10, lineEnd: 15 }); + const findingB = createMockFinding({ lineStart: 20, lineEnd: 25 }); + + const result = aggregator.deduplicateFindings([findingA, findingB]); + expect(result).toHaveLength(2); + }); + + it('should not deduplicate findings with different categories at the same location', () => { + const reentrancy = createMockFinding({ + pluginId: 'reentrancy', + }); + const accessControl = createMockFinding({ + pluginId: 'access-control', + }); + + const result = aggregator.deduplicateFindings([ + { ...reentrancy, category: 'REENTRANCY' }, + { ...accessControl, category: 'ACCESS_CONTROL' }, + ]); + + expect(result).toHaveLength(2); + }); + + it('should fallback to pluginId as category when category is not explicitly provided', () => { + const finding1 = createMockFinding({ pluginId: 'detector-1' }); + const finding2 = createMockFinding({ pluginId: 'detector-2' }); + + const result = aggregator.deduplicateFindings([finding1, finding2]); + expect(result).toHaveLength(2); + }); + }); + + describe('summarize', () => { + it('should reflect deduplicated results in summary counts', () => { + const duplicateLow = createMockFinding({ + filePath: 'contracts/Vault.sol', + lineStart: 50, + lineEnd: 55, + severity: FindingSeverity.LOW, + }); + const duplicateHigh = createMockFinding({ + filePath: 'contracts/Vault.sol', + lineStart: 50, + lineEnd: 55, + severity: FindingSeverity.HIGH, + }); + const uniqueGas = createMockFinding({ + filePath: 'contracts/Vault.sol', + lineStart: 80, + lineEnd: 82, + severity: FindingSeverity.GAS, + }); + + const findings = [ + { ...duplicateLow, category: 'REENTRANCY' }, + { ...duplicateHigh, category: 'REENTRANCY' }, + { ...uniqueGas, category: 'GAS' }, + ]; + + const summary = aggregator.summarize(findings); + + expect(summary.total).toBe(2); + expect(summary.high).toBe(1); + expect(summary.low).toBe(0); + expect(summary.gas).toBe(1); + expect(summary.critical).toBe(0); + expect(summary.medium).toBe(0); + expect(summary.informational).toBe(0); + }); + + it('should correctly summarize empty findings', () => { + const summary = aggregator.summarize([]); + expect(summary.total).toBe(0); + expect(summary.critical).toBe(0); + expect(summary.high).toBe(0); + expect(summary.medium).toBe(0); + expect(summary.low).toBe(0); + expect(summary.gas).toBe(0); + expect(summary.informational).toBe(0); + }); + }); + + describe('calculateScore', () => { + it('should return 100 for empty findings', () => { + expect(aggregator.calculateScore([])).toBe(100); + }); + + it('should calculate score based on findings and confidence', () => { + const findings = [ + createMockFinding({ severity: FindingSeverity.CRITICAL, confidence: 1.0 }), + ]; + const score = aggregator.calculateScore(findings); + expect(score).toBe(0); + }); + }); + + describe('groupByPlugin and groupBySeverity', () => { + it('should group findings by plugin', () => { + const f1 = createMockFinding({ pluginId: 'plugin-1' }); + const f2 = createMockFinding({ pluginId: 'plugin-2' }); + const f3 = createMockFinding({ pluginId: 'plugin-1' }); + + const grouped = aggregator.groupByPlugin([f1, f2, f3]); + expect(grouped.get('plugin-1')).toHaveLength(2); + expect(grouped.get('plugin-2')).toHaveLength(1); + }); + + it('should group findings by severity', () => { + const f1 = createMockFinding({ severity: FindingSeverity.CRITICAL }); + const f2 = createMockFinding({ severity: FindingSeverity.LOW }); + const f3 = createMockFinding({ severity: FindingSeverity.CRITICAL }); + + const grouped = aggregator.groupBySeverity([f1, f2, f3]); + expect(grouped.get(FindingSeverity.CRITICAL)).toHaveLength(2); + expect(grouped.get(FindingSeverity.LOW)).toHaveLength(1); + }); + }); +}); diff --git a/packages/scanner-core/src/result-aggregator.ts b/packages/scanner-core/src/result-aggregator.ts index 195b3d9..bf58916 100644 --- a/packages/scanner-core/src/result-aggregator.ts +++ b/packages/scanner-core/src/result-aggregator.ts @@ -11,16 +11,53 @@ export interface FindingSummary { total: number; } +const SEVERITY_RANK: Record = { + [FindingSeverity.CRITICAL]: 5, + [FindingSeverity.HIGH]: 4, + [FindingSeverity.MEDIUM]: 3, + [FindingSeverity.LOW]: 2, + [FindingSeverity.GAS]: 1, + [FindingSeverity.INFORMATIONAL]: 0, +}; + export class ResultAggregator { + deduplicateFindings(findings: FindingResult[]): FindingResult[] { + const map = new Map(); + + for (const finding of findings) { + const category = + (finding as FindingResult & { category?: string }).category ?? finding.pluginId; + const key = `${finding.filePath}:${finding.lineStart}:${finding.lineEnd}:${category}`; + + const existing = map.get(key); + if (!existing) { + map.set(key, finding); + continue; + } + + const existingRank = SEVERITY_RANK[existing.severity] ?? -1; + const newRank = SEVERITY_RANK[finding.severity] ?? -1; + + if (newRank > existingRank) { + map.set(key, finding); + } else if (newRank === existingRank && finding.confidence > existing.confidence) { + map.set(key, finding); + } + } + + return Array.from(map.values()); + } + summarize(findings: FindingResult[]): FindingSummary { + const deduped = this.deduplicateFindings(findings); return { - critical: this.countBySeverity(findings, FindingSeverity.CRITICAL), - high: this.countBySeverity(findings, FindingSeverity.HIGH), - medium: this.countBySeverity(findings, FindingSeverity.MEDIUM), - low: this.countBySeverity(findings, FindingSeverity.LOW), - gas: this.countBySeverity(findings, FindingSeverity.GAS), - informational: this.countBySeverity(findings, FindingSeverity.INFORMATIONAL), - total: findings.length, + critical: this.countBySeverity(deduped, FindingSeverity.CRITICAL), + high: this.countBySeverity(deduped, FindingSeverity.HIGH), + medium: this.countBySeverity(deduped, FindingSeverity.MEDIUM), + low: this.countBySeverity(deduped, FindingSeverity.LOW), + gas: this.countBySeverity(deduped, FindingSeverity.GAS), + informational: this.countBySeverity(deduped, FindingSeverity.INFORMATIONAL), + total: deduped.length, }; } diff --git a/packages/scanner-types/src/index.ts b/packages/scanner-types/src/index.ts index 5c15f23..8eeb72c 100644 --- a/packages/scanner-types/src/index.ts +++ b/packages/scanner-types/src/index.ts @@ -51,6 +51,7 @@ export interface FindingResult { recommendation: string; confidence: number; references: string[]; + category?: PluginCategory | string; } export interface IRulePlugin { diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 355ed9e..d207860 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -57,4 +57,5 @@ export interface FindingResult { recommendation: string; confidence: number; references: string[]; + category?: PluginCategory | string; }