Skip to content

Commit 8d08159

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 8d08159

12 files changed

Lines changed: 691 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
import org.eclipse.microprofile.config.inject.ConfigProperty;
1010
import org.jboss.logging.Logger;
1111
import org.keycloak.gh.bot.GitHubInstallationProvider;
12+
import org.keycloak.gh.bot.labels.Status;
1213
import org.keycloak.gh.bot.security.common.Constants;
1314
import org.keycloak.gh.bot.utils.Labels;
1415
import org.kohsuke.github.GHIssue;
1516
import org.kohsuke.github.GHIssueState;
17+
import org.kohsuke.github.GHLabel;
1618
import org.kohsuke.github.GHRepository;
1719
import org.kohsuke.github.GitHub;
1820

@@ -21,6 +23,7 @@
2123
import java.util.List;
2224
import java.util.Map;
2325
import java.util.Optional;
26+
import java.util.regex.Matcher;
2427
import java.util.regex.Pattern;
2528

2629
@ApplicationScoped
@@ -39,6 +42,9 @@ public class MailProcessor {
3942
@ConfigProperty(name = "repository.privateRepository")
4043
String repositoryName;
4144

45+
@ConfigProperty(name = "email.sender.secalert")
46+
String secAlertEmail;
47+
4248
@Inject
4349
GmailAdapter gmail;
4450

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

111117
var issueOpt = resolveIssue(github, repository, threadId);
112118

119+
if (issueOpt.isEmpty() && isFromSecAlert(from)) {
120+
issueOpt = resolveIssueBySecAlertThread(github, repository, threadId);
121+
}
122+
113123
if (issueOpt.isPresent()) {
114124
var issue = issueOpt.get();
115125
if (issue.getState() == GHIssueState.CLOSED) {
116126
issue.reopen();
117127
LOGGER.infof("Reopened existing closed issue #%d for thread %s", issue.getNumber(), threadId);
118128
}
119129
appendComment(issue, from, body, attachmentSection);
130+
131+
if (isFromSecAlert(from)) {
132+
applyCveIdFromSecAlert(issue, subject, body);
133+
}
120134
} else {
121135
var newIssue = createNewIssue(repository, threadId, subject, from, body, attachmentSection);
122136
issueCache.put(threadId, newIssue.getNumber());
@@ -203,6 +217,54 @@ private boolean isFromBot(String from) {
203217
return from != null && from.toLowerCase().contains(botEmail.toLowerCase());
204218
}
205219

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

0 commit comments

Comments
 (0)