Skip to content

Commit

Permalink
cli:feat - add SARIF output support (#946)
Browse files Browse the repository at this point in the history
Fixes #937 

Signed-off-by: Anthony Turner <225599+anthturner@users.noreply.github.com>
  • Loading branch information
anthturner authored Feb 2, 2022
1 parent f0df9f4 commit 522076a
Show file tree
Hide file tree
Showing 8 changed files with 411 additions and 2 deletions.
2 changes: 1 addition & 1 deletion cmd/app/start/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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().
Expand Down
23 changes: 23 additions & 0 deletions internal/controllers/printresults/print_results.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ import (
"github.com/ZupIT/horusec/config"
"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() sonarqube.Report
}
Expand All @@ -53,6 +58,7 @@ type PrintResults struct {
analysis *analysis.Analysis
config *config.Config
totalVulns int
sarifService SarifConverter
sonarqubeService SonarQubeConverter
textOutput string
writer io.Writer
Expand All @@ -63,6 +69,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,
Expand Down Expand Up @@ -94,6 +101,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:
Expand Down Expand Up @@ -134,6 +143,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)

Expand Down
1 change: 1 addition & 0 deletions internal/enums/outputtype/output_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ package outputtype
const (
Text = "text"
JSON = "json"
Sarif = "sarif"
SonarQube = "sonarqube"
)
1 change: 1 addition & 0 deletions internal/helpers/messages/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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." +
Expand Down
191 changes: 191 additions & 0 deletions internal/services/sarif/sarif.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
98 changes: 98 additions & 0 deletions internal/services/sarif/sarif_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading

0 comments on commit 522076a

Please sign in to comment.