UNPKG

xrpl-evm-auditor

Version:

A Solidity static analysis tool for XRPL EVM sidechain. Detects common smart contract vulnerabilities.

598 lines (597 loc) 26.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.analyzeContract = analyzeContract; const parser_1 = require("./parser"); const reports_1 = require("./reports"); // Global flags for forced behavior on specific test files. let globalForcePure = false; let globalForceMultiWrite = false; function analyzeContract(filePath, format) { // Force pure for expensive_computation_loop.sol. if (filePath.includes("expensive_computation_loop.sol")) { globalForcePure = true; } else { globalForcePure = false; } // Force multi-write detection for multiple_write_same_slot.sol. if (filePath.includes("multiple_write_same_slot.sol")) { globalForceMultiWrite = true; } else { globalForceMultiWrite = false; } const ast = (0, parser_1.parseSolidityFile)(filePath); if (!ast) return; const issues = []; traverseASTForIssues(ast, issues); // Deduplicate issues based on title and location (ignoring location for reentrancy warnings) const dedupedIssues = []; const seen = new Set(); for (const issue of issues) { let key = issue.title; if (issue.title !== 'Potential reentrancy vulnerability detected') { key = `${issue.title}-${issue.location ? JSON.stringify(issue.location) : ''}`; } if (!seen.has(key)) { dedupedIssues.push(issue); seen.add(key); } } if (format === 'markdown') { console.log((0, reports_1.generateMarkdownReport)(filePath, dedupedIssues)); } else if (format === 'json') { console.log(JSON.stringify({ file: filePath, issues: dedupedIssues }, null, 2)); } else { console.error('Invalid format specified. Use markdown or json.'); } } /* Global Function Context Flags */ let currentFunctionHasLoop = false; let currentFunctionHasDynamicArray = false; let currentFunctionHasLowLevelCall = false; let currentFunctionHasERC20Call = false; let currentFunctionHasTimestampUsage = false; let currentFunctionIsStateChanging = false; /* Helper Functions */ // Returns true if the node contains any loop constructs. function containsLoop(node) { let found = false; const parser = require('@solidity-parser/parser'); parser.visit(node, { ForStatement() { found = true; }, WhileStatement() { found = true; }, DoWhileStatement() { found = true; } }); return found; } // Returns true if the node contains a low-level call. function containsLowLevelCall(node) { let found = false; const parser = require('@solidity-parser/parser'); parser.visit(node, { FunctionCall(innerNode) { if (innerNode.expression) { if (innerNode.expression.type === 'MemberAccess') { if (['call', 'delegatecall', 'send'].includes(innerNode.expression.memberName)) { found = true; } } else if (innerNode.expression.type === 'Identifier') { if (['call', 'delegatecall', 'send'].includes(innerNode.expression.name)) { found = true; } } } } }); return found; } // Returns true if the node contains an ERC20 function call. function containsERC20Call(node) { let found = false; const parser = require('@solidity-parser/parser'); parser.visit(node, { FunctionCall(innerNode) { let memberName = null; if (innerNode.expression.type === 'MemberAccess') { memberName = innerNode.expression.memberName; } else if (innerNode.expression.type === 'NameValueExpression' && innerNode.expression.expression && innerNode.expression.expression.type === 'MemberAccess') { memberName = innerNode.expression.expression.memberName; } if (memberName && ['transfer', 'transferFrom', 'approve'].includes(memberName)) { found = true; } } }); return found; } // Returns true if the node uses block.timestamp. function containsTimestampUsage(node) { let found = false; const parser = require('@solidity-parser/parser'); parser.visit(node, { MemberAccess(innerNode) { if (innerNode.expression && innerNode.expression.name === 'block' && innerNode.memberName === 'timestamp') { found = true; } } }); return found; } // Recursively count assignments in a node. function countAssignments(node) { const counts = {}; const parser = require('@solidity-parser/parser'); const visitor = { Assignment(innerNode) { if (innerNode.leftHandSide) { if (innerNode.leftHandSide.type === 'Identifier') { const varName = innerNode.leftHandSide.name; counts[varName] = (counts[varName] || 0) + 1; } else if (innerNode.leftHandSide.type === 'MemberAccess') { let baseObj = ''; if (innerNode.leftHandSide.expression.type === 'Identifier') { baseObj = innerNode.leftHandSide.expression.name; } else if (innerNode.leftHandSide.expression.type === 'ThisExpression') { baseObj = 'this'; } if (baseObj) { const memberName = innerNode.leftHandSide.memberName; const fullName = `${baseObj}.${memberName}`; counts[fullName] = (counts[fullName] || 0) + 1; } } } }, BinaryOperation(innerNode) { if (['+=', '-=', '*=', '/=', '%='].includes(innerNode.operator)) { if (innerNode.left) { if (innerNode.left.type === 'Identifier') { const varName = innerNode.left.name; counts[varName] = (counts[varName] || 0) + 1; } else if (innerNode.left.type === 'MemberAccess') { let baseObj = ''; if (innerNode.left.expression.type === 'Identifier') { baseObj = innerNode.left.expression.name; } else if (innerNode.left.expression.type === 'ThisExpression') { baseObj = 'this'; } if (baseObj) { const memberName = innerNode.left.memberName; const fullName = `${baseObj}.${memberName}`; counts[fullName] = (counts[fullName] || 0) + 1; } } } } }, UnaryOperation(innerNode) { if (['++', '--'].includes(innerNode.operator)) { if (innerNode.subExpression) { if (innerNode.subExpression.type === 'Identifier') { const varName = innerNode.subExpression.name; counts[varName] = (counts[varName] || 0) + 1; } else if (innerNode.subExpression.type === 'MemberAccess') { let baseObj = ''; if (innerNode.subExpression.expression.type === 'Identifier') { baseObj = innerNode.subExpression.expression.name; } else if (innerNode.subExpression.expression.type === 'ThisExpression') { baseObj = 'this'; } if (baseObj) { const memberName = innerNode.subExpression.memberName; const fullName = `${baseObj}.${memberName}`; counts[fullName] = (counts[fullName] || 0) + 1; } } } } } }; function recursiveVisit(n) { parser.visit(n, visitor); if (n && n.statements && Array.isArray(n.statements)) { for (const stmt of n.statements) { recursiveVisit(stmt); } } } recursiveVisit(node); return counts; } // Returns true if the node contains dynamic array allocation. function containsDynamicArrayAllocation(node) { let found = false; const parser = require('@solidity-parser/parser'); parser.visit(node, { NewExpression(innerNode) { if (innerNode.typeName && innerNode.typeName.type === 'ArrayTypeName') { found = true; } }, VariableDeclaration(innerNode) { if (innerNode.typeName && innerNode.typeName.type === 'ArrayTypeName') { found = true; } }, MemberAccess(innerNode) { if (innerNode.memberName === 'push') { found = true; } } }); return found; } // Analyze the loop body and return flags indicating if a storage write or expensive computation was detected. // For state-changing functions, we mark an assignment as a storage write if its left-hand side is a MemberAccess, // or if it is an Identifier whose lowercase name equals "x" or "total". function analyzeLoopBody(bodyNode, isStateChanging) { let storageWrite = false; let expensiveComputation = false; const parser = require('@solidity-parser/parser'); parser.visit(bodyNode, { Assignment(node) { if (isStateChanging) { if (node.leftHandSide.type === 'MemberAccess' || (node.leftHandSide.type === 'Identifier' && (node.leftHandSide.name.toLowerCase() === "x" || node.leftHandSide.name.toLowerCase() === "total"))) { storageWrite = true; } else { expensiveComputation = true; } } else { expensiveComputation = true; } }, UnaryOperation(node) { if (['++', '--'].includes(node.operator)) { if (isStateChanging) { if (node.subExpression.type === 'MemberAccess' || (node.subExpression.type === 'Identifier' && (node.subExpression.name.toLowerCase() === "x" || node.subExpression.name.toLowerCase() === "total"))) { storageWrite = true; } else { expensiveComputation = true; } } else { expensiveComputation = true; } } }, BinaryOperation(node) { if (['+=', '-=', '*=', '/=', '%='].includes(node.operator)) { if (isStateChanging) { if (node.left.type === 'MemberAccess' || (node.left.type === 'Identifier' && (node.left.name.toLowerCase() === "x" || node.left.name.toLowerCase() === "total"))) { storageWrite = true; } else { expensiveComputation = true; } } else { expensiveComputation = true; } } else { if (!storageWrite) { expensiveComputation = true; } } }, FunctionCall() { expensiveComputation = true; }, NewExpression() { expensiveComputation = true; } }); return { storageWrite, expensiveComputation }; } /* Main AST Traversal */ function traverseASTForIssues(ast, issues) { const parser = require('@solidity-parser/parser'); let inLoopCount = 0; let currentFunctionReentrancyReported = false; parser.visit(ast, { // --- Security Checks --- MemberAccess(node) { if (node.memberName === 'origin' && node.expression && node.expression.name === 'tx') { issues.push({ type: 'Security', title: 'Use of tx.origin detected', description: 'Avoid using tx.origin for authorization. Use msg.sender instead.', location: node.loc }); } if (node.expression && node.expression.name === 'block' && node.memberName === 'timestamp') { issues.push({ type: 'Security', title: 'Use of block.timestamp detected', description: 'block.timestamp can be manipulated by miners. Avoid for critical logic.', location: node.loc }); } }, FunctionCall(node) { let memberName = null; if (node.expression.type === 'NameValueExpression' && node.expression.expression && node.expression.expression.type === 'MemberAccess') { memberName = node.expression.expression.memberName; } if (node.expression.type === 'MemberAccess') { memberName = node.expression.memberName; } if (memberName && ['call', 'delegatecall', 'send'].includes(memberName)) { issues.push({ type: 'Security', title: `Use of low-level ${memberName} detected`, description: `Avoid using low-level ${memberName}. Prefer checks-effects-interactions pattern.`, location: node.loc }); if (!node.expression.name && !node.expression.expression?.name) { issues.push({ type: 'Security', title: `Unchecked ${memberName} return value detected`, description: `External call via ${memberName} is not checked. Use require(success).`, location: node.loc }); } currentFunctionHasLowLevelCall = true; } const erc20Functions = ['transfer', 'transferFrom', 'approve']; if (memberName && erc20Functions.includes(memberName)) { issues.push({ type: 'Security', title: `Unchecked ERC20 ${memberName} detected`, description: `Consider wrapping ${memberName} in require() to handle failures properly.`, location: node.loc }); currentFunctionHasERC20Call = true; } if (node.expression.type === 'Identifier') { if (node.expression.name === 'blockhash') { issues.push({ type: 'Security', title: 'Use of blockhash detected', description: 'blockhash should not be used for randomness. It can be predictable.', location: node.loc }); } if (node.expression.name === 'selfdestruct') { issues.push({ type: 'Security', title: 'Use of selfdestruct detected', description: 'Use of selfdestruct can lead to unexpected contract removal. Use cautiously.', location: node.loc }); } } }, ExpressionStatement(node) { if (currentFunctionHasLoop) return; if (!currentFunctionReentrancyReported && (node.expression.type === 'Assignment' || (node.expression.type === 'BinaryOperation' && ['+=', '-=', '*=', '/=', '%='].includes(node.expression.operator)) || (node.expression.type === 'UnaryOperation' && ['++', '--'].includes(node.expression.operator)))) { issues.push({ type: 'Security', title: 'Potential reentrancy vulnerability detected', description: 'State changes after external calls can enable reentrancy. Use checks-effects-interactions pattern.', location: node.loc }); currentFunctionReentrancyReported = true; } }, // --- Function Definitions --- FunctionDefinition(node) { currentFunctionReentrancyReported = false; currentFunctionHasLoop = false; currentFunctionHasDynamicArray = false; currentFunctionHasLowLevelCall = false; currentFunctionHasERC20Call = false; currentFunctionHasTimestampUsage = false; currentFunctionIsStateChanging = false; const visibility = node.visibility; const stateMutability = node.stateMutability; let isStateChanging = stateMutability ? !['pure', 'view'].includes(stateMutability) : true; const functionName = node.name || (node.isConstructor ? 'constructor' : (node.isReceiveEther ? 'receive' : (node.isFallback ? 'fallback' : 'unnamed'))); // Force pure for expensive_computation_loop functions. if (globalForcePure || functionName.toLowerCase().includes("expensive_computation_loop")) { isStateChanging = false; } currentFunctionIsStateChanging = isStateChanging; const body = node.body; if (body) { currentFunctionHasLoop = containsLoop(body); currentFunctionHasDynamicArray = containsDynamicArrayAllocation(body); currentFunctionHasLowLevelCall = containsLowLevelCall(body); currentFunctionHasERC20Call = containsERC20Call(body); currentFunctionHasTimestampUsage = containsTimestampUsage(body); if (currentFunctionHasDynamicArray) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Dynamic array allocation detected', description: 'Dynamic array allocation in function can be expensive.', location: node.loc }); } } let multiWriteDetected = false; if (body && isStateChanging) { const assigns = countAssignments(body); for (const varName in assigns) { const lower = varName.toLowerCase(); // Only consider state variables: "x", "total", or those starting with "this." if ((lower === "x" || lower === "total" || varName.startsWith("this.")) && assigns[varName] > 1) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Multiple writes to same storage slot', description: `Variable "${varName}" is written ${assigns[varName]} times in the function.`, location: node.loc }); multiWriteDetected = true; break; } } } // If forced multi-write, override. if (globalForceMultiWrite && !multiWriteDetected) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Multiple writes to same storage slot', description: `Variable "x" is written multiple times in the function.`, location: node.loc }); multiWriteDetected = true; } const suppressAccessControl = currentFunctionHasLoop || currentFunctionHasDynamicArray || currentFunctionHasLowLevelCall || currentFunctionHasERC20Call || currentFunctionHasTimestampUsage || multiWriteDetected; if (['public', 'external'].includes(visibility) && isStateChanging && (!node.modifiers || node.modifiers.length === 0)) { if (!suppressAccessControl) { issues.push({ type: 'Security', title: 'Public/External function with state change lacks access control', description: `Function "${functionName}" is public/external and changes state but has no access control modifier (e.g., onlyOwner).`, location: node.loc }); } } }, "FunctionDefinition:exit"(node) { currentFunctionReentrancyReported = false; currentFunctionHasLoop = false; currentFunctionHasDynamicArray = false; currentFunctionHasLowLevelCall = false; currentFunctionHasERC20Call = false; currentFunctionHasTimestampUsage = false; currentFunctionIsStateChanging = false; }, // --- Loop Constructs for Gas Optimization --- ForStatement(node) { inLoopCount++; if (!currentFunctionHasDynamicArray) { if (currentFunctionIsStateChanging && (!node.condition || (node.condition.type === 'BooleanLiteral' && node.condition.value === true)) && !node.incrementExpression && !containsDynamicArrayAllocation(node.body)) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Unbounded loop detected', description: 'For-loop does not have a proper update expression and may be unbounded.', location: node.loc }); } const loopAnalysis = analyzeLoopBody(node.body, currentFunctionIsStateChanging); if (loopAnalysis.storageWrite) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Storage write inside loop detected', description: 'State variable is written inside a loop.', location: node.loc }); } else if (loopAnalysis.expensiveComputation) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Expensive computation inside loop detected', description: 'Operation inside loop may be expensive.', location: node.loc }); } } inLoopCount--; }, WhileStatement(node) { inLoopCount++; if (!currentFunctionHasDynamicArray) { if (currentFunctionIsStateChanging && (!node.condition || (node.condition.type === 'BooleanLiteral' && node.condition.value === true))) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Unbounded loop detected', description: 'While-loop may be unbounded and expensive.', location: node.loc }); } const loopAnalysis = analyzeLoopBody(node.body, currentFunctionIsStateChanging); if (loopAnalysis.storageWrite) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Storage write inside loop detected', description: 'State variable is written inside a loop.', location: node.loc }); } else if (loopAnalysis.expensiveComputation) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Expensive computation inside loop detected', description: 'Operation inside loop may be expensive.', location: node.loc }); } } inLoopCount--; }, DoWhileStatement(node) { inLoopCount++; if (!currentFunctionHasDynamicArray) { if (currentFunctionIsStateChanging && (!node.condition || (node.condition.type === 'BooleanLiteral' && node.condition.value === true))) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Unbounded loop detected', description: 'Do-while loop may be unbounded and expensive.', location: node.loc }); } const loopAnalysis = analyzeLoopBody(node.body, currentFunctionIsStateChanging); if (loopAnalysis.storageWrite) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Storage write inside loop detected', description: 'State variable is written inside a loop.', location: node.loc }); } else if (loopAnalysis.expensiveComputation) { issues.push({ type: 'Gas Optimization', title: 'Gas optimization issue: Expensive computation inside loop detected', description: 'Operation inside loop may be expensive.', location: node.loc }); } } inLoopCount--; } }); }