diff --git a/src/main/java/org/keycloak/gh/bot/security/email/MailProcessor.java b/src/main/java/org/keycloak/gh/bot/security/email/MailProcessor.java index 42899e1..193bfcb 100644 --- a/src/main/java/org/keycloak/gh/bot/security/email/MailProcessor.java +++ b/src/main/java/org/keycloak/gh/bot/security/email/MailProcessor.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -39,6 +41,9 @@ public class MailProcessor { private static final Pattern PSIRT_CLOSURE_PATTERN = Pattern.compile( "Your request\\s+[A-Z]+-\\d+.*this request is now closed", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Comparator CVE_COMPARATOR = Comparator + .comparingInt(MailProcessor::cveYear) + .thenComparingInt(MailProcessor::cveSequence); @ConfigProperty(name = "google.group.target") String targetGroupEmail; @@ -322,10 +327,7 @@ void recordSecAlertThreadIdIfMissing(GHIssue issue, String threadId) throws IOEx } void applyCveIdFromSecAlert(GHIssue issue, String subject, String body) throws IOException { - String cveId = extractCveId(subject); - if (cveId == null) { - cveId = extractCveId(body); - } + String cveId = selectLatestCveId(subject, body); if (cveId == null) return; String title = issue.getTitle(); @@ -350,10 +352,29 @@ void applyCveIdFromSecAlert(GHIssue issue, String subject, String body) throws I } } - static String extractCveId(String text) { - if (text == null) return null; + static List extractAllCveIds(String text) { + if (text == null) return List.of(); + List cves = new ArrayList<>(); Matcher matcher = Constants.CVE_PATTERN.matcher(text); - return matcher.find() ? matcher.group() : null; + while (matcher.find()) { + cves.add(matcher.group()); + } + return List.copyOf(cves); + } + + private static int cveYear(String cve) { + return Integer.parseInt(cve.substring(4, cve.lastIndexOf('-'))); + } + + private static int cveSequence(String cve) { + return Integer.parseInt(cve.substring(cve.lastIndexOf('-') + 1)); + } + + static String selectLatestCveId(String subject, String body) { + return Stream.concat(extractAllCveIds(subject).stream(), extractAllCveIds(body).stream()) + .distinct() + .max(CVE_COMPARATOR) + .orElse(null); } private boolean isValidGroupMessage(Map headers) { diff --git a/src/test/java/org/keycloak/gh/bot/security/email/MailProcessorTest.java b/src/test/java/org/keycloak/gh/bot/security/email/MailProcessorTest.java index c182fd8..66328a7 100644 --- a/src/test/java/org/keycloak/gh/bot/security/email/MailProcessorTest.java +++ b/src/test/java/org/keycloak/gh/bot/security/email/MailProcessorTest.java @@ -75,31 +75,6 @@ void formatReplyComment_containsOnlyFromAndSeparator() { assertEquals("From: bob@example.com\n\n---\n\nReply body", result); } - @Test - void extractCveId_findsIdInSubject() { - assertEquals("CVE-2026-1234", MailProcessor.extractCveId("Re: CVE-2026-1234 XSS in admin console")); - } - - @Test - void extractCveId_findsIdInBody() { - assertEquals("CVE-2026-45678", MailProcessor.extractCveId("The assigned CVE ID is CVE-2026-45678. Please confirm.")); - } - - @Test - void extractCveId_returnsNullWhenNoCvePresent() { - assertNull(MailProcessor.extractCveId("No CVE here, just a regular message.")); - } - - @Test - void extractCveId_returnsNullForNullInput() { - assertNull(MailProcessor.extractCveId(null)); - } - - @Test - void extractCveId_returnsFirstCveWhenMultiplePresent() { - assertEquals("CVE-2026-1111", MailProcessor.extractCveId("CVE-2026-1111 and CVE-2026-2222")); - } - @Test void applyCveIdFromSecAlert_replacesTitleAndRemovesCveRequestLabel() throws Exception { GHIssue issue = mock(GHIssue.class); @@ -181,6 +156,87 @@ void applyCveIdFromSecAlert_doesNotAddCveKindWhenAlreadyPresent() throws Excepti verify(issue, never()).addLabels(Kind.CVE.toLabel()); } + // --- extractAllCveIds --- + + @Test + void extractAllCveIds_findsMultipleCves() { + List result = MailProcessor.extractAllCveIds("CVE-2026-9083 and CVE-2026-19729"); + assertEquals(List.of("CVE-2026-9083", "CVE-2026-19729"), result); + } + + @Test + void extractAllCveIds_returnsEmptyForNull() { + assertTrue(MailProcessor.extractAllCveIds(null).isEmpty()); + } + + @Test + void extractAllCveIds_returnsEmptyWhenNoCve() { + assertTrue(MailProcessor.extractAllCveIds("No CVE here").isEmpty()); + } + + // --- selectLatestCveId --- + + @Test + void selectLatestCveId_picksHighestFromSubjectAndBody() { + assertEquals("CVE-2026-19729", + MailProcessor.selectLatestCveId("Incomplete fix for CVE-2026-9083", "*Reference : CVE-2026-19729")); + } + + @Test + void selectLatestCveId_picksHighestWhenMultipleInBody() { + assertEquals("CVE-2026-19729", + MailProcessor.selectLatestCveId(null, "Report about CVE-2026-9083. Assigned CVE-2026-19729.")); + } + + @Test + void selectLatestCveId_fallsBackToSubjectWhenBodyEmpty() { + assertEquals("CVE-2026-9083", + MailProcessor.selectLatestCveId("Fix for CVE-2026-9083", "No CVE in body")); + } + + @Test + void selectLatestCveId_returnsNullWhenNoCveAnywhere() { + assertNull(MailProcessor.selectLatestCveId("No CVE", "Also no CVE")); + } + + @Test + void selectLatestCveId_returnsNullWhenBothInputsNull() { + assertNull(MailProcessor.selectLatestCveId(null, null)); + } + + @Test + void selectLatestCveId_returnsSingleCveWhenSameInBoth() { + assertEquals("CVE-2026-9083", + MailProcessor.selectLatestCveId("CVE-2026-9083", "Reference: CVE-2026-9083")); + } + + @Test + void selectLatestCveId_comparesAcrossYears() { + assertEquals("CVE-2026-1", + MailProcessor.selectLatestCveId("CVE-2025-999999", "CVE-2026-1")); + } + + // --- applyCveIdFromSecAlert bug scenario --- + + @Test + void applyCveIdFromSecAlert_picksNewCveOverOldInSubject() throws Exception { + GHIssue issue = mock(GHIssue.class); + when(issue.getTitle()).thenReturn("[CVE-TBD] Incomplete fix for CVE-2026-9083"); + when(issue.getNumber()).thenReturn(992); + + GHLabel cveRequestLabel = mock(GHLabel.class); + when(cveRequestLabel.getName()).thenReturn(Status.CVE_REQUEST.toLabel()); + when(issue.getLabels()).thenReturn(List.of(cveRequestLabel)); + + MailProcessor processor = new MailProcessor(); + processor.applyCveIdFromSecAlert(issue, "Incomplete fix for CVE-2026-9083 - #GHI-992", + "*Reference : CVE-2026-19729\n*Embargo status : Public"); + + verify(issue).setTitle("[CVE-2026-19729] Incomplete fix for CVE-2026-9083"); + verify(issue).removeLabels(Status.CVE_REQUEST.toLabel()); + verify(issue).addLabels(Kind.CVE.toLabel()); + } + // --- resolveIssueByGhiTag --- @Test