This guide walks you through creating a complete security detection plugin from scratch. We'll build a Self-Destruct Detection Plugin that detects the use of selfdestruct (formerly suicide) in Solidity contracts.
The selfdestruct opcode allows a contract to delete itself and send its remaining ETH to a target address. This is flagged because:
- The contract can be removed at any time if the owner is compromised
- Users relying on the contract's continued existence may lose funds
selfdestructhas been deprecated in newer Solidity versions
Our plugin will detect:
- Use of
selfdestruct()andsuicide() - Contracts without timelock or multi-sig protections on self-destruct
mkdir -p plugins/self-destruct/srcCreate plugins/self-destruct/package.json:
{
"name": "@veridion/plugin-self-destruct",
"version": "0.1.0",
"private": true,
"description": "Detects selfdestruct usage in Solidity smart contracts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"lint": "eslint src/ --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist"
},
"dependencies": {
"@veridion/scanner-types": "workspace:*",
"@veridion/shared": "workspace:*"
},
"devDependencies": {
"@veridion/eslint-config": "workspace:*",
"@veridion/tsconfig": "workspace:*",
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}{
"extends": "@veridion/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});module.exports = {
root: true,
extends: ['@veridion/eslint-config/base'],
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
};Create plugins/self-destruct/src/index.ts:
import type {
AnalysisContext,
FindingResult,
IRulePlugin,
PluginMetadata,
} from '@veridion/scanner-types';
import { FindingSeverity } from '@veridion/shared';
const metadata: PluginMetadata = {
id: 'self-destruct',
name: 'Self-Destruct Detector',
version: '1.0.0',
description:
'Detects usage of selfdestruct/suicide opcodes which can permanently remove contract code and may indicate centralization risk.',
severity: FindingSeverity.HIGH,
category: 'CUSTOM',
chains: ['ethereum', 'polygon', 'bsc', 'avalanche', 'arbitrum', 'optimism'],
languages: ['solidity'],
tags: ['selfdestruct', 'suicide', 'centralization', 'deprecated', 'eip-6049'],
author: 'Veridion',
references: [
'https://eips.ethereum.org/EIPS/eip-6049',
'https://swcregistry.io/docs/SWC-106',
'https://docs.soliditylang.org/en/latest/introduction-to-smart-contracts.html#deactivate-and-self-destruct',
],
};
export class SelfDestructPlugin implements IRulePlugin {
readonly metadata = metadata;
async initialize(_config?: Record<string, unknown>): Promise<void> {
// No initialization needed
}
async analyze(context: AnalysisContext): Promise<FindingResult[]> {
const findings: FindingResult[] = [];
// Pattern: selfdestruct(address)
const selfDestructPattern = /selfdestruct\s*\(([^)]*)\)/g;
let match;
while ((match = selfDestructPattern.exec(context.sourceCode)) !== null) {
const lineNumber =
context.sourceCode.slice(0, match.index).split('\n').length;
findings.push({
pluginId: this.metadata.id,
title: 'selfdestruct Usage Detected',
description:
'The selfdestruct opcode permanently removes the contract from the blockchain. ' +
'This creates centralization risk if controlled by a single address. ' +
'selfdestruct has been deprecated in EIP-6049 and its behavior may change in future hard forks.',
severity: this.metadata.severity,
filePath: `${context.contractName}.sol`,
lineStart: lineNumber,
lineEnd: lineNumber,
codeSnippet: context.sourceCode
.slice(Math.max(0, match.index - 20), match.index + 80)
.trim(),
recommendation:
'Consider replacing selfdestruct with a controlled pause mechanism or using a ' +
'multi-sig / governance-controlled deactivation pattern.',
confidence: 0.9,
references: this.metadata.references ?? [],
});
}
// Pattern: suicide(address) — legacy alias for selfdestruct
const suicidePattern = /suicide\s*\(([^)]*)\)/g;
while ((match = suicidePattern.exec(context.sourceCode)) !== null) {
const lineNumber =
context.sourceCode.slice(0, match.index).split('\n').length;
findings.push({
pluginId: this.metadata.id,
title: 'suicide() — Legacy selfdestruct Usage',
description:
'The suicide opcode is a deprecated alias for selfdestruct. ' +
'It permanently removes the contract and sends remaining ETH to the target. ' +
'This has been renamed to selfdestruct in newer Solidity versions.',
severity: FindingSeverity.MEDIUM,
filePath: `${context.contractName}.sol`,
lineStart: lineNumber,
lineEnd: lineNumber,
codeSnippet: context.sourceCode
.slice(Math.max(0, match.index - 20), match.index + 80)
.trim(),
recommendation:
'Replace suicide() with selfdestruct() for clarity, or better yet, ' +
'remove the self-destruct mechanism entirely in favor of upgradeable patterns.',
confidence: 0.95,
references: this.metadata.references ?? [],
});
}
return findings;
}
getFixRecommendation(finding: FindingResult): string {
return (
`Fix for ${finding.title}:\n\n` +
`${finding.recommendation}\n\n` +
'For upgradeable contracts, consider using OpenZeppelin UUPS or Transparent Proxy patterns ' +
'instead of selfdestruct. See: https://docs.openzeppelin.com/contracts/5.x/api/proxy'
);
}
supportsContext(context: AnalysisContext): boolean {
return (
this.metadata.chains.includes(context.chain) &&
this.metadata.languages.includes(context.language)
);
}
}Let's examine the choices made in this implementation:
1. Two separate patterns with different severities
The selfdestruct keyword gets HIGH severity (it's an active concern), while the legacy suicide alias gets MEDIUM (it's a style/maintenance issue). This gives users more granularity.
2. Line number calculation
const lineNumber = context.sourceCode.slice(0, match.index).split('\n').length;This counts newlines up to the match position to determine the 1-based line number. More accurate than hardcoding lineStart: 1.
3. Context window for code snippets
codeSnippet: context.sourceCode
.slice(Math.max(0, match.index - 20), match.index + 80)
.trim(),We capture 20 characters before and 80 after the match to show context without overwhelming the report.
4. Confidence scoring
selfdestructdetection: 0.9 — high confidence, it's definitely a self-destruct callsuicidedetection: 0.95 — even higher,suicide()is unambiguously the deprecated alias
Create plugins/self-destruct/src/index.test.ts:
import { describe, expect, it } from 'vitest';
import { SelfDestructPlugin } from './index';
describe('SelfDestructPlugin', () => {
const plugin = new SelfDestructPlugin();
// ──── Metadata ────
it('should have correct metadata', () => {
expect(plugin.metadata.id).toBe('self-destruct');
expect(plugin.metadata.name).toBe('Self-Destruct Detector');
expect(plugin.metadata.version).toBe('1.0.0');
expect(plugin.metadata.severity).toBe('HIGH');
expect(plugin.metadata.category).toBe('CUSTOM');
expect(plugin.metadata.languages).toContain('solidity');
expect(plugin.metadata.chains).toContain('ethereum');
});
// ──── Context Support ────
it('should support solidity on ethereum', () => {
expect(
plugin.supportsContext({
contractName: 'Test',
sourceCode: '',
chain: 'ethereum',
language: 'solidity',
compilerVersion: null,
metadata: {},
}),
).toBe(true);
});
it('should not support vyper', () => {
expect(
plugin.supportsContext({
contractName: 'Test',
sourceCode: '',
chain: 'ethereum',
language: 'vyper',
compilerVersion: null,
metadata: {},
}),
).toBe(false);
});
// ──── Detection: selfdestruct ────
it('should detect selfdestruct usage', async () => {
const code = `
pragma solidity ^0.8.0;
contract Destructible {
address public owner;
constructor() {
owner = msg.sender;
}
function destroy() public {
require(msg.sender == owner);
selfdestruct(payable(owner));
}
}`;
const findings = await plugin.analyze({
contractName: 'Destructible',
sourceCode: code,
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.8.19',
metadata: {},
});
expect(findings.length).toBeGreaterThan(0);
expect(findings.some((f) => f.title.includes('selfdestruct'))).toBe(true);
});
it('should detect suicide legacy usage', async () => {
const code = `
pragma solidity ^0.4.0;
contract OldContract {
address public owner;
function kill() public {
suicide(owner);
}
}`;
const findings = await plugin.analyze({
contractName: 'OldContract',
sourceCode: code,
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.4.24',
metadata: {},
});
expect(findings.length).toBeGreaterThan(0);
expect(findings.some((f) => f.title.includes('suicide'))).toBe(true);
});
// ──── Safe Code ────
it('should not flag contracts without selfdestruct', async () => {
const code = `
pragma solidity ^0.8.0;
contract SafeContract {
uint256 public value;
function setValue(uint256 _value) public {
value = _value;
}
}`;
const findings = await plugin.analyze({
contractName: 'SafeContract',
sourceCode: code,
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.8.19',
metadata: {},
});
expect(findings.length).toBe(0);
});
// ──── Edge Cases ────
it('should handle empty contracts', async () => {
const findings = await plugin.analyze({
contractName: 'Empty',
sourceCode: 'contract Empty {}',
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.8.19',
metadata: {},
});
expect(findings.length).toBe(0);
});
it('should handle multiple selfdestruct calls', async () => {
const code = `
pragma solidity ^0.8.0;
contract MultiDestroy {
function destroyA() public { selfdestruct(payable(msg.sender)); }
function destroyB() public { selfdestruct(payable(address(0))); }
}`;
const findings = await plugin.analyze({
contractName: 'MultiDestroy',
sourceCode: code,
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.8.19',
metadata: {},
});
expect(findings.length).toBe(2);
});
// ──── Finding Structure ────
it('should include correct pluginId in findings', async () => {
const code = `
contract Test {
function die() public { selfdestruct(payable(msg.sender)); }
}`;
const findings = await plugin.analyze({
contractName: 'Test',
sourceCode: code,
chain: 'ethereum',
language: 'solidity',
compilerVersion: '0.8.19',
metadata: {},
});
for (const finding of findings) {
expect(finding.pluginId).toBe('self-destruct');
expect(finding.confidence).toBeGreaterThan(0);
expect(finding.confidence).toBeLessThanOrEqual(1);
expect(finding.references).toBeDefined();
expect(finding.references.length).toBeGreaterThan(0);
}
});
// ──── Fix Recommendation ────
it('should provide fix recommendations', () => {
const finding = {
pluginId: 'self-destruct',
title: 'selfdestruct Usage Detected',
description: '',
severity: 'HIGH' as const,
filePath: 'Test.sol',
lineStart: 1,
lineEnd: 1,
codeSnippet: '',
recommendation: '',
confidence: 0.9,
references: [],
};
const fix = plugin.getFixRecommendation(finding);
expect(fix).toBeTruthy();
expect(fix.length).toBeGreaterThan(20);
});
});| Test Category | Tests | Purpose |
|---|---|---|
| Metadata validation | 1 | Verify plugin identity and configuration |
| Context support | 2 | Verify chain/language filtering |
| Detection (positive) | 2 | Verify selfdestruct and suicide detection |
| Detection (negative) | 1 | Verify no false positives |
| Edge cases | 2 | Empty contracts, multiple matches |
| Finding structure | 1 | Verify finding fields are correctly populated |
| Fix recommendations | 1 | Verify actionable fix text |
# Install the new plugin's dependencies
pnpm install
# Run the plugin's tests
pnpm test --filter=@veridion/plugin-self-destructExpected output:
✓ SelfDestructPlugin (9 tests)
✓ should have correct metadata
✓ should support solidity on ethereum
✓ should not support vyper
✓ should detect selfdestruct usage
✓ should detect suicide legacy usage
✓ should not flag contracts without selfdestruct
✓ should handle empty contracts
✓ should handle multiple selfdestruct calls
✓ should include correct pluginId in findings
Test Files 1 passed (1)
Tests 9 passed (9)
Add your plugin to the application's plugin registration:
import { SelfDestructPlugin } from '@veridion/plugin-self-destruct';
// Add to the registry
registry.register(new SelfDestructPlugin());Run the complete test suite to make sure nothing is broken:
# Typecheck
pnpm typecheck
# Lint
pnpm lint
# All tests
pnpm testIn this walkthrough, you:
- Created the complete file structure for a Veridion scanner plugin
- Implemented the
IRulePlugininterface with metadata, detection logic, and context filtering - Wrote 9 comprehensive tests covering metadata, detection, edge cases, and fix generation
- Used regex-based detection with proper line tracking and context windows
- Applied confidence scoring to distinguish between different detection patterns
- Handled edge cases like empty contracts and multiple matches
When building your own plugin, you can copy:
- The
package.jsontemplate (updatenameanddescription) - The
tsconfig.jsonandvitest.config.ts(no changes needed) - The test structure (update the plugin class and test cases)
- The
supportsContext()pattern (update chains/languages)
- Reference plugins: Study
plugins/reentrancy/,plugins/access-control/, andplugins/oracle/for more complex detection patterns - Add more patterns: Extend the SelfDestructPlugin to also detect unprotected
selfdestruct(noonlyOwner) or missing timelocks - Contribute back: Once your plugin is stable, open a PR to add it to the official Veridion plugins