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
1 change: 1 addition & 0 deletions packages/scanner-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@veridion/logger": "workspace:*",
"@veridion/plugin-unchecked-return": "workspace:*",
"@veridion/scanner-types": "workspace:*",
"@veridion/shared": "workspace:*"
},
Expand Down
13 changes: 12 additions & 1 deletion packages/scanner-core/src/plugin-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { FindingSeverity } from '@veridion/shared';
import { beforeEach, describe, expect, it } from 'vitest';

import { PluginRegistry } from './plugin-registry';
import { BUILTIN_PLUGIN_SPECIFIERS, PluginRegistry } from './plugin-registry';

function createMockPlugin(
id: string,
Expand Down Expand Up @@ -84,4 +84,15 @@
const allMeta = registry.getAllMetadata();
expect(allMeta).toHaveLength(2);
});

it('exposes the built-in plugin specifier catalogue', () => {
expect(BUILTIN_PLUGIN_SPECIFIERS['unchecked-return']).toBe('@veridion/plugin-unchecked-return');
});

it('registerBuiltins resolves without throwing and leaves size unchanged when the package is missing', async () => {
const before = registry.size;
const added = await registry.registerBuiltins();
expect(added).toEqual([]);

Check failure on line 95 in packages/scanner-core/src/plugin-registry.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/plugin-registry.test.ts > PluginRegistry > registerBuiltins resolves without throwing and leaves size unchanged when the package is missing

AssertionError: expected [ UncheckedReturnPlugin{ …(1) } ] to deeply equal [] - Expected + Received - Array [] + Array [ + UncheckedReturnPlugin { + "metadata": Object { + "author": "Veridion", + "category": "UNCHECKED_RETURN", + "chains": Array [ + "ethereum", + "polygon", + "bsc", + "avalanche", + "arbitrum", + "optimism", + ], + "description": "Detects low-level calls (.call/.send/.delegatecall/.staticcall) and ERC-20 transfers whose boolean return value is never checked, allowing failed transfers to fail silently (SWC-104).", + "id": "unchecked-return", + "languages": Array [ + "solidity", + ], + "name": "Unchecked Return Value Detector", + "references": Array [ + "https://swcregistry.io/docs/SWC-104", + "https://consensys.github.io/smart-contract-best-practices/development-recommendations/general/external-calls/", + "https://cwe.mitre.org/data/definitions/252.html", + ], + "severity": "HIGH", + "tags": Array [ + "unchecked-return", + "swc-104", + "call", + "send", + "delegatecall", + "staticcall", + "erc20", + "silent-failure", + ], + "version": "1.0.0", + }, + }, + ] ❯ src/plugin-registry.test.ts:95:19
expect(registry.size).toBe(before);
});
});
92 changes: 92 additions & 0 deletions packages/scanner-core/src/plugin-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,79 @@
import { logger } from '@veridion/logger';
import type { AnalysisContext, IRulePlugin, PluginMetadata } from '@veridion/scanner-types';

/**
* Built-in plugins shipped with the scanner, keyed by plugin id.
*
* Each entry stores a module specifier instead of being a static `import`,
* so that `scanner-core` keeps zero compile-time knowledge of plugin
* implementations (see ARCHITECTURE.md: "Scanner-core has zero knowledge of
* individual plugins"). At runtime the module is loaded dynamically and every
* export that satisfies {@link IRulePlugin} is instantiated.
*/
export const BUILTIN_PLUGIN_SPECIFIERS: Readonly<Record<string, string>> = {
'unchecked-return': '@veridion/plugin-unchecked-return',
};

function isRulePlugin(value: unknown): value is IRulePlugin {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Partial<IRulePlugin>;
return (
typeof candidate.metadata?.id === 'string' &&
typeof candidate.initialize === 'function' &&
typeof candidate.analyze === 'function' &&
typeof candidate.getFixRecommendation === 'function' &&
typeof candidate.supportsContext === 'function'
);
}

/**
* Turn a single module export into a plugin instance. Plugins are shipped as
* a class (constructor), but an already-instantiated object is also accepted.
*/
function tryInstantiate(exportedValue: unknown): IRulePlugin | null {
if (isRulePlugin(exportedValue)) return exportedValue;

if (typeof exportedValue === 'function') {
try {
const instance: unknown = new (exportedValue as new () => unknown)();
if (isRulePlugin(instance)) return instance;
} catch {
// Not a constructable plugin class; ignore this export.
}
}

return null;
}

/**
* Dynamically import every built-in plugin and instantiate it.
*
* A failure to resolve a module (for example because the plugin package has
* not been declared as a dependency of the consuming package) is logged and
* skipped, so this call never throws.
*/
export async function loadBuiltinPlugins(): Promise<IRulePlugin[]> {
const plugins: IRulePlugin[] = [];

for (const [pluginId, specifier] of Object.entries(BUILTIN_PLUGIN_SPECIFIERS)) {
try {
const namespace = (await import(/* webpackIgnore: true */ specifier)) as unknown;
const exports = Object.values(namespace as Record<string, unknown>);
for (const exportedValue of exports) {
const instance = tryInstantiate(exportedValue);
if (instance !== null) plugins.push(instance);
}
} catch (error) {
logger.warn(
{ pluginId, specifier, err: error instanceof Error ? error.message : String(error) },
'Built-in plugin could not be loaded; install the matching workspace package to enable it',
);
}
}

return plugins;
}

export class PluginRegistry {
private plugins = new Map<string, IRulePlugin>();

Expand All @@ -21,6 +94,25 @@ export class PluginRegistry {
}
}

/**
* Register every plugin returned by {@link loadBuiltinPlugins}.
*
* Newly-loaded plugins that were not previously registered are added;
* plugins already in the registry are left untouched (use {@link register}
* to overwrite).
*/
async registerBuiltins(): Promise<IRulePlugin[]> {
const loaded = await loadBuiltinPlugins();
const added: IRulePlugin[] = [];
for (const plugin of loaded) {
if (!this.plugins.has(plugin.metadata.id)) {
this.register(plugin);
added.push(plugin);
}
}
return added;
}

unregister(pluginId: string): boolean {
return this.plugins.delete(pluginId);
}
Expand Down
4 changes: 4 additions & 0 deletions plugins/unchecked-return/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const { createConfig } = require('@veridion/eslint-config/base');

/** @type {import('eslint').Linter.Config} */
module.exports = createConfig(__dirname);
28 changes: 28 additions & 0 deletions plugins/unchecked-return/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@veridion/plugin-unchecked-return",
"version": "0.1.0",
"private": true,
"description": "Unchecked return value detection plugin for Veridion scanner (SWC-104)",
"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:*",
"@veridion/logger": "workspace:*"
},
"devDependencies": {
"@veridion/eslint-config": "workspace:*",
"@veridion/tsconfig": "workspace:*",
"eslint": "^8.57.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}
Loading
Loading