diff --git a/packages/scanner-core/package.json b/packages/scanner-core/package.json index 41a3b94..d7dd214 100644 --- a/packages/scanner-core/package.json +++ b/packages/scanner-core/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@veridion/logger": "workspace:*", + "@veridion/plugin-unchecked-return": "workspace:*", "@veridion/scanner-types": "workspace:*", "@veridion/shared": "workspace:*" }, diff --git a/packages/scanner-core/src/plugin-registry.test.ts b/packages/scanner-core/src/plugin-registry.test.ts index 1d564cc..489ed8b 100644 --- a/packages/scanner-core/src/plugin-registry.test.ts +++ b/packages/scanner-core/src/plugin-registry.test.ts @@ -2,7 +2,8 @@ import type { IRulePlugin, PluginMetadata } from '@veridion/scanner-types'; import { FindingSeverity } from '@veridion/shared'; import { beforeEach, describe, expect, it } from 'vitest'; -import { PluginRegistry } from './plugin-registry'; +import { createDefaultRegistry, PluginRegistry } from './plugin-registry'; +import { Scanner } from './scanner'; function createMockPlugin( id: string, @@ -67,8 +68,7 @@ describe('PluginRegistry', () => { }); expect(matching).toHaveLength(1); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(matching[0]!.metadata.id).toBe('eth'); + expect(matching[0]?.metadata.id).toBe('eth'); }); it('should unregister a plugin', () => { @@ -85,3 +85,43 @@ describe('PluginRegistry', () => { expect(allMeta).toHaveLength(2); }); }); + +describe('createDefaultRegistry', () => { + it('should register unchecked-return plugin by default', () => { + const registry = createDefaultRegistry(); + expect(registry.size).toBe(1); + const plugin = registry.get('unchecked-return'); + expect(plugin).toBeDefined(); + expect(plugin?.metadata.id).toBe('unchecked-return'); + expect(plugin?.metadata.category).toBe('UNCHECKED_RETURN'); + expect(plugin?.metadata.severity).toBe(FindingSeverity.HIGH); + }); + + it('should list unchecked-return in all metadata', () => { + const registry = createDefaultRegistry(); + const ids = registry.getAllMetadata().map((m) => m.id); + expect(ids).toContain('unchecked-return'); + }); + + it('should execute end-to-end scan through Scanner', async () => { + const registry = createDefaultRegistry(); + const scanner = new Scanner(registry); + const result = await scanner.scan({ + contractName: 'TestVault', + sourceCode: ` +contract TestVault { + function withdraw(address payable recipient) public { + recipient.call(""); + } +}`, + chain: 'ethereum', + language: 'solidity', + compilerVersion: '0.8.20', + metadata: {}, + }); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.pluginId).toBe('unchecked-return'); + expect(result.findings[0]?.lineStart).toBe(4); + }); +}); diff --git a/packages/scanner-core/src/plugin-registry.ts b/packages/scanner-core/src/plugin-registry.ts index 710b1bd..15c8a8b 100644 --- a/packages/scanner-core/src/plugin-registry.ts +++ b/packages/scanner-core/src/plugin-registry.ts @@ -1,6 +1,9 @@ import { logger } from '@veridion/logger'; +import { UncheckedReturnPlugin } from '@veridion/plugin-unchecked-return'; import type { AnalysisContext, IRulePlugin, PluginMetadata } from '@veridion/scanner-types'; +export const defaultPlugins: IRulePlugin[] = [new UncheckedReturnPlugin()]; + export class PluginRegistry { private plugins = new Map(); @@ -21,6 +24,10 @@ export class PluginRegistry { } } + registerDefaultPlugins(): void { + this.registerAll(defaultPlugins); + } + unregister(pluginId: string): boolean { return this.plugins.delete(pluginId); } @@ -70,3 +77,9 @@ export class PluginRegistry { return this.plugins.size; } } + +export function createDefaultRegistry(): PluginRegistry { + const registry = new PluginRegistry(); + registry.registerDefaultPlugins(); + return registry; +} diff --git a/plugins/unchecked-return/.eslintrc.js b/plugins/unchecked-return/.eslintrc.js new file mode 100644 index 0000000..7c0ed9b --- /dev/null +++ b/plugins/unchecked-return/.eslintrc.js @@ -0,0 +1,4 @@ +const { createConfig } = require('@veridion/eslint-config/base'); + +/** @type {import('eslint').Linter.Config} */ +module.exports = createConfig(__dirname); diff --git a/plugins/unchecked-return/package.json b/plugins/unchecked-return/package.json new file mode 100644 index 0000000..1a0cf3e --- /dev/null +++ b/plugins/unchecked-return/package.json @@ -0,0 +1,28 @@ +{ + "name": "@veridion/plugin-unchecked-return", + "version": "0.1.0", + "private": true, + "description": "Unchecked return value detection plugin for Veridion scanner", + "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/logger": "workspace:*", + "@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" + } +} diff --git a/plugins/unchecked-return/src/index.test.ts b/plugins/unchecked-return/src/index.test.ts new file mode 100644 index 0000000..a8af60c --- /dev/null +++ b/plugins/unchecked-return/src/index.test.ts @@ -0,0 +1,855 @@ +import type { AnalysisContext } from '@veridion/scanner-types'; +import { FindingSeverity } from '@veridion/shared'; +import { describe, expect, it } from 'vitest'; + +import { + extractFunctionScopes, + getAssignmentVar, + isDirectlyChecked, + isValidTruthCheck, + maskCommentsAndStrings, + UncheckedReturnPlugin, +} from './index'; + +function createCtx(sourceCode: string, contractName = 'TestContract'): AnalysisContext { + return { + contractName, + sourceCode, + chain: 'ethereum', + language: 'solidity', + compilerVersion: '0.8.20', + metadata: {}, + }; +} + +describe('UncheckedReturnPlugin', () => { + const plugin = new UncheckedReturnPlugin(); + + describe('metadata & lifecycle', () => { + it('should expose correct plugin metadata', () => { + expect(plugin.metadata.id).toBe('unchecked-return'); + expect(plugin.metadata.name).toBe('Unchecked Return Value Detector'); + expect(plugin.metadata.version).toBe('1.0.0'); + expect(plugin.metadata.severity).toBe(FindingSeverity.HIGH); + expect(plugin.metadata.category).toBe('UNCHECKED_RETURN'); + expect(plugin.metadata.chains).toContain('ethereum'); + expect(plugin.metadata.languages).toContain('solidity'); + expect(plugin.metadata.tags).toContain('unchecked-return'); + expect(plugin.metadata.tags).toContain('swc-104'); + expect(plugin.metadata.references?.length).toBeGreaterThan(0); + }); + + it('should initialize without error', async () => { + await expect(plugin.initialize()).resolves.toBeUndefined(); + }); + + it('should provide fix recommendation text', () => { + const rec = plugin.getFixRecommendation({ + pluginId: 'unchecked-return', + title: 'Unchecked Return Value from .call()', + description: 'test', + severity: FindingSeverity.HIGH, + filePath: 'Test.sol', + lineStart: 10, + lineEnd: 10, + codeSnippet: 'a.call("")', + recommendation: 'Check return value', + confidence: 0.9, + references: [], + }); + expect(rec).toContain('require(success'); + expect(rec).toContain('.send()'); + expect(rec).toContain('.delegatecall()'); + }); + }); + + describe('supportsContext', () => { + it('should support Solidity on Ethereum', () => { + expect(plugin.supportsContext(createCtx(''))).toBe(true); + }); + + it('should support other EVM chains in metadata', () => { + expect(plugin.supportsContext({ ...createCtx(''), chain: 'polygon' })).toBe(true); + expect(plugin.supportsContext({ ...createCtx(''), chain: 'arbitrum' })).toBe(true); + }); + + it('should reject unsupported languages', () => { + expect(plugin.supportsContext({ ...createCtx(''), language: 'vyper' })).toBe(false); + expect(plugin.supportsContext({ ...createCtx(''), language: 'rust' })).toBe(false); + }); + + it('should reject unsupported chains', () => { + expect(plugin.supportsContext({ ...createCtx(''), chain: 'solana' })).toBe(false); + expect(plugin.supportsContext({ ...createCtx(''), chain: 'bitcoin' })).toBe(false); + }); + }); + + describe('positive cases: standalone calls', () => { + it('should detect unchecked .call()', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + recipient.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.pluginId).toBe('unchecked-return'); + expect(findings[0]?.title).toBe('Unchecked Return Value from .call()'); + expect(findings[0]?.codeSnippet).toContain('recipient.call'); + expect(findings[0]?.lineStart).toBe(4); + }); + + it('should detect unchecked .call() with value brace options', async () => { + const code = ` +contract Vulnerable { + function payout(address payable recipient, uint256 amount) public { + recipient.call{value: amount}(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.description).toContain('.call()'); + }); + + it('should detect unchecked .send()', async () => { + const code = ` +contract Vulnerable { + function sendEther(address payable recipient) public { + recipient.send(1 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.title).toBe('Unchecked Return Value from .send()'); + expect(findings[0]?.codeSnippet).toContain('recipient.send'); + }); + + it('should detect unchecked .delegatecall()', async () => { + const code = ` +contract Vulnerable { + function forward(address target, bytes memory data) public { + target.delegatecall(data); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.title).toBe('Unchecked Return Value from .delegatecall()'); + }); + + it('should detect unchecked .delegatecall() with gas option', async () => { + const code = ` +contract Vulnerable { + function forward(address target, bytes memory data) public { + target.delegatecall{gas: 50000}(data); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect calls on complex receiver expressions', async () => { + const code = ` +contract Vulnerable { + function execute(address a) public { + payable(a).call{value: 1 ether}(""); + address(this).call(""); + getRecipient().call(""); + } + function getRecipient() internal pure returns (address) { + return address(0); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(3); + }); + }); + + describe('positive cases: captured but never validated', () => { + it('should detect captured-but-unused tuple return value', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + // success is never checked with require() or if + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.description).toContain('success'); + }); + + it('should detect captured-but-unused multi-element tuple', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool success, bytes memory data) = recipient.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect tuple with omitted boolean capture', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (, bytes memory data) = recipient.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect captured-but-unused single-variable send', async () => { + const code = ` +contract Vulnerable { + function sendEth(address payable recipient) public { + bool sent = recipient.send(1 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.description).toContain('sent'); + }); + + it('should detect pre-declared variable capture that is never checked', async () => { + const code = ` +contract Vulnerable { + function sendEth(address payable recipient) public { + bool sent; + sent = recipient.send(1 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect pre-declared tuple assignment that is never checked', async () => { + const code = ` +contract Vulnerable { + function sendEth(address payable recipient) public { + bool ok; + (ok, ) = recipient.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect variable re-assignment before check', async () => { + const code = ` +contract Vulnerable { + function sendEth(address a1, address a2) public { + (bool ok, ) = a1.call(""); + (ok, ) = a2.call(""); + require(ok); // only checks a2, a1 was overwritten! + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.codeSnippet).toContain('a1.call'); + }); + + it('should flag call when variable is only used in non-halting if block', async () => { + const code = ` +contract Vulnerable { + event Log(bool status); + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + if (success) { + emit Log(success); + } + // execution continues even if success is false! + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag inline if condition when body does not halt', async () => { + const code = ` +contract Vulnerable { + event Sent(bool ok); + function sendEth(address payable recipient) public { + if (recipient.send(1 ether)) { + emit Sent(true); + } + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag inverted check require(!success)', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + require(!success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag inverted equality check require(success == false)', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + require(success == false); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag comparison against another variable require(ok == trueFlag)', async () => { + const code = ` +contract Vulnerable { + bool trueFlag = false; + function withdraw(address payable recipient) public { + (bool ok, ) = recipient.call(""); + require(ok == trueFlag); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag reverse comparison against another variable require(trueFlag == ok)', async () => { + const code = ` +contract Vulnerable { + bool trueFlag = false; + function withdraw(address payable recipient) public { + (bool ok, ) = recipient.call(""); + require(trueFlag == ok); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag inverted inequality check require(ok != true)', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool ok, ) = recipient.call(""); + require(ok != true); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should flag call if require appears before assignment', async () => { + const code = ` +contract Vulnerable { + function sendEth(address payable recipient) public { + bool sent; + require(sent, "premature check"); + sent = recipient.send(1 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should detect unchecked call inside an if block when require precedes it', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient, bool eligible) public { + require(msg.sender == address(0), "not auth"); + if (eligible) { + recipient.call(""); + } + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.lineStart).toBe(6); + }); + + it('should not treat external object method assert() as Solidity assert()', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool ok, ) = recipient.call(""); + verifier.assert(ok); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + }); + + describe('safe patterns: properly checked calls', () => { + it('should not flag require(success)', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient, uint256 amount) public { + (bool success, ) = recipient.call{value: amount}(""); + require(success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag require(success, "msg")', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + require(success, "Transfer failed"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag require(success == true)', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + require(success == true); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag require(true == success)', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + require(true == success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag assert(success)', async () => { + const code = ` +contract Safe { + function execute(address target, bytes memory data) public { + (bool success, ) = target.delegatecall(data); + assert(success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag if (!success) revert()', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + if (!success) revert("Call failed"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag if (!success) { revert(); } block', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + if (!success) { + revert CustomError(); + } + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag if (!success) return;', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + if (!success) return; + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag if (success) { ... } else { revert(); }', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + if (success) { + doSomething(); + } else { + revert("Failed"); + } + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag inline require(target.send(...))', async () => { + const code = ` +contract Safe { + function sendEth(address payable recipient) public { + require(recipient.send(1 ether), "Send failed"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag inline assert(target.send(...))', async () => { + const code = ` +contract Safe { + function sendEth(address payable recipient) public { + assert(recipient.send(1 ether)); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag inline if (!target.send(...)) revert()', async () => { + const code = ` +contract Safe { + function sendEth(address payable recipient) public { + if (!recipient.send(1 ether)) revert("Send failed"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag inline if (!target.send(...)) { revert(); }', async () => { + const code = ` +contract Safe { + function sendEth(address payable recipient) public { + if (!recipient.send(1 ether)) { + revert("Send failed"); + } + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag direct return of call expression', async () => { + const code = ` +contract Safe { + function forwardSend(address payable recipient) public returns (bool) { + return recipient.send(1 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag returning the captured boolean variable', async () => { + const code = ` +contract Safe { + function forwardCall(address target) public returns (bool) { + (bool ok, ) = target.call(""); + return ok; + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not flag safe require(success) when subsequent variable assignments exist', async () => { + const code = ` +contract Safe { + mapping(address => uint256) public balances; + function withdraw(address payable recipient, uint256 amount) public { + (bool ok, ) = recipient.call{value: amount}(""); + require(ok, "Transfer failed"); + balances[recipient] = 0; + uint256 x = 42; + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should handle nested parentheses in require((success))', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient) public { + (bool ok, ) = recipient.call(""); + require(((ok))); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should handle compound boolean expressions in require(amount > 0 && success)', async () => { + const code = ` +contract Safe { + function withdraw(address payable recipient, uint256 amount) public { + (bool ok, ) = recipient.call{value: amount}(""); + require(amount > 0 && ok, "Invalid"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + }); + + describe('scope isolation & independent checks', () => { + it('should not let a check in one function validate a call in another function', async () => { + const code = ` +contract MultiFunction { + function bad(address target) public { + (bool success, ) = target.call(""); + } + + function good(address target) public { + (bool success, ) = target.call(""); + require(success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.lineStart).toBe(4); + }); + + it('should flag independent calls in constructors and modifiers', async () => { + const code = ` +contract Modifiers { + constructor(address target) { + target.call(""); + } + modifier withCall(address target) { + (bool ok, ) = target.call(""); + _; + } + function safeFunc(address target) public { + (bool ok, ) = target.call(""); + require(ok); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(2); + }); + + it('should isolate functions across multiple contracts in the same file', async () => { + const code = ` +contract SafeContract { + function run(address target) public { + (bool ok, ) = target.call(""); + require(ok); + } +} +contract BadContract { + function run(address target) public { + (bool ok, ) = target.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + expect(findings[0]?.codeSnippet).toContain('target.call'); + }); + }); + + describe('exclusions & formatting edge cases', () => { + it('should ignore address.transfer() as it reverts automatically', async () => { + const code = ` +contract SafeTransfer { + function withdraw(address payable recipient) public { + recipient.transfer(1 ether); + payable(msg.sender).transfer(2 ether); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should ignore ERC20 token.transfer() calls', async () => { + const code = ` +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); +} +contract TokenHolder { + function sendTokens(IERC20 token, address to) public { + token.transfer(to, 100); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should ignore calls in single-line and multi-line comments', async () => { + const code = ` +contract Comments { + // a.call(""); + // (bool ok, ) = a.send(1); + /* + target.delegatecall(data); + (bool success, ) = target.call(""); + */ + function valid() public {} +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should not consider commented-out require() as a check', async () => { + const code = ` +contract Vulnerable { + function withdraw(address payable recipient) public { + (bool success, ) = recipient.call(""); + // require(success); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should ignore calls inside string literals', async () => { + const code = ` +contract Strings { + string msg1 = "recipient.call('') returns (bool, bytes)"; + string msg2 = 'recipient.send(1)'; + function test() public {} +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should ignore lookalike function names', async () => { + const code = ` +contract Lookalikes { + function callSomething(bytes memory) external {} + function callback() external {} + function sendMessage(string calldata) external {} + + function run() public { + callSomething(""); + callback(); + sendMessage("hello"); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(0); + }); + + it('should handle multi-line call statements with formatting', async () => { + const code = ` +contract MultiLine { + function pay(address payable recipient) public { + (bool success, ) = recipient.call{ + value: 1 ether, + gas: 20000 + }(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should handle multiple calls on the same line', async () => { + const code = ` +contract MultiOnLine { + function pay(address a, address b) public { + a.call(""); b.call(""); + } +}`; + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(2); + }); + + it('should handle CRLF line endings', async () => { + const code = [ + 'contract CRLF {', + ' function pay(address payable recipient) public {', + ' recipient.send(1 ether);', + ' }', + '}', + ].join('\r\n'); + const findings = await plugin.analyze(createCtx(code)); + expect(findings).toHaveLength(1); + }); + + it('should handle empty contract or whitespace gracefully', async () => { + expect(await plugin.analyze(createCtx(''))).toHaveLength(0); + expect(await plugin.analyze(createCtx(' \n\t '))).toHaveLength(0); + expect(await plugin.analyze(createCtx('contract Empty {}'))).toHaveLength(0); + expect(await plugin.analyze(createCtx('pragma solidity ^0.8.20;'))).toHaveLength(0); + }); + }); + + describe('internal helper unit tests', () => { + it('maskCommentsAndStrings should preserve character length and newlines', () => { + const input = 'contract A {\n // comment\n string s = "test";\n}'; + const masked = maskCommentsAndStrings(input); + expect(masked.length).toBe(input.length); + expect(masked.split('\n')).toHaveLength(input.split('\n').length); + expect(masked).not.toContain('comment'); + expect(masked).not.toContain('test'); + }); + + it('extractFunctionScopes should find function boundaries accurately', () => { + const code = 'contract A { function f1() public { a(); } function f2() external; }'; + const scopes = extractFunctionScopes(code); + const s0 = scopes[0]; + expect(s0).toBeDefined(); + if (!s0) throw new Error('Scope not found'); + expect(code.slice(s0.start, s0.end + 1)).toBe('{ a(); }'); + }); + + it('isValidTruthCheck should accurately accept and reject conditions', () => { + expect(isValidTruthCheck('ok', 'ok')).toBe(true); + expect(isValidTruthCheck('ok == true', 'ok')).toBe(true); + expect(isValidTruthCheck('true == ok', 'ok')).toBe(true); + expect(isValidTruthCheck('(ok)', 'ok')).toBe(true); + expect(isValidTruthCheck('((ok == true))', 'ok')).toBe(true); + expect(isValidTruthCheck('!ok', 'ok')).toBe(false); + expect(isValidTruthCheck('ok == false', 'ok')).toBe(false); + expect(isValidTruthCheck('false == ok', 'ok')).toBe(false); + expect(isValidTruthCheck('ok != true', 'ok')).toBe(false); + expect(isValidTruthCheck('true != ok', 'ok')).toBe(false); + expect(isValidTruthCheck('ok == trueFlag', 'ok')).toBe(false); + expect(isValidTruthCheck('trueFlag == ok', 'ok')).toBe(false); + }); + + it('isDirectlyChecked should identify inline validation', () => { + expect(isDirectlyChecked('return a.send(1);')).toBe(true); + expect(isDirectlyChecked('require(a.send(1), "msg");')).toBe(true); + expect(isDirectlyChecked('require(!a.send(1));')).toBe(false); + expect(isDirectlyChecked('if (!a.send(1)) revert();')).toBe(true); + expect(isDirectlyChecked('if (a.send(1)) emit S();')).toBe(false); + expect(isDirectlyChecked('if (a.send(1)) emit S(); else revert();')).toBe(true); + }); + + it('getAssignmentVar should extract variable names from tuples and declarations', () => { + expect(getAssignmentVar('(bool success, ) =')).toEqual({ + isAssigned: true, + varName: 'success', + }); + expect(getAssignmentVar('(bool success, bytes memory data) =')).toEqual({ + isAssigned: true, + varName: 'success', + }); + expect(getAssignmentVar('(, bytes memory data) =')).toEqual({ + isAssigned: true, + varName: null, + }); + expect(getAssignmentVar('bool sent =')).toEqual({ isAssigned: true, varName: 'sent' }); + expect(getAssignmentVar('sent =')).toEqual({ isAssigned: true, varName: 'sent' }); + expect(getAssignmentVar('a.call("");')).toEqual({ isAssigned: false, varName: null }); + }); + }); +}); diff --git a/plugins/unchecked-return/src/index.ts b/plugins/unchecked-return/src/index.ts new file mode 100644 index 0000000..493f01d --- /dev/null +++ b/plugins/unchecked-return/src/index.ts @@ -0,0 +1,692 @@ +import type { + AnalysisContext, + FindingResult, + IRulePlugin, + PluginMetadata, +} from '@veridion/scanner-types'; +import { FindingSeverity } from '@veridion/shared'; + +const metadata: PluginMetadata = { + id: 'unchecked-return', + name: 'Unchecked Return Value Detector', + version: '1.0.0', + description: + 'Detects low-level .call(), .send(), and .delegatecall() invocations where the boolean return value is not checked.', + severity: FindingSeverity.HIGH, + category: 'UNCHECKED_RETURN', + chains: ['ethereum', 'polygon', 'bsc', 'avalanche', 'arbitrum', 'optimism'], + languages: ['solidity'], + tags: [ + 'unchecked-return', + 'swc-104', + 'low-level-call', + 'call', + 'send', + 'delegatecall', + 'security', + ], + author: 'Veridion', + references: [ + 'https://swcregistry.io/docs/SWC-104', + 'https://consensys.github.io/smart-contract-best-practices/development-recommendations/general/external-calls/', + ], +}; + +interface Scope { + start: number; + end: number; +} + +interface CallSite { + callKind: string; + dotIndex: number; + callEnd: number; + lineStart: number; + lineEnd: number; + codeSnippet: string; +} + +export function maskCommentsAndStrings(source: string): string { + const chars = source.split(''); + const len = chars.length; + let i = 0; + + while (i < len) { + if (chars[i] === '/' && i + 1 < len && chars[i + 1] === '/') { + chars[i] = ' '; + chars[i + 1] = ' '; + i += 2; + while (i < len && chars[i] !== '\n') { + chars[i] = ' '; + i++; + } + } else if (chars[i] === '/' && i + 1 < len && chars[i + 1] === '*') { + chars[i] = ' '; + chars[i + 1] = ' '; + i += 2; + while (i < len && !(chars[i] === '*' && i + 1 < len && chars[i + 1] === '/')) { + if (chars[i] !== '\n') chars[i] = ' '; + i++; + } + if (i < len) { + chars[i] = ' '; + chars[i + 1] = ' '; + i += 2; + } + } else if (chars[i] === '"') { + chars[i] = ' '; + i++; + while (i < len && chars[i] !== '"') { + if (chars[i] === '\\' && i + 1 < len) { + chars[i] = ' '; + if (chars[i + 1] !== '\n') chars[i + 1] = ' '; + i += 2; + } else { + if (chars[i] !== '\n') chars[i] = ' '; + i++; + } + } + if (i < len) { + chars[i] = ' '; + i++; + } + } else if (chars[i] === "'") { + chars[i] = ' '; + i++; + while (i < len && chars[i] !== "'") { + if (chars[i] === '\\' && i + 1 < len) { + chars[i] = ' '; + if (chars[i + 1] !== '\n') chars[i + 1] = ' '; + i += 2; + } else { + if (chars[i] !== '\n') chars[i] = ' '; + i++; + } + } + if (i < len) { + chars[i] = ' '; + i++; + } + } else { + i++; + } + } + + return chars.join(''); +} + +export function extractFunctionScopes(sanitized: string): Scope[] { + const scopes: Scope[] = []; + const declRegex = /\b(function|constructor|modifier|receive|fallback)\b/g; + let match: RegExpExecArray | null; + + while ((match = declRegex.exec(sanitized)) !== null) { + let idx = match.index + match[0].length; + let foundBrace = false; + + while (idx < sanitized.length) { + const ch = sanitized[idx]; + if (ch === ';') { + break; + } + if (ch === '{') { + foundBrace = true; + break; + } + idx++; + } + + if (foundBrace) { + const braceStart = idx; + let depth = 1; + idx++; + while (idx < sanitized.length && depth > 0) { + if (sanitized[idx] === '{') depth++; + else if (sanitized[idx] === '}') depth--; + idx++; + } + scopes.push({ start: braceStart, end: idx - 1 }); + declRegex.lastIndex = idx; + } else { + declRegex.lastIndex = idx + 1; + } + } + + return scopes; +} + +export function getScopeForCall(scopes: Scope[], callIdx: number, totalLen: number): Scope { + for (const s of scopes) { + if (callIdx >= s.start && callIdx <= s.end) { + return s; + } + } + return { start: 0, end: totalLen }; +} + +export function parseCallSites(sanitized: string, originalSource: string): CallSite[] { + const sites: CallSite[] = []; + const originalLines = originalSource.split('\n'); + const callRegex = /(? 0) { + if (sanitized[i] === '{') braceDepth++; + else if (sanitized[i] === '}') braceDepth--; + i++; + } + while (i < sanitized.length && /\s/.test(sanitized[i] ?? '')) i++; + } + + if (sanitized[i] !== '(') { + continue; + } + + let parenDepth = 1; + i++; + while (i < sanitized.length && parenDepth > 0) { + if (sanitized[i] === '(') parenDepth++; + else if (sanitized[i] === ')') parenDepth--; + i++; + } + const callEnd = i; + + const lineStart = originalSource.slice(0, dotIndex).split('\n').length; + const lineEnd = originalSource.slice(0, callEnd).split('\n').length; + const codeSnippet = (originalLines[lineStart - 1] ?? match[0]).trim(); + + sites.push({ + callKind, + dotIndex, + callEnd, + lineStart, + lineEnd, + codeSnippet, + }); + } + + return sites; +} + +export function findStatementStart( + sanitized: string, + fromIndex: number, + scopeStart: number, +): number { + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + + for (let i = fromIndex - 1; i >= scopeStart; i--) { + const ch = sanitized[i]; + + if (ch === ')') { + parenDepth++; + } else if (ch === '(') { + if (parenDepth > 0) parenDepth--; + } else if (ch === ']') { + bracketDepth++; + } else if (ch === '[') { + if (bracketDepth > 0) bracketDepth--; + } else if (ch === '}') { + braceDepth++; + } else if (ch === '{') { + if (braceDepth > 0) { + braceDepth--; + } else { + return i + 1; + } + } else if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + if (ch === ';') { + return i + 1; + } + } + } + + return scopeStart; +} + +export function findStatementEnd(sanitized: string, fromIndex: number, scopeEnd: number): number { + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + + for (let i = fromIndex; i < scopeEnd; i++) { + const ch = sanitized[i]; + + if (ch === '(') { + parenDepth++; + } else if (ch === ')') { + if (parenDepth > 0) parenDepth--; + } else if (ch === '[') { + bracketDepth++; + } else if (ch === ']') { + if (bracketDepth > 0) bracketDepth--; + } else if (ch === '{') { + braceDepth++; + } else if (ch === '}') { + if (braceDepth > 0) { + braceDepth--; + } else { + return i; + } + } else if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + if (ch === ';') { + return i + 1; + } + } + } + + return scopeEnd; +} + +export function isValidTruthCheck(condition: string, varName: string): boolean { + let expr = condition.trim(); + + // Strip matching outer parentheses repeatedly: ((ok)) -> ok + while (expr.startsWith('(') && expr.endsWith(')')) { + let depth = 0; + let outerCoversAll = true; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === '(') depth++; + else if (expr[i] === ')') depth--; + if (depth === 0) { + outerCoversAll = false; + break; + } + } + if (outerCoversAll) { + expr = expr.slice(1, -1).trim(); + } else { + break; + } + } + + // 1. Rejection: Negations or inequality to true or equality to false + if (new RegExp(`!\\s*\\b${varName}\\b`).test(expr)) return false; + if (new RegExp(`\\b${varName}\\b\\s*==\\s*false\\b`).test(expr)) return false; + if (new RegExp(`\\bfalse\\b\\s*==\\s*\\b${varName}\\b`).test(expr)) return false; + if (new RegExp(`\\b${varName}\\b\\s*!=\\s*true\\b`).test(expr)) return false; + if (new RegExp(`\\btrue\\b\\s*!=\\s*\\b${varName}\\b`).test(expr)) return false; + + // 2. Rejection: Comparison to another variable/literal that is NOT boolean true + const eqAfter = expr.match(new RegExp(`\\b${varName}\\b\\s*==\\s*([A-Za-z0-9_$]+)`)); + if (eqAfter && eqAfter[1] !== 'true') return false; + + const eqBefore = expr.match(new RegExp(`([A-Za-z0-9_$]+)\\s*==\\s*\\b${varName}\\b`)); + if (eqBefore && eqBefore[1] !== 'true') return false; + + // 3. Positive truth check patterns: + if (new RegExp(`^\\b${varName}\\b$`).test(expr)) return true; + if (new RegExp(`^\\b${varName}\\b\\s*==\\s*true$`).test(expr)) return true; + if (new RegExp(`^true\\s*==\\s*\\b${varName}\\b$`).test(expr)) return true; + if (new RegExp(`(?:^|&&)\\s*\\b${varName}\\b\\s*(?:&&|$)`).test(expr)) return true; + if (new RegExp(`(?:^|&&)\\s*\\b${varName}\\b\\s*==\\s*true\\s*(?:&&|$)`).test(expr)) return true; + if (new RegExp(`(?:^|&&)\\s*true\\s*==\\s*\\b${varName}\\b\\s*(?:&&|$)`).test(expr)) return true; + + return false; +} + +export function isDirectlyChecked(fullStmt: string): boolean { + // 1. Direct return: return target.send(...); + if (/^return\b/.test(fullStmt)) { + return true; + } + + // 2. Direct require() or assert() + const reqMatch = fullStmt.match(/^(? 0) parenDepth--; + } else if (ch === '[') { + bracketDepth++; + } else if (ch === ']') { + if (bracketDepth > 0) bracketDepth--; + } else if (ch === '{') { + braceDepth++; + } else if (ch === '}') { + if (braceDepth > 0) braceDepth--; + } else if (parenDepth === 0 && bracketDepth === 0 && braceDepth === 0 && ch === '=') { + const prev = stmtPrefix[i - 1] ?? ''; + const next = stmtPrefix[i + 1] ?? ''; + if (prev !== '=' && prev !== '!' && prev !== '<' && prev !== '>' && next !== '=') { + eqIdx = i; + } + } + } + + if (eqIdx === -1) { + return { isAssigned: false, varName: null }; + } + + const lhs = stmtPrefix.slice(0, eqIdx).trim(); + + // Tuple assignment: (bool success, ) or (success, bytes memory data) or (, bytes memory data) + if (lhs.startsWith('(')) { + const tupleMatch = lhs.match(/^\(\s*(?:bool\s+)?([A-Za-z_$][\w$]*)\s*[,)]/); + if (tupleMatch && tupleMatch[1]) { + return { isAssigned: true, varName: tupleMatch[1] }; + } + return { isAssigned: true, varName: null }; + } + + // Single variable assignment: bool sent = or sent = + const singleMatch = lhs.match(/(?:\bbool\s+)?([A-Za-z_$][\w$]*)\s*$/); + if (singleMatch && singleMatch[1]) { + return { isAssigned: true, varName: singleMatch[1] }; + } + + return { isAssigned: true, varName: null }; +} + +export function isVarCheckedInScope( + sanitized: string, + fromIndex: number, + scopeEnd: number, + varName: string, +): boolean { + const varRegex = new RegExp(`\\b${varName}\\b`, 'g'); + varRegex.lastIndex = fromIndex; + + let match: RegExpExecArray | null; + while ((match = varRegex.exec(sanitized)) !== null) { + if (match.index >= scopeEnd) { + break; + } + + const occurrenceIdx = match.index; + const stmtStart = findStatementStart(sanitized, occurrenceIdx, fromIndex); + const stmtEnd = findStatementEnd(sanitized, occurrenceIdx, scopeEnd); + const stmtText = sanitized.slice(stmtStart, stmtEnd).trim(); + + // 1. Reassignment check: varName is on the LHS of an assignment + const singleReassign = new RegExp( + `^(?:bool\\s+)?\\b${varName}\\b\\s*(?:=(?!=)|\\+=|-=|\\*=|/=|%=|&=|\\|=|\\^=)`, + ).test(stmtText); + const tupleReassign = new RegExp( + `^\\(\\s*(?:bool\\s+)?\\b${varName}\\b\\s*[,)][^;=]*=\\s*[^=]`, + ).test(stmtText); + + if (singleReassign || tupleReassign) { + return false; + } + + // 2. require() or assert() validation + const reqMatch = stmtText.match(/^(?): Promise { + // noop + } + + // eslint-disable-next-line @typescript-eslint/require-await + async analyze(context: AnalysisContext): Promise { + if (!context.sourceCode || typeof context.sourceCode !== 'string') { + return []; + } + + if (!this.supportsContext(context)) { + return []; + } + + const findings: FindingResult[] = []; + const sourceCode = context.sourceCode; + const sanitized = maskCommentsAndStrings(sourceCode); + const scopes = extractFunctionScopes(sanitized); + const callSites = parseCallSites(sanitized, sourceCode); + + for (const site of callSites) { + const scope = getScopeForCall(scopes, site.dotIndex, sanitized.length); + const stmtStart = findStatementStart(sanitized, site.dotIndex, scope.start); + const stmtEnd = findStatementEnd(sanitized, site.callEnd, scope.end); + const fullStmt = sanitized.slice(stmtStart, stmtEnd).trim(); + + if (isDirectlyChecked(fullStmt)) { + continue; + } + + const prefix = sanitized.slice(stmtStart, site.dotIndex); + const { isAssigned, varName } = getAssignmentVar(prefix); + + if (isAssigned && varName !== null) { + if (isVarCheckedInScope(sanitized, stmtEnd, scope.end, varName)) { + continue; + } + } + + const description = varName + ? `Low-level .${site.callKind}() return value is captured in '${varName}' but never validated with require() or a reverting check. Failed calls continue execution silently (SWC-104).` + : `Low-level .${site.callKind}() return value is ignored. Low-level calls return a boolean indicating success or failure. If unhandled, failed calls continue execution silently (SWC-104).`; + + findings.push({ + pluginId: this.metadata.id, + title: `Unchecked Return Value from .${site.callKind}()`, + description, + severity: this.metadata.severity, + filePath: `${context.contractName}.sol`, + lineStart: site.lineStart, + lineEnd: site.lineEnd, + codeSnippet: site.codeSnippet, + recommendation: `Verify the return value using require(success, "Call failed") or revert on failure.`, + confidence: 0.9, + references: this.metadata.references ?? [], + }); + } + + findings.sort((a, b) => a.lineStart - b.lineStart); + return findings; + } + + getFixRecommendation(finding: FindingResult): string { + return `To fix the unchecked return value at ${finding.filePath}:${finding.lineStart}: + +1. Capture the boolean return value from the low-level call. +2. Check the return value using require(success, "Call failed") or revert on failure. + +Example fix: +\`\`\`solidity +(bool success, ) = recipient.call{value: amount}(""); +require(success, "Call failed"); +\`\`\` + +For .send(): +\`\`\`solidity +bool success = recipient.send(amount); +require(success, "Send failed"); +\`\`\` + +For .delegatecall(): +\`\`\`solidity +(bool success, bytes memory data) = target.delegatecall(callData); +require(success, "Delegatecall failed"); +\`\`\` + +Note: Native address.transfer() reverts automatically on failure and does not require a return value check.`; + } + + supportsContext(context: AnalysisContext): boolean { + const languageSupported = + !context.language || this.metadata.languages.includes(context.language); + const chainSupported = !context.chain || this.metadata.chains.includes(context.chain); + return languageSupported && chainSupported; + } +} diff --git a/plugins/unchecked-return/tsconfig.json b/plugins/unchecked-return/tsconfig.json new file mode 100644 index 0000000..54f3e6d --- /dev/null +++ b/plugins/unchecked-return/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@veridion/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/plugins/unchecked-return/vitest.config.ts b/plugins/unchecked-return/vitest.config.ts new file mode 100644 index 0000000..c5e89a5 --- /dev/null +++ b/plugins/unchecked-return/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f79a6f..dc06344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,7 +53,7 @@ importers: version: 5.23.0 '@nestjs/bullmq': specifier: ^10.1.1 - version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.80.5) + version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.80.5) '@nestjs/common': specifier: ^10.3.9 version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -74,10 +74,10 @@ importers: version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/swagger': specifier: ^7.3.1 - version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': specifier: ^6.5.0 - version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2) '@prisma/client': specifier: ^5.14.0 version: 5.22.0(prisma@5.22.0) @@ -156,7 +156,7 @@ importers: version: 10.2.3(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^10.3.9 - version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22) '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 @@ -556,6 +556,9 @@ importers: '@veridion/logger': specifier: workspace:* version: link:../logger + '@veridion/plugin-unchecked-return': + specifier: workspace:* + version: link:../../plugins/unchecked-return '@veridion/scanner-types': specifier: workspace:* version: link:../scanner-types @@ -854,6 +857,34 @@ importers: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.43)(jsdom@24.1.3)(terser@5.50.0) + plugins/unchecked-return: + dependencies: + '@veridion/logger': + specifier: workspace:* + version: link:../../packages/logger + '@veridion/scanner-types': + specifier: workspace:* + version: link:../../packages/scanner-types + '@veridion/shared': + specifier: workspace:* + version: link:../../packages/shared + devDependencies: + '@veridion/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint + '@veridion/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + eslint: + specifier: ^8.57.0 + version: 8.57.1 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.43)(jsdom@24.1.3)(terser@5.50.0) + packages: '@alloc/quick-lru@5.2.0': @@ -8557,15 +8588,15 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(bullmq@5.80.5)': + '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.80.5)': dependencies: - '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.80.5 @@ -8700,7 +8731,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + '@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -8715,7 +8746,7 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22))': + '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -8723,7 +8754,7 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) - '@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + '@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -10999,7 +11030,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -11021,7 +11052,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5(eslint-plugin-import@2.32.0)(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3