diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index 029a805d..bb3b597a 100644 --- a/packages/code-analyzer-core/package.json +++ b/packages/code-analyzer-core/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-core", "description": "Core Package for the Salesforce Code Analyzer", - "version": "0.16.1", + "version": "0.16.2", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", diff --git a/packages/code-analyzer-core/src/output-format.ts b/packages/code-analyzer-core/src/output-format.ts index b36b2893..771ce1c9 100644 --- a/packages/code-analyzer-core/src/output-format.ts +++ b/packages/code-analyzer-core/src/output-format.ts @@ -1,12 +1,10 @@ -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"; -import * as xmlbuilder from "xmlbuilder"; -import * as fs from 'fs'; -import path from "node:path"; +import {RunResults} from "./results"; import {Clock, RealClock} from "./utils"; -import * as sarif from "sarif"; +import {JsonOutputFormatter} from "./output-formats/json-output-format"; +import {CsvOutputFormatter} from "./output-formats/csv-output-format"; +import {XmlOutputFormatter} from "./output-formats/xml-output-format"; +import {HtmlOutputFormatter} from "./output-formats/html-output-format"; +import {SarifOutputFormatter} from "./output-formats/sarif-output-format"; export enum OutputFormat { CSV = "CSV", @@ -36,394 +34,3 @@ export abstract class OutputFormatter { } } } - -type ResultsOutput = { - runDir: string - violationCounts: { - total: number - sev1: number - sev2: number - sev3: number - sev4: number - sev5: number - } - violations: ViolationOutput[] -} - -type ViolationOutput = { - rule: string - engine: string - severity: number - type: string - tags: string[] - file?: string - line?: number - column?: number - endLine?: number - endColumn?: number - primaryLocationIndex?: number - locations?: CodeLocationOutput[] - message: string - resources?: string[] -} - -class CodeLocationOutput { - private readonly file?: string; - private readonly line?: number; - private readonly column?: number; - private readonly endLine?: number; - private readonly endColumn?: number; - private readonly comment?: string; - - public constructor(codeLocation: CodeLocation, runDir: string) { - this.file = codeLocation.getFile() ? makeRelativeIfPossible(codeLocation.getFile()!, runDir) : /* istanbul ignore next */ undefined; - this.line = codeLocation.getStartLine(); - this.column = codeLocation.getStartColumn(); - this.endLine = codeLocation.getEndLine(); - this.endColumn = codeLocation.getEndColumn(); - this.comment = codeLocation.getComment(); - } - - public getFile(): string | undefined { - return this.file; - } - - public getLine(): number | undefined { - return this.line; - } - - public getColumn(): number | undefined { - return this.column; - } - - public getEndLine(): number | undefined { - return this.endLine; - } - - public getEndColumn(): number | undefined { - return this.endColumn; - } - - public getComment(): string | undefined { - return this.comment; - } - - public toString(): string { - let locationString: string = ''; - if (this.file != null) { - locationString += this.file; - if (this.line != null) { - locationString += `:${this.line}`; - if (this.column != null) { - locationString += `:${this.column}`; - } - } - } - if (this.comment != null) { - locationString += ` (${this.comment})`; - } - return locationString; - } -} - -class CsvOutputFormatter implements OutputFormatter { - format(results: RunResults): string { - const violationOutputs: ViolationOutput[] = toViolationOutputs(results.getViolations(), results.getRunDirectory()); - const options: CsvOptions = { - header: true, - quoted_string: true, - columns: ['rule', 'engine', 'severity', 'type', 'tags', 'file', 'line', 'column', - 'endLine', 'endColumn', 'locations', 'message', 'resources'], - cast: { - object: value => { - if (Array.isArray(value)) { - return { value: value.join(','), quoted: true }; - } - /* istanbul ignore next */ - throw new Error(`Unsupported value to cast: ${value}.`) - } - } - }; - return stringifyToCsv(violationOutputs, options); - } -} - -class JsonOutputFormatter implements OutputFormatter { - format(results: RunResults): string { - const resultsOutput: ResultsOutput = toResultsOutput(results); - return JSON.stringify(resultsOutput, undefined, 2); - } -} - -class XmlOutputFormatter implements OutputFormatter { - format(results: RunResults): string { - const resultsOutput: ResultsOutput = toResultsOutput(results); - - const resultsNode: xmlbuilder.XMLElement = xmlbuilder.create('results', {version: '1.0', encoding: 'UTF-8'}); - resultsNode.node('runDir').text(resultsOutput.runDir); - const violationCountsNode: xmlbuilder.XMLElement = resultsNode.node('violationCounts'); - violationCountsNode.node('total').text(`${resultsOutput.violationCounts.total}`); - violationCountsNode.node('sev1').text(`${resultsOutput.violationCounts.sev1}`); - violationCountsNode.node('sev2').text(`${resultsOutput.violationCounts.sev2}`); - violationCountsNode.node('sev3').text(`${resultsOutput.violationCounts.sev3}`); - violationCountsNode.node('sev4').text(`${resultsOutput.violationCounts.sev4}`); - violationCountsNode.node('sev5').text(`${resultsOutput.violationCounts.sev5}`); - - const violationsNode: xmlbuilder.XMLElement = resultsNode.node('violations'); - for (const violationOutput of resultsOutput.violations) { - const violationNode: xmlbuilder.XMLElement = violationsNode.node('violation'); - violationNode.node('rule').text(violationOutput.rule); - violationNode.node('engine').text(violationOutput.engine); - violationNode.node('severity').text(`${violationOutput.severity}`); - violationNode.node('type').text(violationOutput.type); - const tagsNode: xmlbuilder.XMLElement = violationNode.node('tags'); - for (const tag of violationOutput.tags) { - tagsNode.node('tag').text(tag); - } - if (violationOutput.file) { - violationNode.node('file').text(violationOutput.file); - } - if (violationOutput.line) { - violationNode.node('line').text(`${violationOutput.line}`); - } - if (violationOutput.column) { - violationNode.node('column').text(`${violationOutput.column}`); - } - if (violationOutput.endLine) { - violationNode.node('endLine').text(`${violationOutput.endLine}`); - } - if (violationOutput.endColumn) { - violationNode.node('endColumn').text(`${violationOutput.endColumn}`); - } - if (violationOutput.primaryLocationIndex != null) { - violationNode.node('primaryLocationIndex').text(`${violationOutput.primaryLocationIndex}`); - } - if (violationOutput.locations) { - const pathLocationsNode: xmlbuilder.XMLElement = violationNode.node('locations'); - for (const location of violationOutput.locations) { - const locationNode: xmlbuilder.XMLElement = pathLocationsNode.node('location'); - if (location.getFile() != null ) { - locationNode.node('file').text(location.getFile()!); - } - if (location.getLine() != null) { - locationNode.node('line').text(`${location.getLine()}`); - } - if (location.getColumn() != null) { - locationNode.node('column').text(`${location.getColumn()}`); - } - if (location.getEndLine() != null) { - locationNode.node('endLine').text(`${location.getEndLine()}`); - } - if (location.getEndColumn() != null) { - locationNode.node('endColumn').text(`${location.getEndColumn()}`); - } - if (location.getComment() != null) { - locationNode.node('comment').text(location.getComment()!); - } - } - } - violationNode.node('message').text(violationOutput.message); - if (violationOutput.resources) { - const resourcesNode: xmlbuilder.XMLElement = violationNode.node('resources'); - for (const resource of violationOutput.resources) { - resourcesNode.node('resource').text(resource); - } - } - } - - return violationsNode.end({ pretty: true, allowEmpty: true }); - } -} - -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 { - private static readonly TIMESTAMP_HOLE: string = '{{###TIMESTAMP###}}'; - private static readonly RUNDIR_HOLE: string = '{{###RUNDIR###}}'; - private static readonly VIOLATIONS_HOLE: string = '{{###VIOLATIONS###}}'; - private readonly clock: Clock; - - constructor(clock: Clock) { - this.clock = clock; - } - - format(results: RunResults): string { - const resultsOutput: ResultsOutput = toResultsOutput(results, escapeHtml); - const htmlTemplate: string = fs.readFileSync(HTML_TEMPLATE_FILE, 'utf-8'); - const timestampString: string = this.clock.now().toLocaleString('en-us', {year: "numeric", month: "short", - day: "numeric", hour: "numeric", minute: "numeric", hour12: true}); - - // Note that value.replace(a,b) has special handling if b has '$' characters in it, so to avoid this special - // handling, we use value.replace(a, (match) => b) instead so that we always replace with exact text. - return htmlTemplate - .replace(HtmlOutputFormatter.TIMESTAMP_HOLE, (_m) => timestampString) - .replace(HtmlOutputFormatter.RUNDIR_HOLE, (_m) => resultsOutput.runDir) - .replace(HtmlOutputFormatter.VIOLATIONS_HOLE, (_m) => JSON.stringify(resultsOutput.violations)); - } -} - -function toResultsOutput(results: RunResults, sanitizeFcn: (text: string) => string = t => t): ResultsOutput { - return { - runDir: results.getRunDirectory(), - violationCounts: { - total: results.getViolationCount(), - sev1: results.getViolationCountOfSeverity(SeverityLevel.Critical), - sev2: results.getViolationCountOfSeverity(SeverityLevel.High), - sev3: results.getViolationCountOfSeverity(SeverityLevel.Moderate), - sev4: results.getViolationCountOfSeverity(SeverityLevel.Low), - sev5: results.getViolationCountOfSeverity(SeverityLevel.Info), - }, - violations: toViolationOutputs(results.getViolations(), results.getRunDirectory(), sanitizeFcn) - }; -} - -function toViolationOutputs(violations: Violation[], runDir: string, sanitizeFcn: (text: string) => string = t => t): ViolationOutput[] { - return violations.map(v => createViolationOutput(v, runDir, sanitizeFcn)); -} - -function createViolationOutput(violation: Violation, runDir: string, sanitizeFcn: (text: string) => string): ViolationOutput { - const rule: Rule = violation.getRule(); - const codeLocations: CodeLocation[] = violation.getCodeLocations(); - const primaryLocation: CodeLocation = codeLocations[violation.getPrimaryLocationIndex()]; - - return { - rule: sanitizeFcn(rule.getName()), - engine: sanitizeFcn(rule.getEngineName()), - severity: rule.getSeverityLevel(), - type: rule.getType(), - tags: rule.getTags().map(sanitizeFcn), - file: primaryLocation.getFile() ? makeRelativeIfPossible(primaryLocation.getFile() as string, runDir) : undefined, - line: primaryLocation.getStartLine(), - column: primaryLocation.getStartColumn(), - endLine: primaryLocation.getEndLine(), - endColumn: primaryLocation.getEndColumn(), - 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); - }) -} - -function makeRelativeIfPossible(file: string, rootDir: string): string { - if (file.startsWith(rootDir)) { - file = file.substring(rootDir.length); - } - return file; -} - -function escapeHtml(text: string) { - return text.replace(/[&<>"']/g, function (match: string) { - /* istanbul ignore next */ - switch (match) { - case '&': return '&'; - case '<': return '<'; - case '>': return '>'; - case '"': return '"'; - case "'": return '''; - default: return match; - } - }); -} \ No newline at end of file diff --git a/packages/code-analyzer-core/src/output-formats/csv-output-format.ts b/packages/code-analyzer-core/src/output-formats/csv-output-format.ts new file mode 100644 index 00000000..4e925472 --- /dev/null +++ b/packages/code-analyzer-core/src/output-formats/csv-output-format.ts @@ -0,0 +1,28 @@ +import {RunResults} from "../results"; +import {stringify as stringifyToCsv} from "csv-stringify/sync"; +import {Options as CsvOptions} from "csv-stringify"; +import {OutputFormatter} from "../output-format"; +import {JsonViolationOutput, toJsonViolationOutputArray} from "./json-output-format"; + +export class CsvOutputFormatter implements OutputFormatter { + format(results: RunResults): string { + // Leveraging the JsonViolationOutput data structure for now. This may change in the near future. + const violationOutputs: JsonViolationOutput[] = toJsonViolationOutputArray(results.getViolations(), results.getRunDirectory()); + const options: CsvOptions = { + header: true, + quoted_string: true, + columns: ['rule', 'engine', 'severity', 'type', 'tags', 'file', 'line', 'column', + 'endLine', 'endColumn', 'locations', 'message', 'resources'], + cast: { + object: value => { + if (Array.isArray(value)) { + return { value: value.join(','), quoted: true }; + } + /* istanbul ignore next */ + throw new Error(`Unsupported value to cast: ${value}.`) + } + } + }; + return stringifyToCsv(violationOutputs, options); + } +} \ No newline at end of file diff --git a/packages/code-analyzer-core/src/output-formats/html-output-format.ts b/packages/code-analyzer-core/src/output-formats/html-output-format.ts new file mode 100644 index 00000000..1b23b156 --- /dev/null +++ b/packages/code-analyzer-core/src/output-formats/html-output-format.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import {Clock} from "../utils"; +import {RunResults} from "../results"; +import fs from "fs"; +import {OutputFormatter} from "../output-format"; +import { + JsonViolationOutput, + toJsonViolationOutputArray +} from "./json-output-format"; + +const HTML_TEMPLATE_VERSION: string = '0.0.1'; +const HTML_TEMPLATE_FILE: string = path.resolve(__dirname, '..', '..', 'output-templates', `html-template-${HTML_TEMPLATE_VERSION}.txt`); +export class HtmlOutputFormatter implements OutputFormatter { + private static readonly TIMESTAMP_HOLE: string = '{{###TIMESTAMP###}}'; + private static readonly RUNDIR_HOLE: string = '{{###RUNDIR###}}'; + private static readonly VIOLATIONS_HOLE: string = '{{###VIOLATIONS###}}'; + private readonly clock: Clock; + + constructor(clock: Clock) { + this.clock = clock; + } + + format(results: RunResults): string { + const runDir = results.getRunDirectory(); + // It is easiest to put the violations in json format (while escaping html characters) since it is easily + // consumed by our html template. + const jsonViolations: JsonViolationOutput[] = toJsonViolationOutputArray(results.getViolations(), runDir,escapeHtml); + const htmlTemplate: string = fs.readFileSync(HTML_TEMPLATE_FILE, 'utf-8'); + const timestampString: string = this.clock.now().toLocaleString('en-us', {year: "numeric", month: "short", + day: "numeric", hour: "numeric", minute: "numeric", hour12: true}); + + // Note that value.replace(a,b) has special handling if b has '$' characters in it, so to avoid this special + // handling, we use value.replace(a, (match) => b) instead so that we always replace with exact text. + return htmlTemplate + .replace(HtmlOutputFormatter.TIMESTAMP_HOLE, (_m) => timestampString) + .replace(HtmlOutputFormatter.RUNDIR_HOLE, (_m) => runDir) + .replace(HtmlOutputFormatter.VIOLATIONS_HOLE, (_m) => JSON.stringify(jsonViolations)); + } +} + +function escapeHtml(text: string) { + return text.replace(/[&<>"']/g, function (match: string) { + /* istanbul ignore next */ + switch (match) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case "'": return '''; + default: return match; + } + }); +} \ No newline at end of file diff --git a/packages/code-analyzer-core/src/output-formats/json-output-format.ts b/packages/code-analyzer-core/src/output-formats/json-output-format.ts new file mode 100644 index 00000000..55be7aab --- /dev/null +++ b/packages/code-analyzer-core/src/output-formats/json-output-format.ts @@ -0,0 +1,158 @@ +import {CodeLocation, RunResults, Violation} from "../results"; +import {OutputFormatter} from "../output-format"; +import {Rule, RuleType, SeverityLevel} from "../rules"; + +export type JsonResultsOutput = { + runDir: string + violationCounts: { + total: number + sev1: number + sev2: number + sev3: number + sev4: number + sev5: number + } + violations: JsonViolationOutput[] +} + +export type JsonViolationOutput = { + rule: string + engine: string + severity: number + type: string + tags: string[] + file?: string + line?: number + column?: number + endLine?: number + endColumn?: number + primaryLocationIndex?: number + locations?: JsonCodeLocationOutput[] + message: string + resources?: string[] +} + +export class JsonCodeLocationOutput { + private readonly file?: string; + private readonly line?: number; + private readonly column?: number; + private readonly endLine?: number; + private readonly endColumn?: number; + private readonly comment?: string; + + public constructor(codeLocation: CodeLocation, runDir: string) { + this.file = codeLocation.getFile() ? makeRelativeIfPossible(codeLocation.getFile()!, runDir) : /* istanbul ignore next */ undefined; + this.line = codeLocation.getStartLine(); + this.column = codeLocation.getStartColumn(); + this.endLine = codeLocation.getEndLine(); + this.endColumn = codeLocation.getEndColumn(); + this.comment = codeLocation.getComment(); + } + + public getFile(): string | undefined { + return this.file; + } + + public getLine(): number | undefined { + return this.line; + } + + public getColumn(): number | undefined { + return this.column; + } + + public getEndLine(): number | undefined { + return this.endLine; + } + + public getEndColumn(): number | undefined { + return this.endColumn; + } + + public getComment(): string | undefined { + return this.comment; + } + + public toString(): string { + let locationString: string = ''; + if (this.file != null) { + locationString += this.file; + if (this.line != null) { + locationString += `:${this.line}`; + if (this.column != null) { + locationString += `:${this.column}`; + } + } + } + if (this.comment != null) { + locationString += ` (${this.comment})`; + } + return locationString; + } +} + +export class JsonOutputFormatter implements OutputFormatter { + format(results: RunResults): string { + const resultsOutput: JsonResultsOutput = toJsonResultsOutput(results); + return JSON.stringify(resultsOutput, undefined, 2); + } +} + +function makeRelativeIfPossible(file: string, rootDir: string): string { + if (file.startsWith(rootDir)) { + file = file.substring(rootDir.length); + } + return file; +} + +export function toJsonResultsOutput(results: RunResults, sanitizeFcn: (text: string) => string = t => t): JsonResultsOutput { + return { + runDir: results.getRunDirectory(), + violationCounts: { + total: results.getViolationCount(), + sev1: results.getViolationCountOfSeverity(SeverityLevel.Critical), + sev2: results.getViolationCountOfSeverity(SeverityLevel.High), + sev3: results.getViolationCountOfSeverity(SeverityLevel.Moderate), + sev4: results.getViolationCountOfSeverity(SeverityLevel.Low), + sev5: results.getViolationCountOfSeverity(SeverityLevel.Info), + }, + violations: toJsonViolationOutputArray(results.getViolations(), results.getRunDirectory(), sanitizeFcn) + }; +} + +export function toJsonViolationOutputArray(violations: Violation[], runDir: string, sanitizeFcn: (text: string) => string = t => t): JsonViolationOutput[] { + return violations.map(v => toJsonViolationOutput(v, runDir, sanitizeFcn)); +} + +function toJsonViolationOutput(violation: Violation, runDir: string, sanitizeFcn: (text: string) => string): JsonViolationOutput { + const rule: Rule = violation.getRule(); + const codeLocations: CodeLocation[] = violation.getCodeLocations(); + const primaryLocation: CodeLocation = codeLocations[violation.getPrimaryLocationIndex()]; + + return { + rule: sanitizeFcn(rule.getName()), + engine: sanitizeFcn(rule.getEngineName()), + severity: rule.getSeverityLevel(), + type: rule.getType(), + tags: rule.getTags().map(sanitizeFcn), + file: primaryLocation.getFile() ? makeRelativeIfPossible(primaryLocation.getFile() as string, runDir) : undefined, + line: primaryLocation.getStartLine(), + column: primaryLocation.getStartColumn(), + endLine: primaryLocation.getEndLine(), + endColumn: primaryLocation.getEndColumn(), + primaryLocationIndex: typeSupportsMultipleLocations(rule.getType()) ? violation.getPrimaryLocationIndex() : undefined, + locations: typeSupportsMultipleLocations(rule.getType()) ? toJsonCodeLocationOutputArray(codeLocations, runDir) : undefined, + message: sanitizeFcn(violation.getMessage()), + resources: violation.getResourceUrls() + }; +} + +function typeSupportsMultipleLocations(ruleType: RuleType) { + return [RuleType.DataFlow, RuleType.Flow, RuleType.MultiLocation].includes(ruleType); +} + +function toJsonCodeLocationOutputArray(codeLocations: CodeLocation[], runDir: string): JsonCodeLocationOutput[] { + return codeLocations.map(loc => { + return new JsonCodeLocationOutput(loc, runDir); + }) +} \ No newline at end of file diff --git a/packages/code-analyzer-core/src/output-formats/sarif-output-format.ts b/packages/code-analyzer-core/src/output-formats/sarif-output-format.ts new file mode 100644 index 00000000..00f5c496 --- /dev/null +++ b/packages/code-analyzer-core/src/output-formats/sarif-output-format.ts @@ -0,0 +1,100 @@ +import {CodeLocation, EngineRunResults, RunResults, Violation} from "../results"; +import * as sarif from "sarif"; +import {Rule, RuleType, SeverityLevel} from "../rules"; +import {OutputFormatter} from "../output-format"; + +export 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. +} + +function typeSupportsMultipleLocations(ruleType: RuleType) { + return [RuleType.DataFlow, RuleType.Flow, RuleType.MultiLocation].includes(ruleType); +} \ No newline at end of file diff --git a/packages/code-analyzer-core/src/output-formats/xml-output-format.ts b/packages/code-analyzer-core/src/output-formats/xml-output-format.ts new file mode 100644 index 00000000..9d30713b --- /dev/null +++ b/packages/code-analyzer-core/src/output-formats/xml-output-format.ts @@ -0,0 +1,85 @@ +import {RunResults} from "../results"; +import * as xmlbuilder from "xmlbuilder"; +import {OutputFormatter} from "../output-format"; +import {JsonResultsOutput, toJsonResultsOutput} from "./json-output-format"; + +export class XmlOutputFormatter implements OutputFormatter { + format(results: RunResults): string { + // XML and JSON output formats are very similar, so leveraging the same data structure from JSON for now. + const resultsOutput: JsonResultsOutput = toJsonResultsOutput(results); + + const resultsNode: xmlbuilder.XMLElement = xmlbuilder.create('results', {version: '1.0', encoding: 'UTF-8'}); + resultsNode.node('runDir').text(resultsOutput.runDir); + const violationCountsNode: xmlbuilder.XMLElement = resultsNode.node('violationCounts'); + violationCountsNode.node('total').text(`${resultsOutput.violationCounts.total}`); + violationCountsNode.node('sev1').text(`${resultsOutput.violationCounts.sev1}`); + violationCountsNode.node('sev2').text(`${resultsOutput.violationCounts.sev2}`); + violationCountsNode.node('sev3').text(`${resultsOutput.violationCounts.sev3}`); + violationCountsNode.node('sev4').text(`${resultsOutput.violationCounts.sev4}`); + violationCountsNode.node('sev5').text(`${resultsOutput.violationCounts.sev5}`); + + const violationsNode: xmlbuilder.XMLElement = resultsNode.node('violations'); + for (const violationOutput of resultsOutput.violations) { + const violationNode: xmlbuilder.XMLElement = violationsNode.node('violation'); + violationNode.node('rule').text(violationOutput.rule); + violationNode.node('engine').text(violationOutput.engine); + violationNode.node('severity').text(`${violationOutput.severity}`); + violationNode.node('type').text(violationOutput.type); + const tagsNode: xmlbuilder.XMLElement = violationNode.node('tags'); + for (const tag of violationOutput.tags) { + tagsNode.node('tag').text(tag); + } + if (violationOutput.file) { + violationNode.node('file').text(violationOutput.file); + } + if (violationOutput.line) { + violationNode.node('line').text(`${violationOutput.line}`); + } + if (violationOutput.column) { + violationNode.node('column').text(`${violationOutput.column}`); + } + if (violationOutput.endLine) { + violationNode.node('endLine').text(`${violationOutput.endLine}`); + } + if (violationOutput.endColumn) { + violationNode.node('endColumn').text(`${violationOutput.endColumn}`); + } + if (violationOutput.primaryLocationIndex != null) { + violationNode.node('primaryLocationIndex').text(`${violationOutput.primaryLocationIndex}`); + } + if (violationOutput.locations) { + const pathLocationsNode: xmlbuilder.XMLElement = violationNode.node('locations'); + for (const location of violationOutput.locations) { + const locationNode: xmlbuilder.XMLElement = pathLocationsNode.node('location'); + if (location.getFile() != null ) { + locationNode.node('file').text(location.getFile()!); + } + if (location.getLine() != null) { + locationNode.node('line').text(`${location.getLine()}`); + } + if (location.getColumn() != null) { + locationNode.node('column').text(`${location.getColumn()}`); + } + if (location.getEndLine() != null) { + locationNode.node('endLine').text(`${location.getEndLine()}`); + } + if (location.getEndColumn() != null) { + locationNode.node('endColumn').text(`${location.getEndColumn()}`); + } + if (location.getComment() != null) { + locationNode.node('comment').text(location.getComment()!); + } + } + } + violationNode.node('message').text(violationOutput.message); + if (violationOutput.resources) { + const resourcesNode: xmlbuilder.XMLElement = violationNode.node('resources'); + for (const resource of violationOutput.resources) { + resourcesNode.node('resource').text(resource); + } + } + } + + return violationsNode.end({ pretty: true, allowEmpty: true }); + } +} \ No newline at end of file