diff --git a/package-lock.json b/package-lock.json index acdf713..15db85d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1449,6 +1449,11 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==" + }, "node_modules/@types/semver": { "version": "7.5.8", "license": "MIT" @@ -6441,6 +6446,7 @@ "@salesforce/code-analyzer-engine-api": "0.12.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", + "@types/sarif": "^2.1.7", "csv-stringify": "^6.5.0", "js-yaml": "^4.1.0", "xmlbuilder": "^15.1.1" diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 46e7a09..3a3933d 100644 --- a/packages/code-analyzer-core/package.json +++ b/packages/code-analyzer-core/package.json @@ -16,6 +16,7 @@ "@salesforce/code-analyzer-engine-api": "0.12.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", + "@types/sarif": "^2.1.7", "csv-stringify": "^6.5.0", "js-yaml": "^4.1.0", "xmlbuilder": "^15.1.1" diff --git a/packages/code-analyzer-core/src/output-format.ts b/packages/code-analyzer-core/src/output-format.ts index e8c4479..b36b289 100644 --- a/packages/code-analyzer-core/src/output-format.ts +++ b/packages/code-analyzer-core/src/output-format.ts @@ -1,4 +1,4 @@ -import {CodeLocation, RunResults, Violation} from "./results"; +import {CodeLocation, RunResults, Violation, EngineRunResults} from "./results"; import {Rule, RuleType, SeverityLevel} from "./rules"; import {stringify as stringifyToCsv} from "csv-stringify/sync"; import {Options as CsvOptions} from "csv-stringify"; @@ -6,12 +6,14 @@ import * as xmlbuilder from "xmlbuilder"; import * as fs from 'fs'; import path from "node:path"; import {Clock, RealClock} from "./utils"; +import * as sarif from "sarif"; export enum OutputFormat { CSV = "CSV", JSON = "JSON", XML = "XML", - HTML = "HTML" + HTML = "HTML", + SARIF = "SARIF" } export abstract class OutputFormatter { @@ -27,6 +29,8 @@ export abstract class OutputFormatter { return new XmlOutputFormatter(); case OutputFormat.HTML: return new HtmlOutputFormatter(clock); + case OutputFormat.SARIF: + return new SarifOutputFormatter(); default: throw new Error(`Unsupported output format: ${format}`); } @@ -231,6 +235,99 @@ class XmlOutputFormatter implements OutputFormatter { } } +class SarifOutputFormatter implements OutputFormatter { + format(results: RunResults): string { + const runDir = results.getRunDirectory(); + + const sarifRuns: sarif.Run[] = results.getEngineNames() + .map(engineName => results.getEngineRunResults(engineName)) + .filter(engineRunResults => engineRunResults.getViolationCount() > 0) + .map(engineRunResults => toSarifRun(engineRunResults, runDir)); + + // Construct SARIF log + const sarifLog: sarif.Log = { + version: "2.1.0", + $schema: 'http://json.schemastore.org/sarif-2.1.0', + runs: sarifRuns, + }; + + // Return formatted SARIF JSON string + return JSON.stringify(sarifLog, null, 2); + } +} + +function toSarifRun(engineRunResults: EngineRunResults, runDir: string): sarif.Run { + const violations: Violation[] = engineRunResults.getViolations(); + const rules: Rule[] = [... new Set(violations.map(v => v.getRule()))]; + const ruleNames: string[] = rules.map(r => r.getName()); + + return { + tool: { + driver: { + name: engineRunResults.getEngineName(), + informationUri: "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/version-5.html", + rules: rules.map(toSarifReportingDescriptor), + } + }, + results: violations.map(v => toSarifResult(v, ruleNames.indexOf(v.getRule().getName()))), + invocations: [ + { + executionSuccessful: true, + workingDirectory: { + uri: runDir, + }, + }, + ], + }; +} + +function toSarifResult(violation: Violation, ruleIndex: number) : sarif.Result { + const primaryCodeLocation = violation.getCodeLocations()[violation.getPrimaryLocationIndex()]; + const result: sarif.Result = { + ruleId: violation.getRule().getName(), + ruleIndex: ruleIndex, + message: { text: violation.getMessage() }, + locations: [toSarifLocation(primaryCodeLocation)], + }; + if(typeSupportsMultipleLocations(violation.getRule().getType())) { + result.relatedLocations = violation.getCodeLocations().map(toSarifLocation); + } + result.level = toSarifNotificationLevel(violation.getRule().getSeverityLevel()); + return result; +} + +function toSarifLocation(codeLocation: CodeLocation): sarif.Location { + return { + physicalLocation: { + artifactLocation: { + uri: codeLocation.getFile(), + }, + region: { + startLine: codeLocation.getStartLine(), + startColumn: codeLocation.getStartColumn(), + endLine: codeLocation.getEndLine(), + endColumn: codeLocation.getEndColumn() + } as sarif.Region + } + } +} + +function toSarifReportingDescriptor(rule: Rule): sarif.ReportingDescriptor { + return { + id: rule.getName(), + properties: { + category: rule.getTags(), + severity: rule.getSeverityLevel() + }, + ...(rule.getResourceUrls()?.[0] && { helpUri: rule.getResourceUrls()[0] }) + } +} + +function toSarifNotificationLevel(severity: SeverityLevel): sarif.Notification.level { + return severity < 3 ? 'error' : 'warning'; // IF satif.Notification.level is an enum then please return the num instead of the string. +} + + const HTML_TEMPLATE_VERSION: string = '0.0.1'; const HTML_TEMPLATE_FILE: string = path.resolve(__dirname, '..', 'output-templates', `html-template-${HTML_TEMPLATE_VERSION}.txt`); class HtmlOutputFormatter implements OutputFormatter { @@ -293,13 +390,17 @@ function createViolationOutput(violation: Violation, runDir: string, sanitizeFcn column: primaryLocation.getStartColumn(), endLine: primaryLocation.getEndLine(), endColumn: primaryLocation.getEndColumn(), - primaryLocationIndex: [RuleType.DataFlow, RuleType.Flow].includes(rule.getType()) ? violation.getPrimaryLocationIndex() : undefined, - locations: [RuleType.DataFlow, RuleType.Flow].includes(rule.getType()) ? createCodeLocationOutputs(codeLocations, runDir) : undefined, + primaryLocationIndex: typeSupportsMultipleLocations(rule.getType()) ? violation.getPrimaryLocationIndex() : undefined, + locations: typeSupportsMultipleLocations(rule.getType()) ? createCodeLocationOutputs(codeLocations, runDir) : undefined, message: sanitizeFcn(violation.getMessage()), resources: violation.getResourceUrls() }; } +function typeSupportsMultipleLocations(ruleType: RuleType) { + return [RuleType.DataFlow, RuleType.Flow, RuleType.MultiLocation].includes(ruleType); +} + function createCodeLocationOutputs(codeLocations: CodeLocation[], runDir: string): CodeLocationOutput[] { return codeLocations.map(loc => { return new CodeLocationOutput(loc, runDir); diff --git a/packages/code-analyzer-core/test/output-format.test.ts b/packages/code-analyzer-core/test/output-format.test.ts index 863b5b6..8b38f2b 100644 --- a/packages/code-analyzer-core/test/output-format.test.ts +++ b/packages/code-analyzer-core/test/output-format.test.ts @@ -116,6 +116,28 @@ describe("Tests for the XML output format", () => { }); }); +describe("Tests for the SARIF output format", () => { + it("When an empty result is provided, we create a sarif text with summary having zeros", () => { + const results: RunResults = new RunResultsImpl(); + const formattedText: string = results.toFormattedOutput(OutputFormat.SARIF); + const expectedText: string = getContentsOfExpectedOutputFile('zeroViolations.goldfile.sarif', true, true); + expect(formattedText).toEqual(expectedText); + }); + + it("When results contain multiple violations , we create sarif text correctly", () => { + const formattedText: string = runResults.toFormattedOutput(OutputFormat.SARIF); + const expectedText: string = getContentsOfExpectedOutputFile('multipleViolations.goldfile.sarif', true, true); + expect(formattedText).toEqual(expectedText); + }); + + it("When results contain violation of type UnexpectedError, we create sarif text correctly", async () => { + const resultsWithUnexpectedError: RunResults = await createResultsWithUnexpectedError(); + const formattedText: string = resultsWithUnexpectedError.toFormattedOutput(OutputFormat.SARIF); + const expectedText: string = getContentsOfExpectedOutputFile('unexpectedEngineErrorViolation.goldfile.sarif', true, true); + expect(formattedText).toEqual(expectedText); + }); +}); + describe("Other misc output formatting tests", () => { it("When an output format is not supported, then we error", () => { // This test is just a sanity check in case we add in an output format in the future without updating the diff --git a/packages/code-analyzer-core/test/test-data/expectedOutputFiles/multipleViolations.goldfile.sarif b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/multipleViolations.goldfile.sarif new file mode 100644 index 0000000..92bb9ba --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/multipleViolations.goldfile.sarif @@ -0,0 +1,304 @@ +{ + "version": "2.1.0", + "$schema": "http://json.schemastore.org/sarif-2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "stubEngine1", + "informationUri": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/version-5.html", + "rules": [ + { + "id": "stub1RuleA", + "properties": { + "category": [ + "Recommended", + "CodeStyle" + ], + "severity": 4 + }, + "helpUri": "https://example.com/stub1RuleA" + }, + { + "id": "stub1RuleC", + "properties": { + "category": [ + "Recommended", + "Performance", + "Custom" + ], + "severity": 3 + }, + "helpUri": "https://example.com/stub1RuleC" + }, + { + "id": "stub1RuleE", + "properties": { + "category": [ + "Performance" + ], + "severity": 3 + }, + "helpUri": "https://example.com/stub1RuleE" + } + ] + } + }, + "results": [ + { + "ruleId": "stub1RuleA", + "ruleIndex": 0, + "message": { + "text": "SomeViolationMessage1" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}config.test.ts" + }, + "region": { + "startLine": 3, + "startColumn": 6, + "endLine": 11, + "endColumn": 8 + } + } + } + ], + "level": "warning" + }, + { + "ruleId": "stub1RuleC", + "ruleIndex": 1, + "message": { + "text": "SomeViolationMessage2" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}run.test.ts" + }, + "region": { + "startLine": 21, + "startColumn": 7, + "endLine": 25, + "endColumn": 4 + } + } + } + ], + "level": "warning" + }, + { + "ruleId": "stub1RuleE", + "ruleIndex": 2, + "message": { + "text": "Some Violation that contains\na new line in `it` and \"various\" 'quotes'. Also it has that may need to be {escaped}." + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}run.test.ts" + }, + "region": { + "startLine": 56, + "startColumn": 4 + } + } + } + ], + "level": "warning" + } + ], + "invocations": [ + { + "executionSuccessful": true, + "workingDirectory": { + "uri": "{{RUNDIR}}" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "stubEngine2", + "informationUri": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/version-5.html", + "rules": [ + { + "id": "stub2RuleC", + "properties": { + "category": [ + "Recommended", + "BestPractice" + ], + "severity": 2 + } + } + ] + } + }, + "results": [ + { + "ruleId": "stub2RuleC", + "ruleIndex": 0, + "message": { + "text": "SomeViolationMessage3" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 76, + "startColumn": 8 + } + } + } + ], + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 4, + "startColumn": 13 + } + } + }, + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}test-helpers.ts" + }, + "region": { + "startLine": 9, + "startColumn": 1 + } + } + }, + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 76, + "startColumn": 8 + } + } + } + ], + "level": "error" + } + ], + "invocations": [ + { + "executionSuccessful": true, + "workingDirectory": { + "uri": "{{RUNDIR}}" + } + } + ] + }, + { + "tool": { + "driver": { + "name": "stubEngine3", + "informationUri": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/version-5.html", + "rules": [ + { + "id": "stub3RuleA", + "properties": { + "category": [ + "Recommended", + "ErrorProne" + ], + "severity": 3 + } + } + ] + } + }, + "results": [ + { + "ruleId": "stub3RuleA", + "ruleIndex": 0, + "message": { + "text": "SomeViolationMessage4" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 90, + "startColumn": 1, + "endLine": 95, + "endColumn": 10 + } + } + } + ], + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 20, + "startColumn": 10, + "endLine": 22, + "endColumn": 25 + } + } + }, + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}test-helpers.ts" + }, + "region": { + "startLine": 5, + "startColumn": 10 + } + } + }, + { + "physicalLocation": { + "artifactLocation": { + "uri": "{{RUNDIR}}test{{PATHSEP}}stubs.ts" + }, + "region": { + "startLine": 90, + "startColumn": 1, + "endLine": 95, + "endColumn": 10 + } + } + } + ], + "level": "warning" + } + ], + "invocations": [ + { + "executionSuccessful": true, + "workingDirectory": { + "uri": "{{RUNDIR}}" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/code-analyzer-core/test/test-data/expectedOutputFiles/unexpectedEngineErrorViolation.goldfile.sarif b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/unexpectedEngineErrorViolation.goldfile.sarif new file mode 100644 index 0000000..99ea4f3 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/unexpectedEngineErrorViolation.goldfile.sarif @@ -0,0 +1,49 @@ +{ + "version": "2.1.0", + "$schema": "http://json.schemastore.org/sarif-2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "throwingEngine", + "informationUri": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/guide/version-5.html", + "rules": [ + { + "id": "UnexpectedEngineError", + "properties": { + "category": [], + "severity": 1 + } + } + ] + } + }, + "results": [ + { + "ruleId": "UnexpectedEngineError", + "ruleIndex": 0, + "message": { + "text": "The engine with name 'throwingEngine' threw an unexpected error: SomeErrorMessageFromThrowingEngine" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {}, + "region": {} + } + } + ], + "level": "error" + } + ], + "invocations": [ + { + "executionSuccessful": true, + "workingDirectory": { + "uri": "{{RUNDIR}}" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/code-analyzer-core/test/test-data/expectedOutputFiles/zeroViolations.goldfile.sarif b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/zeroViolations.goldfile.sarif new file mode 100644 index 0000000..b3ec9f0 --- /dev/null +++ b/packages/code-analyzer-core/test/test-data/expectedOutputFiles/zeroViolations.goldfile.sarif @@ -0,0 +1,5 @@ +{ + "version": "2.1.0", + "$schema": "http://json.schemastore.org/sarif-2.1.0", + "runs": [] +} \ No newline at end of file