diff --git a/rules/auditability/interfaces/detect-missing-contract-interface.ts b/rules/auditability/interfaces/detect-missing-contract-interface.ts new file mode 100644 index 0000000..d612b6a --- /dev/null +++ b/rules/auditability/interfaces/detect-missing-contract-interface.ts @@ -0,0 +1,97 @@ +export interface MissingInterfaceViolation { + type: string; + line: number; + description: string; + recommendation: string; +} + +export interface MissingContractInterfaceResult { + detected: boolean; + violations: MissingInterfaceViolation[]; + message: string; + suggestion: string; +} + +interface DirectInteractionPattern { + type: string; + pattern: RegExp; + description: string; + recommendation: string; +} + +const INTERACTION_PATTERNS: DirectInteractionPattern[] = [ + { + type: "direct-abi-encode", + pattern: + /abi\.encodeWithSignature|abi\.encodeWithSelector|abi\.encodeCall/g, + description: + "Direct ABI encoding bypasses type safety and interface validation.", + recommendation: + "Define and import an interface (e.g., IERC20) instead of using raw abi.encodeWithSignature calls.", + }, + { + type: "raw-address-cast", + pattern: /(?:I[A-Z]\w*)?\(\s*address\s*\(/g, + description: + "Casting an address to a contract type without a proper interface may hide interface mismatches.", + recommendation: + "Use a well-defined interface and cast to the interface type, not raw address.", + }, + { + type: "inline-interface", + pattern: /interface\s+\w+\s*\{[^}]*\bfunction\b[^}]*\}\s*$/gm, + description: + "Inline or local interface definitions may lead to duplication and maintenance issues.", + recommendation: + "Extract interfaces into shared modules for reuse and consistency across the codebase.", + }, +]; + +function lineNumber(code: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (code[i] === "\n") line++; + } + return line; +} + +export function detectMissingContractInterface( + code: string, +): MissingContractInterfaceResult { + const violations: MissingInterfaceViolation[] = []; + + for (const { + type, + pattern, + description, + recommendation, + } of INTERACTION_PATTERNS) { + const re = new RegExp(pattern.source, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(code)) !== null) { + violations.push({ + type, + line: lineNumber(code, match.index), + description, + recommendation, + }); + } + } + + if (violations.length === 0) { + return { + detected: false, + violations: [], + message: "No missing contract interface issues detected.", + suggestion: "", + }; + } + + return { + detected: true, + violations, + message: `Detected ${violations.length} instance(s) of missing or improper interface usage.`, + suggestion: + "Always use well-defined interfaces for external contract interactions to improve readability and safety.", + }; +} diff --git a/rules/security/external-calls/detect-unsafe-low-level-calls.ts b/rules/security/external-calls/detect-unsafe-low-level-calls.ts new file mode 100644 index 0000000..b82ce36 --- /dev/null +++ b/rules/security/external-calls/detect-unsafe-low-level-calls.ts @@ -0,0 +1,96 @@ +export interface UnsafeLowLevelCall { + type: string; + line: number; + description: string; + recommendation: string; +} + +export interface UnsafeLowLevelCallsResult { + detected: boolean; + calls: UnsafeLowLevelCall[]; + message: string; + suggestion: string; +} + +interface LowLevelCallPattern { + type: string; + pattern: RegExp; + description: string; + recommendation: string; +} + +const LOW_LEVEL_PATTERNS: LowLevelCallPattern[] = [ + { + type: "raw-call", + pattern: /\.call\s*\(/g, + description: + "Low-level .call() bypasses safety checks and may propagate errors silently.", + recommendation: + "Use higher-level abstractions (e.g. ISomeInterface) instead of raw .call(). Always check the return value.", + }, + { + type: "unchecked-call-result", + pattern: /\.call\s*\{[^}]*\}\s*\([^;]*\);(?!\s*require)/g, + description: + "Raw .call() result is not checked. Failed calls will not revert the transaction.", + recommendation: + "Use require(success) after .call() or use the ReentrancyGuard pattern when interacting with external contracts.", + }, + { + type: "delegatecall-usage", + pattern: /\.delegatecall\s*\(/g, + description: + "delegatecall() executes code in the caller context and may corrupt state.", + recommendation: + "Avoid delegatecall() where possible. When necessary, ensure the target contract is trusted and properly validated.", + }, +]; + +function lineNumber(code: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (code[i] === "\n") line++; + } + return line; +} + +export function detectUnsafeLowLevelCalls( + code: string, +): UnsafeLowLevelCallsResult { + const calls: UnsafeLowLevelCall[] = []; + + for (const { + type, + pattern, + description, + recommendation, + } of LOW_LEVEL_PATTERNS) { + const re = new RegExp(pattern.source, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(code)) !== null) { + calls.push({ + type, + line: lineNumber(code, match.index), + description, + recommendation, + }); + } + } + + if (calls.length === 0) { + return { + detected: false, + calls: [], + message: "No unsafe low-level calls detected.", + suggestion: "", + }; + } + + return { + detected: true, + calls, + message: `Detected ${calls.length} unsafe low-level call(s): ${[...new Set(calls.map((c) => c.type))].join(", ")}.`, + suggestion: + "Use Solidity higher-level interfaces or safe wrappers. Always validate return values from external calls.", + }; +} diff --git a/rules/security/math/detect-unsafe-integer-downcasting.ts b/rules/security/math/detect-unsafe-integer-downcasting.ts new file mode 100644 index 0000000..d4179bf --- /dev/null +++ b/rules/security/math/detect-unsafe-integer-downcasting.ts @@ -0,0 +1,97 @@ +export interface UnsafeDowncast { + type: string; + line: number; + description: string; + recommendation: string; +} + +export interface UnsafeIntegerDowncastingResult { + detected: boolean; + downcasts: UnsafeDowncast[]; + message: string; + suggestion: string; +} + +interface DowncastPattern { + type: string; + pattern: RegExp; + description: string; + recommendation: string; +} + +const DOWNCAST_PATTERNS: DowncastPattern[] = [ + { + type: "explicit-narrowing-cast", + pattern: /\b(?:uint\d+\(|int\d+\()\s*[a-zA-Z_]\w*\s*\)/g, + description: + "Explicit integer downcast may silently truncate value if the source exceeds the target range.", + recommendation: + "Use OpenZeppelin SafeCast or add an explicit bounds check before downcasting.", + }, + { + type: "solidity-downcast", + pattern: + /\buint(?:8|16|24|32|40|48|56|64|72|80|88|96|104|112|120|128)\s*\(/g, + description: + "Downcasting to smaller unsigned integer types may truncate high-order bits without warning.", + recommendation: + "Validate the value fits in the target type using SafeCast library or manual range checks.", + }, + { + type: "int-downcast", + pattern: /\bint(?:8|16|24|32|40|48|56|64|72|80|88|96|104|112|120)\s*\(/g, + description: + "Downcasting to smaller signed integer types may silently truncate or produce unexpected negative values.", + recommendation: + "Use safe casting utilities and verify the value is within the valid range of the target type.", + }, +]; + +function lineNumber(code: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (code[i] === "\n") line++; + } + return line; +} + +export function detectUnsafeIntegerDowncasting( + code: string, +): UnsafeIntegerDowncastingResult { + const downcasts: UnsafeDowncast[] = []; + + for (const { + type, + pattern, + description, + recommendation, + } of DOWNCAST_PATTERNS) { + const re = new RegExp(pattern.source, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(code)) !== null) { + downcasts.push({ + type, + line: lineNumber(code, match.index), + description, + recommendation, + }); + } + } + + if (downcasts.length === 0) { + return { + detected: false, + downcasts: [], + message: "No unsafe integer downcasting detected.", + suggestion: "", + }; + } + + return { + detected: true, + downcasts, + message: `Detected ${downcasts.length} unsafe integer downcast(s).`, + suggestion: + "Use SafeCast from OpenZeppelin or add bounds checks before downcasting to prevent silent truncation.", + }; +} diff --git a/rules/security/tokens/detect-improper-erc20-approval.ts b/rules/security/tokens/detect-improper-erc20-approval.ts new file mode 100644 index 0000000..2410e8e --- /dev/null +++ b/rules/security/tokens/detect-improper-erc20-approval.ts @@ -0,0 +1,88 @@ +export interface ImproperApproval { + type: string; + line: number; + description: string; + recommendation: string; +} + +export interface ImproperERC20ApprovalResult { + detected: boolean; + approvals: ImproperApproval[]; + message: string; + suggestion: string; +} + +interface ApprovalPattern { + type: string; + pattern: RegExp; + description: string; + recommendation: string; +} + +const APPROVAL_PATTERNS: ApprovalPattern[] = [ + { + type: "direct-approve-overwrite", + pattern: /\.approve\([^)]*\)/g, + description: + "Direct approve() call may overwrite existing allowance, enabling race conditions.", + recommendation: + "Use increaseAllowance() / decreaseAllowance() instead of approve() to prevent front-running attacks.", + }, + { + type: "unchecked-approval-return", + pattern: /approve\([^)]*\);(?!\s*\{)/g, + description: + "approve() return value is not checked. The call may fail silently.", + recommendation: + "Check the boolean return value of approve() using require() or a safe wrapper like safeApprove().", + }, +]; + +function lineNumber(code: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (code[i] === "\n") line++; + } + return line; +} + +export function detectImproperERC20Approval( + code: string, +): ImproperERC20ApprovalResult { + const approvals: ImproperApproval[] = []; + + for (const { + type, + pattern, + description, + recommendation, + } of APPROVAL_PATTERNS) { + const re = new RegExp(pattern.source, "g"); + let match: RegExpExecArray | null; + while ((match = re.exec(code)) !== null) { + approvals.push({ + type, + line: lineNumber(code, match.index), + description, + recommendation, + }); + } + } + + if (approvals.length === 0) { + return { + detected: false, + approvals: [], + message: "No improper ERC20 approval patterns detected.", + suggestion: "", + }; + } + + return { + detected: true, + approvals, + message: `Detected ${approvals.length} improper ERC20 approval pattern(s).`, + suggestion: + "Replace direct approve() with increaseAllowance() / decreaseAllowance() and always check return values.", + }; +} diff --git a/tests/rules/detect-improper-erc20-approval.spec.ts b/tests/rules/detect-improper-erc20-approval.spec.ts new file mode 100644 index 0000000..f7eb0a2 --- /dev/null +++ b/tests/rules/detect-improper-erc20-approval.spec.ts @@ -0,0 +1,22 @@ +import { detectImproperERC20Approval } from '../../rules/security/tokens/detect-improper-erc20-approval'; + +describe('detectImproperERC20Approval', () => { + it('flags direct approve() calls', () => { + const code = `token.approve(spender, amount);`; + const result = detectImproperERC20Approval(code); + expect(result.detected).toBe(true); + expect(result.approvals.some(a => a.type === 'direct-approve-overwrite')).toBe(true); + }); + + it('flags unchecked approve return values', () => { + const code = `erc20.approve(address(this), 1000);`; + const result = detectImproperERC20Approval(code); + expect(result.detected).toBe(true); + }); + + it('returns clean for no approval usage', () => { + const code = `function transfer(to, amount) { balance -= amount; }`; + const result = detectImproperERC20Approval(code); + expect(result.detected).toBe(false); + }); +}); diff --git a/tests/rules/detect-missing-contract-interface.spec.ts b/tests/rules/detect-missing-contract-interface.spec.ts new file mode 100644 index 0000000..b6ccad1 --- /dev/null +++ b/tests/rules/detect-missing-contract-interface.spec.ts @@ -0,0 +1,22 @@ +import { detectMissingContractInterface } from '../../rules/auditability/interfaces/detect-missing-contract-interface'; + +describe('detectMissingContractInterface', () => { + it('flags direct abi.encodeWithSignature calls', () => { + const code = `bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", to, amount);`; + const result = detectMissingContractInterface(code); + expect(result.detected).toBe(true); + expect(result.violations.some(v => v.type === 'direct-abi-encode')).toBe(true); + }); + + it('flags raw address casts', () => { + const code = `IERC20(address(contractAddr)).transfer(to, amount);`; + const result = detectMissingContractInterface(code); + expect(result.detected).toBe(true); + }); + + it('returns clean for proper interface usage', () => { + const code = `import "./IERC20.sol";\nIERC20(token).transfer(to, amount);`; + const result = detectMissingContractInterface(code); + expect(result.detected).toBe(false); + }); +}); diff --git a/tests/rules/detect-unsafe-integer-downcasting.spec.ts b/tests/rules/detect-unsafe-integer-downcasting.spec.ts new file mode 100644 index 0000000..8f8b05f --- /dev/null +++ b/tests/rules/detect-unsafe-integer-downcasting.spec.ts @@ -0,0 +1,30 @@ +import { detectUnsafeIntegerDowncasting } from '../../rules/security/math/detect-unsafe-integer-downcasting'; + +describe('detectUnsafeIntegerDowncasting', () => { + it('flags uint256 to uint8 downcast', () => { + const code = `uint8 small = uint8(largeValue);`; + const result = detectUnsafeIntegerDowncasting(code); + expect(result.detected).toBe(true); + expect(result.downcasts.some(d => d.type === 'explicit-narrowing-cast')).toBe(true); + }); + + it('flags solidity uint downcast pattern', () => { + const code = `uint128(amount);`; + const result = detectUnsafeIntegerDowncasting(code); + expect(result.detected).toBe(true); + expect(result.downcasts.some(d => d.type === 'solidity-downcast')).toBe(true); + }); + + it('flags int256 to int64 downcast', () => { + const code = `int64(value);`; + const result = detectUnsafeIntegerDowncasting(code); + expect(result.detected).toBe(true); + expect(result.downcasts.some(d => d.type === 'int-downcast')).toBe(true); + }); + + it('returns clean for safe code without downcasting', () => { + const code = `uint256 total = sum + 1;`; + const result = detectUnsafeIntegerDowncasting(code); + expect(result.detected).toBe(false); + }); +}); diff --git a/tests/rules/detect-unsafe-low-level-calls.spec.ts b/tests/rules/detect-unsafe-low-level-calls.spec.ts new file mode 100644 index 0000000..a940028 --- /dev/null +++ b/tests/rules/detect-unsafe-low-level-calls.spec.ts @@ -0,0 +1,30 @@ +import { detectUnsafeLowLevelCalls } from '../../rules/security/external-calls/detect-unsafe-low-level-calls'; + +describe('detectUnsafeLowLevelCalls', () => { + it('flags raw .call() usage', () => { + const code = `(bool success, ) = address(target).call(data);`; + const result = detectUnsafeLowLevelCalls(code); + expect(result.detected).toBe(true); + expect(result.calls.some(c => c.type === 'raw-call')).toBe(true); + }); + + it('flags .delegatecall() usage', () => { + const code = `(bool ok, ) = target.delegatecall(data);`; + const result = detectUnsafeLowLevelCalls(code); + expect(result.detected).toBe(true); + expect(result.calls.some(c => c.type === 'delegatecall-usage')).toBe(true); + }); + + it('flags unchecked call result', () => { + const code = `target.call{value: amount}("");`; + const result = detectUnsafeLowLevelCalls(code); + expect(result.detected).toBe(true); + expect(result.calls.some(c => c.type === 'unchecked-call-result')).toBe(true); + }); + + it('returns clean for safe code', () => { + const code = `function safeTransfer(to, amount) { IERC20(token).transfer(to, amount); }`; + const result = detectUnsafeLowLevelCalls(code); + expect(result.detected).toBe(false); + }); +});