diff --git a/.gitignore b/.gitignore index 78a25644..5635b923 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ gophish_admin.crt gophish_admin.key *.exe -*.db +gophish.db* +gophish \ No newline at end of file diff --git a/controllers/phish.go b/controllers/phish.go index fd2fefdf..d37c7954 100644 --- a/controllers/phish.go +++ b/controllers/phish.go @@ -2,7 +2,6 @@ package controllers import ( "bytes" - "encoding/json" "errors" "fmt" "html/template" @@ -26,13 +25,6 @@ var ErrInvalidRequest = errors.New("Invalid request") // has already been marked as complete. var ErrCampaignComplete = errors.New("Event received on completed campaign") -// eventDetails is a struct that wraps common attributes we want to store -// in an event -type eventDetails struct { - Payload url.Values `json:"payload"` - Browser map[string]string `json:"browser"` -} - // CreatePhishingRouter creates the router that handles phishing connections. func CreatePhishingRouter() http.Handler { router := mux.NewRouter() @@ -59,16 +51,8 @@ func PhishTracker(w http.ResponseWriter, r *http.Request) { return } rs := ctx.Get(r, "result").(models.Result) - c := ctx.Get(r, "campaign").(models.Campaign) - rj := ctx.Get(r, "details").([]byte) - c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_OPENED, Details: string(rj)}) - // Don't update the status if the user already clicked the link - // or submitted data to the campaign - if rs.Status == models.EVENT_CLICKED || rs.Status == models.EVENT_DATA_SUBMIT { - http.ServeFile(w, r, "static/images/pixel.png") - return - } - err = rs.UpdateStatus(models.EVENT_OPENED) + d := ctx.Get(r, "details").(models.EventDetails) + err = rs.HandleEmailOpened(d) if err != nil { log.Error(err) } @@ -87,11 +71,9 @@ func PhishReporter(w http.ResponseWriter, r *http.Request) { return } rs := ctx.Get(r, "result").(models.Result) - c := ctx.Get(r, "campaign").(models.Campaign) - rj := ctx.Get(r, "details").([]byte) - c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_REPORTED, Details: string(rj)}) + d := ctx.Get(r, "details").(models.EventDetails) - err = rs.UpdateReported(true) + err = rs.HandleEmailReport(d) if err != nil { log.Error(err) } @@ -112,7 +94,7 @@ func PhishHandler(w http.ResponseWriter, r *http.Request) { } rs := ctx.Get(r, "result").(models.Result) c := ctx.Get(r, "campaign").(models.Campaign) - rj := ctx.Get(r, "details").([]byte) + d := ctx.Get(r, "details").(models.EventDetails) p, err := models.GetPage(c.PageId, c.UserId) if err != nil { log.Error(err) @@ -121,18 +103,12 @@ func PhishHandler(w http.ResponseWriter, r *http.Request) { } switch { case r.Method == "GET": - if rs.Status != models.EVENT_CLICKED && rs.Status != models.EVENT_DATA_SUBMIT { - rs.UpdateStatus(models.EVENT_CLICKED) - } - err = c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_CLICKED, Details: string(rj)}) + err = rs.HandleClickedLink(d) if err != nil { log.Error(err) } case r.Method == "POST": - // If data was POST'ed, let's record it - rs.UpdateStatus(models.EVENT_DATA_SUBMIT) - // Store the data in an event - c.AddEvent(models.Event{Email: rs.Email, Message: models.EVENT_DATA_SUBMIT, Details: string(rj)}) + err = rs.HandleFormSubmit(d) if err != nil { log.Error(err) } @@ -224,16 +200,15 @@ func setupContext(r *http.Request) (error, *http.Request) { if err != nil { log.Error(err) } - d := eventDetails{ + d := models.EventDetails{ Payload: r.Form, Browser: make(map[string]string), } d.Browser["address"] = ip d.Browser["user-agent"] = r.Header.Get("User-Agent") - rj, err := json.Marshal(d) r = ctx.Set(r, "result", rs) r = ctx.Set(r, "campaign", c) - r = ctx.Set(r, "details", rj) + r = ctx.Set(r, "details", d) return nil, r } diff --git a/controllers/phish_test.go b/controllers/phish_test.go index 5a23adf5..4b034bc7 100644 --- a/controllers/phish_test.go +++ b/controllers/phish_test.go @@ -66,7 +66,10 @@ func (s *ControllersSuite) TestOpenedPhishingEmail() { campaign = s.getFirstCampaign() result = campaign.Results[0] + lastEvent := campaign.Events[len(campaign.Events)-1] s.Equal(result.Status, models.EVENT_OPENED) + s.Equal(lastEvent.Message, models.EVENT_OPENED) + s.Equal(result.ModifiedDate, lastEvent.Time) } func (s *ControllersSuite) TestReportedPhishingEmail() { @@ -78,8 +81,10 @@ func (s *ControllersSuite) TestReportedPhishingEmail() { campaign = s.getFirstCampaign() result = campaign.Results[0] + lastEvent := campaign.Events[len(campaign.Events)-1] s.Equal(result.Reported, true) - s.Equal(campaign.Events[len(campaign.Events)-1].Message, models.EVENT_REPORTED) + s.Equal(lastEvent.Message, models.EVENT_REPORTED) + s.Equal(result.ModifiedDate, lastEvent.Time) } func (s *ControllersSuite) TestClickedPhishingLinkAfterOpen() { @@ -92,7 +97,10 @@ func (s *ControllersSuite) TestClickedPhishingLinkAfterOpen() { campaign = s.getFirstCampaign() result = campaign.Results[0] + lastEvent := campaign.Events[len(campaign.Events)-1] s.Equal(result.Status, models.EVENT_CLICKED) + s.Equal(lastEvent.Message, models.EVENT_CLICKED) + s.Equal(result.ModifiedDate, lastEvent.Time) } func (s *ControllersSuite) TestNoRecipientID() { diff --git a/db/db_mysql/migrations/20180524203752_0.7.0_result_last_modified.sql b/db/db_mysql/migrations/20180524203752_0.7.0_result_last_modified.sql new file mode 100644 index 00000000..e5e829e4 --- /dev/null +++ b/db/db_mysql/migrations/20180524203752_0.7.0_result_last_modified.sql @@ -0,0 +1,17 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +ALTER TABLE results ADD COLUMN modified_date DATETIME; + +UPDATE results + SET `modified_date`= ( + SELECT max(events.time) FROM events + WHERE events.email=results.email + AND events.campaign_id=results.campaign_id + ); + + + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back + diff --git a/db/db_sqlite3/migrations/20180524203752_0.7.0_result_last_modified.sql b/db/db_sqlite3/migrations/20180524203752_0.7.0_result_last_modified.sql new file mode 100644 index 00000000..e5e829e4 --- /dev/null +++ b/db/db_sqlite3/migrations/20180524203752_0.7.0_result_last_modified.sql @@ -0,0 +1,17 @@ + +-- +goose Up +-- SQL in section 'Up' is executed when this migration is applied +ALTER TABLE results ADD COLUMN modified_date DATETIME; + +UPDATE results + SET `modified_date`= ( + SELECT max(events.time) FROM events + WHERE events.email=results.email + AND events.campaign_id=results.campaign_id + ); + + + +-- +goose Down +-- SQL section 'Down' is executed when this migration is rolled back + diff --git a/models/campaign.go b/models/campaign.go index 7cc15ca4..8c20d3d1 100644 --- a/models/campaign.go +++ b/models/campaign.go @@ -2,6 +2,7 @@ package models import ( "errors" + "net/url" "time" log "github.com/gophish/gophish/logger" @@ -68,6 +69,30 @@ type CampaignStats struct { Error int64 `json:"error"` } +// Event contains the fields for an event +// that occurs during the campaign +type Event struct { + Id int64 `json:"-"` + CampaignId int64 `json:"-"` + Email string `json:"email"` + Time time.Time `json:"time"` + Message string `json:"message"` + Details string `json:"details"` +} + +// EventDetails is a struct that wraps common attributes we want to store +// in an event +type EventDetails struct { + Payload url.Values `json:"payload"` + Browser map[string]string `json:"browser"` +} + +// EventError is a struct that wraps an error that occurs when sending an +// email to a recipient +type EventError struct { + Error string `json:"error"` +} + // ErrCampaignNameNotSpecified indicates there was no template given by the user var ErrCampaignNameNotSpecified = errors.New("Campaign name not specified") @@ -122,10 +147,10 @@ func (c *Campaign) UpdateStatus(s string) error { } // AddEvent creates a new campaign event in the database -func (c *Campaign) AddEvent(e Event) error { +func (c *Campaign) AddEvent(e *Event) error { e.CampaignId = c.Id e.Time = time.Now().UTC() - return db.Save(&e).Error + return db.Save(e).Error } // getDetails retrieves the related attributes of the campaign @@ -220,17 +245,6 @@ func getCampaignStats(cid int64) (CampaignStats, error) { return s, err } -// Event contains the fields for an event -// that occurs during the campaign -type Event struct { - Id int64 `json:"-"` - CampaignId int64 `json:"-"` - Email string `json:"email"` - Time time.Time `json:"time"` - Message string `json:"message"` - Details string `json:"details"` -} - // GetCampaigns returns the campaigns owned by the given user. func GetCampaigns(uid int64) ([]Campaign, error) { cs := []Campaign{} @@ -422,7 +436,7 @@ func PostCampaign(c *Campaign, uid int64) error { log.Error(err) return err } - err = c.AddEvent(Event{Message: "Campaign Created"}) + err = c.AddEvent(&Event{Message: "Campaign Created"}) if err != nil { log.Error(err) } @@ -438,15 +452,16 @@ func PostCampaign(c *Campaign, uid int64) error { } resultMap[t.Email] = true r := &Result{ - Email: t.Email, - Position: t.Position, - Status: STATUS_SCHEDULED, - CampaignId: c.Id, - UserId: c.UserId, - FirstName: t.FirstName, - LastName: t.LastName, - SendDate: c.LaunchDate, - Reported: false, + Email: t.Email, + Position: t.Position, + Status: STATUS_SCHEDULED, + CampaignId: c.Id, + UserId: c.UserId, + FirstName: t.FirstName, + LastName: t.LastName, + SendDate: c.LaunchDate, + Reported: false, + ModifiedDate: c.CreatedDate, } if c.Status == CAMPAIGN_IN_PROGRESS { r.Status = STATUS_SENDING diff --git a/models/maillog.go b/models/maillog.go index dffd6422..c09d0fee 100644 --- a/models/maillog.go +++ b/models/maillog.go @@ -3,7 +3,6 @@ package models import ( "bytes" "encoding/base64" - "encoding/json" "errors" "fmt" "io" @@ -58,20 +57,16 @@ func GenerateMailLog(c *Campaign, r *Result) error { // too many times. Backoff also unlocks the maillog so that it can be processed // again in the future. func (m *MailLog) Backoff(reason error) error { - if m.SendAttempt == MaxSendAttempts { - err = m.addError(ErrMaxSendAttempts) - return ErrMaxSendAttempts - } r, err := GetResult(m.RId) if err != nil { return err } + if m.SendAttempt == MaxSendAttempts { + r.HandleEmailError(ErrMaxSendAttempts) + return ErrMaxSendAttempts + } // Add an error, since we had to backoff because of a // temporary error of some sort during the SMTP transaction - err = m.addError(reason) - if err != nil { - return err - } m.SendAttempt++ backoffDuration := math.Pow(2, float64(m.SendAttempt)) m.SendDate = m.SendDate.Add(time.Minute * time.Duration(backoffDuration)) @@ -79,9 +74,7 @@ func (m *MailLog) Backoff(reason error) error { if err != nil { return err } - r.Status = STATUS_RETRY - r.SendDate = m.SendDate - err = db.Save(r).Error + err = r.HandleEmailBackoff(reason, m.SendDate) if err != nil { return err } @@ -101,32 +94,6 @@ func (m *MailLog) Lock() error { return db.Save(&m).Error } -// addError adds an error to the associated campaign -func (m *MailLog) addError(e error) error { - c, err := GetCampaign(m.CampaignId, m.UserId) - if err != nil { - return err - } - // This is redundant in the case of permanent - // errors, but the extra query makes for - // a cleaner API. - r, err := GetResult(m.RId) - if err != nil { - return err - } - es := struct { - Error string `json:"error"` - }{ - Error: e.Error(), - } - ej, err := json.Marshal(es) - if err != nil { - log.Warn(err) - } - err = c.AddEvent(Event{Email: r.Email, Message: EVENT_SENDING_ERROR, Details: string(ej)}) - return err -} - // Error sets the error status on the models.Result that the // maillog refers to. Since MailLog errors are permanent, // this action also deletes the maillog. @@ -136,14 +103,7 @@ func (m *MailLog) Error(e error) error { log.Warn(err) return err } - // Update the result - err = r.UpdateStatus(ERROR) - if err != nil { - log.Warn(err) - return err - } - // Update the campaign events - err = m.addError(e) + err = r.HandleEmailError(e) if err != nil { log.Warn(err) return err @@ -159,15 +119,7 @@ func (m *MailLog) Success() error { if err != nil { return err } - err = r.UpdateStatus(EVENT_SENT) - if err != nil { - return err - } - c, err := GetCampaign(m.CampaignId, m.UserId) - if err != nil { - return err - } - err = c.AddEvent(Event{Email: r.Email, Message: EVENT_SENT}) + err = r.HandleEmailSent() if err != nil { return err } diff --git a/models/maillog_test.go b/models/maillog_test.go index f77be871..4cadb048 100644 --- a/models/maillog_test.go +++ b/models/maillog_test.go @@ -105,11 +105,7 @@ func (s *ModelsSuite) TestMailLogError(ch *check.C) { ch.Assert(len(campaign.Events), check.Equals, expectedEventLength) gotEvent := campaign.Events[1] - es := struct { - Error string `json:"error"` - }{ - Error: expectedError.Error(), - } + es := EventError{Error: expectedError.Error()} ej, _ := json.Marshal(es) expectedEvent := Event{ Id: gotEvent.Id, diff --git a/models/result.go b/models/result.go index b91f8795..431b6f50 100644 --- a/models/result.go +++ b/models/result.go @@ -2,6 +2,7 @@ package models import ( "crypto/rand" + "encoding/json" "fmt" "math/big" "net" @@ -25,30 +26,133 @@ type mmGeoPoint struct { // Result contains the fields for a result object, // which is a representation of a target in a campaign. type Result struct { - Id int64 `json:"-"` - CampaignId int64 `json:"-"` - UserId int64 `json:"-"` - RId string `json:"id"` - Email string `json:"email"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Position string `json:"position"` - Status string `json:"status" sql:"not null"` - IP string `json:"ip"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - SendDate time.Time `json:"send_date"` - Reported bool `json:"reported" sql:"not null"` + Id int64 `json:"-"` + CampaignId int64 `json:"-"` + UserId int64 `json:"-"` + RId string `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Position string `json:"position"` + Status string `json:"status" sql:"not null"` + IP string `json:"ip"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + SendDate time.Time `json:"send_date"` + Reported bool `json:"reported" sql:"not null"` + ModifiedDate time.Time `json:"modified_date"` } -// UpdateStatus updates the status of the result in the database -func (r *Result) UpdateStatus(s string) error { - return db.Table("results").Where("id=?", r.Id).Update("status", s).Error +func (r *Result) createEvent(status string, details interface{}) (*Event, error) { + c, err := GetCampaign(r.CampaignId, r.UserId) + if err != nil { + return nil, err + } + e := &Event{Email: r.Email, Message: status} + if details != nil { + dj, err := json.Marshal(details) + if err != nil { + return nil, err + } + e.Details = string(dj) + } + c.AddEvent(e) + return e, nil } -// UpdateReported updates when a user reports a campaign -func (r *Result) UpdateReported(s bool) error { - return db.Table("results").Where("id=?", r.Id).Update("reported", s).Error +// HandleEmailSent updates a Result to indicate that the email has been +// successfully sent to the remote SMTP server +func (r *Result) HandleEmailSent() error { + event, err := r.createEvent(EVENT_SENT, nil) + if err != nil { + return err + } + r.Status = EVENT_SENT + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleEmailError updates a Result to indicate that there was an error when +// attempting to send the email to the remote SMTP server. +func (r *Result) HandleEmailError(err error) error { + event, err := r.createEvent(EVENT_SENDING_ERROR, EventError{Error: err.Error()}) + if err != nil { + return err + } + r.Status = ERROR + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleEmailBackoff updates a Result to indicate that the email received a +// temporary error and needs to be retried +func (r *Result) HandleEmailBackoff(err error, sendDate time.Time) error { + event, err := r.createEvent(EVENT_SENDING_ERROR, EventError{Error: err.Error()}) + if err != nil { + return err + } + r.Status = STATUS_RETRY + r.SendDate = sendDate + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleEmailOpened updates a Result in the case where the recipient opened the +// email. +func (r *Result) HandleEmailOpened(details EventDetails) error { + event, err := r.createEvent(EVENT_OPENED, details) + if err != nil { + return err + } + // Don't update the status if the user already clicked the link + // or submitted data to the campaign + if r.Status == EVENT_CLICKED || r.Status == EVENT_DATA_SUBMIT { + return nil + } + r.Status = EVENT_OPENED + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleClickedLink updates a Result in the case where the recipient clicked +// the link in an email. +func (r *Result) HandleClickedLink(details EventDetails) error { + event, err := r.createEvent(EVENT_CLICKED, details) + if err != nil { + return err + } + // Don't update the status if the user has already submitted data via the + // landing page form. + if r.Status == EVENT_DATA_SUBMIT { + return nil + } + r.Status = EVENT_CLICKED + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleFormSubmit updates a Result in the case where the recipient submitted +// credentials to the form on a Landing Page. +func (r *Result) HandleFormSubmit(details EventDetails) error { + event, err := r.createEvent(EVENT_DATA_SUBMIT, details) + if err != nil { + return err + } + r.Status = EVENT_DATA_SUBMIT + r.ModifiedDate = event.Time + return db.Save(r).Error +} + +// HandleEmailReport updates a Result in the case where they report a simulated +// phishing email using the HTTP handler. +func (r *Result) HandleEmailReport(details EventDetails) error { + event, err := r.createEvent(EVENT_REPORTED, details) + if err != nil { + return err + } + r.Reported = true + r.ModifiedDate = event.Time + return db.Save(r).Error } // UpdateGeo updates the latitude and longitude of the result in @@ -68,11 +172,10 @@ func (r *Result) UpdateGeo(addr string) error { return err } // Update the database with the record information - return db.Table("results").Where("id=?", r.Id).Updates(map[string]interface{}{ - "ip": addr, - "latitude": city.GeoPoint.Latitude, - "longitude": city.GeoPoint.Longitude, - }).Error + r.IP = addr + r.Latitude = city.GeoPoint.Latitude + r.Longitude = city.GeoPoint.Longitude + return db.Save(r).Error } // GenerateId generates a unique key to represent the result diff --git a/models/result_test.go b/models/result_test.go index 5a819dee..e7cbdeb0 100644 --- a/models/result_test.go +++ b/models/result_test.go @@ -1,7 +1,6 @@ package models import ( - "fmt" "net/mail" "regexp" "time" @@ -40,10 +39,9 @@ func (s *ModelsSuite) TestResultSendingStatus(ch *check.C) { ch.Assert(PostCampaign(&c, c.UserId), check.Equals, nil) // This campaign wasn't scheduled, so we expect the status to // be sending - fmt.Println("Campaign STATUS") - fmt.Println(c.Status) for _, r := range c.Results { ch.Assert(r.Status, check.Equals, STATUS_SENDING) + ch.Assert(r.ModifiedDate, check.Equals, c.CreatedDate) } } func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) { @@ -54,6 +52,7 @@ func (s *ModelsSuite) TestResultScheduledStatus(ch *check.C) { // be sending for _, r := range c.Results { ch.Assert(r.Status, check.Equals, STATUS_SCHEDULED) + ch.Assert(r.ModifiedDate, check.Equals, c.CreatedDate) } }