From 6560caa74ffe21344917cbb4371e2d8228d22456 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Sun, 28 Dec 2025 11:02:11 +0200 Subject: [PATCH] Group CVEs many times similar CVEs are opened for the same U/S components this patch groups them in the same message and the same assignee to make sure we are not duplicating work triage the issue --- cmd/pretriage/cve.go | 77 +++++++++++++++++++++++++++++++++++++++++ cmd/pretriage/main.go | 47 +++++++++++++++++++++++++ cmd/pretriage/notify.go | 28 +++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 cmd/pretriage/cve.go diff --git a/cmd/pretriage/cve.go b/cmd/pretriage/cve.go new file mode 100644 index 0000000..cbcfc25 --- /dev/null +++ b/cmd/pretriage/cve.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "strings" + + jira "github.com/andygrunwald/go-jira" +) + +// CVEFieldID is the JIRA custom field ID for the CVE identifier +const CVEFieldID = "customfield_12324749" + +// CVEGroup represents a group of related CVE issues +type CVEGroup struct { + CVEID string + Component string + Issues []jira.Issue +} + +// isVulnerability checks if an issue is of type "Vulnerability" +func isVulnerability(issue jira.Issue) bool { + if issue.Fields == nil || issue.Fields.Type.Name == "" { + return false + } + return issue.Fields.Type.Name == "Vulnerability" +} + +// extractCVEID extracts the CVE identifier from an issue's custom field +func extractCVEID(issue jira.Issue) string { + if issue.Fields == nil || issue.Fields.Unknowns == nil { + return "" + } + + if cveValue, ok := issue.Fields.Unknowns[CVEFieldID]; ok { + if cveStr, ok := cveValue.(string); ok { + return strings.TrimSpace(cveStr) + } + } + return "" +} + +// extractComponent extracts the first component name from an issue +func extractComponent(issue jira.Issue) string { + if issue.Fields == nil || len(issue.Fields.Components) == 0 { + return "unknown" + } + return issue.Fields.Components[0].Name +} + +// groupKey creates a unique key for grouping: "CVE-ID|Component" +func groupKey(issue jira.Issue) string { + cveID := extractCVEID(issue) + component := extractComponent(issue) + return fmt.Sprintf("%s|%s", cveID, component) +} + +// GroupCVEIssues groups issues by CVE ID + Component +// Returns a map where key is "CVE-ID|Component" and value is the CVEGroup +func GroupCVEIssues(issues []jira.Issue) map[string]*CVEGroup { + groups := make(map[string]*CVEGroup) + + for _, issue := range issues { + key := groupKey(issue) + + if groups[key] == nil { + groups[key] = &CVEGroup{ + CVEID: extractCVEID(issue), + Component: extractComponent(issue), + Issues: []jira.Issue{issue}, + } + } else { + groups[key].Issues = append(groups[key].Issues, issue) + } + } + + return groups +} diff --git a/cmd/pretriage/main.go b/cmd/pretriage/main.go index 71678c5..d53720c 100644 --- a/cmd/pretriage/main.go +++ b/cmd/pretriage/main.go @@ -109,7 +109,54 @@ func main() { slackClient := slack.New() log.Print("Running the actual triage assignment...") + + // Collect all issues first, separating CVEs from regular bugs + var cveIssues []jira.Issue + var regularIssues []jira.Issue + for issue := range query.SearchIssues(ctx, jiraClient, queryUntriaged) { + if isVulnerability(issue) { + cveIssues = append(cveIssues, issue) + } else { + regularIssues = append(regularIssues, issue) + } + } + + // Process CVE issues: group by CVE ID + Component, assign group together + if len(cveIssues) > 0 { + log.Printf("Found %d CVE issues, grouping...", len(cveIssues)) + cveGroups := GroupCVEIssues(cveIssues) + log.Printf("Grouped into %d CVE groups", len(cveGroups)) + + for key, group := range cveGroups { + assignee := &triagers[rand.Intn(len(triagers))] + + log.Printf("Assigning CVE group %q (%d issues) to %q", + key, len(group.Issues), censorEmail(assignee.Jira)) + + // Assign all issues in the group to the same person + for _, issue := range group.Issues { + wg.Add(1) + go func(issue jira.Issue) { + defer wg.Done() + if err := assign(jiraClient, issue, assignee.Jira); err != nil { + gotErrors = true + log.Print(err) + } + }(issue) + } + wg.Wait() + + // Send single grouped notification + if err := slackClient.Send(SLACK_HOOK, cveGroupNotification(group, assignee.Slack)); err != nil { + gotErrors = true + log.Print(err) + } + } + } + + // Process regular bugs: existing individual assignment flow + for _, issue := range regularIssues { wg.Add(1) go func(issue jira.Issue) { defer wg.Done() diff --git a/cmd/pretriage/notify.go b/cmd/pretriage/notify.go index 29d44f7..c4aaa5b 100644 --- a/cmd/pretriage/notify.go +++ b/cmd/pretriage/notify.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" jira "github.com/andygrunwald/go-jira" @@ -16,3 +17,30 @@ func notification(issue jira.Issue, slackId string) string { notification.WriteString(slack.Link(query.JiraBaseURL+"browse/"+issue.Key, issue.Key)) return notification.String() } + +// cveGroupNotification creates a Slack message for a group of related CVE issues +func cveGroupNotification(group *CVEGroup, slackId string) string { + var notification strings.Builder + notification.WriteByte('<') + notification.WriteString(slackId) + notification.WriteString("> ") + + // Format: "CVE-2024-XXXX (Component): ISSUE-1 ISSUE-2 ISSUE-3" + notification.WriteString(group.CVEID) + notification.WriteString(" (") + notification.WriteString(group.Component) + notification.WriteString("): ") + + for i, issue := range group.Issues { + if i > 0 { + notification.WriteByte(' ') + } + notification.WriteString(slack.Link(query.JiraBaseURL+"browse/"+issue.Key, issue.Key)) + } + + if len(group.Issues) > 1 { + notification.WriteString(fmt.Sprintf(" (%d related issues)", len(group.Issues))) + } + + return notification.String() +}