diff --git a/packages/scanner-core/src/plugin-registry.test.ts b/packages/scanner-core/src/plugin-registry.test.ts index 1d564cc..ae32cc4 100644 --- a/packages/scanner-core/src/plugin-registry.test.ts +++ b/packages/scanner-core/src/plugin-registry.test.ts @@ -84,4 +84,73 @@ describe('PluginRegistry', () => { const allMeta = registry.getAllMetadata(); expect(allMeta).toHaveLength(2); }); + + describe('Plugin Configuration', () => { + it('should register a plugin with optional configuration and retrieve it', () => { + const plugin = createMockPlugin('configurable'); + registry.register(plugin, { + enabled: true, + severityOverride: FindingSeverity.CRITICAL, + thresholds: { maxGas: 50000 }, + }); + + const config = registry.getPluginConfig('configurable'); + expect(config).toBeDefined(); + expect(config?.enabled).toBe(true); + expect(config?.severityOverride).toBe(FindingSeverity.CRITICAL); + expect(config?.thresholds).toEqual({ maxGas: 50000 }); + }); + + it('should return undefined when no configuration was set', () => { + const plugin = createMockPlugin('unconfigured'); + registry.register(plugin); + expect(registry.getPluginConfig('unconfigured')).toBeUndefined(); + }); + + it('should allow updating plugin config with setPluginConfig', () => { + const plugin = createMockPlugin('updateable'); + registry.register(plugin); + expect(registry.getPluginConfig('updateable')).toBeUndefined(); + + registry.setPluginConfig('updateable', { enabled: false }); + expect(registry.getPluginConfig('updateable')?.enabled).toBe(false); + }); + + it('should clean up config when plugin is unregistered', () => { + const plugin = createMockPlugin('to-remove'); + registry.register(plugin, { enabled: true }); + expect(registry.getPluginConfig('to-remove')).toBeDefined(); + + registry.unregister('to-remove'); + expect(registry.getPluginConfig('to-remove')).toBeUndefined(); + }); + + it('should validate config against schema when schema is defined', () => { + const pluginWithSchema: IRulePlugin = { + ...createMockPlugin('schema-plugin'), + metadata: { + ...createMockPlugin('schema-plugin').metadata, + configSchema: { + maxDepth: { type: 'number', required: true }, + allowFallback: { type: 'boolean' }, + }, + }, + }; + + // Valid config + expect(() => { + registry.register(pluginWithSchema, { maxDepth: 5, allowFallback: true }); + }).not.toThrow(); + + // Missing required property + expect(() => { + registry.register(pluginWithSchema, { allowFallback: true }); + }).toThrow(/missing required property 'maxDepth'/); + + // Wrong type + expect(() => { + registry.register(pluginWithSchema, { maxDepth: 'not-a-number' as unknown as number }); + }).toThrow(/expected type 'number', got 'string'/); + }); + }); }); diff --git a/packages/scanner-core/src/plugin-registry.ts b/packages/scanner-core/src/plugin-registry.ts index 710b1bd..378b35c 100644 --- a/packages/scanner-core/src/plugin-registry.ts +++ b/packages/scanner-core/src/plugin-registry.ts @@ -1,13 +1,25 @@ import { logger } from '@veridion/logger'; -import type { AnalysisContext, IRulePlugin, PluginMetadata } from '@veridion/scanner-types'; +import type { + AnalysisContext, + IRulePlugin, + PluginConfig, + PluginMetadata, +} from '@veridion/scanner-types'; export class PluginRegistry { private plugins = new Map(); + private configs = new Map(); - register(plugin: IRulePlugin): void { + register(plugin: IRulePlugin, config?: PluginConfig): void { if (this.plugins.has(plugin.metadata.id)) { logger.warn({ pluginId: plugin.metadata.id }, 'Plugin already registered, overwriting'); } + + if (config) { + this.validateConfig(plugin, config); + this.configs.set(plugin.metadata.id, config); + } + this.plugins.set(plugin.metadata.id, plugin); logger.info( { pluginId: plugin.metadata.id, version: plugin.metadata.version }, @@ -15,6 +27,40 @@ export class PluginRegistry { ); } + getPluginConfig(pluginId: string): PluginConfig | undefined { + return this.configs.get(pluginId); + } + + setPluginConfig(pluginId: string, config: PluginConfig): void { + const plugin = this.plugins.get(pluginId); + if (plugin) { + this.validateConfig(plugin, config); + } + this.configs.set(pluginId, config); + } + + private validateConfig(plugin: IRulePlugin, config: PluginConfig): void { + const schema = plugin.metadata.configSchema; + if (!schema) return; + + for (const [key, propSchema] of Object.entries(schema)) { + const val = config[key]; + if (propSchema.required && (val === undefined || val === null)) { + throw new Error( + `Configuration validation failed for plugin '${plugin.metadata.id}': missing required property '${key}'`, + ); + } + if (val !== undefined && val !== null) { + const actualType = Array.isArray(val) ? 'array' : typeof val; + if (actualType !== propSchema.type) { + throw new Error( + `Configuration validation failed for plugin '${plugin.metadata.id}': property '${key}' expected type '${propSchema.type}', got '${actualType}'`, + ); + } + } + } + } + registerAll(plugins: IRulePlugin[]): void { for (const plugin of plugins) { this.register(plugin); @@ -22,6 +68,7 @@ export class PluginRegistry { } unregister(pluginId: string): boolean { + this.configs.delete(pluginId); return this.plugins.delete(pluginId); } diff --git a/packages/scanner-core/src/scanner.test.ts b/packages/scanner-core/src/scanner.test.ts new file mode 100644 index 0000000..aad5d76 --- /dev/null +++ b/packages/scanner-core/src/scanner.test.ts @@ -0,0 +1,119 @@ +import type { AnalysisContext, FindingResult, IRulePlugin, PluginMetadata } from '@veridion/scanner-types'; +import { FindingSeverity } from '@veridion/shared'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { PluginRegistry } from './plugin-registry'; +import { Scanner } from './scanner'; + +function createMockFinding(pluginId: string, title: string, snippet: string): FindingResult { + return { + pluginId, + title, + description: 'Mock finding', + severity: FindingSeverity.MEDIUM, + filePath: 'Test.sol', + lineStart: 1, + lineEnd: 2, + codeSnippet: snippet, + recommendation: 'Fix it', + confidence: 0.8, + references: [], + }; +} + +function createMockPlugin(id: string, findings: FindingResult[]): IRulePlugin { + const metadata: PluginMetadata = { + id, + name: `Plugin ${id}`, + version: '1.0.0', + description: 'Mock', + severity: FindingSeverity.MEDIUM, + category: 'CUSTOM', + chains: ['ethereum'], + languages: ['solidity'], + tags: ['test'], + }; + + return { + metadata, + initialize: async () => {}, + // eslint-disable-next-line @typescript-eslint/require-await + analyze: async () => findings, + getFixRecommendation: () => 'Fix', + supportsContext: () => true, + }; +} + +describe('Scanner with Plugin Configuration', () => { + let registry: PluginRegistry; + let context: AnalysisContext; + + beforeEach(() => { + registry = new PluginRegistry(); + context = { + contractName: 'Vault', + sourceCode: 'contract Vault {}', + chain: 'ethereum', + language: 'solidity', + compilerVersion: null, + metadata: {}, + }; + }); + + it('should run plugin without config by default', async () => { + const findings = [createMockFinding('test-plugin', 'Issue 1', 'call()')]; + registry.register(createMockPlugin('test-plugin', findings)); + + const scanner = new Scanner(registry); + const result = await scanner.scan(context); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.severity).toBe(FindingSeverity.MEDIUM); + }); + + it('should override severity of findings when severityOverride is configured', async () => { + const findings = [createMockFinding('test-plugin', 'Issue 1', 'call()')]; + registry.register(createMockPlugin('test-plugin', findings), { + severityOverride: FindingSeverity.CRITICAL, + }); + + const scanner = new Scanner(registry); + const result = await scanner.scan(context); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.severity).toBe(FindingSeverity.CRITICAL); + expect(result.summary.critical).toBe(1); + expect(result.summary.medium).toBe(0); + }); + + it('should filter out findings matching disabledPatterns', async () => { + const findings = [ + createMockFinding('multi-pattern', 'Timestamp equality check', 'block.timestamp == target'), + createMockFinding('multi-pattern', 'Timestamp interval check', 'block.timestamp > deadline'), + createMockFinding('multi-pattern', 'Unrelated check', 'msg.sender == owner'), + ]; + + registry.register(createMockPlugin('multi-pattern', findings), { + disabledPatterns: ['interval', 'msg.sender'], + }); + + const scanner = new Scanner(registry); + const result = await scanner.scan(context); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.title).toBe('Timestamp equality check'); + }); + + it('should skip plugin when enabled is false in config', async () => { + const findings = [createMockFinding('disabled-plugin', 'Should not appear', 'xyz')]; + registry.register(createMockPlugin('disabled-plugin', findings), { + enabled: false, + }); + + const scanner = new Scanner(registry); + const result = await scanner.scan(context); + + expect(result.findings).toHaveLength(0); + expect(result.summary.total).toBe(0); + }); +}); diff --git a/packages/scanner-core/src/scanner.ts b/packages/scanner-core/src/scanner.ts index bae40c8..024294a 100644 --- a/packages/scanner-core/src/scanner.ts +++ b/packages/scanner-core/src/scanner.ts @@ -1,6 +1,6 @@ import { logger } from '@veridion/logger'; import type { AnalysisContext, FindingResult, IRulePlugin } from '@veridion/scanner-types'; -import { AuditStatus } from '@veridion/shared'; +import { AuditStatus, type FindingSeverity } from '@veridion/shared'; import type { PluginRegistry } from './plugin-registry'; import { ResultAggregator } from './result-aggregator'; @@ -90,8 +90,35 @@ export class Scanner { private async runPlugin(plugin: IRulePlugin, context: AnalysisContext): Promise { try { + const config = this.registry.getPluginConfig(plugin.metadata.id); + if (config?.enabled === false) { + logger.debug({ pluginId: plugin.metadata.id }, 'Plugin disabled by configuration, skipping'); + return []; + } + logger.debug({ pluginId: plugin.metadata.id }, 'Running plugin'); - const findings = await plugin.analyze(context); + let findings = await plugin.analyze(context); + + if (config) { + if (config.disabledPatterns && config.disabledPatterns.length > 0) { + findings = findings.filter((f) => { + const matchesPattern = config.disabledPatterns?.some( + (pattern) => + f.title.toLowerCase().includes(pattern.toLowerCase()) || + f.codeSnippet.toLowerCase().includes(pattern.toLowerCase()), + ); + return !matchesPattern; + }); + } + + if (config.severityOverride) { + findings = findings.map((f) => ({ + ...f, + severity: config.severityOverride as FindingSeverity, + })); + } + } + logger.debug( { pluginId: plugin.metadata.id, findingCount: findings.length }, 'Plugin completed', diff --git a/packages/scanner-types/src/index.ts b/packages/scanner-types/src/index.ts index 5c15f23..8d3c9f8 100644 --- a/packages/scanner-types/src/index.ts +++ b/packages/scanner-types/src/index.ts @@ -16,6 +16,23 @@ export type PluginCategory = | 'UPGRADE' | 'CUSTOM'; +export interface ConfigPropertySchema { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + default?: unknown; + required?: boolean; +} + +export type PluginConfigSchema = Record; + +export interface PluginConfig { + enabled?: boolean; + severityOverride?: FindingSeverity; + disabledPatterns?: string[]; + thresholds?: Record; + [key: string]: unknown; +} + export interface PluginMetadata { id: string; name: string; @@ -28,6 +45,7 @@ export interface PluginMetadata { tags: string[]; author?: string; references?: string[]; + configSchema?: PluginConfigSchema; } export interface AnalysisContext {