report.ts
1 import Controller from '.'; 2 import { Environment, Format } from '../types'; 3 import { Logger } from 'embark-logger'; 4 import { CompilationInputs } from '../types'; 5 import * as path from 'path'; 6 import chalk from 'chalk'; 7 import Analysis from '../analysis'; 8 9 const eslintCliEngine = require('eslint').CLIEngine; 10 const SourceMappingDecoder = require('remix-lib/src/sourceMappingDecoder'); 11 12 enum Severity { 13 High = 2, 14 Medium = 1 15 } 16 17 export default class ReportController extends Controller { 18 private decoder: any; 19 constructor(env: Environment, logger: Logger) { 20 super(env, logger); 21 22 this.decoder = new SourceMappingDecoder(); 23 } 24 25 public async run( 26 uuid: string, 27 format: Format, 28 inputs: CompilationInputs, 29 analysis: Analysis | null = null, 30 doLogin = true 31 ) { 32 if (!uuid) { 33 throw new Error("Argument 'uuid' must be provided."); 34 } 35 36 if (doLogin) { 37 await this.login(); 38 } 39 const issues = await this.client.getReport(uuid); 40 41 this.render(issues, format, inputs, analysis); 42 } 43 44 public async render( 45 report: any, 46 format: Format, 47 inputs: CompilationInputs, 48 analysis: Analysis | null = null 49 ) { 50 this.logger.info( 51 `Rendering ${ 52 analysis?.contractName ? analysis.contractName + ' ' : '' 53 }analysis report...\n` 54 ); 55 56 // filter out the free mode message from MythX (sorry) 57 report.map( 58 (issueList: any) => 59 (issueList.issues = issueList.issues.filter( 60 (issue: any) => 61 !( 62 issue.swcID === '' && 63 issue.description.head.includes( 64 'You are running MythX in free mode.' 65 ) 66 ) 67 )) 68 ); 69 70 const functionHashes = analysis?.getFunctionHashes() ?? {}; 71 72 const data = { functionHashes, sources: { ...inputs } }; 73 74 const uniqueIssues = this.formatIssues(data, report); 75 76 if (uniqueIssues.length === 0) { 77 this.logger.info( 78 chalk.green( 79 `✔ No errors/warnings found${ 80 analysis?.contractName ? ' for ' + analysis.contractName : '' 81 }!` 82 ) 83 ); 84 } else { 85 const formatter = this.getFormatter(format); 86 const output = formatter(uniqueIssues); 87 this.logger.info(output); 88 } 89 } 90 91 /** 92 * @param {string} name - formatter name 93 * @returns {object} - ESLint formatter module 94 */ 95 private getFormatter(name: Format) { 96 const custom = ['text']; 97 let format: string = name; 98 99 if (custom.includes(name)) { 100 format = path.join(__dirname, '../formatters/', name + '.js'); 101 } 102 103 return eslintCliEngine.getFormatter(format); 104 } 105 106 /** 107 * Turn a srcmap entry (the thing between semicolons) into a line and 108 * column location. 109 * We make use of this.sourceMappingDecoder of this class to make 110 * the conversion. 111 * 112 * @param {string} srcEntry - a single entry of solc sourceMap 113 * @param {Array} lineBreakPositions - array returned by the function 'mapLineBreakPositions' 114 * @returns {object} - line and column location 115 */ 116 private textSrcEntry2lineColumn(srcEntry: string, lineBreakPositions: any) { 117 const ary = srcEntry.split(':'); 118 const sourceLocation = { 119 length: parseInt(ary[1], 10), 120 start: parseInt(ary[0], 10) 121 }; 122 const loc = this.decoder.convertOffsetToLineColumn( 123 sourceLocation, 124 lineBreakPositions 125 ); 126 // FIXME: note we are lossy in that we don't return the end location 127 if (loc.start) { 128 // Adjust because routines starts lines at 0 rather than 1. 129 loc.start.line++; 130 } 131 if (loc.end) { 132 loc.end.line++; 133 } 134 return [loc.start, loc.end]; 135 } 136 137 /** 138 * Convert a MythX issue into an ESLint-style issue. 139 * The eslint report format which we use, has these fields: 140 * 141 * - column, 142 * - endCol, 143 * - endLine, 144 * - fatal, 145 * - line, 146 * - message, 147 * - ruleId, 148 * - severity 149 * 150 * but a MythX JSON report has these fields: 151 * 152 * - description.head 153 * - description.tail, 154 * - locations 155 * - severity 156 * - swcId 157 * - swcTitle 158 * 159 * @param {object} issue - the MythX issue we want to convert 160 * @param {string} sourceCode - holds the contract code 161 * @param {object[]} locations - array of text-only MythX API issue locations 162 * @returns {object} eslint - issue object 163 */ 164 private issue2EsLint(issue: any, sourceCode: string, locations: any) { 165 const swcLink = issue.swcID 166 ? 'https://swcregistry.io/docs/' + issue.swcID 167 : 'N/A'; 168 169 const esIssue = { 170 mythxIssue: issue, 171 mythxTextLocations: locations, 172 sourceCode, 173 174 fatal: false, 175 ruleId: swcLink, 176 message: issue.description.head, 177 severity: Severity[issue.severity] || 1, 178 line: -1, 179 column: 0, 180 endLine: -1, 181 endCol: 0 182 }; 183 184 let startLineCol; 185 let endLineCol; 186 187 const lineBreakPositions = this.decoder.getLinebreakPositions(sourceCode); 188 189 if (locations.length) { 190 [startLineCol, endLineCol] = this.textSrcEntry2lineColumn( 191 locations[0].sourceMap, 192 lineBreakPositions 193 ); 194 } 195 196 if (startLineCol) { 197 esIssue.line = startLineCol.line; 198 esIssue.column = startLineCol.column; 199 200 esIssue.endLine = endLineCol.line; 201 esIssue.endCol = endLineCol.column; 202 } 203 204 return esIssue; 205 } 206 207 /** 208 * Gets the source index from the issue sourcemap 209 * 210 * @param {object} location - MythX API issue location object 211 * @returns {number} - source index 212 */ 213 private getSourceIndex(location: any) { 214 const sourceMapRegex = /(\d+):(\d+):(\d+)/g; 215 const match = sourceMapRegex.exec(location.sourceMap); 216 // Ignore `-1` source index for compiler generated code 217 return match ? match[3] : '0'; 218 } 219 220 /** 221 * Converts MythX analyze API output item to Eslint compatible object 222 * @param {object} report - issue item from the collection MythX analyze API output 223 * @param {object} data - Contains array of solidity contracts source code and the input filepath of contract 224 * @returns {object} - Eslint compatible object 225 */ 226 private convertMythXReport2EsIssue(report: any, data: any) { 227 const { sources, functionHashes } = data; 228 const results: { [key: string]: any } = {}; 229 230 /** 231 * Filters locations only for source files. 232 * Other location types are not supported to detect code. 233 * 234 * @param {object} location - locations to filter 235 * @returns {object} - filtered locations 236 */ 237 const textLocationFilterFn = (location: any) => 238 location.sourceType === 'solidity-file' && 239 location.sourceFormat === 'text'; 240 241 (report?.issues ?? []).forEach((issue: any) => { 242 const locations = issue.locations.filter(textLocationFilterFn); 243 const location = locations.length ? locations[0] : undefined; 244 245 let sourceCode = ''; 246 let sourcePath = '<unknown>'; 247 248 if (location) { 249 const sourceIndex = parseInt(this.getSourceIndex(location) ?? 0, 10); 250 // if DApp's contracts have changed, we can no longer guarantee our sources will be the 251 // same as at the time of submission. This should only be an issue when getting a past 252 // analysis report (ie verify report uuid), and not during a just-completed analysis (ie verify) 253 const fileName = Object.keys(sources)[sourceIndex]; 254 255 if (fileName) { 256 sourcePath = path.basename(fileName); 257 sourceCode = sources[fileName].content; 258 } 259 } 260 261 if (!results[sourcePath]) { 262 results[sourcePath] = { 263 errorCount: 0, 264 warningCount: 0, 265 fixableErrorCount: 0, 266 fixableWarningCount: 0, 267 filePath: sourcePath, 268 functionHashes, 269 sourceCode, 270 messages: [] 271 }; 272 } 273 274 results[sourcePath].messages.push( 275 this.issue2EsLint(issue, sourceCode, locations) 276 ); 277 }); 278 279 for (const key of Object.keys(results)) { 280 const result = results[key]; 281 282 for (const { fatal, severity } of result.messages) { 283 if (this.isFatal(fatal, severity)) { 284 result.errorCount++; 285 } else { 286 result.warningCount++; 287 } 288 } 289 } 290 291 return Object.values(results); 292 } 293 294 private formatIssues(data: any, issues: any) { 295 const eslintIssues = issues 296 .map((report: any) => this.convertMythXReport2EsIssue(report, data)) 297 .reduce((acc: any, curr: any) => acc.concat(curr), []); 298 299 return this.getUniqueIssues(eslintIssues); 300 } 301 302 private isFatal(fatal: any, severity: any) { 303 return fatal || severity === 2; 304 } 305 306 private getUniqueMessages(messages: any) { 307 const jsonValues = messages.map((m: any) => JSON.stringify(m)); 308 const uniqueValues = jsonValues.reduce((acc: any, curr: any) => { 309 if (acc.indexOf(curr) === -1) { 310 acc.push(curr); 311 } 312 313 return acc; 314 }, []); 315 316 return uniqueValues.map((v: any) => JSON.parse(v)); 317 } 318 319 private calculateErrors(messages: any) { 320 return messages.reduce( 321 (acc: any, { fatal, severity }: any) => 322 this.isFatal(fatal, severity) ? acc + 1 : acc, 323 0 324 ); 325 } 326 327 private calculateWarnings(messages: any) { 328 return messages.reduce( 329 (acc: any, { fatal, severity }: any) => 330 !this.isFatal(fatal, severity) ? acc + 1 : acc, 331 0 332 ); 333 } 334 335 private getUniqueIssues(issues: any) { 336 return issues.map(({ messages, ...restProps }: any) => { 337 const uniqueMessages = this.getUniqueMessages(messages); 338 const warningCount = this.calculateWarnings(uniqueMessages); 339 const errorCount = this.calculateErrors(uniqueMessages); 340 341 return { 342 ...restProps, 343 messages: uniqueMessages, 344 errorCount, 345 warningCount 346 }; 347 }); 348 } 349 }