Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/org/keycloak/gh/bot/labels/Kind.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

public enum Kind {

BUG;
BUG,
CVE;

@Override
public String toString() {
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/org/keycloak/gh/bot/labels/Status.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public enum Status {
MISSING_INFORMATION,
BUMPED_BY_BOT,
TRIAGE,
REOPENED;
REOPENED,
CVE_REQUEST;

@Override
public String toString() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,51 @@
import org.eclipse.microprofile.config.ConfigProvider;
import org.jboss.logging.Logger;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHIssueComment;
import org.kohsuke.github.ReactionContent;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class CommandParser implements BotCommand {

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

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

@Override
public final void run(GHEventPayload.IssueComment issueCommentPayload) throws IOException {
if (isAuthorizedRepository(issueCommentPayload)) {
if (!isAuthorizedRepository(issueCommentPayload)) {
return;
}

String commandKey = buildCommandKey(issueCommentPayload);
if (!ACTIVE_COMMANDS.add(commandKey)) {
LOGGER.infof("Duplicate webhook detected for %s. Skipping.", commandKey);
return;
}

try {
execute(issueCommentPayload);
} finally {
ACTIVE_COMMANDS.remove(commandKey);
}
}

private String buildCommandKey(GHEventPayload.IssueComment payload) {
return getClass().getSimpleName()
+ ":" + payload.getComment().getNodeId();
}

private boolean isAuthorizedRepository(GHEventPayload.IssueComment payload) {
String allowedRepository = ConfigProvider.getConfig()
.getOptionalValue("repository.privateRepository", String.class)
Expand Down Expand Up @@ -54,17 +78,42 @@ protected void fail(GHEventPayload.IssueComment payload, String reason) throws I
/**
* Ensure that a body exists and is placed on a new line.
*/
protected ParsedMessage extractMessage(GHEventPayload.IssueComment payload) throws IOException {
String[] parts = payload.getComment().getBody().trim().split("[\\r\\n]+", 2);

if (parts.length < 2 || parts[1].trim().isEmpty()) {
fail(payload, parts.length < 2
? "Invalid formatting. The message body MUST be placed on a new line below the command."
: "Empty message body provided.");
return null;
protected Optional<ParsedMessage> extractMessage(GHEventPayload.IssueComment payload) throws IOException {
String commentBody = payload.getComment().getBody();
if (commentBody == null || commentBody.isBlank()) {
fail(payload, "Empty comment body.");
return Optional.empty();
}

int newlineIndex = commentBody.indexOf('\n');
if (newlineIndex == -1) {
fail(payload, "Invalid formatting. The message body MUST be placed on a new line below the command.");
return Optional.empty();
}

return new ParsedMessage(parts[0].trim().replaceAll("\\s+", " ").toLowerCase(), parts[1].trim());
String rawCommand = commentBody.substring(0, newlineIndex);
String commandLine = rawCommand.trim().replaceAll("\\s+", " ");
String body = commentBody.substring(newlineIndex + 1).trim();

if (body.isEmpty()) {
fail(payload, "Empty message body provided.");
return Optional.empty();
}

return Optional.of(new ParsedMessage(commandLine, body));
}

protected Optional<String> findThreadId(GHEventPayload.IssueComment payload, Pattern pattern) throws IOException {
for (GHIssueComment comment : payload.getIssue().queryComments().list()) {
String body = comment.getBody();
if (body == null) continue;

Matcher matcher = pattern.matcher(body);
if (matcher.find()) {
return Optional.of(matcher.group(1));
}
}
return Optional.empty();
}

protected record ParsedMessage(String commandLine, String body) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
import org.keycloak.gh.bot.security.common.Constants;
import org.keycloak.gh.bot.security.email.MailSender;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHIssueComment;

import java.io.IOException;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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

@Override
protected void execute(GHEventPayload.IssueComment payload) throws IOException {
ParsedMessage msg = extractMessage(payload);
if (msg == null) return;
Optional<ParsedMessage> msg = extractMessage(payload);
if (msg.isEmpty()) return;

if (!msg.commandLine().equals("@security reply")) {
fail(payload, "Invalid command signature. Extra text found on the command line.");
return;
}

Optional<String> threadId = findGmailThreadId(payload);
Optional<String> threadId = findThreadId(payload, THREAD_ID_PATTERN);
if (threadId.isEmpty()) {
fail(payload, "Gmail Thread ID not found in issue comments. Cannot reply without a linked email thread.");
return;
}

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

if (mailSender.sendReply(threadId.get(), msg.body(), targetGroup)) {
if (mailSender.sendReply(threadId.get(), msg.get().body(), targetGroup)) {
success(payload);
} else {
fail(payload, "Failed to send reply via Gmail API.");
}
}

private Optional<String> findGmailThreadId(GHEventPayload.IssueComment payload) throws IOException {
for (GHIssueComment comment : payload.getIssue().queryComments().list()) {
String body = comment.getBody();
if (body == null) continue;

Matcher matcher = THREAD_ID_PATTERN.matcher(body);
if (matcher.find()) {
return Optional.of(matcher.group(1));
}
}
return Optional.empty();
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,96 @@
package org.keycloak.gh.bot.security.command;

import com.github.rvesse.airline.annotations.Command;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.keycloak.gh.bot.labels.Status;
import org.keycloak.gh.bot.security.common.Constants;
import org.keycloak.gh.bot.security.email.MailSender;
import org.kohsuke.github.GHEventPayload;
import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHLabel;

import java.io.IOException;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

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

private static final Logger LOGGER = Logger.getLogger(SecAlertCommand.class);
private static final Pattern SECALERT_THREAD_ID_PATTERN = Pattern.compile(
Pattern.quote(Constants.SECALERT_THREAD_ID_PREFIX) + "\\s*([a-f0-9]+)");

@Inject
MailSender mailSender;

@ConfigProperty(name = "email.sender.secalert")
String secAlertEmail;

@ConfigProperty(name = "google.group.target")
String targetGroup;

@Override
protected void execute(GHEventPayload.IssueComment payload) throws IOException {
ParsedMessage msg = extractMessage(payload);
if (msg == null) return;
Optional<ParsedMessage> msg = extractMessage(payload);
if (msg.isEmpty()) return;

String subject = String.join(" ", unparsedArgs);
String body = msg.get().body();

Optional<String> existingThreadId = findThreadId(payload, SECALERT_THREAD_ID_PATTERN);

if (existingThreadId.isPresent()) {
replyToExistingThread(payload, existingThreadId.get(), body);
} else {
createNewThread(payload, subject, body);
}
}

private void createNewThread(GHEventPayload.IssueComment payload, String subject, String body) throws IOException {
if (subject.isEmpty()) {
fail(payload, "Subject is required for the first SecAlert email. Usage: @security secalert <subject>");
return;
}

LOGGER.infof("Sending new SecAlert email for issue #%d, subject: %s", payload.getIssue().getNumber(), subject);

Optional<String> threadId = mailSender.sendNewEmail(secAlertEmail, targetGroup, subject, body);
if (threadId.isEmpty()) {
fail(payload, "Failed to send email to SecAlert via Gmail API.");
return;
}

String marker = Constants.SECALERT_THREAD_ID_PREFIX + " " + threadId.get();
GHIssue issue = payload.getIssue();
issue.comment("SecAlert email sent. " + marker);

Set<String> currentLabels = issue.getLabels().stream().map(GHLabel::getName).collect(Collectors.toSet());
if (currentLabels.contains(Status.TRIAGE.toLabel())) {
issue.removeLabels(Status.TRIAGE.toLabel());
}
issue.addLabels(Status.CVE_REQUEST.toLabel());

String title = issue.getTitle();
if (!title.startsWith(Constants.CVE_TBD_PREFIX)) {
issue.setTitle(Constants.CVE_TBD_PREFIX + " " + title);
}
success(payload);
}

private void replyToExistingThread(GHEventPayload.IssueComment payload, String threadId, String body) throws IOException {
LOGGER.infof("Replying to existing SecAlert thread %s for issue #%d", threadId, payload.getIssue().getNumber());

if (msg.commandLine().equals("@security secalert")) {
LOGGER.infof("New Message to secalert:\n%s", msg.body());
if (mailSender.sendThreadedEmail(threadId, secAlertEmail, targetGroup, body)) {
success(payload);
} else {
fail(payload, "Invalid command. Extra text found on the command line.");
fail(payload, "Failed to send reply to SecAlert thread " + threadId + ".");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package org.keycloak.gh.bot.security.common;

import java.util.regex.Pattern;

public final class Constants {

public static final Pattern CVE_PATTERN = Pattern.compile("CVE-\\d{4}-\\d+");

public static final String GMAIL_THREAD_ID_PREFIX = "**Gmail-Thread-ID:**";
public static final String SECALERT_THREAD_ID_PREFIX = "**SecAlert-Thread-ID:**";
public static final String CVE_TBD_PREFIX = "[CVE-TBD]";
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._";
public static final String ATTACHMENTS_FOOTER = "\n_Attachments are not uploaded for security reasons. [View them in Google Groups](%s)_";

Expand Down
Loading
Loading