From e40a5ba8d697e19eb1ed088b38f2411b07e911fb Mon Sep 17 00:00:00 2001 From: Harold Albertsson Date: Sun, 19 Jul 2026 17:08:02 +0100 Subject: [PATCH] fix(output): filter CI/CD results to breached rules and standardize output streams When the CI/CD flag was active and any rule exceeded its cicdmaxissues threshold, the tool printed findings for every rule, burying the new violations. The findings JSON also went to stdout while the threshold summary went to stderr, so the two could interleave unpredictably across platforms. - In CI/CD mode, print findings only for rules that breached their cicdmaxissues threshold, with Count updated to match - Write findings and threshold summary sequentially to stdout: issue details first, summary at the bottom - Print an explicit "No rules exceeded their cicdmaxissues threshold." summary when CI/CD mode passes - Extract countFindingsPerRule, getViolatedRuleIds and filterFindingsByRules helpers with unit tests Behavior without the CI/CD flag is unchanged. --- finding/Finding_test.go | 4 +- message/messages.go | 4 ++ output/output.go | 98 ++++++++++++++++++++++++++++------------- output/output_test.go | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 32 deletions(-) diff --git a/finding/Finding_test.go b/finding/Finding_test.go index 05855c9..693f7ad 100644 --- a/finding/Finding_test.go +++ b/finding/Finding_test.go @@ -9,8 +9,8 @@ import ( func TestCreateFindingID(t *testing.T) { // Given occurrence := rules.Occurrence{ - FileName: "TestMessageChannel.messageChannel-meta.xml", - LineContent: " true", + FileName: "TestMessageChannel.messageChannel-meta.xml", + LineContent: " true", LineNumber: 10, ColumnRange: []int{1, 28}, IsFalsePositive: false, diff --git a/message/messages.go b/message/messages.go index 1e785f0..1881793 100644 --- a/message/messages.go +++ b/message/messages.go @@ -74,3 +74,7 @@ func GetThresholdViolation(ruleId string, count int, max int) string { func GetThresholdViolationSummary(count int) string { return fmt.Sprintf("%d rule(s) exceeded their cicdmaxissues threshold.", count) } + +func GetNoThresholdViolationSummary() string { + return "No rules exceeded their cicdmaxissues threshold." +} diff --git a/output/output.go b/output/output.go index de01998..fae86da 100644 --- a/output/output.go +++ b/output/output.go @@ -43,47 +43,74 @@ func ListRules(ruleInstances []*rules.Rule) { } /** - * CheckThresholdViolations checks if any rule exceeds its configured cicdmaxissues. - * Default cicdmaxissues is 0 (no issues allowed). - * Returns true if any threshold is violated, false otherwise. + * countFindingsPerRule counts the findings of each rule. */ -func CheckThresholdViolations(w io.Writer, finalResult *finding.Output, configFile *config.Config) bool { +func countFindingsPerRule(finalResult *finding.Output) map[rules.RuleID]int { findingsPerRule := make(map[rules.RuleID]int) - for _, finding := range finalResult.Results { findingsPerRule[finding.ID]++ } + return findingsPerRule +} - // Sort rule IDs for deterministic output - sortedRuleIds := make([]rules.RuleID, 0, len(findingsPerRule)) - for ruleId := range findingsPerRule { - sortedRuleIds = append(sortedRuleIds, ruleId) +/** + * getViolatedRuleIds returns the IDs of rules whose finding count exceeds their + * configured cicdmaxissues, sorted for deterministic output. + * Default cicdmaxissues is 0 (no issues allowed). + */ +func getViolatedRuleIds(findingsPerRule map[rules.RuleID]int, configFile *config.Config) []rules.RuleID { + violatedRuleIds := []rules.RuleID{} + for ruleId, count := range findingsPerRule { + if count > configFile.GetRuleCicdMaxIssues(ruleId) { + violatedRuleIds = append(violatedRuleIds, ruleId) + } } - sort.Slice(sortedRuleIds, func(i, j int) bool { - return string(sortedRuleIds[i]) < string(sortedRuleIds[j]) + sort.Slice(violatedRuleIds, func(i, j int) bool { + return string(violatedRuleIds[i]) < string(violatedRuleIds[j]) }) + return violatedRuleIds +} - // Check each rule that has findings - violationCount := 0 - - for _, ruleId := range sortedRuleIds { - count := findingsPerRule[ruleId] - maxIssuesAllowed := configFile.GetRuleCicdMaxIssues(ruleId) +/** + * filterFindingsByRules removes findings that do not belong to the given rules + * and updates the result count accordingly. + */ +func filterFindingsByRules(finalResult *finding.Output, ruleIds []rules.RuleID) { + includedRuleIds := make(map[rules.RuleID]bool, len(ruleIds)) + for _, ruleId := range ruleIds { + includedRuleIds[ruleId] = true + } - if count > maxIssuesAllowed { - if violationCount == 0 { - fmt.Fprintf(w, "\n%s\n", message.GetThresholdViolationHeader()) - } - fmt.Fprintf(w, "%s\n", message.GetThresholdViolation(string(ruleId), count, maxIssuesAllowed)) - violationCount++ + filteredResults := []finding.Finding{} + for _, finding := range finalResult.Results { + if includedRuleIds[finding.ID] { + filteredResults = append(filteredResults, finding) } } + finalResult.Results = filteredResults + finalResult.Count = len(filteredResults) +} + +/** + * CheckThresholdViolations checks if any rule exceeds its configured cicdmaxissues. + * Default cicdmaxissues is 0 (no issues allowed). + * Returns true if any threshold is violated, false otherwise. + */ +func CheckThresholdViolations(w io.Writer, finalResult *finding.Output, configFile *config.Config) bool { + findingsPerRule := countFindingsPerRule(finalResult) + violatedRuleIds := getViolatedRuleIds(findingsPerRule, configFile) + + if len(violatedRuleIds) == 0 { + return false + } - if violationCount > 0 { - fmt.Fprintf(w, "\n%s\n", message.GetThresholdViolationSummary(violationCount)) + fmt.Fprintf(w, "\n%s\n", message.GetThresholdViolationHeader()) + for _, ruleId := range violatedRuleIds { + fmt.Fprintf(w, "%s\n", message.GetThresholdViolation(string(ruleId), findingsPerRule[ruleId], configFile.GetRuleCicdMaxIssues(ruleId))) } + fmt.Fprintf(w, "\n%s\n", message.GetThresholdViolationSummary(len(violatedRuleIds))) - return violationCount > 0 + return true } /** @@ -100,13 +127,24 @@ func DisplayOutput(finalResult *finding.Output, scanTime *ScanTime) { finalResult.ScanStartedTime = scanTime.StartedTime finalResult.ScanEndingTime = scanTime.EndingTime finalResult.Count = len(finalResult.Results) - displayOutput(finalResult) - configFile := config.GetConfigInstance() + if options.IsCICDScan() { + configFile := config.GetConfigInstance() + violatedRuleIds := getViolatedRuleIds(countFindingsPerRule(finalResult), configFile) - if options.IsCICDScan() && CheckThresholdViolations(os.Stderr, finalResult, configFile) { - os.Exit(int(errorhandler.ExitCodeOccurrence)) + if len(violatedRuleIds) > 0 { + filterFindingsByRules(finalResult, violatedRuleIds) + displayOutput(finalResult) + CheckThresholdViolations(os.Stdout, finalResult, configFile) + os.Exit(int(errorhandler.ExitCodeOccurrence)) + } + + displayOutput(finalResult) + fmt.Printf("\n%s\n", message.GetNoThresholdViolationSummary()) + return } + + displayOutput(finalResult) } } diff --git a/output/output_test.go b/output/output_test.go index 8114ea5..58f03a5 100644 --- a/output/output_test.go +++ b/output/output_test.go @@ -335,6 +335,88 @@ func TestCheckThresholdViolations_WhenConfigNil_DefaultsToZero(t *testing.T) { } } +func TestGetViolatedRuleIds_ReturnsOnlyBreachedRulesSorted(t *testing.T) { + //Given + finalResult := &finding.Output{ + Results: []finding.Finding{ + {ID: "ZRule"}, + {ID: "ZRule"}, + {ID: "ARule"}, + {ID: "ARule"}, + {ID: "MRule"}, + }, + } + configFile := &config.Config{ + RuleOverrides: map[string]rules.RuleMetadataOverride{ + "ZRule": {CicdMaxIssues: intPtr(1)}, // 2 > 1, violation + "ARule": {CicdMaxIssues: intPtr(1)}, // 2 > 1, violation + "MRule": {CicdMaxIssues: intPtr(5)}, // 1 <= 5, no violation + }, + } + + //When + violatedRuleIds := getViolatedRuleIds(countFindingsPerRule(finalResult), configFile) + + //Then + expectedRuleIds := []rules.RuleID{"ARule", "ZRule"} + if !reflect.DeepEqual(violatedRuleIds, expectedRuleIds) { + t.Errorf("Expected violated rules %v, got %v", expectedRuleIds, violatedRuleIds) + } +} + +func TestFilterFindingsByRules_KeepsOnlyGivenRulesAndUpdatesCount(t *testing.T) { + //Given + finalResult := &finding.Output{ + Count: 5, + Results: []finding.Finding{ + {ID: "RuleA", Name: "First A"}, + {ID: "RuleB"}, + {ID: "RuleA", Name: "Second A"}, + {ID: "RuleC"}, + {ID: "RuleC"}, + }, + } + + //When + filterFindingsByRules(finalResult, []rules.RuleID{"RuleA", "RuleC"}) + + //Then + expectedResults := []finding.Finding{ + {ID: "RuleA", Name: "First A"}, + {ID: "RuleA", Name: "Second A"}, + {ID: "RuleC"}, + {ID: "RuleC"}, + } + if !reflect.DeepEqual(finalResult.Results, expectedResults) { + t.Errorf("Expected filtered results %+v, got %+v", expectedResults, finalResult.Results) + } + if finalResult.Count != 4 { + t.Errorf("Expected count 4 after filtering, got %d", finalResult.Count) + } +} + +func TestFilterFindingsByRules_WhenNoRulesGiven_RemovesAllFindings(t *testing.T) { + //Given + finalResult := &finding.Output{ + Count: 2, + Results: []finding.Finding{ + {ID: "RuleA"}, + {ID: "RuleB"}, + }, + } + + //When + filterFindingsByRules(finalResult, []rules.RuleID{}) + + //Then + if len(finalResult.Results) != 0 { + t.Errorf("Expected no results after filtering, got %+v", finalResult.Results) + } + if finalResult.Count != 0 { + t.Errorf("Expected count 0 after filtering, got %d", finalResult.Count) + } +} + func TestCheckThresholdViolations_OutputIsSortedByRuleID(t *testing.T) { //Given finalResult := &finding.Output{