diff --git a/README.md b/README.md index f5357a61..b4900446 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Linter for Solidity programming language Options: -V, --version output the version number - -f, --formatter [name] report formatter name (stylish, table, tap, unix, json, compact) + -f, --formatter [name] report formatter name (stylish, table, tap, unix, json, compact, sarif) -w, --max-warnings [maxWarningsNumber] number of allowed warnings -c, --config [file_name] file to use as your .solhint.json -q, --quiet report errors only - default: false diff --git a/e2e/formatters-test.js b/e2e/formatters-test.js index 668c19d5..09df3bc6 100644 --- a/e2e/formatters-test.js +++ b/e2e/formatters-test.js @@ -4,6 +4,7 @@ const fs = require('fs-extra') const os = require('os') const path = require('path') const shell = require('shelljs') +const url = require('url') function useFixture(dir) { beforeEach(`switch to ${dir}`, function () { @@ -344,5 +345,338 @@ describe('e2e', function () { expect(code).to.equal(1) }) }) + + describe('sarif formatter tests', () => { + const formatterType = 'sarif' + + it('should always output with correct SARIF version and tool metadata', () => { + const { code, stdout } = shell.exec(`solhint -f ${formatterType}`) + const sarifOutput = JSON.parse(stdout) + + expect(code).to.equal(0) + expect(sarifOutput['$schema']).to.eq('http://json.schemastore.org/sarif-2.1.0-rtm.5') + expect(sarifOutput.version).to.eq('2.1.0') + expect(sarifOutput.runs[0].tool.driver.name).to.eq('solhint') + expect(sarifOutput.runs[0].tool.driver.informationUri).to.eq('https://github.com/protofire/solhint') + expect(sarifOutput.runs[0].tool.driver.version).to.eq(require('../package.json').version) + }) + + it('should output with empty results and no artifacts when file does not exist and sarif is the formatter', () => { + const { code, stdout } = shell.exec(`solhint ${PATH}contracts/Foo1.sol -f ${formatterType}`) + const sarifOutput = JSON.parse(stdout) + + expect(code).to.equal(0) + expect(sarifOutput.runs[0].artifacts).to.be.undefined + expect(sarifOutput.runs[0].results).to.be.empty + }) + + it('should output with sarif formatter for Foo2', () => { + const { code, stdout } = shell.exec(`solhint ${PATH}contracts/Foo2.sol -f ${formatterType}`) + const sarifOutput = JSON.parse(stdout) + + const expectedUriPath = url.pathToFileURL(`${PATH}contracts/Foo2.sol`).toString() + const expectedResults = [ + { + level: "warning", + message: { + text: "Constant name must be in capitalized SNAKE_CASE" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPath, + index: 0 + }, + region: { + startLine: 5, + startColumn: 5 + } + } + } + ], + ruleId: "const-name-snakecase" + }, + { + level: "warning", + message: { + text: "Explicitly mark visibility in function (Set ignoreConstructors to true if using solidity >=0.7.0)" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPath, + index: 0 + }, + region: { + startLine: 7, + startColumn: 5 + } + } + } + ], + ruleId: "func-visibility" + }, + { + level: "warning", + message: { + text: "Code contains empty blocks" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPath, + index: 0 + }, + region: { + startLine: 7, + startColumn: 19 + } + } + } + ], + ruleId: "no-empty-blocks" + } + ] + + expect(code).to.equal(0) + expect(sarifOutput.runs[0].artifacts[0].location.uri).to.eq(expectedUriPath) + expect(sarifOutput.runs[0].results).to.deep.equal(expectedResults) + }) + + + it('should output with sarif formatter for Foo and Foo2 and Foo3', () => { + const { code, stdout } = shell.exec( + `solhint ${PATH}contracts/Foo.sol ${PATH}contracts/Foo2.sol ${PATH}contracts/Foo3.sol -f ${formatterType}` + ) + const sarifOutput = JSON.parse(stdout) + + const expectedUriPathFoo = url.pathToFileURL(`${PATH}contracts/Foo.sol`).toString() + const expectedUriPathFoo2 = url.pathToFileURL(`${PATH}contracts/Foo2.sol`).toString() + const expectedUriPathFoo3 = url.pathToFileURL(`${PATH}contracts/Foo3.sol`).toString() + const expectedResults = [ + { + level: "error", + message: { + text: "Compiler version >=0.6.0 does not satisfy the ^0.8.0 semver requirement" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 2, + startColumn: 1 + } + } + } + ], + ruleId: "compiler-version" + }, + { + level: "warning", + message: { + text: "Constant name must be in capitalized SNAKE_CASE" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 5, + startColumn: 5 + } + } + } + ], + ruleId: "const-name-snakecase" + }, + { + level: "warning", + message: { + text: "Explicitly mark visibility of state" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 6, + startColumn: 5 + } + } + } + ], + ruleId: "state-visibility" + }, + { + level: "warning", + message: { + text: "'TEST2' should start with _" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 6, + startColumn: 5 + } + } + } + ], + ruleId: "private-vars-leading-underscore" + }, + { + level: "warning", + message: { + text: "Variable name must be in mixedCase" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 6, + startColumn: 5 + } + } + } + ], + ruleId: "var-name-mixedcase" + }, + { + level: "warning", + message: { + text: "Explicitly mark visibility in function (Set ignoreConstructors to true if using solidity >=0.7.0)" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 8, + startColumn: 5 + } + } + } + ], + ruleId: "func-visibility" + }, + { + level: "warning", + message: { + text: "Code contains empty blocks" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo, + index: 0 + }, + region: { + startLine: 8, + startColumn: 19 + } + } + } + ], + ruleId: "no-empty-blocks" + }, + { + level: "warning", + message: { + text: "Constant name must be in capitalized SNAKE_CASE" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo2, + index: 1 + }, + region: { + startLine: 5, + startColumn: 5 + } + } + } + ], + ruleId: "const-name-snakecase" + }, + { + level: "warning", + message: { + text: "Explicitly mark visibility in function (Set ignoreConstructors to true if using solidity >=0.7.0)" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo2, + index: 1 + }, + region: { + startLine: 7, + startColumn: 5 + } + } + } + ], + ruleId: "func-visibility" + }, + { + level: "warning", + message: { + text: "Code contains empty blocks" + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: expectedUriPathFoo2, + index: 1 + }, + region: { + startLine: 7, + startColumn: 19 + } + } + } + ], + ruleId: "no-empty-blocks" + } + ] + + // There's an error, that is why exit code is 1 + expect(code).to.equal(1) + expect(sarifOutput.runs[0].artifacts[0].location.uri).to.eq(expectedUriPathFoo) + expect(sarifOutput.runs[0].artifacts[1].location.uri).to.eq(expectedUriPathFoo2) + expect(sarifOutput.runs[0].artifacts[2].location.uri).to.eq(expectedUriPathFoo3) + expect(sarifOutput.runs[0].results).to.deep.equal(expectedResults) + }) + + }) }) }) diff --git a/lib/formatters/README.md b/lib/formatters/README.md index 9d6ee6c1..ae5c1471 100644 --- a/lib/formatters/README.md +++ b/lib/formatters/README.md @@ -8,4 +8,5 @@ files in this directory are pulled from eslint repository: - stylish.js: eslint - Sindre Sorhus - json.js: eslint - Artur Lukianov - compact.js: eslint - Nicholas C. Zakas +- sarif.js: [@microsoft/eslint-formatter-sarif](https://www.npmjs.com/package/@microsoft/eslint-formatter-sarif) \ No newline at end of file diff --git a/lib/formatters/sarif.js b/lib/formatters/sarif.js new file mode 100644 index 00000000..52f51cb0 --- /dev/null +++ b/lib/formatters/sarif.js @@ -0,0 +1,264 @@ +/** + * @fileoverview SARIF v2.1 formatter + * @author Adapted for solhint by @eshaan7 (Original: Microsoft ) + */ + +const url = require('url') +const lodash = require('lodash') + +//------------------------------------------------------------------------------ +// Helper Functions +//------------------------------------------------------------------------------ + +/** + * Returns the version of used solhint package + * @returns {string} solhint version or undefined + * @private + */ +function getSolhintVersion() { + try { + // Resolve solhint relative to main entry script, not the formatter + const solhintPackageJson = require('../../package.json') + return solhintPackageJson.version + } catch { + // Formatter was not called from solhint, return undefined + } +} + +/** + * Returns the severity of warning or error + * @param {Object} message message object to examine + * @returns {string} severity level + * @private + */ +function getResultLevel(message) { + if (message.fatal || message.severity === 2) { + return 'error' + } + return 'warning' +} + +//------------------------------------------------------------------------------ +// Public Interface +//------------------------------------------------------------------------------ + +module.exports = function (results, data) { + const rulesMeta = lodash.get(data, 'rulesMeta', null) + + const sarifLog = { + version: '2.1.0', + $schema: 'http://json.schemastore.org/sarif-2.1.0-rtm.5', + runs: [ + { + tool: { + driver: { + name: 'solhint', + informationUri: 'https://github.com/protofire/solhint', + rules: [], + }, + }, + }, + ], + } + + const solhintVersion = getSolhintVersion() + if (typeof solhintVersion !== 'undefined') { + sarifLog.runs[0].tool.driver.version = solhintVersion + } + + const sarifFiles = {} + const sarifArtifactIndices = {} + let nextArtifactIndex = 0 + const sarifRules = {} + const sarifRuleIndices = {} + let nextRuleIndex = 0 + const sarifResults = [] + + // Emit a tool configuration notification with this id if ESLint emits a message with + // no ruleId (which indicates an internal error in ESLint). + // + // It is not clear whether we should treat these messages tool configuration notifications, + // tool execution notifications, or a mixture of the two, based on the properties of the + // message. https://github.com/microsoft/sarif-sdk/issues/1798, "ESLint formatter can't + // distinguish between an internal error and a misconfiguration", tracks this issue. + const internalErrorId = 'ESL0999' + + const toolConfigurationNotifications = [] + let executionSuccessful = true + + for (const result of results) { + // Only add it if not already there. + if (typeof sarifFiles[result.filePath] === 'undefined') { + sarifArtifactIndices[result.filePath] = nextArtifactIndex++ + + // Create a new entry in the files dictionary. + sarifFiles[result.filePath] = { + location: { + uri: url.pathToFileURL(result.filePath), + }, + } + + const containsSuppressedMessages = + result.suppressedMessages && result.suppressedMessages.length > 0 + const messages = containsSuppressedMessages + ? [...result.messages, ...result.suppressedMessages] + : result.messages + + if (messages.length > 0) { + for (const message of messages) { + const sarifRepresentation = { + level: getResultLevel(message), + message: { + text: message.message, + }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: url.pathToFileURL(result.filePath), + index: sarifArtifactIndices[result.filePath], + }, + }, + }, + ], + } + + if (message.ruleId) { + sarifRepresentation.ruleId = message.ruleId + + if (rulesMeta && typeof sarifRules[message.ruleId] === 'undefined') { + const meta = rulesMeta[message.ruleId] + + // An unknown ruleId will return null. This check prevents unit test failure. + if (meta) { + sarifRuleIndices[message.ruleId] = nextRuleIndex++ + + if (meta.docs) { + // Create a new entry in the rules dictionary. + sarifRules[message.ruleId] = { + id: message.ruleId, + helpUri: meta.docs.url, + properties: { + category: meta.docs.category, + }, + } + if (meta.docs.description) { + sarifRules[message.ruleId].shortDescription = { + text: meta.docs.description, + } + } + // Some rulesMetas do not have docs property + } else { + sarifRules[message.ruleId] = { + id: message.ruleId, + helpUri: 'Please see details in message', + properties: { + category: 'No category provided', + }, + } + } + } + } + + if (sarifRuleIndices[message.ruleId] !== 'undefined') { + sarifRepresentation.ruleIndex = sarifRuleIndices[message.ruleId] + } + + if (containsSuppressedMessages) { + sarifRepresentation.suppressions = message.suppressions + ? message.suppressions.map((suppression) => { + return { + kind: suppression.kind === 'directive' ? 'inSource' : 'external', + justification: suppression.justification, + } + }) + : [] + } + } else { + // ESLint produces a message with no ruleId when it encounters an internal + // error. SARIF represents this as a tool execution notification rather + // than as a result, and a notification has a descriptor.id property rather + // than a ruleId property. + sarifRepresentation.descriptor = { + id: internalErrorId, + } + + // As far as we know, whenever ESLint produces a message with no rule id, + // it has severity: 2 which corresponds to a SARIF error. But check here + // anyway. + if (sarifRepresentation.level === 'error') { + // An error-level notification means that the tool failed to complete + // its task. + executionSuccessful = false + } + } + + if (message.line > 0 || message.column > 0) { + sarifRepresentation.locations[0].physicalLocation.region = {} + if (message.line > 0) { + sarifRepresentation.locations[0].physicalLocation.region.startLine = message.line + } + if (message.column > 0) { + sarifRepresentation.locations[0].physicalLocation.region.startColumn = message.column + } + if (message.endLine > 0) { + sarifRepresentation.locations[0].physicalLocation.region.endLine = message.endLine + } + if (message.endColumn > 0) { + sarifRepresentation.locations[0].physicalLocation.region.endColumn = message.endColumn + } + } + + if (message.source) { + // Create an empty region if we don't already have one from the line / column block above. + sarifRepresentation.locations[0].physicalLocation.region = + sarifRepresentation.locations[0].physicalLocation.region || {} + sarifRepresentation.locations[0].physicalLocation.region.snippet = { + text: message.source, + } + } + + if (message.ruleId) { + sarifResults.push(sarifRepresentation) + } else { + toolConfigurationNotifications.push(sarifRepresentation) + } + } + } + } + } + + if (Object.keys(sarifFiles).length > 0) { + sarifLog.runs[0].artifacts = [] + + for (const path of Object.keys(sarifFiles)) { + sarifLog.runs[0].artifacts.push(sarifFiles[path]) + } + } + + // Per the SARIF spec ยง3.14.23, run.results must be present even if there are no results. + // This provides a positive indication that the run completed and no results were found. + sarifLog.runs[0].results = sarifResults + + if (toolConfigurationNotifications.length > 0) { + sarifLog.runs[0].invocations = [ + { + toolConfigurationNotifications, + executionSuccessful, + }, + ] + } + + if (Object.keys(sarifRules).length > 0) { + for (const ruleId of Object.keys(sarifRules)) { + const rule = sarifRules[ruleId] + sarifLog.runs[0].tool.driver.rules.push(rule) + } + } + + return JSON.stringify( + sarifLog, + null, // replacer function + 2 // # of spaces for indents + ) +} diff --git a/solhint.js b/solhint.js old mode 100644 new mode 100755 index 520a6e5c..485cbf25 --- a/solhint.js +++ b/solhint.js @@ -21,7 +21,7 @@ function init() { .usage('[options] [...other_files]') .option( '-f, --formatter [name]', - 'report formatter name (stylish, table, tap, unix, json, compact)' + 'report formatter name (stylish, table, tap, unix, json, compact, sarif)' ) .option('-w, --max-warnings [maxWarningsNumber]', 'number of allowed warnings') .option('-c, --config [file_name]', 'file to use as your .solhint.json')