diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index c90df0d5e..a780bd301 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -276,13 +276,22 @@ func (o *attestJiraOptions) run(args []string) error { if err != nil { return err } - jiraIssueKeyPattern := jira.MakeJiraIssueKeyPattern(o.projectKeys) - - issueIDs, commitInfo, err := gv.MatchPatternInCommitMessageORBranchName(jiraIssueKeyPattern, o.payload.Commit.Sha1, - o.secondarySource, o.ignoreBranchMatch) + commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.payload.Commit.Sha1, true, []string{}) if err != nil { return err } + + // Search commit message, branch name, and secondary source for Jira issue keys, + // filtering out false positives from multi-segment identifiers like CVE-2026-41284. + searchTexts := []string{commitInfo.Message} + if !o.ignoreBranchMatch { + searchTexts = append(searchTexts, commitInfo.Branch) + } + if o.secondarySource != "" { + searchTexts = append(searchTexts, o.secondarySource) + } + combinedText := strings.Join(searchTexts, "\n") + issueIDs := jira.FindJiraIssueKeys(combinedText, o.projectKeys) logger.Debug("Checked for Jira issue references in Git commit %s on branch %s commit message:\n%s", commitInfo.Sha1, commitInfo.Branch, commitInfo.Message) logger.Debug("the following Jira references are found in commit message or branch name: %v", issueIDs) diff --git a/internal/jira/jira.go b/internal/jira/jira.go index e854286b9..52192cbb5 100644 --- a/internal/jira/jira.go +++ b/internal/jira/jira.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "net/url" + "regexp" + "sort" "strings" jira "github.com/andygrunwald/go-jira" @@ -115,3 +117,59 @@ func MakeJiraIssueKeyPattern(projectKeys []string) string { return `(` + strings.Join(projectKeys, "|") + `)-[0-9]+` } } + +// FindJiraIssueKeys finds all Jira issue keys in text, filtering out +// partial matches from multi-segment identifiers like CVE-2026-41284. +// A match is discarded if every occurrence in text is immediately +// followed by "-". +func FindJiraIssueKeys(text string, projectKeys []string) []string { + pattern := MakeJiraIssueKeyPattern(projectKeys) + re := regexp.MustCompile(pattern) + candidates := re.FindAllString(text, -1) + + // Deduplicate + seen := make(map[string]struct{}) + var unique []string + for _, c := range candidates { + if _, ok := seen[c]; !ok { + seen[c] = struct{}{} + unique = append(unique, c) + } + } + + // Filter out matches that are always followed by - in the text + dashDigit := regexp.MustCompile(`^-\d`) + var result []string + for _, m := range unique { + if isPartialMultiSegment(text, m, dashDigit) { + continue + } + result = append(result, m) + } + + sort.Strings(result) + if len(result) == 0 { + return nil + } + return result +} + +// isPartialMultiSegment returns true if every occurrence of match in text +// is immediately followed by a "-" suffix, indicating it is part +// of a longer multi-segment identifier (e.g. CVE-2026-41284). +// Precondition: match must exist in text (guaranteed when called from FindJiraIssueKeys). +func isPartialMultiSegment(text, match string, dashDigit *regexp.Regexp) bool { + start := 0 + for { + idx := strings.Index(text[start:], match) + if idx < 0 { + break + } + afterIdx := start + idx + len(match) + if afterIdx >= len(text) || !dashDigit.MatchString(text[afterIdx:]) { + return false + } + start = start + idx + 1 + } + return true +} diff --git a/internal/jira/jira_test.go b/internal/jira/jira_test.go index d3e080a1a..741dbd151 100644 --- a/internal/jira/jira_test.go +++ b/internal/jira/jira_test.go @@ -3,6 +3,8 @@ package jira import ( "regexp" "testing" + + "github.com/stretchr/testify/assert" ) func TestMakeJiraIssueKey(t *testing.T) { @@ -81,3 +83,92 @@ func TestMakeJiraIssueKey(t *testing.T) { }) } } + +func TestFindJiraIssueKeys(t *testing.T) { + tests := []struct { + name string + text string + projectKeys []string + want []string + }{ + { + name: "Jira key alongside CVE identifiers", + text: "PROJ-42: Upgrade dependency for CVE-2026-41284 / CVE-2026-42498", + projectKeys: []string{}, + want: []string{"PROJ-42"}, + }, + { + name: "CVE identifier is not matched", + text: "fix CVE-2026-41284", + projectKeys: []string{}, + want: nil, + }, + { + name: "multiple CVE identifiers produce no matches", + text: "CVE-2026-41284 and CVE-2025-12345", + projectKeys: []string{}, + want: nil, + }, + { + name: "CWE-like multi-segment identifier is not matched", + text: "addresses CWE-79-1234", + projectKeys: []string{}, + want: nil, + }, + { + name: "multiple valid Jira keys", + text: "PROJ-1 and PROJ-2 are related", + projectKeys: []string{}, + want: []string{"PROJ-1", "PROJ-2"}, + }, + { + name: "Jira key at end of string", + text: "fix for PROJ-999", + projectKeys: []string{}, + want: []string{"PROJ-999"}, + }, + { + name: "standalone Jira key", + text: "ABC-123", + projectKeys: []string{}, + want: []string{"ABC-123"}, + }, + { + name: "with project keys filters to specified projects", + text: "PROJ-10: fix for OTHER-789", + projectKeys: []string{"PROJ"}, + want: []string{"PROJ-10"}, + }, + { + name: "with project keys still rejects CVE-like matches", + text: "PROJ-10: Upgrade for CVE-2026-41284", + projectKeys: []string{"PROJ", "CVE"}, + want: []string{"PROJ-10"}, + }, + { + name: "key appears both standalone and in multi-segment identifier", + text: "CVE-2026-41284 and also CVE-2026 standalone", + projectKeys: []string{}, + want: []string{"CVE-2026"}, + }, + { + name: "duplicate keys are deduplicated", + text: "PROJ-42 and PROJ-42 again", + projectKeys: []string{}, + want: []string{"PROJ-42"}, + }, + { + name: "empty text returns nil", + text: "", + projectKeys: []string{}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindJiraIssueKeys(tt.text, tt.projectKeys) + assert.Equal(t, tt.want, got) + }) + } +}