gophish/imap/monitor.go
Jordan Wright 9561846979
Update workflow actions and Go versions (#3245)
This PR:

* Updates the versions of various actions used by the CI and release workflows
* Updates the release workflow to use Go version 1.22
* Updates the test matrix to use Go versions 1.21, 1.22, and 1.23

It also updates the CI workflow to run when pull requests are created or changed. This will help give feedback when formatting or tests are broken during a PR.

As a good example of why this is useful, you'll see that I needed to run `gofmt` to get this to pass! We should have caught that earlier and now we'll catch it moving forward.
2024-09-22 23:24:43 -05:00

238 lines
7.8 KiB
Go

package imap
/* TODO:
* - Have a counter per config for number of consecutive login errors and backoff (e.g if supplied creds are incorrect)
* - Have a DB field "last_login_error" if last login failed
* - DB counter for non-campaign emails that the admin should investigate
* - Add field to User for numner of non-campaign emails reported
*/
import (
"bytes"
"context"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
log "github.com/gophish/gophish/logger"
"github.com/jordan-wright/email"
"github.com/gophish/gophish/models"
)
// Pattern for GoPhish emails e.g ?rid=AbC1234
// We include the optional quoted-printable 3D at the front, just in case decoding fails. e.g ?rid=3DAbC1234
// We also include alternative URL encoded representations of '=' and '?' to handle Microsoft ATP URLs e.g %3Frid%3DAbC1234
var goPhishRegex = regexp.MustCompile("((\\?|%3F)rid(=|%3D)(3D)?([A-Za-z0-9]{7}))")
// Monitor is a worker that monitors IMAP servers for reported campaign emails
type Monitor struct {
cancel func()
}
// Monitor.start() checks for campaign emails
// As each account can have its own polling frequency set we need to run one Go routine for
// each, as well as keeping an eye on newly created user accounts.
func (im *Monitor) start(ctx context.Context) {
usermap := make(map[int64]int) // Keep track of running go routines, one per user. We assume incrementing non-repeating UIDs (for the case where users are deleted and re-added).
for {
select {
case <-ctx.Done():
return
default:
dbusers, err := models.GetUsers() //Slice of all user ids. Each user gets their own IMAP monitor routine.
if err != nil {
log.Error(err)
break
}
for _, dbuser := range dbusers {
if _, ok := usermap[dbuser.Id]; !ok { // If we don't currently have a running Go routine for this user, start one.
log.Info("Starting new IMAP monitor for user ", dbuser.Username)
usermap[dbuser.Id] = 1
go monitor(dbuser.Id, ctx)
}
}
time.Sleep(10 * time.Second) // Every ten seconds we check if a new user has been created
}
}
}
// monitor will continuously login to the IMAP settings associated to the supplied user id (if the user account has IMAP settings, and they're enabled.)
// It also verifies the user account exists, and returns if not (for the case of a user being deleted).
func monitor(uid int64, ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// 1. Check if user exists, if not, return.
_, err := models.GetUser(uid)
if err != nil { // Not sure if there's a better way to determine user existence via id.
log.Info("User ", uid, " seems to have been deleted. Stopping IMAP monitor for this user.")
return
}
// 2. Check if user has IMAP settings.
imapSettings, err := models.GetIMAP(uid)
if err != nil {
log.Error(err)
break
}
if len(imapSettings) > 0 {
im := imapSettings[0]
// 3. Check if IMAP is enabled
if im.Enabled {
log.Debug("Checking IMAP for user ", uid, ": ", im.Username, " -> ", im.Host)
checkForNewEmails(im)
time.Sleep((time.Duration(im.IMAPFreq) - 10) * time.Second) // Subtract 10 to compensate for the default sleep of 10 at the bottom
}
}
}
time.Sleep(10 * time.Second)
}
}
// NewMonitor returns a new instance of imap.Monitor
func NewMonitor() *Monitor {
im := &Monitor{}
return im
}
// Start launches the IMAP campaign monitor
func (im *Monitor) Start() error {
log.Info("Starting IMAP monitor manager")
ctx, cancel := context.WithCancel(context.Background()) // ctx is the derivedContext
im.cancel = cancel
go im.start(ctx)
return nil
}
// Shutdown attempts to gracefully shutdown the IMAP monitor.
func (im *Monitor) Shutdown() error {
log.Info("Shutting down IMAP monitor manager")
im.cancel()
return nil
}
// checkForNewEmails logs into an IMAP account and checks unread emails for the
// rid campaign identifier.
func checkForNewEmails(im models.IMAP) {
im.Host = im.Host + ":" + strconv.Itoa(int(im.Port)) // Append port
mailServer := Mailbox{
Host: im.Host,
TLS: im.TLS,
IgnoreCertErrors: im.IgnoreCertErrors,
User: im.Username,
Pwd: im.Password,
Folder: im.Folder,
}
msgs, err := mailServer.GetUnread(true, false)
if err != nil {
log.Error(err)
return
}
// Update last_succesful_login here via im.Host
err = models.SuccessfulLogin(&im)
if len(msgs) > 0 {
log.Debugf("%d new emails for %s", len(msgs), im.Username)
var reportingFailed []uint32 // SeqNums of emails that were unable to be reported to phishing server, mark as unread
var deleteEmails []uint32 // SeqNums of campaign emails. If DeleteReportedCampaignEmail is true, we will delete these
for _, m := range msgs {
// Check if sender is from company's domain, if enabled. TODO: Make this an IMAP filter
if im.RestrictDomain != "" { // e.g domainResitct = widgets.com
splitEmail := strings.Split(m.Email.From, "@")
senderDomain := splitEmail[len(splitEmail)-1]
if senderDomain != im.RestrictDomain {
log.Debug("Ignoring email as not from company domain: ", senderDomain)
continue
}
}
rids, err := matchEmail(m.Email) // Search email Text, HTML, and each attachment for rid parameters
if err != nil {
log.Errorf("Error searching email for rids from user '%s': %s", m.Email.From, err.Error())
continue
}
if len(rids) < 1 {
// In the future this should be an alert in Gophish
log.Infof("User '%s' reported email with subject '%s'. This is not a GoPhish campaign; you should investigate it.", m.Email.From, m.Email.Subject)
}
for rid := range rids {
log.Infof("User '%s' reported email with rid %s", m.Email.From, rid)
result, err := models.GetResult(rid)
if err != nil {
log.Error("Error reporting GoPhish email with rid ", rid, ": ", err.Error())
reportingFailed = append(reportingFailed, m.SeqNum)
continue
}
err = result.HandleEmailReport(models.EventDetails{})
if err != nil {
log.Error("Error updating GoPhish email with rid ", rid, ": ", err.Error())
continue
}
if im.DeleteReportedCampaignEmail {
deleteEmails = append(deleteEmails, m.SeqNum)
}
}
}
// Check if any emails were unable to be reported, so we can mark them as unread
if len(reportingFailed) > 0 {
log.Debugf("Marking %d emails as unread as failed to report", len(reportingFailed))
err := mailServer.MarkAsUnread(reportingFailed) // Set emails as unread that we failed to report to GoPhish
if err != nil {
log.Error("Unable to mark emails as unread: ", err.Error())
}
}
// If the DeleteReportedCampaignEmail flag is set, delete reported Gophish campaign emails
if len(deleteEmails) > 0 {
log.Debugf("Deleting %d campaign emails", len(deleteEmails))
err := mailServer.DeleteEmails(deleteEmails) // Delete GoPhish campaign emails.
if err != nil {
log.Error("Failed to delete emails: ", err.Error())
}
}
} else {
log.Debug("No new emails for ", im.Username)
}
}
func checkRIDs(em *email.Email, rids map[string]bool) {
// Check Text and HTML
emailContent := string(em.Text) + string(em.HTML)
for _, r := range goPhishRegex.FindAllStringSubmatch(emailContent, -1) {
newrid := r[len(r)-1]
if !rids[newrid] {
rids[newrid] = true
}
}
}
// returns a slice of gophish rid paramters found in the email HTML, Text, and attachments
func matchEmail(em *email.Email) (map[string]bool, error) {
rids := make(map[string]bool)
checkRIDs(em, rids)
// Next check each attachment
for _, a := range em.Attachments {
ext := filepath.Ext(a.Filename)
if a.Header.Get("Content-Type") == "message/rfc822" || ext == ".eml" {
// Let's decode the email
rawBodyStream := bytes.NewReader(a.Content)
attachmentEmail, err := email.NewEmailFromReader(rawBodyStream)
if err != nil {
return rids, err
}
checkRIDs(attachmentEmail, rids)
}
}
return rids, nil
}