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
JavaScript
"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--;
}
});
}