/ src / controllers / report.ts
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  }