1414import org .keycloak .gh .bot .security .common .Constants ;
1515import org .keycloak .gh .bot .utils .Labels ;
1616import org .kohsuke .github .GHIssue ;
17+ import org .kohsuke .github .GHIssueComment ;
1718import org .kohsuke .github .GHIssueState ;
1819import org .kohsuke .github .GHLabel ;
1920import org .kohsuke .github .GHRepository ;
2324import java .time .Duration ;
2425import java .util .List ;
2526import java .util .Map ;
27+ import java .util .Objects ;
2628import java .util .Optional ;
2729import java .util .regex .Matcher ;
2830import java .util .regex .Pattern ;
31+ import java .util .stream .Stream ;
2932
3033@ ApplicationScoped
3134public class MailProcessor {
@@ -43,8 +46,8 @@ public class MailProcessor {
4346 @ ConfigProperty (name = "repository.privateRepository" )
4447 String repositoryName ;
4548
46- @ ConfigProperty (name = "email.sender.secalert " )
47- String secAlertEmail ;
49+ @ ConfigProperty (name = "secalert. email.reply-to " )
50+ String secAlertReplyTo ;
4851
4952 @ Inject
5053 GmailAdapter gmail ;
@@ -62,6 +65,11 @@ public class MailProcessor {
6265 .expireAfterWrite (Duration .ofDays (7 ))
6366 .build ();
6467
68+ private final Cache <Integer , Boolean > secAlertThreadIdCache = Caffeine .newBuilder ()
69+ .maximumSize (500 )
70+ .expireAfterWrite (Duration .ofDays (7 ))
71+ .build ();
72+
6573 @ PostConstruct
6674 void init () {
6775 this .targetGroup = TargetGroup .from (targetGroupEmail );
@@ -100,15 +108,19 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor
100108 var msg = gmail .getMessage (msgSummary .getId ());
101109 var headers = gmail .getHeadersMap (msg );
102110 var from = headers .getOrDefault ("From" , "" );
111+ var replyTo = headers .getOrDefault ("Reply-To" , "" );
103112
104- if (isFromBot (from ) || !isValidGroupMessage (headers )) {
113+ boolean fromSecAlert = isFromSecAlert (from , replyTo );
114+
115+ if (isFromBot (from ) || (!fromSecAlert && !isValidGroupMessage (headers ))) {
105116 gmail .markAsRead (msgSummary .getId ());
106117 return ;
107118 }
108119
109120 var threadId = msg .getThreadId ();
110121 var messageId = headers .getOrDefault ("Message-ID" , "" ).replaceAll ("^<|>$" , "" );
111- var subject = normalizeSubject (headers .getOrDefault ("Subject" , "(No Subject)" ).trim ());
122+ var rawSubject = headers .getOrDefault ("Subject" , "(No Subject)" ).trim ();
123+ var subject = normalizeSubject (rawSubject );
112124
113125 var body = bodySanitizer .sanitize (gmail .getBody (msg )).orElse ("(No content)" );
114126 var attachments = gmail .getAttachments (msg );
@@ -117,8 +129,10 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor
117129
118130 var issueOpt = resolveIssue (github , repository , threadId );
119131
120- if (issueOpt .isEmpty () && isFromSecAlert (from )) {
121- issueOpt = resolveIssueBySecAlertThread (github , repository , threadId );
132+ if (issueOpt .isEmpty () && fromSecAlert ) {
133+ issueOpt = resolveIssueByGhiTag (repository , rawSubject )
134+ .or (() -> resolveIssueBySecAlertThreadId (github , repository , threadId ));
135+ issueOpt .ifPresent (issue -> issueCache .put (threadId , issue .getNumber ()));
122136 }
123137
124138 if (issueOpt .isPresent ()) {
@@ -129,8 +143,9 @@ private void processSingleMessage(Message msgSummary, GitHub github, GHRepositor
129143 }
130144 appendComment (issue , from , body , attachmentSection );
131145
132- if (isFromSecAlert ( from ) ) {
146+ if (fromSecAlert ) {
133147 applyCveIdFromSecAlert (issue , subject , body );
148+ recordSecAlertThreadIdIfMissing (issue , threadId );
134149 }
135150 } else {
136151 var newIssue = createNewIssue (repository , threadId , subject , from , body , attachmentSection );
@@ -218,25 +233,76 @@ private boolean isFromBot(String from) {
218233 return from != null && from .toLowerCase ().contains (botEmail .toLowerCase ());
219234 }
220235
221- private boolean isFromSecAlert (String from ) {
222- return from != null && from .toLowerCase ().contains (secAlertEmail .toLowerCase ());
236+ private boolean isFromSecAlert (String from , String replyTo ) {
237+ String needle = secAlertReplyTo .toLowerCase ();
238+ return Stream .of (from , replyTo )
239+ .filter (Objects ::nonNull )
240+ .anyMatch (header -> header .toLowerCase ().contains (needle ));
223241 }
224242
225- private Optional <GHIssue > resolveIssueBySecAlertThread (GitHub github , GHRepository repository , String threadId ) {
243+ private Optional <GHIssue > resolveIssueBySecAlertThreadId (GitHub github , GHRepository repository , String threadId ) {
226244 try {
227- var query = "repo:%s \" %s %s \" is:issue in:comments" . formatted (
228- repositoryName , Constants . SECALERT_THREAD_ID_PREFIX , threadId );
245+ var expectedMarker = Constants . SECALERT_THREAD_ID_PREFIX + " " + threadId ;
246+ var query = "repo:%s \" %s \" is:issue in:comments" . formatted ( repositoryName , expectedMarker );
229247 var iterator = github .searchIssues ().q (query ).list ().iterator ();
230- if (iterator .hasNext ()) {
248+ while (iterator .hasNext ()) {
231249 int issueNumber = iterator .next ().getNumber ();
232- return Optional .ofNullable (repository .getIssue (issueNumber ));
250+ var issue = repository .getIssue (issueNumber );
251+ if (hasExactSecAlertThreadId (issue , expectedMarker )) {
252+ issueCache .put (threadId , issueNumber );
253+ return Optional .of (issue );
254+ }
233255 }
234256 } catch (Exception e ) {
235- LOGGER .warnf (e , "GitHub search failed for SecAlert thread %s" , threadId );
257+ LOGGER .warnf (e , "GitHub search by SecAlert-Thread-ID failed for thread %s" , threadId );
236258 }
237259 return Optional .empty ();
238260 }
239261
262+ private boolean hasExactSecAlertThreadId (GHIssue issue , String expectedMarker ) throws IOException {
263+ for (GHIssueComment c : issue .queryComments ().list ()) {
264+ String body = c .getBody ();
265+ if (body != null && body .trim ().equals (expectedMarker )) {
266+ secAlertThreadIdCache .put (issue .getNumber (), Boolean .TRUE );
267+ return true ;
268+ }
269+ }
270+ return false ;
271+ }
272+
273+ Optional <GHIssue > resolveIssueByGhiTag (GHRepository repository , String subject ) {
274+ if (subject == null ) return Optional .empty ();
275+ Matcher matcher = Constants .GHI_ISSUE_PATTERN .matcher (subject );
276+ if (!matcher .find ()) {
277+ return Optional .empty ();
278+ }
279+ int issueNumber = Integer .parseInt (matcher .group (1 ));
280+ try {
281+ return Optional .ofNullable (repository .getIssue (issueNumber ));
282+ } catch (IOException e ) {
283+ LOGGER .warnf (e , "Failed to fetch issue #%d referenced by SecAlert subject tag" , issueNumber );
284+ return Optional .empty ();
285+ }
286+ }
287+
288+ void recordSecAlertThreadIdIfMissing (GHIssue issue , String threadId ) throws IOException {
289+ if (threadId == null || threadId .isBlank ()) return ;
290+ int issueNumber = issue .getNumber ();
291+ if (Boolean .TRUE .equals (secAlertThreadIdCache .getIfPresent (issueNumber ))) {
292+ return ;
293+ }
294+ for (GHIssueComment c : issue .queryComments ().list ()) {
295+ String body = c .getBody ();
296+ if (body != null && body .contains (Constants .SECALERT_THREAD_ID_PREFIX )) {
297+ secAlertThreadIdCache .put (issueNumber , Boolean .TRUE );
298+ return ;
299+ }
300+ }
301+ issue .comment (Constants .SECALERT_THREAD_ID_PREFIX + " " + threadId );
302+ secAlertThreadIdCache .put (issueNumber , Boolean .TRUE );
303+ LOGGER .infof ("Recorded %s %s on issue #%d" , Constants .SECALERT_THREAD_ID_PREFIX , threadId , issueNumber );
304+ }
305+
240306 void applyCveIdFromSecAlert (GHIssue issue , String subject , String body ) throws IOException {
241307 String cveId = extractCveId (subject );
242308 if (cveId == null ) {
0 commit comments