diff --git a/README.md b/README.md index 0e72b65d..07cc6b4d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Options: -s, --skip [ruleName] provide multiple rules to skip -j, --json-schema treat $ref like JSON Schema and convert to OpenAPI Schema Objects -v, --verbose set verbosity (use multiple times to increase level) + -f, --format [format] result format (sarif or default) -h, --help output usage information ``` diff --git a/docs/index.md b/docs/index.md index 8ed92627..514dabe4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ Options: -s, --skip [ruleName] provide multiple rules to skip -j, --json-schema treat $ref like JSON Schema and convert to OpenAPI Schema Objects -v, --verbose increase verbosity + -f, --format [format] result format -h, --help output usage information ``` diff --git a/lib/config.js b/lib/config.js index 1f86ae9e..b90f9842 100644 --- a/lib/config.js +++ b/lib/config.js @@ -17,6 +17,7 @@ class Config { lint: { rules: this.notEmptyArray(args.rules), skip: this.notEmptyArray(args.skip), + format: args.format, }, resolve: { output: args.output, diff --git a/lint.js b/lint.js index 312a936c..b5e6bc75 100644 --- a/lint.js +++ b/lint.js @@ -2,6 +2,9 @@ 'use strict' +const fs = require('fs'); +const url = require('url'); +const path = require('path'); const config = require('./lib/config.js'); const loader = require('./lib/loader.js'); const linter = require('oas-linter'); @@ -49,7 +52,7 @@ const truncateLongMessages = message => { return lines.join('\n'); } -const formatLintResults = lintResults => { +const formatLintResultsAsDefault = lintResults => { let output = ''; lintResults.forEach(result => { const { rule, error, pointer } = result; @@ -65,12 +68,57 @@ More information: ${rule.url}#${rule.name} return output; } + +const formatLintResults = (lintResults, format, specFile) => { + if (format === 'sarif') { + return formatLintResultsAsSarif(lintResults, specFile); + } + else { + return formatLintResultsAsDefault(lintResults); + } +} + +const formatLintResultsAsSarif = (lintResults, specFile) => { + const specFileDir = path.dirname(specFile) + const specFileName = path.basename(specFile) + const specFileDirFileUrl = url.pathToFileURL(specFileDir) + const specFileDirFileUrlString = specFileDirFileUrl.toString() + '/' + const templateFile = path.resolve(__dirname, 'templates/sarif_template.json'); + const template = fs.readFileSync(templateFile); + let sarif = JSON.parse(template); + sarif['runs'][0]['originalUriBaseIds']['ROOTPATH']['uri'] = specFileDirFileUrlString; + sarif['runs'][0]['tool']['driver']['rules'] = []; + sarif['runs'][0]['results'] = []; + + lintResults.forEach(result => { + const { rule, error, pointer } = result; + const ruleExists = sarif['runs'][0]['tool']['driver']['rules'].some(it => it.id === rule.name); + if (!ruleExists){ + var newRule = { 'id' : rule.name}; + newRule['shortDescription'] = { 'text' : rule.description }; + newRule['helpUri'] = rule.url + "#" + rule.name; + sarif['runs'][0]['tool']['driver']['rules'].push(newRule); + } + const ruleIndex = sarif['runs'][0]['tool']['driver']['rules'].findIndex(it => it.id === rule.name); + var result = { + 'ruleId' : rule.name, + 'ruleIndex' : ruleIndex, + 'message' : { 'text' : result.dataPath + ': ' + result.message }, + 'locations' : [{ 'physicalLocation' : { 'artifactLocation' : { 'uri' : specFileName, 'uriBaseId' : 'ROOTPATH' } } }], + }; + sarif['runs'][0]['results'].push(result); + }); + + return JSON.stringify(sarif); +} + const command = async (specFile, cmd) => { config.init(cmd); const jsonSchema = config.get('jsonSchema'); const verbose = config.get('quiet') ? 0 : config.get('verbose', 1); const rulesets = config.get('lint:rules', []); const skip = config.get('lint:skip', []); + const format = config.get('lint:format'); rules.init({ skip @@ -101,7 +149,7 @@ const command = async (specFile, cmd) => { if (warnings.length) { console.error(colors.red + 'Specification contains lint errors: ' + warnings.length + colors.reset); - console.warn(formatLintResults(warnings)) + console.warn(formatLintResults(warnings, format, specFile)) return reject(); } diff --git a/speccy.js b/speccy.js index 35b98b2d..9e4ca1ad 100755 --- a/speccy.js +++ b/speccy.js @@ -32,6 +32,7 @@ program .option('-s, --skip [ruleName]', 'provide multiple rules to skip', collect, []) .option('-j, --json-schema', 'treat $ref like JSON Schema and convert to OpenAPI Schema Objects (default: false)') .option('-v, --verbose', 'increase verbosity', increaseVerbosity, 1) + .option('-f, --format [format]', 'Result format, currently support sarif format and default format.') .action((specFile, cmd) => { lint.command(specFile, cmd) .then(() => { process.exit(0) }) diff --git a/templates/sarif_template.json b/templates/sarif_template.json new file mode 100644 index 00000000..7860304f --- /dev/null +++ b/templates/sarif_template.json @@ -0,0 +1,49 @@ +{ + "$schema":"https://www.schemastore.org/schemas/json/sarif-2.1.0-rtm.5.json", + "version":"2.1.0", + "runs":[ + { + "tool":{ + "driver":{ + "name":"Speccy", + "informationUri":"http://speccy.io/", + "version":"0.11.0", + "rules":[ + { + "id":"info-contact", + "shortDescription":{ + "text":"info object should contain contact object" + }, + "helpUri": "https://speccy.io/rules/1-rulesets" + } + ] + } + }, + "results":[ + { + "ruleId":"info-contact", + "ruleIndex":0, + "message":{ + "text":"#/info: expected Object { version: '1.0.0', title: 'Swagger 2.0 Without Scheme' } to have property contact" + }, + "locations":[ + { + "physicalLocation":{ + "artifactLocation":{ + "uri":"no-contact.yaml", + "uriBaseId":"ROOTPATH" + } + } + } + ] + } + ], + "columnKind":"utf16CodeUnits", + "originalUriBaseIds":{ + "ROOTPATH":{ + "uri":"file:///home/user/testopenapidir/" + } + } + } + ] +} \ No newline at end of file diff --git a/test/integration/lint.test.js b/test/integration/lint.test.js index 56e3f7c3..1b1655ca 100644 --- a/test/integration/lint.test.js +++ b/test/integration/lint.test.js @@ -11,6 +11,14 @@ const commandConfig = { skip: [] }; +const commandConfigSarif = { + quiet: false, + verbose: false, + rules: [], + skip: [], + format: 'sarif' +}; + beforeEach(() => { jest.restoreAllMocks(); }); @@ -77,6 +85,25 @@ describe('Lint command', () => { }); }); + describe('properly handles linter warnings with format SARIF', () => { + test('displays a linting error on missing contact field with format SARIF', () => { + expect.assertions(7); + const logSpy = jest.spyOn(console, 'log'); + const warnSpy = jest.spyOn(console, 'warn'); + const errorSpy = jest.spyOn(console, 'error'); + + return lint.command('./test/fixtures/integration/missing-contact.yaml', commandConfigSarif).catch(() => { + expect(logSpy).toBeCalledTimes(0); + expect(warnSpy).toBeCalledTimes(1); + expect(errorSpy).toBeCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toEqual('\x1b[31mSpecification contains lint errors: 1\x1b[0m'); + expect(warnSpy.mock.calls[0][0]).toContain('"tool":{"driver":{"name":"Speccy","informationUri":"http://speccy.io/"'); + expect(warnSpy.mock.calls[0][0]).toContain('"results":[{"ruleId":"info-contact"'); + expect(warnSpy.mock.calls[0][0]).toContain('"helpUri":"https://speccy.io/rules/1-rulesets#info-contact"'); + }); + }); + }); + describe('properly support stdin and pipes', () => { let stdin = null;