Skip to content

Commit 4eb0523

Browse files
committed
Automation of Keycloak Security Vulnerability reports
Signed-off-by: Bruno Oliveira da Silva <bruno@abstractj.com>
1 parent a1b7d82 commit 4eb0523

16 files changed

Lines changed: 1354 additions & 0 deletions

pom.xml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,30 @@
5959
<artifactId>commons-io</artifactId>
6060
<version>${commons-io.version}</version>
6161
</dependency>
62+
<dependency>
63+
<groupId>io.quarkus</groupId>
64+
<artifactId>quarkus-mailer</artifactId>
65+
</dependency>
66+
<dependency>
67+
<groupId>com.google.apis</groupId>
68+
<artifactId>google-api-services-gmail</artifactId>
69+
<version>v1-rev20220404-1.32.1</version>
70+
</dependency>
71+
<dependency>
72+
<groupId>com.google.auth</groupId>
73+
<artifactId>google-auth-library-oauth2-http</artifactId>
74+
<version>1.19.0</version>
75+
</dependency>
76+
<dependency>
77+
<groupId>com.google.http-client</groupId>
78+
<artifactId>google-http-client-gson</artifactId>
79+
<version>1.43.3</version>
80+
</dependency>
81+
<dependency>
82+
<groupId>org.eclipse.angus</groupId>
83+
<artifactId>angus-mail</artifactId>
84+
<version>2.0.3</version>
85+
</dependency>
6286
<dependency>
6387
<groupId>io.quarkus</groupId>
6488
<artifactId>quarkus-junit5</artifactId>
@@ -76,6 +100,11 @@
76100
<version>${mockito-core.version}</version>
77101
<scope>test</scope>
78102
</dependency>
103+
<dependency>
104+
<groupId>io.quarkus</groupId>
105+
<artifactId>quarkus-junit5-mockito</artifactId>
106+
<scope>test</scope>
107+
</dependency>
79108
</dependencies>
80109
<build>
81110
<plugins>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package org.keycloak.gh.bot.email;
2+
3+
import jakarta.enterprise.context.ApplicationScoped;
4+
import jakarta.inject.Inject;
5+
import org.keycloak.gh.bot.GitHubInstallationProvider;
6+
7+
import java.util.Optional;
8+
import java.util.regex.Matcher;
9+
import java.util.regex.Pattern;
10+
11+
/**
12+
* Responsible for parsing raw text from GitHub comments into Command objects.
13+
*/
14+
@ApplicationScoped
15+
public class CommandParser {
16+
17+
@Inject
18+
GitHubInstallationProvider gitHubProvider;
19+
20+
private Pattern mentionStartOfLine;
21+
private Pattern replySecurity;
22+
private Pattern newSecAlert;
23+
private String cachedBotName;
24+
25+
public enum CommandType {
26+
REPLY_KEYCLOAK_SECURITY,
27+
NEW_SECALERT,
28+
UNKNOWN
29+
}
30+
31+
public record Command(CommandType type, Optional<String> subject, String body) {}
32+
33+
public Optional<Command> parse(String text) {
34+
if (text == null || text.isBlank()) return Optional.empty();
35+
36+
ensurePatternsInitialized();
37+
String trimmedText = text.trim();
38+
39+
if (!mentionStartOfLine.matcher(trimmedText).find()) {
40+
return Optional.empty();
41+
}
42+
43+
Matcher mSecurity = replySecurity.matcher(trimmedText);
44+
if (mSecurity.find()) {
45+
String body = mSecurity.group(1).trim();
46+
return body.isEmpty() ? Optional.empty() : Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), body));
47+
}
48+
49+
Matcher mSecAlert = newSecAlert.matcher(trimmedText);
50+
if (mSecAlert.find()) {
51+
String subject = mSecAlert.group(1).trim();
52+
String body = mSecAlert.group(2).trim();
53+
if (subject.isEmpty() || body.isEmpty()) return Optional.empty();
54+
return Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of(subject), body));
55+
}
56+
57+
return Optional.of(new Command(CommandType.UNKNOWN, Optional.empty(), trimmedText));
58+
}
59+
60+
public String getBotName() {
61+
String login = gitHubProvider.getBotLogin();
62+
if (login == null) return "unknown-bot";
63+
return login.endsWith("[bot]") ? login.replace("[bot]", "") : login;
64+
}
65+
66+
public String getHelpMessage() {
67+
String n = getBotName();
68+
return String.format("""
69+
I don't know this command or the format is incorrect.
70+
**Rule:** Commands must be on their own line. The message body starts on the next line.
71+
72+
**Available Commands:**
73+
74+
`@%s /reply keycloak-security`
75+
`REPLY BODY (Sent to: Sender + Keycloak Security List)`
76+
77+
`@%s /new secalert "Subject"`
78+
`EMAIL BODY (Sent to: SecAlert + Keycloak Security List)`
79+
""", n, n);
80+
}
81+
82+
private synchronized void ensurePatternsInitialized() {
83+
String currentBotName = getBotName();
84+
85+
if (cachedBotName == null || !cachedBotName.equals(currentBotName)) {
86+
cachedBotName = currentBotName;
87+
String quotedName = Pattern.quote(currentBotName);
88+
89+
mentionStartOfLine = Pattern.compile("(?mi)^@" + quotedName + "\\b");
90+
replySecurity = Pattern.compile("(?msi)^@" + quotedName + "\\s+/reply\\s+keycloak-security\\s*[\r\n]+(.*)");
91+
newSecAlert = Pattern.compile("(?msi)^@" + quotedName + "\\s+/new\\s+secalert\\s+\"([^\"]+)\"\\s*[\r\n]+(.*)");
92+
}
93+
}
94+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package org.keycloak.gh.bot.email;
2+
3+
import jakarta.enterprise.context.ApplicationScoped;
4+
import jakarta.inject.Inject;
5+
import org.eclipse.microprofile.config.inject.ConfigProperty;
6+
import org.jboss.logging.Logger;
7+
import org.kohsuke.github.GHIssue;
8+
import org.kohsuke.github.GHIssueComment;
9+
import org.kohsuke.github.GHReaction;
10+
import org.kohsuke.github.ReactionContent;
11+
12+
import java.io.IOException;
13+
import java.time.Instant;
14+
import java.time.temporal.ChronoUnit;
15+
import java.util.Collections;
16+
import java.util.Date;
17+
import java.util.LinkedHashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.Optional;
21+
import java.util.Set;
22+
import java.util.regex.Matcher;
23+
import java.util.regex.Pattern;
24+
25+
/**
26+
* Handles the processing of GitHub comments to identify and execute bot commands
27+
*/
28+
@ApplicationScoped
29+
public class CommandProcessor {
30+
31+
private static final Logger LOG = Logger.getLogger(CommandProcessor.class);
32+
private static final Pattern VISIBLE_MARKER_PATTERN = Pattern.compile("\\*\\*Gmail-Thread-ID:\\*\\*\\s*([a-f0-9]+)");
33+
private static final Pattern RAW_HEX_PATTERN = Pattern.compile("\\b([a-f0-9]{16})\\b");
34+
35+
private static final int MAX_PROCESSED_HISTORY = 10000;
36+
37+
@ConfigProperty(name = "google.group.target")
38+
String targetGroup;
39+
40+
@ConfigProperty(name = "email.target.secalert")
41+
String secAlertEmail;
42+
43+
@Inject GitHubAdapter github;
44+
@Inject CommandParser parser;
45+
@Inject MailSender mailSender;
46+
47+
private final Set<Long> processedComments = Collections.synchronizedSet(Collections.newSetFromMap(
48+
new LinkedHashMap<Long, Boolean>(MAX_PROCESSED_HISTORY + 1, .75F, true) {
49+
@Override
50+
protected boolean removeEldestEntry(Map.Entry<Long, Boolean> eldest) {
51+
return size() > MAX_PROCESSED_HISTORY;
52+
}
53+
}));
54+
55+
private Instant lastPollTime = Instant.now().minus(10, ChronoUnit.MINUTES);
56+
57+
public void processCommands() {
58+
try {
59+
String myLogin = parser.getBotName();
60+
if (myLogin == null || myLogin.isEmpty()) return;
61+
62+
Instant executionStart = Instant.now();
63+
64+
Date querySince = Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES));
65+
List<GHIssue> updatedIssues = github.getIssuesUpdatedSince(querySince);
66+
67+
for (GHIssue issue : updatedIssues) {
68+
try {
69+
scanIssue(issue, myLogin);
70+
} catch (Exception e) {
71+
LOG.errorf(e, "Failed to scan issue #%d", issue.getNumber());
72+
}
73+
}
74+
75+
lastPollTime = executionStart;
76+
} catch (Exception e) {
77+
LOG.error("Fatal error fetching updated issues", e);
78+
}
79+
}
80+
81+
private void scanIssue(GHIssue issue, String myLogin) throws IOException {
82+
Optional<String> threadIdOpt = findThreadIdInComments(issue);
83+
84+
List<GHIssueComment> recentComments = issue.queryComments()
85+
.since(Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES)))
86+
.list()
87+
.toList();
88+
89+
for (GHIssueComment comment : recentComments) {
90+
if (hasAlreadyProcessed(comment, myLogin)) continue;
91+
92+
parser.parse(comment.getBody()).ifPresent(cmd -> executeCommand(issue, comment, cmd, threadIdOpt));
93+
}
94+
}
95+
96+
private void executeCommand(GHIssue issue, GHIssueComment comment, CommandParser.Command cmd, Optional<String> threadId) {
97+
boolean success = false;
98+
ReactionContent reaction = ReactionContent.EYES;
99+
100+
switch (cmd.type()) {
101+
case NEW_SECALERT:
102+
success = mailSender.sendNewEmail(secAlertEmail, targetGroup, cmd.subject().orElse("No Subject"), cmd.body());
103+
if (!success) {
104+
replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
105+
reaction = ReactionContent.CONFUSED;
106+
}
107+
break;
108+
case REPLY_KEYCLOAK_SECURITY:
109+
if (threadId.isPresent()) {
110+
success = mailSender.sendReply(threadId.get(), issue.getTitle(), cmd.body(), targetGroup);
111+
if (!success) {
112+
replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API.");
113+
reaction = ReactionContent.CONFUSED;
114+
}
115+
} else {
116+
replyWithError(issue, comment, "❌ Error: Gmail Thread ID not found.");
117+
success = true;
118+
reaction = ReactionContent.CONFUSED;
119+
}
120+
break;
121+
case UNKNOWN:
122+
sendHelpMessage(issue, comment);
123+
success = true;
124+
reaction = ReactionContent.CONFUSED;
125+
break;
126+
}
127+
128+
if (success) {
129+
processedComments.add(comment.getId());
130+
addReaction(comment, reaction);
131+
LOG.debugf("✅ Command executed: %s", cmd.type());
132+
}
133+
}
134+
135+
private void addReaction(GHIssueComment comment, ReactionContent reaction) {
136+
try {
137+
comment.createReaction(reaction);
138+
} catch (IOException e) {
139+
LOG.errorf("Failed to react to comment %d", comment.getId());
140+
}
141+
}
142+
143+
private void replyWithError(GHIssue issue, GHIssueComment comment, String message) {
144+
try {
145+
github.commentOnIssue(issue, "@" + comment.getUser().getLogin() + " " + message);
146+
} catch (IOException e) {
147+
LOG.error("Failed to send error reply", e);
148+
}
149+
}
150+
151+
private void sendHelpMessage(GHIssue issue, GHIssueComment comment) {
152+
try {
153+
String body = "@" + comment.getUser().getLogin() + " " + parser.getHelpMessage();
154+
github.commentOnIssue(issue, body);
155+
} catch (IOException e) {
156+
LOG.error("Failed to send help message", e);
157+
}
158+
}
159+
160+
private boolean hasAlreadyProcessed(GHIssueComment comment, String myLogin) throws IOException {
161+
if (processedComments.contains(comment.getId())) return true;
162+
163+
for (GHReaction reaction : comment.listReactions()) {
164+
String user = reaction.getUser().getLogin();
165+
if ((reaction.getContent() == ReactionContent.EYES || reaction.getContent() == ReactionContent.CONFUSED) &&
166+
(user.equalsIgnoreCase(myLogin) || user.equalsIgnoreCase(myLogin + "[bot]"))) {
167+
processedComments.add(comment.getId());
168+
return true;
169+
}
170+
}
171+
return false;
172+
}
173+
174+
private Optional<String> findThreadIdInComments(GHIssue issue) throws IOException {
175+
for (GHIssueComment comment : issue.getComments()) {
176+
Matcher m = VISIBLE_MARKER_PATTERN.matcher(comment.getBody());
177+
if (m.find()) return Optional.of(m.group(1).trim());
178+
Matcher raw = RAW_HEX_PATTERN.matcher(comment.getBody());
179+
if (raw.find()) return Optional.of(raw.group(1).trim());
180+
}
181+
return Optional.empty();
182+
}
183+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package org.keycloak.gh.bot.email;
2+
3+
import io.quarkus.scheduler.Scheduled;
4+
import jakarta.enterprise.context.ApplicationScoped;
5+
import jakarta.inject.Inject;
6+
import io.quarkus.scheduler.Scheduled.ConcurrentExecution;
7+
8+
/**
9+
* Manages the scheduled execution of email synchronization and command processing tasks.
10+
*/
11+
@ApplicationScoped
12+
public class EmailSyncScheduler {
13+
14+
@Inject IncomingMailProcessor incomingMail;
15+
@Inject CommandProcessor commandProcessor;
16+
17+
@Scheduled(every = "${bot.email.sync.interval:60s}", concurrentExecution = ConcurrentExecution.SKIP)
18+
public void syncGmailToGitHub() {
19+
incomingMail.processUnreadEmails();
20+
}
21+
22+
@Scheduled(every = "${bot.command.process.interval:10s}", concurrentExecution = ConcurrentExecution.SKIP)
23+
public void processGitHubCommands() {
24+
commandProcessor.processCommands();
25+
}
26+
}

0 commit comments

Comments
 (0)