Skip to content

Commit 7a26d6f

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 7a26d6f

13 files changed

Lines changed: 750 additions & 75 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
public enum Kind {
44

5-
BUG;
5+
BUG,
6+
CVE;
67

78
@Override
89
public String toString() {

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: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,51 @@
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+
return getClass().getSimpleName()
49+
+ ":" + payload.getComment().getNodeId();
50+
}
51+
2852
private boolean isAuthorizedRepository(GHEventPayload.IssueComment payload) {
2953
String allowedRepository = ConfigProvider.getConfig()
3054
.getOptionalValue("repository.privateRepository", String.class)
@@ -54,17 +78,42 @@ protected void fail(GHEventPayload.IssueComment payload, String reason) throws I
5478
/**
5579
* Ensure that a body exists and is placed on a new line.
5680
*/
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;
81+
protected Optional<ParsedMessage> extractMessage(GHEventPayload.IssueComment payload) throws IOException {
82+
String commentBody = payload.getComment().getBody();
83+
if (commentBody == null || commentBody.isBlank()) {
84+
fail(payload, "Empty comment body.");
85+
return Optional.empty();
86+
}
87+
88+
int newlineIndex = commentBody.indexOf('\n');
89+
if (newlineIndex == -1) {
90+
fail(payload, "Invalid formatting. The message body MUST be placed on a new line below the command.");
91+
return Optional.empty();
6592
}
6693

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

70119
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: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,96 @@
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+
String title = issue.getTitle();
81+
if (!title.startsWith(Constants.CVE_TBD_PREFIX)) {
82+
issue.setTitle(Constants.CVE_TBD_PREFIX + " " + title);
83+
}
84+
success(payload);
85+
}
86+
87+
private void replyToExistingThread(GHEventPayload.IssueComment payload, String threadId, String body) throws IOException {
88+
LOGGER.infof("Replying to existing SecAlert thread %s for issue #%d", threadId, payload.getIssue().getNumber());
1889

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

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

0 commit comments

Comments
 (0)