From 5aaed5c1e90fa0669ca96d1d2f8a81c0449d7f88 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira da Silva Date: Fri, 2 Oct 2026 14:44:22 -0300 Subject: [PATCH] Auto-acknowledge incoming security reports from the mailing list (#76) Signed-off-by: Bruno Oliveira da Silva --- .../gh/bot/security/email/MailProcessor.java | 31 ++++++++- .../security/email/SecurityEmailMessages.java | 43 ++++++++++++ src/main/resources/application.properties | 3 + .../email/security-email-messages.properties | 12 ++++ .../bot/security/email/MailProcessorTest.java | 65 +++++++++++++++++++ .../email/SecurityEmailMessagesTest.java | 33 ++++++++++ 6 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/keycloak/gh/bot/security/email/SecurityEmailMessages.java create mode 100644 src/main/resources/org/keycloak/gh/bot/security/email/security-email-messages.properties create mode 100644 src/test/java/org/keycloak/gh/bot/security/email/SecurityEmailMessagesTest.java 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 c7b2fb1..9252766 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 @@ -63,8 +63,17 @@ public class MailProcessor { @Inject GitHubInstallationProvider gitHubInstallationProvider; + @ConfigProperty(name = "bot.email.auto-reply.enabled", defaultValue = "true") + boolean autoReplyEnabled; + + @Inject + MailSender mailSender; + + @Inject + SecurityEmailMessages emailMessages; + @Inject - EmailBodySanitizer bodySanitizer; // Extracted parsing logic dependency + EmailBodySanitizer bodySanitizer; private TargetGroup targetGroup; @@ -158,6 +167,10 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor var newIssue = createNewIssue(repository, threadId, subject, from, body, attachmentSection); issueCache.put(threadId, newIssue.getNumber()); LOGGER.infof("Creating new issue #%d for thread %s", newIssue.getNumber(), threadId); + + if (!fromSecAlert) { + sendAcknowledgmentReply(threadId); + } } gmail.markAsRead(msgSummary.getId()); @@ -221,6 +234,22 @@ private GHIssue createNewIssue(GHRepository repo, String threadId, String subjec return issue; } + void sendAcknowledgmentReply(String threadId) { + if (!autoReplyEnabled) { + return; + } + try { + boolean sent = mailSender.sendReply(threadId, emailMessages.getAcknowledgmentMessage(), targetGroup.email()); + if (sent) { + LOGGER.infof("Acknowledgment reply sent for thread %s", threadId); + } else { + LOGGER.warnf("Acknowledgment reply could not be sent for thread %s", threadId); + } + } catch (Exception e) { + LOGGER.errorf(e, "Failed to send acknowledgment reply for thread %s", threadId); + } + } + static String formatNewIssueComment(String threadId, String subject, String from, String body, String attachmentSection) { return "%s %s\nSubject: %s\nFrom: %s\n\n---\n\n%s%s".formatted( Constants.GMAIL_THREAD_ID_PREFIX, threadId, subject, from, body, attachmentSection diff --git a/src/main/java/org/keycloak/gh/bot/security/email/SecurityEmailMessages.java b/src/main/java/org/keycloak/gh/bot/security/email/SecurityEmailMessages.java new file mode 100644 index 0000000..b546e2f --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/security/email/SecurityEmailMessages.java @@ -0,0 +1,43 @@ +package org.keycloak.gh.bot.security.email; + +import io.quarkus.runtime.Startup; +import jakarta.inject.Singleton; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * Loads externalized email message templates for the security pipeline. + */ +@Singleton +@Startup +public class SecurityEmailMessages { + + private static final String RESOURCE = "security-email-messages.properties"; + private static final String ACK_KEY = "ack.mailing-list"; + + private final String acknowledgmentMessage; + + public SecurityEmailMessages() { + var properties = new Properties(); + try (InputStream is = SecurityEmailMessages.class.getResourceAsStream(RESOURCE)) { + if (is == null) { + throw new IllegalStateException("Missing classpath resource: %s".formatted(RESOURCE)); + } + properties.load(is); + } catch (IOException e) { + throw new RuntimeException(e); + } + + acknowledgmentMessage = properties.getProperty(ACK_KEY); + if (acknowledgmentMessage == null) { + throw new IllegalStateException("Missing property '%s' in %s".formatted(ACK_KEY, RESOURCE)); + } + } + + public String getAcknowledgmentMessage() { + return acknowledgmentMessage; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8968c14..b0889e7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,6 +56,9 @@ repository.privateRepository=keycloak/keycloak-private # Toggle to disable keycloak security features #bot.security.enabled=false +# Auto-reply to mailing list reporters when a new issue is created +bot.email.auto-reply.enabled=true + # Jira Integration jira.url=https://issues.redhat.com jira.pat=${JIRA_PAT} diff --git a/src/main/resources/org/keycloak/gh/bot/security/email/security-email-messages.properties b/src/main/resources/org/keycloak/gh/bot/security/email/security-email-messages.properties new file mode 100644 index 0000000..6817558 --- /dev/null +++ b/src/main/resources/org/keycloak/gh/bot/security/email/security-email-messages.properties @@ -0,0 +1,12 @@ +ack.mailing-list=Hi,\n\ +\n\ +Thank you for taking the time to report this. We really appreciate it.\n\ +\n\ +We've received your security report and our team will review it. We'll get back to you if we need more details.\n\ +\n\ +Want to be credited? Reply with your name and organization (e.g., Jane Smith, ACME). We'll publish it as "Jane Smith (ACME)". If you don't want to use your real name, you can use a nickname or alias.\n\ +\n\ +Thanks for helping make Keycloak better.\n\ +\n\ +Best regards,\n\ +Keycloak Security team. 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 c2666db..6acea35 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 @@ -19,11 +19,15 @@ import java.util.List; import java.util.Optional; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -380,6 +384,67 @@ void isPsirtClosureNotification_returnsFalseForBlankBody() { assertFalse(MailProcessor.isPsirtClosureNotification("")); } + // --- sendAcknowledgmentReply --- + + @Test + void sendAcknowledgmentReply_callsSendReplyWithCorrectArguments() { + MailSender mailSender = mock(MailSender.class); + SecurityEmailMessages emailMessages = mock(SecurityEmailMessages.class); + when(emailMessages.getAcknowledgmentMessage()).thenReturn("ack body"); + when(mailSender.sendReply(anyString(), anyString(), anyString())).thenReturn(true); + + MailProcessor processor = new MailProcessor(); + setField(processor, "mailSender", mailSender); + setField(processor, "emailMessages", emailMessages); + setField(processor, "autoReplyEnabled", true); + setField(processor, "targetGroupEmail", "keycloak-security@googlegroups.com"); + processor.init(); + + processor.sendAcknowledgmentReply("thread-abc"); + + verify(mailSender).sendReply(eq("thread-abc"), eq("ack body"), eq("keycloak-security@googlegroups.com")); + } + + @Test + void sendAcknowledgmentReply_doesNotSendWhenDisabled() { + MailSender mailSender = mock(MailSender.class); + + MailProcessor processor = new MailProcessor(); + setField(processor, "mailSender", mailSender); + setField(processor, "autoReplyEnabled", false); + + processor.sendAcknowledgmentReply("thread-abc"); + + verify(mailSender, never()).sendReply(anyString(), anyString(), anyString()); + } + + @Test + void sendAcknowledgmentReply_doesNotPropagateException() { + MailSender mailSender = mock(MailSender.class); + SecurityEmailMessages emailMessages = mock(SecurityEmailMessages.class); + when(emailMessages.getAcknowledgmentMessage()).thenReturn("ack body"); + when(mailSender.sendReply(anyString(), anyString(), anyString())).thenThrow(new RuntimeException("Gmail API down")); + + MailProcessor processor = new MailProcessor(); + setField(processor, "mailSender", mailSender); + setField(processor, "emailMessages", emailMessages); + setField(processor, "autoReplyEnabled", true); + setField(processor, "targetGroupEmail", "keycloak-security@googlegroups.com"); + processor.init(); + + assertDoesNotThrow(() -> processor.sendAcknowledgmentReply("thread-abc")); + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to set field " + fieldName, e); + } + } + @SuppressWarnings("unchecked") private void stubIssueComments(GHIssue issue, String... commentBodies) throws IOException { GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class); diff --git a/src/test/java/org/keycloak/gh/bot/security/email/SecurityEmailMessagesTest.java b/src/test/java/org/keycloak/gh/bot/security/email/SecurityEmailMessagesTest.java new file mode 100644 index 0000000..f1a1fd1 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/security/email/SecurityEmailMessagesTest.java @@ -0,0 +1,33 @@ +package org.keycloak.gh.bot.security.email; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SecurityEmailMessagesTest { + + @Test + void constructor_loadsPropertiesSuccessfully() { + assertDoesNotThrow(SecurityEmailMessages::new); + } + + @Test + void getAcknowledgmentMessage_returnsNonEmptyText() { + var messages = new SecurityEmailMessages(); + String ack = messages.getAcknowledgmentMessage(); + assertNotNull(ack); + assertFalse(ack.isBlank()); + } + + @Test + void getAcknowledgmentMessage_containsExpectedPhrases() { + var messages = new SecurityEmailMessages(); + String ack = messages.getAcknowledgmentMessage(); + assertTrue(ack.contains("Thank you for taking the time to report this")); + assertTrue(ack.contains("Want to be credited?")); + assertTrue(ack.contains("Keycloak Security team")); + } +}