Skip to content

Commit ecc5251

Browse files
committed
Add the @security secalert command that enables team members to send emails
to SecAlert directly from GitHub issue comments The command supports two flows: - New thread: @security secalert <subject> sends a new email and posts the thread ID back to the issue for future tracking. - Reply: when a SecAlert-Thread-ID already exists on the issue, subsequent invocations reply on the existing email thread regardless of subject. Signed-off-by: Bruno Oliveira da Silva <bruno@abstractj.com>
1 parent fd6fc2c commit ecc5251

12 files changed

Lines changed: 612 additions & 74 deletions

File tree

src/main/java/org/keycloak/gh/bot/labels/Status.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ public enum Status {
88
MISSING_INFORMATION,
99
BUMPED_BY_BOT,
1010
TRIAGE,
11-
REOPENED;
11+
REOPENED,
12+
CVE_REQUEST;
1213

1314
@Override
1415
public String toString() {

src/main/java/org/keycloak/gh/bot/security/command/CommandParser.java

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,54 @@
44
import org.eclipse.microprofile.config.ConfigProvider;
55
import org.jboss.logging.Logger;
66
import org.kohsuke.github.GHEventPayload;
7+
import org.kohsuke.github.GHIssueComment;
78
import org.kohsuke.github.ReactionContent;
89

910
import java.io.IOException;
1011
import java.util.ArrayList;
1112
import java.util.List;
13+
import java.util.Optional;
14+
import java.util.Set;
15+
import java.util.concurrent.ConcurrentHashMap;
16+
import java.util.regex.Matcher;
17+
import java.util.regex.Pattern;
1218

1319
public abstract class CommandParser implements BotCommand {
1420

1521
private static final Logger LOGGER = Logger.getLogger(CommandParser.class);
22+
private static final Set<String> ACTIVE_COMMANDS = ConcurrentHashMap.newKeySet();
1623

1724
// Absorbs all invalid trailing text to prevent parser crashes
1825
@Arguments
1926
protected List<String> unparsedArgs = new ArrayList<>();
2027

2128
@Override
2229
public final void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
23-
if (isAuthorizedRepository(issueCommentPayload)) {
30+
if (!isAuthorizedRepository(issueCommentPayload)) {
31+
return;
32+
}
33+
34+
String commandKey = buildCommandKey(issueCommentPayload);
35+
if (!ACTIVE_COMMANDS.add(commandKey)) {
36+
LOGGER.infof("Duplicate webhook detected for %s. Skipping.", commandKey);
37+
return;
38+
}
39+
40+
try {
2441
execute(issueCommentPayload);
42+
} finally {
43+
ACTIVE_COMMANDS.remove(commandKey);
2544
}
2645
}
2746

47+
private String buildCommandKey(GHEventPayload.IssueComment payload) {
48+
String body = payload.getComment().getBody();
49+
int bodyHash = body != null ? body.hashCode() : 0;
50+
return getClass().getSimpleName()
51+
+ ":" + payload.getIssue().getNumber()
52+
+ ":" + bodyHash;
53+
}
54+
2855
private boolean isAuthorizedRepository(GHEventPayload.IssueComment payload) {
2956
String allowedRepository = ConfigProvider.getConfig()
3057
.getOptionalValue("repository.privateRepository", String.class)
@@ -54,17 +81,42 @@ protected void fail(GHEventPayload.IssueComment payload, String reason) throws I
5481
/**
5582
* Ensure that a body exists and is placed on a new line.
5683
*/
57-
protected ParsedMessage extractMessage(GHEventPayload.IssueComment payload) throws IOException {
58-
String[] parts = payload.getComment().getBody().trim().split("[\\r\\n]+", 2);
59-
60-
if (parts.length < 2 || parts[1].trim().isEmpty()) {
61-
fail(payload, parts.length < 2
62-
? "Invalid formatting. The message body MUST be placed on a new line below the command."
63-
: "Empty message body provided.");
64-
return null;
84+
protected Optional<ParsedMessage> extractMessage(GHEventPayload.IssueComment payload) throws IOException {
85+
String commentBody = payload.getComment().getBody();
86+
if (commentBody == null || commentBody.isBlank()) {
87+
fail(payload, "Empty comment body.");
88+
return Optional.empty();
89+
}
90+
91+
int newlineIndex = commentBody.indexOf('\n');
92+
if (newlineIndex == -1) {
93+
fail(payload, "Invalid formatting. The message body MUST be placed on a new line below the command.");
94+
return Optional.empty();
6595
}
6696

67-
return new ParsedMessage(parts[0].trim().replaceAll("\\s+", " ").toLowerCase(), parts[1].trim());
97+
String rawCommand = commentBody.substring(0, newlineIndex);
98+
String commandLine = rawCommand.trim().replaceAll("\\s+", " ");
99+
String body = commentBody.substring(newlineIndex + 1).trim();
100+
101+
if (body.isEmpty()) {
102+
fail(payload, "Empty message body provided.");
103+
return Optional.empty();
104+
}
105+
106+
return Optional.of(new ParsedMessage(commandLine, body));
107+
}
108+
109+
protected Optional<String> findThreadId(GHEventPayload.IssueComment payload, Pattern pattern) throws IOException {
110+
for (GHIssueComment comment : payload.getIssue().queryComments().list()) {
111+
String body = comment.getBody();
112+
if (body == null) continue;
113+
114+
Matcher matcher = pattern.matcher(body);
115+
if (matcher.find()) {
116+
return Optional.of(matcher.group(1));
117+
}
118+
}
119+
return Optional.empty();
68120
}
69121

70122
protected record ParsedMessage(String commandLine, String body) {

src/main/java/org/keycloak/gh/bot/security/command/MailingListCommand.java

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
import org.keycloak.gh.bot.security.common.Constants;
88
import org.keycloak.gh.bot.security.email.MailSender;
99
import org.kohsuke.github.GHEventPayload;
10-
import org.kohsuke.github.GHIssueComment;
1110

1211
import java.io.IOException;
1312
import java.util.Optional;
14-
import java.util.regex.Matcher;
1513
import java.util.regex.Pattern;
1614

1715
/**
@@ -32,39 +30,22 @@ public class MailingListCommand extends CommandParser implements BotCommand {
3230

3331
@Override
3432
protected void execute(GHEventPayload.IssueComment payload) throws IOException {
35-
ParsedMessage msg = extractMessage(payload);
36-
if (msg == null) return;
33+
Optional<ParsedMessage> msg = extractMessage(payload);
34+
if (msg.isEmpty()) return;
3735

38-
if (!msg.commandLine().equals("@security reply")) {
39-
fail(payload, "Invalid command signature. Extra text found on the command line.");
40-
return;
41-
}
42-
43-
Optional<String> threadId = findGmailThreadId(payload);
36+
Optional<String> threadId = findThreadId(payload, THREAD_ID_PATTERN);
4437
if (threadId.isEmpty()) {
4538
fail(payload, "Gmail Thread ID not found in issue comments. Cannot reply without a linked email thread.");
4639
return;
4740
}
4841

4942
LOGGER.infof("Sending reply to Gmail thread %s for issue #%d", threadId.get(), payload.getIssue().getNumber());
5043

51-
if (mailSender.sendReply(threadId.get(), msg.body(), targetGroup)) {
44+
if (mailSender.sendReply(threadId.get(), msg.get().body(), targetGroup)) {
5245
success(payload);
5346
} else {
5447
fail(payload, "Failed to send reply via Gmail API.");
5548
}
5649
}
5750

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();
69-
}
7051
}
Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,93 @@
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.labels.Status;
8+
import org.keycloak.gh.bot.security.common.Constants;
9+
import org.keycloak.gh.bot.security.email.MailSender;
510
import org.kohsuke.github.GHEventPayload;
11+
import org.kohsuke.github.GHIssue;
12+
import org.kohsuke.github.GHLabel;
613

714
import java.io.IOException;
15+
import java.util.Optional;
16+
import java.util.Set;
17+
import java.util.regex.Pattern;
18+
import java.util.stream.Collectors;
819

9-
@Command(name = "secalert", description = "Handles security alerts and multiline replies")
10-
public class SecAlertCommand extends CommandParser implements BotCommand { //Required due to a bug on Airline
20+
/**
21+
* Sends emails to SecAlert. Creates a new thread when no SecAlert-Thread-ID exists, otherwise replies on the existing thread.
22+
*/
23+
@Command(name = "secalert", description = "Sends a new email or reply to SecAlert and tracks the thread ID")
24+
public class SecAlertCommand extends CommandParser implements BotCommand {
1125

1226
private static final Logger LOGGER = Logger.getLogger(SecAlertCommand.class);
27+
private static final Pattern SECALERT_THREAD_ID_PATTERN = Pattern.compile(
28+
Pattern.quote(Constants.SECALERT_THREAD_ID_PREFIX) + "\\s*([a-f0-9]+)");
29+
30+
@Inject
31+
MailSender mailSender;
32+
33+
@ConfigProperty(name = "email.sender.secalert")
34+
String secAlertEmail;
35+
36+
@ConfigProperty(name = "google.group.target")
37+
String targetGroup;
1338

1439
@Override
1540
protected void execute(GHEventPayload.IssueComment payload) throws IOException {
16-
ParsedMessage msg = extractMessage(payload);
17-
if (msg == null) return;
41+
Optional<ParsedMessage> msg = extractMessage(payload);
42+
if (msg.isEmpty()) return;
43+
44+
String subject = String.join(" ", unparsedArgs);
45+
String body = msg.get().body();
46+
47+
Optional<String> existingThreadId = findThreadId(payload, SECALERT_THREAD_ID_PATTERN);
48+
49+
if (existingThreadId.isPresent()) {
50+
replyToExistingThread(payload, existingThreadId.get(), body);
51+
} else {
52+
createNewThread(payload, subject, body);
53+
}
54+
}
55+
56+
private void createNewThread(GHEventPayload.IssueComment payload, String subject, String body) throws IOException {
57+
if (subject.isEmpty()) {
58+
fail(payload, "Subject is required for the first SecAlert email. Usage: @security secalert <subject>");
59+
return;
60+
}
61+
62+
LOGGER.infof("Sending new SecAlert email for issue #%d, subject: %s", payload.getIssue().getNumber(), subject);
63+
64+
Optional<String> threadId = mailSender.sendNewEmail(secAlertEmail, targetGroup, subject, body);
65+
if (threadId.isEmpty()) {
66+
fail(payload, "Failed to send email to SecAlert via Gmail API.");
67+
return;
68+
}
69+
70+
String marker = Constants.SECALERT_THREAD_ID_PREFIX + " " + threadId.get();
71+
GHIssue issue = payload.getIssue();
72+
issue.comment("SecAlert email sent. " + marker);
73+
74+
Set<String> currentLabels = issue.getLabels().stream().map(GHLabel::getName).collect(Collectors.toSet());
75+
if (currentLabels.contains(Status.TRIAGE.toLabel())) {
76+
issue.removeLabels(Status.TRIAGE.toLabel());
77+
}
78+
issue.addLabels(Status.CVE_REQUEST.toLabel());
79+
80+
issue.setTitle(Constants.CVE_TBD_PREFIX + " " + issue.getTitle());
81+
success(payload);
82+
}
83+
84+
private void replyToExistingThread(GHEventPayload.IssueComment payload, String threadId, String body) throws IOException {
85+
LOGGER.infof("Replying to existing SecAlert thread %s for issue #%d", threadId, payload.getIssue().getNumber());
1886

19-
if (msg.commandLine().equals("@security secalert")) {
20-
LOGGER.infof("New Message to secalert:\n%s", msg.body());
87+
if (mailSender.sendThreadedEmail(threadId, secAlertEmail, targetGroup, body)) {
2188
success(payload);
2289
} else {
23-
fail(payload, "Invalid command. Extra text found on the command line.");
90+
fail(payload, "Failed to send reply to SecAlert thread " + threadId + ".");
2491
}
2592
}
26-
}
93+
}

src/main/java/org/keycloak/gh/bot/security/common/Constants.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package org.keycloak.gh.bot.security.common;
22

3+
import java.util.regex.Pattern;
4+
35
public final class Constants {
46

7+
public static final Pattern CVE_PATTERN = Pattern.compile("CVE-\\d{4}-\\d+");
8+
59
public static final String GMAIL_THREAD_ID_PREFIX = "**Gmail-Thread-ID:**";
10+
public static final String SECALERT_THREAD_ID_PREFIX = "**SecAlert-Thread-ID:**";
11+
public static final String CVE_TBD_PREFIX = "[CVE-TBD]";
612
public static final String ISSUE_DESCRIPTION_TEMPLATE = "_Thread originally started in the keycloak-security mailing list. Replace the content here with a proper CVE description._";
713
public static final String ATTACHMENTS_FOOTER = "\n_Attachments are not uploaded for security reasons. [View them in Google Groups](%s)_";
814

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.List;
2222
import java.util.Map;
2323
import java.util.Optional;
24+
import java.util.regex.Matcher;
2425
import java.util.regex.Pattern;
2526

2627
@ApplicationScoped
@@ -39,6 +40,9 @@ public class MailProcessor {
3940
@ConfigProperty(name = "repository.privateRepository")
4041
String repositoryName;
4142

43+
@ConfigProperty(name = "email.sender.secalert")
44+
String secAlertEmail;
45+
4246
@Inject
4347
GmailAdapter gmail;
4448

@@ -110,13 +114,21 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor
110114

111115
var issueOpt = resolveIssue(github, repository, threadId);
112116

117+
if (issueOpt.isEmpty() && isFromSecAlert(from)) {
118+
issueOpt = resolveIssueBySecAlertThread(github, repository, threadId);
119+
}
120+
113121
if (issueOpt.isPresent()) {
114122
var issue = issueOpt.get();
115123
if (issue.getState() == GHIssueState.CLOSED) {
116124
issue.reopen();
117125
LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId);
118126
}
119127
appendComment(issue, from, body, attachmentSection);
128+
129+
if (isFromSecAlert(from)) {
130+
applyCveIdFromSecAlert(issue, subject, body);
131+
}
120132
} else {
121133
var newIssue = createNewIssue(repository, threadId, subject, from, body, attachmentSection);
122134
issueCache.put(threadId, newIssue.getNumber());
@@ -203,6 +215,46 @@ private boolean isFromBot(String from) {
203215
return from != null && from.toLowerCase().contains(botEmail.toLowerCase());
204216
}
205217

218+
private boolean isFromSecAlert(String from) {
219+
return from != null && from.toLowerCase().contains(secAlertEmail.toLowerCase());
220+
}
221+
222+
private Optional<GHIssue> resolveIssueBySecAlertThread(GitHub github, GHRepository repository, String threadId) {
223+
try {
224+
var query = "repo:%s \"%s %s\" is:issue in:comments".formatted(
225+
repositoryName, Constants.SECALERT_THREAD_ID_PREFIX, threadId);
226+
var iterator = github.searchIssues().q(query).list().iterator();
227+
if (iterator.hasNext()) {
228+
int issueNumber = iterator.next().getNumber();
229+
return Optional.ofNullable(repository.getIssue(issueNumber));
230+
}
231+
} catch (Exception e) {
232+
LOGGER.warnf(e, "GitHub search failed for SecAlert thread %s", threadId);
233+
}
234+
return Optional.empty();
235+
}
236+
237+
private void applyCveIdFromSecAlert(GHIssue issue, String subject, String body) throws IOException {
238+
String cveId = extractCveId(subject);
239+
if (cveId == null) {
240+
cveId = extractCveId(body);
241+
}
242+
if (cveId == null) return;
243+
244+
String title = issue.getTitle();
245+
if (title != null && title.startsWith(Constants.CVE_TBD_PREFIX)) {
246+
String newTitle = title.replace(Constants.CVE_TBD_PREFIX, "[" + cveId + "]");
247+
issue.setTitle(newTitle);
248+
LOGGER.infof("Replaced %s with [%s] in issue #%d", Constants.CVE_TBD_PREFIX, cveId, issue.getNumber());
249+
}
250+
}
251+
252+
static String extractCveId(String text) {
253+
if (text == null) return null;
254+
Matcher matcher = Constants.CVE_PATTERN.matcher(text);
255+
return matcher.find() ? matcher.group() : null;
256+
}
257+
206258
private boolean isValidGroupMessage(Map<String, String> headers) {
207259
if (targetGroup.matchesListId(headers.get("List-ID"))) {
208260
return true;

0 commit comments

Comments
 (0)