Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/scanner-core/src/plugin-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'/);
});
});
});
51 changes: 49 additions & 2 deletions packages/scanner-core/src/plugin-registry.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,74 @@
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<string, IRulePlugin>();
private configs = new Map<string, PluginConfig>();

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 },
'Plugin registered',
);
}

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);
}
}

unregister(pluginId: string): boolean {
this.configs.delete(pluginId);
return this.plugins.delete(pluginId);
}

Expand Down
119 changes: 119 additions & 0 deletions packages/scanner-core/src/scanner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 29 additions & 2 deletions packages/scanner-core/src/scanner.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -90,8 +90,35 @@ export class Scanner {

private async runPlugin(plugin: IRulePlugin, context: AnalysisContext): Promise<FindingResult[]> {
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',
Expand Down
18 changes: 18 additions & 0 deletions packages/scanner-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ConfigPropertySchema>;

export interface PluginConfig {
enabled?: boolean;
severityOverride?: FindingSeverity;
disabledPatterns?: string[];
thresholds?: Record<string, number>;
[key: string]: unknown;
}

export interface PluginMetadata {
id: string;
name: string;
Expand All @@ -28,6 +45,7 @@ export interface PluginMetadata {
tags: string[];
author?: string;
references?: string[];
configSchema?: PluginConfigSchema;
}

export interface AnalysisContext {
Expand Down
Loading