From 88588dfbaca5fbaf05771b51c8095d563ff76cb5 Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Sun, 26 Apr 2026 10:45:33 +0300 Subject: [PATCH 1/4] [PPSC-717] fix(sarif): stabilize ruleId to prevent recurring false positive alerts Derive ruleId from CWE/CVE/category instead of the server-assigned finding.ID which embeds line numbers and causes duplicate alerts when lines shift. Add partialFingerprints for cross-run dedup. --- internal/output/sarif.go | 61 +++++++++--- internal/output/sarif_test.go | 181 ++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 12 deletions(-) diff --git a/internal/output/sarif.go b/internal/output/sarif.go index 0cbc3bf..7a151c5 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -1,6 +1,8 @@ package output import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -62,16 +64,18 @@ type sarifRuleProperties struct { } type sarifResult struct { - RuleID string `json:"ruleId"` - RuleIndex int `json:"ruleIndex"` - Level string `json:"level"` - Message sarifMessage `json:"message"` - Locations []sarifLocation `json:"locations,omitempty"` - Fixes []sarifFix `json:"fixes,omitempty"` - Properties *sarifResultProperties `json:"properties,omitempty"` + RuleID string `json:"ruleId"` + RuleIndex int `json:"ruleIndex"` + Level string `json:"level"` + Message sarifMessage `json:"message"` + Locations []sarifLocation `json:"locations,omitempty"` + PartialFingerprints map[string]string `json:"partialFingerprints,omitempty"` + Fixes []sarifFix `json:"fixes,omitempty"` + Properties *sarifResultProperties `json:"properties,omitempty"` } type sarifResultProperties struct { + FindingID string `json:"findingId,omitempty"` Severity string `json:"severity"` Type string `json:"type,omitempty"` CodeSnippet string `json:"codeSnippet,omitempty"` @@ -192,6 +196,33 @@ func (f *SARIFFormatter) Format(result *model.ScanResult, w io.Writer) error { return encoder.Encode(report) } +// stableRuleID derives a stable SARIF ruleId from a finding's classification. +// Falls back through CWE → CVE → FindingCategory → finding.ID. +func stableRuleID(finding model.Finding) string { + if len(finding.CWEs) > 0 && finding.CWEs[0] != "" { + return finding.CWEs[0] + } + if len(finding.CVEs) > 0 && finding.CVEs[0] != "" { + return finding.CVEs[0] + } + if finding.FindingCategory != "" { + return finding.FindingCategory + } + return finding.ID +} + +// computeFingerprint produces a stable SHA-256 fingerprint for cross-run dedup. +func computeFingerprint(ruleID, file, snippet string, startLine int) string { + var input string + if snippet != "" { + input = ruleID + ":" + file + ":" + snippet + } else { + input = ruleID + ":" + file + ":" + strconv.Itoa(startLine) + } + h := sha256.Sum256([]byte(input)) + return hex.EncodeToString(h[:]) +} + // buildRules creates SARIF rules from findings, deduplicating by rule ID. // Returns the rules array and a map of rule ID to index. func buildRules(findings []model.Finding) ([]sarifRule, map[string]int) { @@ -199,10 +230,11 @@ func buildRules(findings []model.Finding) ([]sarifRule, map[string]int) { var rules []sarifRule for _, finding := range findings { - if _, exists := ruleIndexMap[finding.ID]; !exists { - ruleIndexMap[finding.ID] = len(rules) + ruleID := stableRuleID(finding) + if _, exists := ruleIndexMap[ruleID]; !exists { + ruleIndexMap[ruleID] = len(rules) rule := sarifRule{ - ID: finding.ID, + ID: ruleID, ShortDescription: sarifMessage{ Text: finding.Title, }, @@ -267,14 +299,19 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int results := make([]sarifResult, 0, capacity) for _, finding := range findings { + ruleID := stableRuleID(finding) result := sarifResult{ - RuleID: finding.ID, - RuleIndex: ruleIndexMap[finding.ID], + RuleID: ruleID, + RuleIndex: ruleIndexMap[ruleID], Level: severityToSarifLevel(finding.Severity), Message: sarifMessage{ Text: buildMessageText(finding.Title, finding.Description), }, + PartialFingerprints: map[string]string{ + "primaryLocationLineHash": computeFingerprint(ruleID, finding.File, finding.CodeSnippet, finding.StartLine), + }, Properties: &sarifResultProperties{ + FindingID: finding.ID, Severity: string(finding.Severity), Type: string(finding.Type), CodeSnippet: util.MaskSecretInLine(finding.CodeSnippet), // Defense-in-depth: always sanitize diff --git a/internal/output/sarif_test.go b/internal/output/sarif_test.go index 5627fbf..9cbfaf2 100644 --- a/internal/output/sarif_test.go +++ b/internal/output/sarif_test.go @@ -1211,3 +1211,184 @@ func TestSARIF_SchemaValidation(t *testing.T) { }) } } + +func TestStableRuleID(t *testing.T) { + tests := []struct { + name string + finding model.Finding + expected string + }{ + { + name: "CWE takes priority", + finding: model.Finding{ID: "server-id-123", CWEs: []string{"CWE-79"}, CVEs: []string{"CVE-2023-1234"}, FindingCategory: "xss"}, + expected: "CWE-79", + }, + { + name: "CVE when no CWE", + finding: model.Finding{ID: "server-id-456", CVEs: []string{"CVE-2023-5678"}}, + expected: "CVE-2023-5678", + }, + { + name: "FindingCategory when no CWE/CVE", + finding: model.Finding{ID: "server-id-789", FindingCategory: "hardcoded-secret"}, + expected: "hardcoded-secret", + }, + { + name: "falls back to finding.ID", + finding: model.Finding{ID: "server-id-000"}, + expected: "server-id-000", + }, + { + name: "empty CWE slice falls through", + finding: model.Finding{ID: "fallback", CWEs: []string{}, CVEs: []string{"CVE-2024-1111"}}, + expected: "CVE-2024-1111", + }, + { + name: "empty string CWE falls through", + finding: model.Finding{ID: "fallback2", CWEs: []string{""}, CVEs: []string{"CVE-2024-2222"}}, + expected: "CVE-2024-2222", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stableRuleID(tt.finding) + if got != tt.expected { + t.Errorf("stableRuleID() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestComputeFingerprint(t *testing.T) { + t.Run("deterministic", func(t *testing.T) { + fp1 := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 10) + fp2 := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 10) + if fp1 != fp2 { + t.Errorf("fingerprint not deterministic: %q != %q", fp1, fp2) + } + }) + + t.Run("uses snippet when available", func(t *testing.T) { + withSnippet := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 10) + diffLine := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 99) + if withSnippet != diffLine { + t.Error("fingerprint should ignore startLine when snippet is present") + } + }) + + t.Run("falls back to startLine without snippet", func(t *testing.T) { + fp1 := computeFingerprint("CWE-79", "src/app.js", "", 10) + fp2 := computeFingerprint("CWE-79", "src/app.js", "", 20) + if fp1 == fp2 { + t.Error("fingerprint should differ when startLine differs and no snippet") + } + }) + + t.Run("different inputs produce different hashes", func(t *testing.T) { + fp1 := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 10) + fp2 := computeFingerprint("CWE-22", "src/app.js", "eval(input)", 10) + fp3 := computeFingerprint("CWE-79", "src/other.js", "eval(input)", 10) + if fp1 == fp2 { + t.Error("different ruleIDs should produce different fingerprints") + } + if fp1 == fp3 { + t.Error("different files should produce different fingerprints") + } + }) + + t.Run("returns hex-encoded SHA-256", func(t *testing.T) { + fp := computeFingerprint("CWE-79", "src/app.js", "eval(input)", 10) + if len(fp) != 64 { + t.Errorf("expected 64 hex chars, got %d", len(fp)) + } + }) +} + +func TestBuildRulesStableCollapsing(t *testing.T) { + findings := []model.Finding{ + {ID: "CWE-79_repo_111_file1_10_1_10_5", CWEs: []string{"CWE-79"}, Title: "XSS in file1", Severity: model.SeverityHigh}, + {ID: "CWE-79_repo_222_file2_20_1_20_5", CWEs: []string{"CWE-79"}, Title: "XSS in file2", Severity: model.SeverityHigh}, + {ID: "CWE-22_repo_333_file3_30_1_30_5", CWEs: []string{"CWE-22"}, Title: "Path traversal", Severity: model.SeverityMedium}, + } + + rules, ruleIndexMap := buildRules(findings) + + if len(rules) != 2 { + t.Fatalf("expected 2 rules (CWE-79, CWE-22), got %d", len(rules)) + } + + if rules[0].ID != "CWE-79" { + t.Errorf("first rule ID = %q, want CWE-79", rules[0].ID) + } + if rules[1].ID != "CWE-22" { + t.Errorf("second rule ID = %q, want CWE-22", rules[1].ID) + } + + if idx, ok := ruleIndexMap["CWE-79"]; !ok || idx != 0 { + t.Errorf("CWE-79 ruleIndex = %d, ok = %v, want 0", idx, ok) + } + if idx, ok := ruleIndexMap["CWE-22"]; !ok || idx != 1 { + t.Errorf("CWE-22 ruleIndex = %d, ok = %v, want 1", idx, ok) + } +} + +func TestSARIFFormatter_FindingIDAndFingerprints(t *testing.T) { + formatter := &SARIFFormatter{} + result := &model.ScanResult{ + ScanID: "test-fingerprint", + Status: "completed", + Findings: []model.Finding{ + { + ID: "CWE-79_repo_12345_src/app.js_10_1_10_20", + Title: "Cross-Site Scripting", + Description: "Reflected XSS vulnerability", + Severity: model.SeverityHigh, + CWEs: []string{"CWE-79"}, + File: "src/app.js", + StartLine: 10, + StartColumn: 1, + CodeSnippet: "document.write(input)", + }, + }, + } + + var buf bytes.Buffer + if err := formatter.Format(result, &buf); err != nil { + t.Fatalf("Format failed: %v", err) + } + + var report sarifReport + if err := json.Unmarshal(buf.Bytes(), &report); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + run := report.Runs[0] + if len(run.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(run.Results)) + } + + res := run.Results[0] + + if res.RuleID != "CWE-79" { + t.Errorf("ruleId = %q, want CWE-79", res.RuleID) + } + + if res.PartialFingerprints == nil { + t.Fatal("expected partialFingerprints to be set") + } + if fp, ok := res.PartialFingerprints["primaryLocationLineHash"]; !ok || fp == "" { + t.Error("expected primaryLocationLineHash to be non-empty") + } + + if res.Properties == nil { + t.Fatal("expected properties to be set") + } + if res.Properties.FindingID != "CWE-79_repo_12345_src/app.js_10_1_10_20" { + t.Errorf("findingId = %q, want original server-assigned ID", res.Properties.FindingID) + } + + if len(run.Tool.Driver.Rules) != 1 || run.Tool.Driver.Rules[0].ID != "CWE-79" { + t.Errorf("rule ID = %q, want CWE-79", run.Tool.Driver.Rules[0].ID) + } +} From 78d7a14128cf0da8d4cc89dcd6338f41c083116f Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Sun, 26 Apr 2026 11:53:49 +0300 Subject: [PATCH 2/4] [PPSC-717] fix(lint): suppress goconst for test data string across package --- internal/output/human_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/output/human_test.go b/internal/output/human_test.go index 349c243..7f812cf 100644 --- a/internal/output/human_test.go +++ b/internal/output/human_test.go @@ -50,7 +50,7 @@ func TestGroupFindingsByCWE(t *testing.T) { for _, group := range groups { switch group.Key { - case "CWE-79": + case "CWE-79": //nolint:goconst // test data repeated across package test files cwe79Count = len(group.Findings) case "CWE-89": cwe89Count = len(group.Findings) From b7ebe0b2b7b925891a96d91ba2fe78a902a290be Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Sun, 26 Apr 2026 11:59:04 +0300 Subject: [PATCH 3/4] [PPSC-717] fix(sarif): address code review feedback on stability guarantees - stableRuleID: filter empty/whitespace values, sort for deterministic selection regardless of API ordering - computeFingerprint: use length-prefixed fields to prevent separator collision between different tuples - generateHelpURI: align priority with stableRuleID (CWE first, then CVE) - Compute fingerprint from sanitized path to match emitted artifact URI --- internal/output/sarif.go | 85 ++++++++++++++++++++++++----------- internal/output/sarif_test.go | 30 ++++++++++++- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/internal/output/sarif.go b/internal/output/sarif.go index 7a151c5..8f795bd 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "regexp" + "sort" "strconv" "strings" @@ -196,14 +197,30 @@ func (f *SARIFFormatter) Format(result *model.ScanResult, w io.Writer) error { return encoder.Encode(report) } +// firstNonEmpty returns the lexicographically smallest non-empty, non-whitespace +// value from a slice, ensuring deterministic selection regardless of API ordering. +func firstNonEmpty(values []string) string { + var candidates []string + for _, v := range values { + if trimmed := strings.TrimSpace(v); trimmed != "" { + candidates = append(candidates, trimmed) + } + } + if len(candidates) == 0 { + return "" + } + sort.Strings(candidates) + return candidates[0] +} + // stableRuleID derives a stable SARIF ruleId from a finding's classification. // Falls back through CWE → CVE → FindingCategory → finding.ID. func stableRuleID(finding model.Finding) string { - if len(finding.CWEs) > 0 && finding.CWEs[0] != "" { - return finding.CWEs[0] + if v := firstNonEmpty(finding.CWEs); v != "" { + return v } - if len(finding.CVEs) > 0 && finding.CVEs[0] != "" { - return finding.CVEs[0] + if v := firstNonEmpty(finding.CVEs); v != "" { + return v } if finding.FindingCategory != "" { return finding.FindingCategory @@ -212,15 +229,24 @@ func stableRuleID(finding model.Finding) string { } // computeFingerprint produces a stable SHA-256 fingerprint for cross-run dedup. +// Uses length-prefixed fields to prevent separator collision between different tuples. func computeFingerprint(ruleID, file, snippet string, startLine int) string { - var input string + h := sha256.New() + + writeField := func(name, value string) { + fmt.Fprintf(h, "%s:%d:", name, len(value)) //nolint:errcheck,gosec // hash.Write never returns an error + io.WriteString(h, value) //nolint:errcheck,gosec // hash.Write never returns an error + } + + writeField("ruleID", ruleID) + writeField("file", file) if snippet != "" { - input = ruleID + ":" + file + ":" + snippet + writeField("snippet", snippet) } else { - input = ruleID + ":" + file + ":" + strconv.Itoa(startLine) + writeField("startLine", strconv.Itoa(startLine)) } - h := sha256.Sum256([]byte(input)) - return hex.EncodeToString(h[:]) + + return hex.EncodeToString(h.Sum(nil)) } // buildRules creates SARIF rules from findings, deduplicating by rule ID. @@ -300,6 +326,18 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int for _, finding := range findings { ruleID := stableRuleID(finding) + + // Sanitize file path early so the fingerprint and the emitted + // artifact URI use the same normalized value. + sanitizedFile := finding.File + sanitizeOK := false + if finding.File != "" { + if s, err := util.SanitizePath(finding.File); err == nil { + sanitizedFile = s + sanitizeOK = true + } + } + result := sarifResult{ RuleID: ruleID, RuleIndex: ruleIndexMap[ruleID], @@ -308,7 +346,7 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int Text: buildMessageText(finding.Title, finding.Description), }, PartialFingerprints: map[string]string{ - "primaryLocationLineHash": computeFingerprint(ruleID, finding.File, finding.CodeSnippet, finding.StartLine), + "primaryLocationLineHash": computeFingerprint(ruleID, sanitizedFile, finding.CodeSnippet, finding.StartLine), }, Properties: &sarifResultProperties{ FindingID: finding.ID, @@ -353,17 +391,15 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int } if finding.File != "" { - // Sanitize file path to prevent path traversal in SARIF output - sanitizedFile, err := util.SanitizePath(finding.File) - if err != nil { - cli.PrintWarningf("could not sanitize file path for finding %s: %v", finding.ID, err) - // Use finding ID to ensure unique placeholder paths in SARIF output - sanitizedFile = fmt.Sprintf("unknown-%s", finding.ID) + locationFile := sanitizedFile + if !sanitizeOK { + cli.PrintWarningf("could not sanitize file path for finding %s", finding.ID) + locationFile = fmt.Sprintf("unknown-%s", finding.ID) } location := sarifLocation{ PhysicalLocation: sarifPhysicalLocation{ ArtifactLocation: sarifArtifactLocation{ - URI: sanitizedFile, + URI: locationFile, }, }, } @@ -428,25 +464,22 @@ func severityToSecurityScore(severity model.Severity) string { } } -// generateHelpURI returns a documentation URL for the finding (CVE, CWE, or reference URL). +// generateHelpURI returns a documentation URL for the finding. +// Priority matches stableRuleID: CWE → CVE → reference URL. func generateHelpURI(finding model.Finding) string { - if len(finding.CVEs) > 0 { - return "https://nvd.nist.gov/vuln/detail/" + finding.CVEs[0] - } - if len(finding.CWEs) > 0 { - cweID := finding.CWEs[0] + if cweID := firstNonEmpty(finding.CWEs); cweID != "" { var cweNum string - // Handle case-insensitive CWE prefix (e.g., "CWE-123", "cwe-123", or just "123") if strings.HasPrefix(strings.ToUpper(cweID), "CWE-") { cweNum = cweID[4:] } else { cweNum = cweID } - // Validate CWE number is numeric before generating URL if _, err := strconv.Atoi(cweNum); err == nil { return "https://cwe.mitre.org/data/definitions/" + cweNum + ".html" } - // Fall through to URL fallback if CWE number is invalid + } + if cve := firstNonEmpty(finding.CVEs); cve != "" { + return "https://nvd.nist.gov/vuln/detail/" + cve } if len(finding.URLs) > 0 { return finding.URLs[0] diff --git a/internal/output/sarif_test.go b/internal/output/sarif_test.go index 9cbfaf2..de016cc 100644 --- a/internal/output/sarif_test.go +++ b/internal/output/sarif_test.go @@ -740,8 +740,13 @@ func TestGenerateHelpURI(t *testing.T) { expected string }{ { - name: "CVE takes priority", + name: "CWE takes priority over CVE", finding: model.Finding{CVEs: []string{"CVE-2023-1234"}, CWEs: []string{"CWE-89"}}, + expected: "https://cwe.mitre.org/data/definitions/89.html", + }, + { + name: "CVE used when no CWE", + finding: model.Finding{CVEs: []string{"CVE-2023-1234"}}, expected: "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", }, { @@ -1248,6 +1253,21 @@ func TestStableRuleID(t *testing.T) { finding: model.Finding{ID: "fallback2", CWEs: []string{""}, CVEs: []string{"CVE-2024-2222"}}, expected: "CVE-2024-2222", }, + { + name: "whitespace-only CWE falls through", + finding: model.Finding{ID: "fallback3", CWEs: []string{" "}, CVEs: []string{"CVE-2024-3333"}}, + expected: "CVE-2024-3333", + }, + { + name: "multiple CWEs sorted deterministically", + finding: model.Finding{ID: "multi", CWEs: []string{"CWE-89", "CWE-22", "CWE-79"}}, + expected: "CWE-22", + }, + { + name: "multiple CVEs sorted deterministically", + finding: model.Finding{ID: "multi-cve", CVEs: []string{"CVE-2024-9999", "CVE-2024-1111"}}, + expected: "CVE-2024-1111", + }, } for _, tt := range tests { @@ -1303,6 +1323,14 @@ func TestComputeFingerprint(t *testing.T) { t.Errorf("expected 64 hex chars, got %d", len(fp)) } }) + + t.Run("no separator collision", func(t *testing.T) { + fp1 := computeFingerprint("CWE:7", "9", "snippet", 1) + fp2 := computeFingerprint("CWE", "7:9", "snippet", 1) + if fp1 == fp2 { + t.Error("length-prefixed encoding should prevent separator collision") + } + }) } func TestBuildRulesStableCollapsing(t *testing.T) { From bf9c6027fe39d28866d3fc7020e2635a6f5c021d Mon Sep 17 00:00:00 2001 From: Yiftach Cohen Date: Sun, 26 Apr 2026 12:04:33 +0300 Subject: [PATCH 4/4] [PPSC-717] fix(sarif): address second round of review feedback - Trim whitespace on FindingCategory before using as ruleId - Use single resolvedFile for both fingerprint and artifact URI, including placeholder on sanitization failure - Capture and log sanitization error for debuggability --- internal/output/sarif.go | 28 ++++++++++++---------------- internal/output/sarif_test.go | 5 +++++ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/internal/output/sarif.go b/internal/output/sarif.go index 8f795bd..0b05b97 100644 --- a/internal/output/sarif.go +++ b/internal/output/sarif.go @@ -222,8 +222,8 @@ func stableRuleID(finding model.Finding) string { if v := firstNonEmpty(finding.CVEs); v != "" { return v } - if finding.FindingCategory != "" { - return finding.FindingCategory + if v := strings.TrimSpace(finding.FindingCategory); v != "" { + return v } return finding.ID } @@ -327,14 +327,15 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int for _, finding := range findings { ruleID := stableRuleID(finding) - // Sanitize file path early so the fingerprint and the emitted - // artifact URI use the same normalized value. - sanitizedFile := finding.File - sanitizeOK := false + // Resolve the file path once so the fingerprint and artifact URI + // always use the same value — including the placeholder on failure. + resolvedFile := finding.File if finding.File != "" { - if s, err := util.SanitizePath(finding.File); err == nil { - sanitizedFile = s - sanitizeOK = true + if s, err := util.SanitizePath(finding.File); err != nil { + cli.PrintWarningf("could not sanitize file path for finding %s: %v", finding.ID, err) + resolvedFile = fmt.Sprintf("unknown-%s", finding.ID) + } else { + resolvedFile = s } } @@ -346,7 +347,7 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int Text: buildMessageText(finding.Title, finding.Description), }, PartialFingerprints: map[string]string{ - "primaryLocationLineHash": computeFingerprint(ruleID, sanitizedFile, finding.CodeSnippet, finding.StartLine), + "primaryLocationLineHash": computeFingerprint(ruleID, resolvedFile, finding.CodeSnippet, finding.StartLine), }, Properties: &sarifResultProperties{ FindingID: finding.ID, @@ -391,15 +392,10 @@ func convertToSarifResults(findings []model.Finding, ruleIndexMap map[string]int } if finding.File != "" { - locationFile := sanitizedFile - if !sanitizeOK { - cli.PrintWarningf("could not sanitize file path for finding %s", finding.ID) - locationFile = fmt.Sprintf("unknown-%s", finding.ID) - } location := sarifLocation{ PhysicalLocation: sarifPhysicalLocation{ ArtifactLocation: sarifArtifactLocation{ - URI: locationFile, + URI: resolvedFile, }, }, } diff --git a/internal/output/sarif_test.go b/internal/output/sarif_test.go index de016cc..3f81941 100644 --- a/internal/output/sarif_test.go +++ b/internal/output/sarif_test.go @@ -1268,6 +1268,11 @@ func TestStableRuleID(t *testing.T) { finding: model.Finding{ID: "multi-cve", CVEs: []string{"CVE-2024-9999", "CVE-2024-1111"}}, expected: "CVE-2024-1111", }, + { + name: "whitespace-only FindingCategory falls through", + finding: model.Finding{ID: "ws-category", FindingCategory: " \t "}, + expected: "ws-category", + }, } for _, tt := range tests {