Skip to content

Commit f85d0e5

Browse files
committed
Implement @security reply command for mailing list responses
Add MailSender to send threaded Gmail replies that target both the original vulnerability reporter and the keycloak-security mailing list. Signed-off-by: Bruno Oliveira da Silva <bruno@abstractj.com>
1 parent 216ae54 commit f85d0e5

5 files changed

Lines changed: 520 additions & 4 deletions

File tree

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
package org.keycloak.gh.bot.security.command;
22

33
import com.github.rvesse.airline.annotations.Command;
4+
import jakarta.inject.Inject;
5+
import org.eclipse.microprofile.config.inject.ConfigProperty;
46
import org.jboss.logging.Logger;
7+
import org.keycloak.gh.bot.security.common.Constants;
8+
import org.keycloak.gh.bot.security.email.MailSender;
59
import org.kohsuke.github.GHEventPayload;
10+
import org.kohsuke.github.GHIssueComment;
611

712
import java.io.IOException;
13+
import java.util.Optional;
14+
import java.util.regex.Matcher;
15+
import java.util.regex.Pattern;
816

17+
/**
18+
* Replies to the keycloak-security mailing list and the original sender when invoked via @security reply.
19+
*/
920
@Command(name = "reply", description = "Reply to e-mails received from the keycloak-security mailing list and the sender")
10-
public class MailingListCommand extends CommandParser implements BotCommand { //Required due to a bug on Airline
21+
public class MailingListCommand extends CommandParser implements BotCommand {
1122

1223
private static final Logger LOGGER = Logger.getLogger(MailingListCommand.class);
24+
private static final Pattern THREAD_ID_PATTERN = Pattern.compile(
25+
Pattern.quote(Constants.GMAIL_THREAD_ID_PREFIX) + "\\s*([a-f0-9]+)");
26+
27+
@Inject
28+
MailSender mailSender;
29+
30+
@ConfigProperty(name = "google.group.target")
31+
String targetGroup;
1332

1433
@Override
1534
protected void execute(GHEventPayload.IssueComment payload) throws IOException {
16-
LOGGER.infof("Replying to the keycloak security: %s", payload.getRepository().getFullName());
1735
ParsedMessage msg = extractMessage(payload);
1836
if (msg == null) return;
1937

@@ -22,6 +40,31 @@ protected void execute(GHEventPayload.IssueComment payload) throws IOException {
2240
return;
2341
}
2442

25-
success(payload);
43+
Optional<String> threadId = findGmailThreadId(payload);
44+
if (threadId.isEmpty()) {
45+
fail(payload, "Gmail Thread ID not found in issue comments. Cannot reply without a linked email thread.");
46+
return;
47+
}
48+
49+
LOGGER.infof("Sending reply to Gmail thread %s for issue #%d", threadId.get(), payload.getIssue().getNumber());
50+
51+
if (mailSender.sendReply(threadId.get(), msg.body(), targetGroup)) {
52+
success(payload);
53+
} else {
54+
fail(payload, "Failed to send reply via Gmail API.");
55+
}
56+
}
57+
58+
private Optional<String> findGmailThreadId(GHEventPayload.IssueComment payload) throws IOException {
59+
for (GHIssueComment comment : payload.getIssue().queryComments().list()) {
60+
String body = comment.getBody();
61+
if (body == null) continue;
62+
63+
Matcher matcher = THREAD_ID_PATTERN.matcher(body);
64+
if (matcher.find()) {
65+
return Optional.of(matcher.group(1));
66+
}
67+
}
68+
return Optional.empty();
2669
}
27-
}
70+
}

src/main/java/org/keycloak/gh/bot/security/email/GmailAdapter.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import com.google.api.services.gmail.model.ModifyMessageRequest;
88
import jakarta.enterprise.context.ApplicationScoped;
99
import jakarta.inject.Inject;
10+
import jakarta.mail.MessagingException;
11+
import jakarta.mail.internet.MimeMessage;
1012
import org.eclipse.microprofile.config.inject.ConfigProperty;
1113

14+
import java.io.ByteArrayOutputStream;
1215
import java.io.IOException;
1316
import java.nio.charset.StandardCharsets;
1417
import java.util.ArrayList;
@@ -38,6 +41,26 @@ public Message getMessage(String id) throws IOException {
3841
return gmail.users().messages().get("me", id).execute();
3942
}
4043

44+
public com.google.api.services.gmail.model.Thread getThread(String threadId) throws IOException {
45+
return gmail.users().threads().get("me", threadId).setFormat("METADATA").execute();
46+
}
47+
48+
public Message sendMessage(String threadId, MimeMessage email) throws IOException {
49+
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
50+
try {
51+
email.writeTo(buffer);
52+
} catch (MessagingException e) {
53+
throw new IOException("Failed to serialize MimeMessage", e);
54+
}
55+
56+
String encodedEmail = Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.toByteArray());
57+
Message message = new Message().setRaw(encodedEmail);
58+
if (threadId != null) {
59+
message.setThreadId(threadId);
60+
}
61+
return gmail.users().messages().send("me", message).execute();
62+
}
63+
4164
public void markAsRead(String messageId) throws IOException {
4265
var mods = new ModifyMessageRequest().setRemoveLabelIds(List.of("UNREAD"));
4366
gmail.users().messages().modify("me", messageId, mods).execute();
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package org.keycloak.gh.bot.security.email;
2+
3+
import com.google.api.services.gmail.model.Message;
4+
import jakarta.annotation.PostConstruct;
5+
import jakarta.enterprise.context.ApplicationScoped;
6+
import jakarta.inject.Inject;
7+
import jakarta.mail.Session;
8+
import jakarta.mail.internet.InternetAddress;
9+
import jakarta.mail.internet.MimeMessage;
10+
import org.eclipse.microprofile.config.inject.ConfigProperty;
11+
import org.jboss.logging.Logger;
12+
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.Properties;
16+
17+
/**
18+
* Sends threaded replies via the Gmail API, preserving conversation threading headers.
19+
*/
20+
@ApplicationScoped
21+
public class MailSender {
22+
23+
private static final Logger LOG = Logger.getLogger(MailSender.class);
24+
25+
@ConfigProperty(name = "gmail.user.email")
26+
String botEmail;
27+
28+
@Inject
29+
GmailAdapter gmail;
30+
31+
private Session mailSession;
32+
33+
@PostConstruct
34+
public void init() {
35+
this.mailSession = Session.getDefaultInstance(new Properties(), null);
36+
}
37+
38+
public boolean sendReply(String threadId, String body, String ccTarget) {
39+
try {
40+
com.google.api.services.gmail.model.Thread thread = gmail.getThread(threadId);
41+
if (thread == null || thread.getMessages() == null || thread.getMessages().isEmpty()) return false;
42+
43+
Message lastMsg = thread.getMessages().get(thread.getMessages().size() - 1);
44+
Map<String, String> threadingHeaders = gmail.getHeadersMap(lastMsg);
45+
46+
Message lastHumanMsg = findLastHumanMessage(thread.getMessages());
47+
if (lastHumanMsg == null) return false;
48+
49+
Map<String, String> recipientHeaders = gmail.getHeadersMap(lastHumanMsg);
50+
String sender = recipientHeaders.getOrDefault("Reply-To", recipientHeaders.get("From"));
51+
52+
MimeMessage email = createBaseMessage();
53+
setupThreadingHeaders(email, threadingHeaders);
54+
55+
if (sender != null) email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(sender));
56+
if (ccTarget != null) email.addRecipient(jakarta.mail.Message.RecipientType.CC, new InternetAddress(ccTarget));
57+
58+
setSubjectFromOriginal(email, threadingHeaders);
59+
email.setText(body);
60+
61+
gmail.sendMessage(threadId, email);
62+
LOG.infof("Reply sent to thread %s (TO: %s, CC: %s)", threadId, sender, ccTarget);
63+
return true;
64+
} catch (Exception e) {
65+
LOG.errorf(e, "Failed to send reply to thread %s", threadId);
66+
return false;
67+
}
68+
}
69+
70+
private MimeMessage createBaseMessage() throws Exception {
71+
MimeMessage email = new MimeMessage(mailSession);
72+
email.setFrom(new InternetAddress(botEmail));
73+
return email;
74+
}
75+
76+
private void setSubjectFromOriginal(MimeMessage email, Map<String, String> headers) throws Exception {
77+
String originalSubject = headers.getOrDefault("Subject", "No Subject");
78+
String newSubject = originalSubject.toLowerCase().startsWith("re:")
79+
? originalSubject
80+
: "Re: " + originalSubject;
81+
email.setSubject(newSubject);
82+
}
83+
84+
private void setupThreadingHeaders(MimeMessage email, Map<String, String> headers) throws Exception {
85+
String parentId = headers.get("Message-ID");
86+
String refs = headers.get("References");
87+
if (parentId != null && !parentId.isEmpty()) {
88+
email.setHeader("In-Reply-To", parentId);
89+
email.setHeader("References", (refs == null || refs.isEmpty() ? "" : refs + " ") + parentId);
90+
}
91+
}
92+
93+
Message findLastHumanMessage(List<Message> history) {
94+
for (int i = history.size() - 1; i >= 0; i--) {
95+
Message msg = history.get(i);
96+
Map<String, String> headers = gmail.getHeadersMap(msg);
97+
String from = headers.get("From");
98+
99+
if (from != null && !from.toLowerCase().contains(botEmail.toLowerCase())) {
100+
return msg;
101+
}
102+
}
103+
return history.get(history.size() - 1);
104+
}
105+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package org.keycloak.gh.bot.security.command;
2+
3+
import org.junit.jupiter.api.BeforeEach;
4+
import org.junit.jupiter.api.Test;
5+
import org.keycloak.gh.bot.security.email.MailSender;
6+
import org.kohsuke.github.GHEventPayload;
7+
import org.kohsuke.github.GHIssue;
8+
import org.kohsuke.github.GHIssueComment;
9+
import org.kohsuke.github.GHIssueCommentQueryBuilder;
10+
import org.kohsuke.github.GHRepository;
11+
import org.kohsuke.github.PagedIterable;
12+
import org.kohsuke.github.PagedIterator;
13+
import org.kohsuke.github.ReactionContent;
14+
15+
import java.io.IOException;
16+
import java.lang.reflect.Field;
17+
import java.util.List;
18+
19+
import static org.mockito.ArgumentMatchers.*;
20+
import static org.mockito.Mockito.*;
21+
22+
class MailingListCommandTest {
23+
24+
private MailSender mailSender;
25+
private MailingListCommand command;
26+
private GHEventPayload.IssueComment payload;
27+
private GHIssueComment comment;
28+
private GHIssue issue;
29+
30+
@BeforeEach
31+
@SuppressWarnings("unchecked")
32+
void setUp() throws Exception {
33+
mailSender = mock(MailSender.class);
34+
command = new MailingListCommand();
35+
36+
setField(command, "mailSender", mailSender);
37+
setField(command, "targetGroup", "keycloak-security@googlegroups.com");
38+
39+
payload = mock(GHEventPayload.IssueComment.class);
40+
comment = mock(GHIssueComment.class);
41+
issue = mock(GHIssue.class);
42+
43+
GHRepository repository = mock(GHRepository.class);
44+
when(repository.getFullName()).thenReturn("keycloak/keycloak-private");
45+
when(payload.getRepository()).thenReturn(repository);
46+
when(payload.getComment()).thenReturn(comment);
47+
when(payload.getIssue()).thenReturn(issue);
48+
}
49+
50+
@Test
51+
void execute_sendsReplyAndReactsWithThumbsUp() throws Exception {
52+
when(comment.getBody()).thenReturn("@security reply\n\nThis is my reply to the mailing list");
53+
setupIssueComments("**Gmail-Thread-ID:** abc123def");
54+
when(mailSender.sendReply("abc123def", "This is my reply to the mailing list", "keycloak-security@googlegroups.com"))
55+
.thenReturn(true);
56+
57+
command.run(payload);
58+
59+
verify(mailSender).sendReply("abc123def", "This is my reply to the mailing list", "keycloak-security@googlegroups.com");
60+
verify(comment).createReaction(ReactionContent.PLUS_ONE);
61+
}
62+
63+
@Test
64+
void execute_reactsWithThumbsDownWhenSendFails() throws Exception {
65+
when(comment.getBody()).thenReturn("@security reply\n\nFailed reply");
66+
setupIssueComments("**Gmail-Thread-ID:** abc123def");
67+
when(mailSender.sendReply("abc123def", "Failed reply", "keycloak-security@googlegroups.com"))
68+
.thenReturn(false);
69+
70+
command.run(payload);
71+
72+
verify(comment).createReaction(ReactionContent.MINUS_ONE);
73+
}
74+
75+
@Test
76+
void execute_reactsWithThumbsDownWhenNoThreadIdFound() throws Exception {
77+
when(comment.getBody()).thenReturn("@security reply\n\nOrphan reply");
78+
setupIssueComments("Just a regular comment with no thread ID");
79+
80+
command.run(payload);
81+
82+
verify(comment).createReaction(ReactionContent.MINUS_ONE);
83+
verify(mailSender, never()).sendReply(anyString(), anyString(), anyString());
84+
}
85+
86+
@Test
87+
void execute_reactsWithThumbsDownOnInvalidCommandSignature() throws Exception {
88+
when(comment.getBody()).thenReturn("@security reply extra-stuff\n\nBody text");
89+
90+
command.run(payload);
91+
92+
verify(comment).createReaction(ReactionContent.MINUS_ONE);
93+
verify(mailSender, never()).sendReply(anyString(), anyString(), anyString());
94+
}
95+
96+
@Test
97+
void execute_reactsWithThumbsDownWhenNoBody() throws Exception {
98+
when(comment.getBody()).thenReturn("@security reply");
99+
100+
command.run(payload);
101+
102+
verify(comment).createReaction(ReactionContent.MINUS_ONE);
103+
verify(mailSender, never()).sendReply(anyString(), anyString(), anyString());
104+
}
105+
106+
@Test
107+
void execute_findsThreadIdAcrossMultipleComments() throws Exception {
108+
when(comment.getBody()).thenReturn("@security reply\n\nMulti-comment reply");
109+
setupIssueComments(
110+
"First comment without thread ID",
111+
"**Gmail-Thread-ID:** deadbeef42\nSubject: CVE report\nFrom: reporter@example.com"
112+
);
113+
when(mailSender.sendReply("deadbeef42", "Multi-comment reply", "keycloak-security@googlegroups.com"))
114+
.thenReturn(true);
115+
116+
command.run(payload);
117+
118+
verify(mailSender).sendReply("deadbeef42", "Multi-comment reply", "keycloak-security@googlegroups.com");
119+
verify(comment).createReaction(ReactionContent.PLUS_ONE);
120+
}
121+
122+
@SuppressWarnings("unchecked")
123+
private void setupIssueComments(String... commentBodies) throws IOException {
124+
GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class);
125+
when(issue.queryComments()).thenReturn(queryBuilder);
126+
127+
List<GHIssueComment> comments = new java.util.ArrayList<>();
128+
for (String body : commentBodies) {
129+
GHIssueComment c = mock(GHIssueComment.class);
130+
when(c.getBody()).thenReturn(body);
131+
comments.add(c);
132+
}
133+
134+
PagedIterable<GHIssueComment> pagedIterable = mock(PagedIterable.class);
135+
when(queryBuilder.list()).thenReturn(pagedIterable);
136+
137+
PagedIterator<GHIssueComment> pagedIterator = mock(PagedIterator.class);
138+
final int[] index = {0};
139+
when(pagedIterator.hasNext()).thenAnswer(inv -> index[0] < comments.size());
140+
when(pagedIterator.next()).thenAnswer(inv -> comments.get(index[0]++));
141+
when(pagedIterable.iterator()).thenReturn(pagedIterator);
142+
}
143+
144+
private static void setField(Object target, String fieldName, Object value) throws Exception {
145+
Class<?> clazz = target.getClass();
146+
while (clazz != null) {
147+
try {
148+
Field field = clazz.getDeclaredField(fieldName);
149+
field.setAccessible(true);
150+
field.set(target, value);
151+
return;
152+
} catch (NoSuchFieldException e) {
153+
clazz = clazz.getSuperclass();
154+
}
155+
}
156+
throw new NoSuchFieldException(fieldName);
157+
}
158+
}

0 commit comments

Comments
 (0)