diff --git a/internal/presenters/components.go b/internal/presenters/components.go index 6f1d3b9ea..149990e6f 100644 --- a/internal/presenters/components.go +++ b/internal/presenters/components.go @@ -102,7 +102,15 @@ func RenderTip(str string) string { return fmt.Sprintf("\nšŸ’” Tip\n\n%s", str) } -func RenderSummary(summary *json_schemas.TestSummary, orgName string, testPath string) (string, error) { +func FilterSeverityASC(original []string, severityMinLevel string) []string { + minLevelPointer := slices.Index(original, severityMinLevel) + if minLevelPointer >= 0 { + return original[minLevelPointer:] + } + return original +} + +func RenderSummary(summary *json_schemas.TestSummary, orgName string, testPath string, severityMinLevel string) (string, error) { var buff bytes.Buffer var summaryTemplate = template.Must(template.New("summary").Parse(`Test Summary @@ -120,11 +128,19 @@ func RenderSummary(summary *json_schemas.TestSummary, orgName string, testPath s openIssueLabelledCount := "" ignoredIssueLabelledCount := "" - slices.Reverse(summary.SeverityOrderAsc) + filteredSeverityASC := FilterSeverityASC(summary.SeverityOrderAsc, severityMinLevel) + reversedSlice := slices.Clone(summary.SeverityOrderAsc) + slices.Reverse(reversedSlice) - for _, severity := range summary.SeverityOrderAsc { + for _, severity := range reversedSlice { + satisfyMinLevel := slices.Contains(filteredSeverityASC, severity) for _, result := range summary.Results { if result.Severity == severity { + if !satisfyMinLevel { + openIssueLabelledCount += renderInSeverityColor(severity, fmt.Sprintf(" %d %s ", 0, strings.ToUpper(severity))) + ignoredIssueLabelledCount += renderInSeverityColor(severity, fmt.Sprintf(" %d %s ", 0, strings.ToUpper(severity))) + continue + } totalIssueCount += result.Total openIssueCount += result.Open ignoredIssueCount += result.Ignored diff --git a/internal/presenters/presenter_sarif_results_pretty.go b/internal/presenters/presenter_sarif_results_pretty.go index 88571a070..10f1f4e75 100644 --- a/internal/presenters/presenter_sarif_results_pretty.go +++ b/internal/presenters/presenter_sarif_results_pretty.go @@ -7,6 +7,7 @@ import ( "time" "github.com/snyk/code-client-go/sarif" + sarif_utils "github.com/snyk/go-application-framework/internal/utils/sarif" ) @@ -24,11 +25,12 @@ type FindingProperty struct { } type Presenter struct { - ShowIgnored bool - ShowOpen bool - Input sarif.SarifDocument - OrgName string - TestPath string + ShowIgnored bool + ShowOpen bool + Input sarif.SarifDocument + OrgName string + TestPath string + SeverityMinLevel string } type PresenterOption func(*Presenter) @@ -57,13 +59,20 @@ func WithTestPath(testPath string) PresenterOption { } } +func WithSeverityThershold(severityMinLevel string) PresenterOption { + return func(p *Presenter) { + p.SeverityMinLevel = severityMinLevel + } +} + func SarifTestResults(sarifDocument sarif.SarifDocument, options ...PresenterOption) *Presenter { p := &Presenter{ - ShowIgnored: false, - ShowOpen: true, - Input: sarifDocument, - OrgName: "", - TestPath: "", + ShowIgnored: false, + ShowOpen: true, + Input: sarifDocument, + OrgName: "", + TestPath: "", + SeverityMinLevel: "low", } for _, option := range options { @@ -73,16 +82,31 @@ func SarifTestResults(sarifDocument sarif.SarifDocument, options ...PresenterOpt return p } +func FilterFindingsBySeverity(findings []Finding, minLevel string, severityOrder []string) []Finding { + var filteredFindings []Finding + + filteredSeverityASC := FilterSeverityASC(severityOrder, minLevel) + for _, finding := range findings { + if slices.Contains(filteredSeverityASC, finding.Severity) { + filteredFindings = append(filteredFindings, finding) + } + } + return filteredFindings +} + func (p *Presenter) Render() (string, error) { summaryData := sarif_utils.CreateCodeSummary(&p.Input) findings := SortFindings(convertSarifToFindingsList(p.Input), summaryData.SeverityOrderAsc) - summaryOutput, err := RenderSummary(summaryData, p.OrgName, p.TestPath) + summaryOutput, err := RenderSummary(summaryData, p.OrgName, p.TestPath, p.SeverityMinLevel) if err != nil { return "", err } + // Filter findings based on severity + findings = FilterFindingsBySeverity(findings, p.SeverityMinLevel, summaryData.SeverityOrderAsc) + str := strings.Join([]string{ "", renderBold(fmt.Sprintf("Testing %s ...", p.TestPath)), diff --git a/internal/presenters/presenter_sarif_results_pretty_test.go b/internal/presenters/presenter_sarif_results_pretty_test.go index e7a754d77..41d0de4f4 100644 --- a/internal/presenters/presenter_sarif_results_pretty_test.go +++ b/internal/presenters/presenter_sarif_results_pretty_test.go @@ -9,8 +9,10 @@ import ( "github.com/gkampitakis/go-snaps/snaps" "github.com/muesli/termenv" "github.com/snyk/code-client-go/sarif" - "github.com/snyk/go-application-framework/internal/presenters" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/snyk/go-application-framework/internal/presenters" ) func TestPresenterSarifResultsPretty_NoIssues(t *testing.T) { @@ -187,3 +189,25 @@ func TestPresenterSarifResultsPretty_OnlyIgnored(t *testing.T) { snaps.MatchSnapshot(t, result) } + +func TestFilterSeverityASC(t *testing.T) { + input := []string{"low", "medium", "high", "critical"} + + t.Run("Threshold medium", func(t *testing.T) { + expected := []string{"medium", "high", "critical"} + actual := presenters.FilterSeverityASC(input, "medium") + assert.Equal(t, expected, actual) + }) + + t.Run("Threshold critical", func(t *testing.T) { + expected := []string{"critical"} + actual := presenters.FilterSeverityASC(input, "critical") + assert.Equal(t, expected, actual) + }) + + t.Run("Threshold unknown", func(t *testing.T) { + expected := input + actual := presenters.FilterSeverityASC(input, "unknown") + assert.Equal(t, expected, actual) + }) +} diff --git a/pkg/configuration/constants.go b/pkg/configuration/constants.go index ad94fd71b..a3930198b 100644 --- a/pkg/configuration/constants.go +++ b/pkg/configuration/constants.go @@ -34,7 +34,8 @@ const ( FF_CODE_CONSISTENT_IGNORES string = "internal_snyk_code_ignores_enabled" // flags - FLAG_EXPERIMENTAL string = "experimental" - FLAG_INCLUDE_IGNORES string = "include-ignores" - FLAG_ONLY_IGNORES string = "only-ignores" + FLAG_EXPERIMENTAL string = "experimental" + FLAG_INCLUDE_IGNORES string = "include-ignores" + FLAG_ONLY_IGNORES string = "only-ignores" + FLAG_SEVERITY_THRESHOLD string = "severity-threshold" ) diff --git a/pkg/local_workflows/output_workflow.go b/pkg/local_workflows/output_workflow.go index 6f7282752..d08c4c8bb 100644 --- a/pkg/local_workflows/output_workflow.go +++ b/pkg/local_workflows/output_workflow.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "github.com/snyk/code-client-go/sarif" "github.com/spf13/pflag" @@ -13,6 +14,7 @@ import ( iUtils "github.com/snyk/go-application-framework/internal/utils" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/local_workflows/content_type" + "github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas" "github.com/snyk/go-application-framework/pkg/workflow" ) @@ -36,6 +38,7 @@ func InitOutputWorkflow(engine workflow.Engine) error { outputConfig.String(OUTPUT_CONFIG_KEY_SARIF_FILE, "", "Write sarif output to file") outputConfig.Bool(configuration.FLAG_INCLUDE_IGNORES, false, "Include ignored findings in the output") outputConfig.Bool(configuration.FLAG_ONLY_IGNORES, false, "Hide open issues in the output") + outputConfig.String(configuration.FLAG_SEVERITY_THRESHOLD, "low", "Severity threshold for findings to be included in the output") entry, err := engine.Register(WORKFLOWID_OUTPUT_WORKFLOW, workflow.ConfigurationOptionsFromFlagset(outputConfig), outputWorkflowEntryPointImpl) entry.SetVisibility(false) @@ -43,6 +46,43 @@ func InitOutputWorkflow(engine workflow.Engine) error { return err } +func filterSummaryOutput(config configuration.Configuration, input workflow.Data) (workflow.Data, error) { + // Parse the summary data + summary := json_schemas.NewTestSummary("") + payload, ok := input.GetPayload().([]byte) + if !ok { + return nil, fmt.Errorf("invalid payload type: %T", input.GetPayload()) + } + err := json.Unmarshal(payload, &summary) + if err != nil { + return input, err + } + + minSeverity := config.GetString(configuration.FLAG_SEVERITY_THRESHOLD) + filteredSeverityOrderAsc := presenters.FilterSeverityASC(summary.SeverityOrderAsc, minSeverity) + + // Filter out the results based on the configuration + var filteredResults []json_schemas.TestSummaryResult + + for _, severity := range filteredSeverityOrderAsc { + for _, result := range summary.Results { + if severity == result.Severity { + filteredResults = append(filteredResults, result) + } + } + } + + summary.Results = filteredResults + + bytes, err := json.Marshal(summary) + if err != nil { + return input, err + } + + workflowId := workflow.NewTypeIdentifier(WORKFLOWID_OUTPUT_WORKFLOW, "FilterTestSummary") + return workflow.NewData(workflowId, content_type.TEST_SUMMARY, bytes), nil +} + // outputWorkflowEntryPoint defines the output entry point // the entry point is called by the engine when the workflow is invoked func outputWorkflowEntryPoint(invocation workflow.InvocationContext, input []workflow.Data, outputDestination iUtils.OutputDestination) ([]workflow.Data, error) { @@ -55,6 +95,12 @@ func outputWorkflowEntryPoint(invocation workflow.InvocationContext, input []wor mimeType := input[i].GetContentType() if strings.HasPrefix(mimeType, content_type.TEST_SUMMARY) { + outputSummary, err := filterSummaryOutput(config, input[i]) + if err != nil { + log.Warn().Err(err).Msg("Failed to filter test summary output") + output = append(output, input[i]) + } + output = append(output, outputSummary) continue } @@ -118,7 +164,7 @@ func handleContentTypeJson(config configuration.Configuration, input []workflow. // yes: use presenter // no: print json to cmd if showToHuman && input[i].GetContentType() == content_type.SARIF_JSON { - humanReadanbleSarifOutput(config, input, i, outputDestination, debugLogger, singleData) + humanReadableSarifOutput(config, input, i, outputDestination, debugLogger, singleData) } else { // if json data is processed but non of the json related output configuration is specified, default printJsonToCmd is enabled if !printJsonToCmd && !writeToFile { @@ -151,7 +197,7 @@ func jsonWriteToFile(debugLogger *zerolog.Logger, input []workflow.Data, i int, return nil } -func humanReadanbleSarifOutput(config configuration.Configuration, input []workflow.Data, i int, outputDestination iUtils.OutputDestination, debugLogger *zerolog.Logger, singleData []byte) { +func humanReadableSarifOutput(config configuration.Configuration, input []workflow.Data, i int, outputDestination iUtils.OutputDestination, debugLogger *zerolog.Logger, singleData []byte) { includeOpenFindings := !config.GetBool(configuration.FLAG_ONLY_IGNORES) includeIgnoredFindings := config.GetBool(configuration.FLAG_INCLUDE_IGNORES) || config.GetBool(configuration.FLAG_ONLY_IGNORES) @@ -167,6 +213,7 @@ func humanReadanbleSarifOutput(config configuration.Configuration, input []workf presenters.WithTestPath(input[i].GetContentLocation()), presenters.WithIgnored(includeIgnoredFindings), presenters.WithOpen(includeOpenFindings), + presenters.WithSeverityThershold(config.GetString(configuration.FLAG_SEVERITY_THRESHOLD)), ) humanReadableResult, err := p.Render() diff --git a/pkg/local_workflows/output_workflow_test.go b/pkg/local_workflows/output_workflow_test.go index 88e30b140..d0e4c7bf3 100644 --- a/pkg/local_workflows/output_workflow_test.go +++ b/pkg/local_workflows/output_workflow_test.go @@ -2,11 +2,13 @@ package localworkflows import ( "encoding/json" + "fmt" "testing" "github.com/golang/mock/gomock" "github.com/rs/zerolog" "github.com/snyk/code-client-go/sarif" + "github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas" "github.com/stretchr/testify/assert" iMocks "github.com/snyk/go-application-framework/internal/mocks" @@ -176,7 +178,7 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { assert.Equal(t, "unsupported output type: hammer/head", err.Error()) }) - t.Run("should reject test summary mimeType", func(t *testing.T) { + t.Run("should not output anything for test summary mimeType", func(t *testing.T) { workflowIdentifier := workflow.NewTypeIdentifier(WORKFLOWID_OUTPUT_WORKFLOW, "output") data := workflow.NewData(workflowIdentifier, content_type.TEST_SUMMARY, []byte(payload)) @@ -188,10 +190,10 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { // assert assert.Nil(t, err) - assert.Equal(t, []workflow.Data{}, output) + assert.Equal(t, 1, len(output)) }) - t.Run("should reject versioned test summary mimeType", func(t *testing.T) { + t.Run("should not output anything for versioned test summary mimeType", func(t *testing.T) { versionedTestSummaryContentType := content_type.TEST_SUMMARY + "; version=2024-04-10" workflowIdentifier := workflow.NewTypeIdentifier(WORKFLOWID_OUTPUT_WORKFLOW, "output") data := workflow.NewData(workflowIdentifier, versionedTestSummaryContentType, []byte(payload)) @@ -204,7 +206,7 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { // assert assert.Nil(t, err) - assert.Equal(t, []workflow.Data{}, output) + assert.Equal(t, 1, len(output)) }) t.Run("should reject test summary mimeType and display known mimeType", func(t *testing.T) { @@ -220,7 +222,7 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { // assert assert.Nil(t, err) - assert.Equal(t, []workflow.Data{}, output) + assert.Equal(t, 1, len(output)) }) t.Run("should print human readable output for sarif data without ignored rules", func(t *testing.T) { @@ -236,6 +238,7 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { // mock assertions outputDestination.EXPECT().Println(gomock.Any()).Do(func(str string) { assert.Contains(t, str, "Total issues: 5") + assert.Contains(t, str, "āœ— [MEDIUM]") assert.NotContains(t, str, "Ignored rule") }).Times(1) @@ -274,9 +277,61 @@ func Test_Output_outputWorkflowEntryPoint(t *testing.T) { assert.Equal(t, []workflow.Data{}, output) }) - t.Run("should print human readable output for sarif data only showing ignored data", func(t *testing.T) { + t.Run("should print human readable output excluding medium severity issues", func(t *testing.T) { input := getSarifInput() + rawSarif, err := json.Marshal(input) + assert.Nil(t, err) + + workflowIdentifier := workflow.NewTypeIdentifier(WORKFLOWID_OUTPUT_WORKFLOW, "output") + + summaryPayload, err := json.Marshal(json_schemas.TestSummary{ + Results: []json_schemas.TestSummaryResult{{ + Severity: "critical", + Total: 99, + Open: 97, + Ignored: 2, + }, { + Severity: "medium", + Total: 99, + Open: 97, + Ignored: 2, + }}, + Type: "sast", + }) + assert.Nil(t, err) + testSummaryData := workflow.NewData(workflowIdentifier, content_type.TEST_SUMMARY, summaryPayload) + sarifData := workflow.NewData(workflowIdentifier, content_type.SARIF_JSON, rawSarif) + sarifData.SetContentLocation("/mypath") + + // mock assertions + outputDestination.EXPECT().Println(gomock.Any()).Do(func(str string) { + assert.Contains(t, str, "Open issues: 2") + assert.Contains(t, str, "0 MEDIUM") + assert.NotContains(t, str, "āœ— [MEDIUM]") + }).Times(1) + + config.Set(configuration.FLAG_SEVERITY_THRESHOLD, "high") + defer config.Set(configuration.FLAG_SEVERITY_THRESHOLD, nil) + + // execute + output, err := outputWorkflowEntryPoint(invocationContextMock, []workflow.Data{sarifData, testSummaryData}, outputDestination) + assert.Nil(t, err) + + // Parse output payload + summary := json_schemas.NewTestSummary("") + err = json.Unmarshal(output[0].GetPayload().([]byte), &summary) + assert.Nil(t, err) + // assert + for _, result := range summary.Results { + fmt.Println(result.Severity) + assert.NotEqual(t, "medium", result.Severity) + } + assert.Equal(t, 1, len(output)) + }) + + t.Run("should print human readable output for sarif data only showing ignored data", func(t *testing.T) { + input := getSarifInput() rawSarif, err := json.Marshal(input) assert.Nil(t, err)