truffle-analyze
Version:
Add vulnerability and weakness analysis via the MythX
340 lines (289 loc) • 11 kB
JavaScript
;
const fs = require('fs');
const armlet = require('armlet');
const mythx = require('./lib/mythx');
const trufstuf = require('./lib/trufstuf');
const { MythXIssues } = require('./lib/issues2eslint');
const contracts = require('truffle-workflow-compile');
const util = require('util');
const readFile = util.promisify(fs.readFile);
const contractsCompile = util.promisify(contracts.compile);
/**
*
* Loads preferred ESLint formatter for warning reports.
*
* @param {String} config
* @returns ESLint formatter module
*/
function getFormatter(style) {
const formatterName = style || 'stylish';
try {
return require(`eslint/lib/formatters/${formatterName}`);
} catch (ex) {
ex.message = `\nThere was a problem loading formatter option: ${style} \nError: ${
ex.message
}`;
throw ex;
}
}
/**
*
* Returns a JSON object from a version response. Each attribute/key
* is a tool name and the value is a version string of the tool.
*
* @param {Object} jsonResponse
* @returns string A comma-separated string of tool: version
*/
function versionJSON2String(jsonResponse) {
return Object.keys(jsonResponse).map((key) => `${key}: ${jsonResponse[key]}`).join(', ');
}
/**
*
* Handles: truffle run analyze --help
*
* @returns promise which resolves after help is shown
*/
function printHelpMessage() {
return new Promise(resolve => {
const helpMessage = `Usage: truffle run analyze [options] [*contract-name1* [*contract-name2*] ...]
Runs MythX analyses on given Solidity contracts. If no contracts are
given, all are analyzed.
Options:
--debug Provide additional debug output
--mode { quick | full }
Perform quick or in-depth (full) analysis.
--style {stylish | unix | visualstudio | table | tap | ...},
Output report in the given es-lint style style.
See https://eslint.org/docs/user-guide/formatters/ for a full list.
--timeout *seconds* ,
Limit MythX analyses time to *s* seconds.
The default is 120 seconds (two minutes).
--version show package and MythX version information
`;
// FIXME: decide if this is okay or whether we need
// to pass in `config` and use `config.logger.log`.
console.log(helpMessage);
resolve(null);
});
}
/**
*
* Handles: truffle run analyze --version
* Shows version information for this plugin and each of the MythX components.
*
* @returns promise which resolves after MythX version information is shown
*/
function printVersion() {
return new Promise(resolve => {
const pjson = require('./package.json');
// FIXME: decide if this is okay or whether we need
// to pass in `config` and use `config.logger.log`.
console.log(`${pjson.name} ${pjson.version}`);
const version = armlet.ApiVersion();
console.log(versionJSON2String(version));
resolve(null);
});
}
/**
* Runs MythX security analyses on smart contract build json files found
* in truffle build folder
*
* @param {armlet.Client} client - instance of armlet.Client to send data to API.
* @param {Object} config - Truffle configuration object.
* @param {Array<String>} jsonFiles - List of smart contract build json files.
* @param {Array<String>} contractNames - List of smart contract name to run analyze (*Optional*).
* @returns {Promise} - Resolves array of hashmaps with issues for each contract.
*/
const doAnalysis = async (client, config, jsonFiles, contractNames = null) => {
/**
* Multiple smart contracts need to be run concurrently
* to speed up analyze report output.
* Because simple forEach or map can't handle async operations -
* async map is used and Promise.all to be notified when all analyses
* are finished.
*/
const results = await Promise.all(jsonFiles.map(async file => {
const buildJson = await readFile(file, 'utf8');
const buildObj = JSON.parse(buildJson);
/**
* If contractNames have been passed then skip analyze for unwanted ones.
*/
if (contractNames && contractNames.indexOf(buildObj.contractName) < 0) {
return [null, null];
}
const obj = new MythXIssues(buildObj);
const analyzeOpts = {
_: config._,
debug: config.debug,
data: obj.buildObj,
logger: config.logger,
style: config.style,
timeout: (config.timeout || 120) * 1000,
// FIXME: The below "partners" will change when
// https://github.com/ConsenSys/mythril-api/issues/59
// is resolved.
partners: ['truffle'],
};
analyzeOpts.data.analysisMode = analyzeOpts.mode || 'full';
try {
const reports = await client.analyze(analyzeOpts);
// For debugging:
// const util = require('util');
// console.log(`${util.inspect(reports, {depth: null})}`);
obj.setIssues(reports);
return [null, obj];
} catch (err) {
return [err, null];
}
}));
return results.reduce((accum, curr) => {
const [ err, obj ] = curr;
if (err) {
accum.errors.push(err);
} else if (obj) {
accum.objects.push(obj);
}
return accum;
}, { errors: [], objects: [] });
};
/**
*
* @param {Object} config - truffle configuration object.
*/
async function analyze(config) {
const armletOptions = {
// FIXME: The below "partners" will change when
// https://github.com/ConsenSys/mythril-api/issues/59
// is resolved.
platforms: ['truffle'] // client chargeback
};
if (process.env.MYTHX_API_KEY) {
armletOptions.apiKey = process.env.MYTHX_API_KEY;
} else {
if (!process.env.MYTHX_PASSWORD) {
throw new Error('You need to set environment variable MYTHX_PASSWORD to run analyze.');
}
armletOptions.password = process.env.MYTHX_PASSWORD;
if (process.env.MYTHX_ETH_ADDRESS) {
armletOptions.ethAddress = process.env.MYTHX_ETH_ADDRESS;
} else if (process.env.MYTHX_EMAIL) {
armletOptions.email = process.env.MYTHX_EMAIL;
} else {
throw new Error('You need to set either environment variable MYTHX_ETH_ADDRESS or MYTHX_EMAIL to run analyze.');
}
}
const client = new armlet.Client(armletOptions);
// Extract list of contracts passed in cli to analyze
const contractNames = config._.length > 1 ? config._.slice(1, config._.length) : null;
// Get list of smart contract build json files from truffle build folder
const jsonFiles = await trufstuf.getTruffleBuildJsonFiles(config.contracts_build_directory);
const { objects, errors } = await doAnalysis(client, config, jsonFiles, contractNames);
const spaceLimited = ['tap', 'markdown'].indexOf(config.style) !== -1;
const eslintIssues = objects
.map(obj => obj.getEslintIssues(spaceLimited))
.reduce((acc, curr) => acc.concat(curr), []);;
errors.forEach(err => console.error(err, err.stack));
// FIXME: temporary solution until backend will return correct filepath and output.
const eslintIssuesBtBaseName = groupEslintIssuesByBasename(eslintIssues);
const formatter = getFormatter(config.style);
console.log(formatter(eslintIssuesBtBaseName));
}
// FIXME: this stuff is cut and paste from truffle-workflow-compile writeContracts
var mkdirp = require('mkdirp');
var path = require('path');
var { promisify } = require('util');
var OS = require('os');
/**
* A 2-level line-column comparison function.
* @returns {integer} -
zero: line1/column1 == line2/column2
negative: line1/column1 < line2/column2
positive: line1/column1 > line2/column2
*/
function compareLineCol(line1, column1, line2, column2) {
return line1 === line2 ?
(column1 - column2) :
(line1 - line2);
}
/**
* A 2-level comparison function for eslint message structure ranges
* the fields off a message
* We use the start position in the first comparison and then the
* end position only when the start positions are the same.
*
* @returns {integer} -
zero: range(mess1) == range(mess2)
negative: range(mess1) < range(mess2)
positive: range(mess1) > range(mess)
*/
function compareMessLCRange(mess1, mess2) {
const c = compareLineCol(mess1.line, mess1.column, mess2.line, mess2.column)
return c != 0 ? c : compareLineCol(mess1.endLine, mess1.endCol, mess2.endLine, mess2.endCol);
}
async function writeContracts(contracts, options) {
var logger = options.logger || console;
const result = await promisify(mkdirp)(options.contracts_build_directory);
if (options.quiet != true && options.quietWrite != true) {
logger.log('Writing artifacts to .' + path.sep + path.relative(options.working_directory, options.contracts_build_directory) + OS.EOL);
}
var extra_opts = {
network_id: options.network_id
};
const contractNames = Object.keys(contracts).sort();
const sources = contractNames.map(c => contracts[c].sourcePath);
for (let c of contractNames) {
contracts[c].sources = sources;
}
await options.artifactor.saveAll(contracts, extra_opts);
}
/**
* Temporary function which turns eslint issues grouped by filepath
* to eslint issues rouped by filename.
* @param {ESLintIssue[]}
* @returns {ESListIssue[]}
*/
const groupEslintIssuesByBasename = issues => {
const path = require('path');
const mappedIssues = issues.reduce((accum, issue) => {
const {
errorCount,
warningCount,
fixableErrorCount,
fixableWarningCount,
filePath,
messages,
} = issue;
const basename = path.basename(filePath);
if (!accum[basename]) {
accum[basename] = {
errorCount: 0,
warningCount: 0,
fixableErrorCount: 0,
fixableWarningCount: 0,
filePath: filePath,
messages: [],
};
}
accum[basename].errorCount += errorCount;
accum[basename].warningCount += warningCount;
accum[basename].fixableErrorCount += fixableErrorCount;
accum[basename].fixableWarningCount += fixableWarningCount;
accum[basename].messages = accum[basename].messages.concat(messages);
return accum;
}, {});
const issueGroups = Object.values(mappedIssues);
for (const group of issueGroups) {
group.messages = group.messages.sort(function(mess1, mess2) {
return compareMessLCRange(mess1, mess2);
});
};
return issueGroups;
};
module.exports = {
analyze,
compareLineCol,
printVersion,
printHelpMessage,
contractsCompile,
writeContracts,
};