Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading