From 209144817b6c1ad48a643765256be92d76161bfe Mon Sep 17 00:00:00 2001 From: Bruno Oliveira da Silva Date: Mon, 31 Aug 2026 17:48:08 -0300 Subject: [PATCH] Skip re-open on PSIRT closure notifications Detect Jira Service Desk closure emails from podsec by checking for both a PSIRT ticket reference pattern and the "this request is now closed" phrase. When detected on a closed issue, skip re-opening but still append the email content as a comment. Closes #79 Signed-off-by: Bruno Oliveira da Silva --- .../gh/bot/security/email/MailProcessor.java | 23 ++++++++-- .../bot/security/email/MailProcessorTest.java | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) 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 25d3c72..42899e1 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 @@ -36,6 +36,9 @@ public class MailProcessor { private static final Logger LOGGER = Logger.getLogger(MailProcessor.class); private static final Pattern BRACKET_PREFIX_PATTERN = Pattern.compile("^.*?\\[.*?]\\s*"); private static final Pattern REPLY_PREFIX_PATTERN = Pattern.compile("^(?:\\s*(?:Re|Fwd|Fw)\\s*:\\s*)+", Pattern.CASE_INSENSITIVE); + 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); @ConfigProperty(name = "google.group.target") String targetGroupEmail; @@ -138,9 +141,7 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor if (issueOpt.isPresent()) { var issue = issueOpt.get(); if (issue.getState() == GHIssueState.CLOSED) { - issue.reopen(); - issue.addLabels(Labels.STATUS_TRIAGE, Labels.REOPENED_BY_BOT); - LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId); + handleClosedIssue(issue, fromSecAlert, body, threadId); } appendComment(issue, from, body, attachmentSection); @@ -241,6 +242,22 @@ private boolean isFromSecAlert(String from, String replyTo) { .anyMatch(header -> header.toLowerCase().contains(needle)); } + private void handleClosedIssue(GHIssue issue, boolean fromSecAlert, String body, String threadId) throws IOException { + if (fromSecAlert && isPsirtClosureNotification(body)) { + LOGGER.infof("PSIRT closure notification for issue #%d — skipping reopen for thread %s", + issue.getNumber(), threadId); + return; + } + issue.reopen(); + issue.addLabels(Labels.STATUS_TRIAGE, Labels.REOPENED_BY_BOT); + LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId); + } + + static boolean isPsirtClosureNotification(String body) { + if (body == null || body.isBlank()) return false; + return PSIRT_CLOSURE_PATTERN.matcher(body).find(); + } + private Optional resolveIssueBySecAlertThreadId(GitHub github, GHRepository repository, String threadId) { try { var expectedMarker = Constants.SECALERT_THREAD_ID_PREFIX + " " + threadId; 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 a408513..c182fd8 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 @@ -20,6 +20,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; @@ -276,6 +277,50 @@ void recordSecAlertThreadIdIfMissing_usesCache() throws Exception { verify(issue.queryComments(), org.mockito.Mockito.times(1)).list(); } + // --- isPsirtClosureNotification --- + + @Test + void isPsirtClosureNotification_detectsRealClosureBody() { + String body = "Your request PSIRTSUPT-21634: Incomplete fix for CVE-2026-9083 - #GHI-992 has been resolved.\n\n" + + "This request is now closed. If you have a new security concern, please reach out."; + assertTrue(MailProcessor.isPsirtClosureNotification(body)); + } + + @Test + void isPsirtClosureNotification_detectsDifferentProjectKey() { + String body = "Your request SECVULN-1234: Some issue has been resolved.\n\n" + + "This request is now closed."; + assertTrue(MailProcessor.isPsirtClosureNotification(body)); + } + + @Test + void isPsirtClosureNotification_returnsFalseForCveAssignment() { + String body = "The assigned CVE ID is CVE-2026-9999. Please confirm the details."; + assertFalse(MailProcessor.isPsirtClosureNotification(body)); + } + + @Test + void isPsirtClosureNotification_returnsFalseForTicketRefOnly() { + String body = "Your request PSIRTSUPT-21634 has been updated with new information."; + assertFalse(MailProcessor.isPsirtClosureNotification(body)); + } + + @Test + void isPsirtClosureNotification_returnsFalseForClosurePhraseOnly() { + String body = "This request is now closed. Thank you."; + assertFalse(MailProcessor.isPsirtClosureNotification(body)); + } + + @Test + void isPsirtClosureNotification_returnsFalseForNullBody() { + assertFalse(MailProcessor.isPsirtClosureNotification(null)); + } + + @Test + void isPsirtClosureNotification_returnsFalseForBlankBody() { + assertFalse(MailProcessor.isPsirtClosureNotification("")); + } + @SuppressWarnings("unchecked") private void stubIssueComments(GHIssue issue, String... commentBodies) throws IOException { GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class);