diff --git a/cmd/app/start/start.go b/cmd/app/start/start.go index 2bcc71cec..bf1643925 100644 --- a/cmd/app/start/start.go +++ b/cmd/app/start/start.go @@ -96,7 +96,7 @@ func (s *Start) CreateStartCommand() *cobra.Command { StringP( "output-format", "o", s.configs.PrintOutputType, - `Output format of analysis ("text"|"json"|"sonarqube"). For json and sonarqube --json-output-file is required`, + `Output format of analysis ("text"|"json"|"sarif"|"sonarqube"). For json, sarif, and sonarqube --json-output-file is required`, ) startCmd.PersistentFlags(). diff --git a/internal/controllers/printresults/print_results.go b/internal/controllers/printresults/print_results.go index 8cf53307a..f61ec4e0c 100644 --- a/internal/controllers/printresults/print_results.go +++ b/internal/controllers/printresults/print_results.go @@ -33,12 +33,17 @@ import ( sq "github.com/ZupIT/horusec/internal/entities/sonarqube" "github.com/ZupIT/horusec/internal/enums/outputtype" "github.com/ZupIT/horusec/internal/helpers/messages" + "github.com/ZupIT/horusec/internal/services/sarif" "github.com/ZupIT/horusec/internal/services/sonarqube" "github.com/ZupIT/horusec/internal/utils/file" ) var ErrOutputJSON = errors.New("{HORUSEC_CLI} error creating and/or writing to the specified file") +type SarifConverter interface { + ConvertVulnerabilityToSarif() sarif.Report +} + type SonarQubeConverter interface { ConvertVulnerabilityToSonarQube() sq.Report } @@ -54,6 +59,7 @@ type PrintResults struct { analysis *analysis.Analysis config *config.Config totalVulns int + sarifService SarifConverter sonarqubeService SonarQubeConverter textOutput string writer io.Writer @@ -64,6 +70,7 @@ func NewPrintResults(entity *analysis.Analysis, cfg *config.Config) *PrintResult return &PrintResults{ analysis: entity, config: cfg, + sarifService: sarif.NewSarif(entity), sonarqubeService: sonarqube.NewSonarQube(entity), writer: os.Stdout, totalVulns: 0, @@ -95,6 +102,8 @@ func (pr *PrintResults) printByOutputType() error { switch { case pr.config.PrintOutputType == outputtype.JSON: return pr.printResultsJSON() + case pr.config.PrintOutputType == outputtype.Sarif: + return pr.printResultsSarif() case pr.config.PrintOutputType == outputtype.SonarQube: return pr.printResultsSonarQube() default: @@ -135,6 +144,20 @@ func (pr *PrintResults) printResultsJSON() error { return pr.createOutputJSON(b) } +func (pr *PrintResults) printResultsSarif() error { + logger.LogInfoWithLevel(messages.MsgInfoStartGenerateSARIFFile) + + report := pr.sarifService.ConvertVulnerabilityToSarif() + + b, err := json.MarshalIndent(report, "", " ") + if err != nil { + logger.LogErrorWithLevel(messages.MsgErrorGenerateJSONFile, err) + return err + } + + return pr.createOutputJSON(b) +} + func (pr *PrintResults) printResultsSonarQube() error { logger.LogInfoWithLevel(messages.MsgInfoStartGenerateSonarQubeFile) diff --git a/internal/enums/outputtype/output_type.go b/internal/enums/outputtype/output_type.go index 0b3c43ba3..715493aa0 100644 --- a/internal/enums/outputtype/output_type.go +++ b/internal/enums/outputtype/output_type.go @@ -17,5 +17,6 @@ package outputtype const ( Text = "text" JSON = "json" + Sarif = "sarif" SonarQube = "sonarqube" ) diff --git a/internal/helpers/messages/info.go b/internal/helpers/messages/info.go index 44927c785..c9bfab13e 100644 --- a/internal/helpers/messages/info.go +++ b/internal/helpers/messages/info.go @@ -25,6 +25,7 @@ const ( https://git-scm.com/downloads ` MsgInfoStartGenerateSonarQubeFile = "{HORUSEC_CLI} Generating SonarQube output..." + MsgInfoStartGenerateSARIFFile = "{HORUSEC_CLI} Generating SARIF output..." MsgInfoStartWriteFile = "{HORUSEC_CLI} Writing output JSON to file in the path: " MsgInfoAnalysisLoading = " Scanning code ..." MsgInfoDockerLowerVersion = "{HORUSEC_CLI} We recommend version 19.03 or higher of the docker." + diff --git a/internal/services/sarif/sarif.go b/internal/services/sarif/sarif.go new file mode 100644 index 000000000..108f1f53a --- /dev/null +++ b/internal/services/sarif/sarif.go @@ -0,0 +1,191 @@ +// Copyright 2022 ZUP IT SERVICOS EM TECNOLOGIA E INOVACAO SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sarif + +import ( + "strconv" + "strings" + + "github.com/ZupIT/horusec-devkit/pkg/entities/analysis" + "github.com/ZupIT/horusec-devkit/pkg/entities/vulnerability" + "github.com/ZupIT/horusec-devkit/pkg/enums/severities" + + "github.com/ZupIT/horusec/cmd/app/version" +) + +type Sarif struct { + analysiss *analysis.Analysis + + resultsByTool map[string][]Result + rulesByToolAndID map[string]map[string]Rule + artifactsByToolAndName map[string]map[string]Artifact +} + +func NewSarif(analysiss *analysis.Analysis) *Sarif { + return &Sarif{ + analysiss: analysiss, + resultsByTool: make(map[string][]Result), + rulesByToolAndID: make(map[string]map[string]Rule), + artifactsByToolAndName: make(map[string]map[string]Artifact), + } +} + +func (s *Sarif) ConvertVulnerabilityToSarif() (report Report) { + report.Runs = []ReportRun{} + s.populateReferenceMaps(&report) + s.buildReportRun(&report) + + // SARIF output format version/schema + report.Version = "2.1.0" + report.SchemaURI = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json" + + return report +} + +// populateReferenceMaps iterates all vulnerabilities and break them into the maps/associations necessary +// to build the SARIF report; resultsByTool, artifactsByToolAndName, rulesByToolAndID. +// resultsByTool: Groups individual vuln issue types into the tool which created them +// artifactsByToolAndName: Groups areas of the scanned code into the tool/issue type creating them +// rulesByToolAndId: Groups issue types which are presented by the tool, based on the vulns given +// This prevents the need from iterating over the entire vuln list multiple times. +func (s *Sarif) populateReferenceMaps(report *Report) { + runsByTool := make(map[string]ReportRun) + for index := range s.analysiss.AnalysisVulnerabilities { + vuln := &s.analysiss.AnalysisVulnerabilities[index].Vulnerability + if _, exists := runsByTool[string(vuln.SecurityTool)]; !exists { + report.Runs = append(report.Runs, s.initToolStructure(vuln, runsByTool)) + } + s.resultsByTool[string(vuln.SecurityTool)] = append(s.resultsByTool[string(vuln.SecurityTool)], s.newResult(vuln)) + artifact := s.newArtifact(vuln) + s.artifactsByToolAndName[string(vuln.SecurityTool)][artifact.Location.URI] = artifact + rule := s.newRule(vuln) + s.rulesByToolAndID[string(vuln.SecurityTool)][rule.ID] = rule + } +} + +// buildReportRun builds a single "run" for the report. For SARIF, a "run" has a single tool. +// Therefore, each group of vulnerabilities reported by a specific tool are all +// organized in the same "run". +func (s *Sarif) buildReportRun(report *Report) { + for idx, runReport := range report.Runs { + for _, artifact := range s.artifactsByToolAndName[runReport.Tool.Driver.Name] { + report.Runs[idx].Artifacts = append(report.Runs[idx].Artifacts, artifact) + } + for _, rule := range s.rulesByToolAndID[runReport.Tool.Driver.Name] { + report.Runs[idx].Tool.Driver.Rules = append(report.Runs[idx].Tool.Driver.Rules, rule) + } + report.Runs[idx].Results = append(report.Runs[idx].Results, s.resultsByTool[runReport.Tool.Driver.Name]...) + } +} + +// initToolStructure initializes the structure for a single report "run", as well as updating +// the association maps in the SARIF object to reflect the run's existence +func (s *Sarif) initToolStructure( + vulnerabilityy *vulnerability.Vulnerability, + runsByTool map[string]ReportRun) ReportRun { + s.rulesByToolAndID[string(vulnerabilityy.SecurityTool)] = make(map[string]Rule) + s.artifactsByToolAndName[string(vulnerabilityy.SecurityTool)] = make(map[string]Artifact) + + reportRun := ReportRun{ + Tool: s.newTool(vulnerabilityy), + } + + runsByTool[string(vulnerabilityy.SecurityTool)] = reportRun + + return reportRun +} + +func (s *Sarif) convertNonZeroIntStr(str string) int { + newInt, _ := strconv.Atoi(str) + if newInt > 0 { + return newInt + } + return 1 +} + +func (s *Sarif) newTool(vulnerabilityy *vulnerability.Vulnerability) ScanTool { + return ScanTool{ + Driver: ScanToolDriver{ + Name: vulnerabilityy.SecurityTool.ToString(), + MoreInformationURI: "https://docs.horusec.io/docs/cli/analysis-tools/overview/", + Version: version.Version, + }, + } +} + +func (s *Sarif) newRule(vulnerabilityy *vulnerability.Vulnerability) Rule { + return Rule{ + ID: vulnerabilityy.RuleID, + ShortDescription: TextDisplayComponent{ + Text: vulnerabilityy.Details, + }, + FullDescription: TextDisplayComponent{ + Text: vulnerabilityy.Details, + }, + HelpURI: "https://docs.horusec.io/docs/cli/analysis-tools/overview/", + Name: strings.Split(vulnerabilityy.Details, "\n")[0], + } +} + +func (s *Sarif) newArtifact(vulnerabilityy *vulnerability.Vulnerability) Artifact { + return Artifact{ + Location: LocationComponent{ + URI: vulnerabilityy.File, + }, + } +} + +func (s *Sarif) newResult(vulnerabilityy *vulnerability.Vulnerability) Result { + return Result{ + Message: TextDisplayComponent{ + Text: vulnerabilityy.Details, + }, + Level: ResultLevel(s.convertHorusecSeverityToSarif(vulnerabilityy.Severity)), + Locations: []Location{s.createLocation(vulnerabilityy)}, + RuleID: vulnerabilityy.RuleID, + } +} + +func (s *Sarif) createLocation(vulnerabilityy *vulnerability.Vulnerability) Location { + return Location{ + PhysicalLocation: PhysicalLocation{ + ArtifactLocation: LocationComponent{ + URI: vulnerabilityy.File, + }, + Region: SnippetRegion{ + Snippet: TextDisplayComponent{ + Text: vulnerabilityy.Code, + }, + StartLine: s.convertNonZeroIntStr(vulnerabilityy.Line), + StartColumn: s.convertNonZeroIntStr(vulnerabilityy.Column), + }, + }, + } +} + +func (s *Sarif) convertHorusecSeverityToSarif(severity severities.Severity) string { + return s.getSarifSeverityMap()[severity] +} + +func (s *Sarif) getSarifSeverityMap() map[severities.Severity]string { + return map[severities.Severity]string{ + severities.Critical: Error, + severities.High: Error, + severities.Medium: Warning, + severities.Low: Note, + severities.Unknown: Note, + severities.Info: Note, + } +} diff --git a/internal/services/sarif/sarif_test.go b/internal/services/sarif/sarif_test.go new file mode 100644 index 000000000..82d05bfe9 --- /dev/null +++ b/internal/services/sarif/sarif_test.go @@ -0,0 +1,98 @@ +// Copyright 2022 ZUP IT SERVICOS EM TECNOLOGIA E INOVACAO SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sarif + +import ( + "testing" + "time" + + "github.com/ZupIT/horusec-devkit/pkg/entities/analysis" + "github.com/ZupIT/horusec-devkit/pkg/entities/vulnerability" + analysisenum "github.com/ZupIT/horusec-devkit/pkg/enums/analysis" + "github.com/ZupIT/horusec-devkit/pkg/enums/languages" + "github.com/ZupIT/horusec-devkit/pkg/enums/severities" + "github.com/ZupIT/horusec-devkit/pkg/enums/tools" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestConvertVulnerabilityDataToSarif(t *testing.T) { + t.Run("should successfully parse analysis to sarif output", func(t *testing.T) { + entity := &analysis.Analysis{ + ID: uuid.New(), + CreatedAt: time.Now(), + Status: analysisenum.Success, + AnalysisVulnerabilities: []analysis.AnalysisVulnerabilities{ + { + Vulnerability: vulnerability.Vulnerability{ + Line: "1", + Column: "1", + Severity: severities.High, + File: "sample.c", + Code: "assert(true == false);", + Details: "Universe failure; please restart reality", + SecurityTool: tools.Bandit, + Language: languages.C, + }, + }, + }, + } + + service := NewSarif(entity) + + result := service.ConvertVulnerabilityToSarif() + assert.NotEmpty(t, result.Runs) + }) + + t.Run("field sets should be populated", func(t *testing.T) { + analysis := &analysis.Analysis{ + ID: uuid.New(), + CreatedAt: time.Now(), + Status: analysisenum.Success, + AnalysisVulnerabilities: []analysis.AnalysisVulnerabilities{ + { + Vulnerability: vulnerability.Vulnerability{ + Line: "1", + Column: "1", + Severity: severities.High, + File: "sample.c", + Code: "assert(true == false);", + Details: "Universe failure; please restart reality", + SecurityTool: tools.Bandit, + Language: languages.C, + }, + }, + }, + } + + service := NewSarif(analysis) + + result := service.ConvertVulnerabilityToSarif() + assert.NotNil(t, result.Runs) + assert.Len(t, result.Runs, 1) + assert.Len(t, result.Runs[0].Results, 1) + + assert.EqualValues(t, result.Runs[0].Results[0].Message.Text, "Universe failure; please restart reality") + + assert.EqualValues(t, result.Runs[0].Results[0].Locations[0].PhysicalLocation.ArtifactLocation.URI, "sample.c") + + assert.EqualValues(t, result.Runs[0].Results[0].Locations[0].PhysicalLocation.Region.Snippet.Text, "assert(true == false);") + assert.EqualValues(t, result.Runs[0].Results[0].Locations[0].PhysicalLocation.Region.StartColumn, 1) + assert.EqualValues(t, result.Runs[0].Results[0].Locations[0].PhysicalLocation.Region.StartLine, 1) + + assert.NotNil(t, result.Runs[0].Tool) + assert.EqualValues(t, result.Runs[0].Tool.Driver.Name, "Bandit") + }) +} diff --git a/internal/services/sarif/schema.go b/internal/services/sarif/schema.go new file mode 100644 index 000000000..47ca9ed91 --- /dev/null +++ b/internal/services/sarif/schema.go @@ -0,0 +1,95 @@ +// Copyright 2022 ZUP IT SERVICOS EM TECNOLOGIA E INOVACAO SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sarif + +type Artifact struct { + Location LocationComponent `json:"location"` +} + +type LocationComponent struct { + URI string `json:"uri"` +} + +type Location struct { + PhysicalLocation PhysicalLocation `json:"physicalLocation"` +} + +type PhysicalLocation struct { + ArtifactLocation LocationComponent `json:"artifactLocation"` + Region SnippetRegion `json:"region"` +} + +type ReportRun struct { + Tool ScanTool `json:"tool"` + Artifacts []Artifact `json:"artifacts"` + Results []Result `json:"results"` +} + +type Report struct { + Runs []ReportRun `json:"runs"` + Version string `json:"version"` + SchemaURI string `json:"$schema"` +} + +type Result struct { + Message TextDisplayComponent `json:"message"` + Level ResultLevel `json:"level"` + Locations []Location `json:"locations"` + RuleID string `json:"ruleId"` +} + +type ResultLevel string + +const ( + Error = "error" + Warning = "warning" + Note = "note" +) + +type Rule struct { + ID string `json:"id"` + ShortDescription TextDisplayComponent `json:"shortDescription"` + FullDescription TextDisplayComponent `json:"fullDescription"` + HelpURI string `json:"helpUri"` + Name string `json:"name"` +} + +type ScanTool struct { + Driver ScanToolDriver `json:"driver"` +} + +type ScanToolDriver struct { + Name string `json:"name"` + MoreInformationURI string `json:"informationUri"` + Rules []Rule `json:"rules"` + Version string `json:"version"` +} + +type SnippetRegion struct { + Snippet TextDisplayComponent `json:"snippet"` + StartLine int `json:"startLine"` + StartColumn int `json:"startColumn"` +} + +type TextDisplayComponent struct { + Text string `json:"text"` +} + +type TextRange struct { + StartLine int `json:"startLine"` + EndLine int `json:"endLine,omitempty"` + StartColumn int `json:"startColumn,omitempty"` + EndColumn int `json:"endColumn,omitempty"` +} diff --git a/internal/usecases/cli/cli.go b/internal/usecases/cli/cli.go index 44b17c55b..1f376d4d2 100644 --- a/internal/usecases/cli/cli.go +++ b/internal/usecases/cli/cli.go @@ -46,7 +46,7 @@ func ValidateConfig(cfg *config.Config) error { validation.Field(&cfg.TimeoutInSecondsAnalysis, validation.Required, validation.Min(10)), validation.Field(&cfg.MonitorRetryInSeconds, validation.Required, validation.Min(10)), validation.Field(&cfg.RepositoryAuthorization, validation.Required, is.UUID), - validation.Field(&cfg.PrintOutputType, validation.In(outputtype.JSON, outputtype.SonarQube, outputtype.Text)), + validation.Field(&cfg.PrintOutputType, validation.In(outputtype.JSON, outputtype.Sarif, outputtype.SonarQube, outputtype.Text)), validation.Field(&cfg.JSONOutputFilePath, validation.By(validateJSONOutputFilePath(cfg))), validation.Field(&cfg.SeveritiesToIgnore, validation.By(validationSeverities(cfg))), validation.Field(&cfg.ReturnErrorIfFoundVulnerability, validation.In(true, false)),