diff --git a/backend/app/administration.go b/backend/app/administration.go index 9f9168a..bde30aa 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -170,6 +170,8 @@ const ( ROUTE_V1_CAMPAIGN_NAME = "/api/v1/campaign/name/:name" ROUTE_V1_CAMPAIGN_RECIPIENTS = "/api/v1/campaign/:id/recipients" ROUTE_V1_CAMPAIGN_RESULT_STATS = "/api/v1/campaign/:id/statistics" + ROUTE_V1_CAMPAIGN_GROUPED_STATS = "/api/v1/campaign/:id/grouped-statistics" + ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA = "/api/v1/campaign/:id/has-group-data" ROUTE_V1_CAMPAIGN_EVENTS = "/api/v1/campaign/:id/events" ROUTE_V1_CAMPAIGN_ALL_EVENTS = "/api/v1/campaign/events" ROUTE_V1_CAMPAIGN_EVENT_ID = "/api/v1/campaign/event/:id" @@ -501,6 +503,8 @@ func setupRoutes( DELETE(ROUTE_V1_CAMPAIGN_EVENT_ID, middleware.SessionHandler, controllers.Campaign.DeleteEventByID). GET(ROUTE_V1_CAMPAIGN_STATS, middleware.SessionHandler, controllers.Campaign.GetStats). GET(ROUTE_V1_CAMPAIGN_RESULT_STATS, middleware.SessionHandler, controllers.Campaign.GetResultStats). + GET(ROUTE_V1_CAMPAIGN_GROUPED_STATS, middleware.SessionHandler, controllers.Campaign.GetGroupedResultStats). + GET(ROUTE_V1_CAMPAIGN_HAS_GROUP_DATA, middleware.SessionHandler, controllers.Campaign.GetHasGroupData). GET(ROUTE_V1_CAMPAIGN_STATS_ID, middleware.SessionHandler, controllers.Campaign.GetCampaignStats). GET(ROUTE_V1_CAMPAIGN_STATS_ALL, middleware.SessionHandler, controllers.Campaign.GetAllCampaignStats). POST(ROUTE_V1_CAMPAIGN_STATS_CREATE, middleware.SessionHandler, controllers.Campaign.CreateCampaignStats). diff --git a/backend/app/server.go b/backend/app/server.go index 6de7181..010c089 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -692,6 +692,54 @@ func trainingMilestoneEventName(pageType string) string { } } +// applyEventAnonymization strips identity and stamps the recipient's pseudonym on an +// event for an anonymous campaign, so it stays countable but carries no identity. It +// is a no-op for a normal campaign. Call before persisting an event. +func (s *Server) applyEventAnonymization( + ctx context.Context, + campaign *model.Campaign, + campaignID uuid.UUID, + recipientID uuid.UUID, + event *model.CampaignEvent, +) { + if campaign == nil { + return + } + // resolve anonymity; if the flag is unset fall back to the db, and strip identity + // if it still cannot be determined (fail closed) + isAnon, err := campaign.IsAnonymous.Get() + if err != nil { + isAnon, err = s.repositories.Campaign.IsAnonymousByID(ctx, &campaignID) + if err != nil { + s.logger.Errorw("failed to resolve campaign anonymity, stripping identity", "error", err) + event.Anonymize(nil) + return + } + } + if !isAnon { + return + } + // strip identity and attach the pseudonym if it loads; on failure the event is + // stored uncounted rather than with identity (fail closed) + cr, err := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID( + ctx, + &campaignID, + &recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + s.logger.Errorw("failed to load pseudonym for anonymous event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + aid, err := cr.AnonymizedID.Get() + if err != nil { + event.Anonymize(nil) + return + } + event.Anonymize(&aid) +} + // emitTrainingMilestoneIfNeeded records a training_started or training_completed // event for a training campaign, at most once per recipient. The raw page visit // event is still recorded separately, so this adds a deduplicated milestone on @@ -720,8 +768,20 @@ func (s *Server) emitTrainingMilestoneIfNeeded( if milestoneEventID == nil { return nil } - // a milestone is recorded once per recipient per campaign - has, err := s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID) + // a milestone is recorded once per recipient per campaign. an anonymous + // campaign's events carry the pseudonym rather than a recipient id, so dedup on + // that; otherwise a milestone would be recorded on every page visit. + var has bool + var err error + if isAnon, _ := campaign.IsAnonymous.Get(); isAnon { + if cr, crErr := s.repositories.CampaignRecipient.GetByCampaignAndRecipientID(c, &campaignID, &recipientID, &repository.CampaignRecipientOption{}); crErr == nil { + if aid, aerr := cr.AnonymizedID.Get(); aerr == nil { + has, err = s.repositories.Campaign.HasEventByAnonymizedID(c, &campaignID, &aid, milestoneEventID) + } + } + } else { + has, err = s.repositories.Campaign.HasEvent(c, &campaignID, &recipientID, milestoneEventID) + } if err != nil { s.logger.Errorw("failed to check for existing training milestone event", "error", err) // continue and attempt to create it @@ -742,6 +802,7 @@ func (s *Server) emitTrainingMilestoneIfNeeded( Data: vo.NewEmptyOptionalString1MB(), Metadata: model.ExtractCampaignEventMetadata(c, campaign), } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) if err := s.repositories.Campaign.SaveEvent(c, event); err != nil { return fmt.Errorf("failed to save training milestone event: %s", err) } @@ -1308,6 +1369,7 @@ func (s *Server) checkAndServePhishingPage( Data: submittedData, Metadata: metadata, } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent(c, event) if err != nil { return true, fmt.Errorf("failed to save campaign event: %s", err) @@ -1456,6 +1518,7 @@ func (s *Server) checkAndServePhishingPage( } // save the synthetic message read event + s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent) err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) if err != nil { s.logger.Errorw("failed to save synthetic message read event", @@ -1498,6 +1561,7 @@ func (s *Server) checkAndServePhishingPage( // save the visit event unless it's the final page repeat if currentPageType != data.PAGE_TYPE_DONE { + s.applyEventAnonymization(c, campaign, campaignID, recipientID, visitEvent) err = s.repositories.Campaign.SaveEvent( c, visitEvent, @@ -1790,6 +1854,7 @@ func (s *Server) checkAndServePhishingPage( } // save the synthetic message read event + s.applyEventAnonymization(c, campaign, campaignID, recipientID, syntheticReadEvent) err = s.repositories.Campaign.SaveEvent(c, syntheticReadEvent) if err != nil { s.logger.Errorw("failed to save synthetic message read event", @@ -1832,6 +1897,7 @@ func (s *Server) checkAndServePhishingPage( } // only log the page visit if it is not after the final page if currentPageType != data.PAGE_TYPE_DONE { + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent( c, event, @@ -2034,6 +2100,7 @@ func (s *Server) renderDenyPage( Metadata: metadata, } + s.applyEventAnonymization(c, campaign, campaignID, recipientID, event) err = s.repositories.Campaign.SaveEvent(c, event) if err != nil { s.logger.Errorw("failed to save deny page visit event", diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index f68de8b..d7c6f78 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -265,6 +265,54 @@ func (c *Campaign) GetResultStats(g *gin.Context) { c.Response.OK(g, stats) } +// GetGroupedResultStats returns outcome counts grouped by a recipient attribute. +// The group is chosen with the `by` query parameter and is restricted to the +// snapshot columns position and department; small groups are suppressed by the +// service so an individual cannot be re-identified. +func (c *Campaign) GetGroupedResultStats(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + groupBy := g.DefaultQuery("by", "position") + if groupBy != "position" && groupBy != "department" { + c.Response.BadRequest(g) + return + } + stats, err := c.CampaignService.GetGroupedResultStats( + g.Request.Context(), + session, + id, + groupBy, + ) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, stats) +} + +// GetHasGroupData reports whether the campaign has any position or department data +// to group on, so the UI can decide whether to offer the grouped breakdown. +func (c *Campaign) GetHasGroupData(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + has, err := c.CampaignService.HasGroupData(g.Request.Context(), session, id) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{"hasGroupData": has}) +} + // GetCampaignStats get campaign stats // if no company id is provided it gets the global stats including all companies func (c *Campaign) GetStats(g *gin.Context) { diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 2bdb7ec..2730962 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -172,7 +172,7 @@ func (a *activeSession) runScreencast(ctx context.Context, page *rod.Page, t *sc proto.RuntimeEvaluate{Expression: "window.requestAnimationFrame(function(){void 0})"}.Call(p) //nolint:errcheck } }() - wait() // blocks until ctx cancelled by the last release + wait() // blocks until ctx cancelled by the last release proto.PageStopScreencast{}.Call(page) //nolint:errcheck } @@ -728,27 +728,27 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { return } var cmd struct { - Type string `json:"type"` - Event string `json:"event"` - Data json.RawMessage `json:"data"` - Name string `json:"name"` - Action string `json:"action"` - X float64 `json:"x"` - Y float64 `json:"y"` - Button string `json:"button"` - DeltaX float64 `json:"deltaX"` - DeltaY float64 `json:"deltaY"` - Key string `json:"key"` - Code string `json:"code"` - KeyCode int64 `json:"keyCode"` - Modifiers int64 `json:"modifiers"` - CharText string `json:"charText"` - Text string `json:"text"` - Width float64 `json:"width"` - Height float64 `json:"height"` - Dpr float64 `json:"dpr"` - ScreenWidth float64 `json:"screenWidth"` - ScreenHeight float64 `json:"screenHeight"` + Type string `json:"type"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` + Name string `json:"name"` + Action string `json:"action"` + X float64 `json:"x"` + Y float64 `json:"y"` + Button string `json:"button"` + DeltaX float64 `json:"deltaX"` + DeltaY float64 `json:"deltaY"` + Key string `json:"key"` + Code string `json:"code"` + KeyCode int64 `json:"keyCode"` + Modifiers int64 `json:"modifiers"` + CharText string `json:"charText"` + Text string `json:"text"` + Width float64 `json:"width"` + Height float64 `json:"height"` + Dpr float64 `json:"dpr"` + ScreenWidth float64 `json:"screenWidth"` + ScreenHeight float64 `json:"screenHeight"` } if json.Unmarshal(msg, &cmd) != nil { continue @@ -1016,15 +1016,40 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { return } campaignFilter := g.Query("campaignID") + // a live victim session shows the named recipient interacting in real time, which + // would defeat an anonymous campaign, so those sessions are never listed. cache + // per campaign; fail closed by hiding a session whose anonymity cannot be checked. + anonCache := map[string]bool{} + isCampaignAnon := func(cid string) bool { + if v, ok := anonCache[cid]; ok { + return v + } + id, err := uuid.Parse(cid) + if err != nil { + anonCache[cid] = true + return true + } + anon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &id) + if err != nil { + m.Logger.Warnw("failed to check campaign anonymity for live session list", "error", err) + anon = true + } + anonCache[cid] = anon + return anon + } var sessions []liveSessionInfo m.RemoteBrowserService.RangeSessions(func(_ string, val service.LiveSession) bool { sess := val.(*activeSession) if sess.isTest { return true } - if campaignFilter == "" || sess.CampaignID.String() == campaignFilter { - sessions = append(sessions, m.sessionToInfo(sess)) + if campaignFilter != "" && sess.CampaignID.String() != campaignFilter { + return true } + if isCampaignAnon(sess.CampaignID.String()) { + return true + } + sessions = append(sessions, m.sessionToInfo(sess)) return true }) if sessions == nil { @@ -1033,6 +1058,29 @@ func (m *RemoteBrowserController) ListLiveSessions(g *gin.Context) { m.Response.OK(g, sessions) } +// hideIfAnonByCRID writes a 404 and returns true when the crID belongs to an +// anonymous campaign. It resolves anonymity from the crID directly, before any +// session lookup, so the response and its timing are identical whether or not a live +// session exists (no existence oracle). A crID matching no recipient (test runs) +// returns false. Fails closed: on a lookup error it hides with 404. +func (m *RemoteBrowserController) hideIfAnonByCRID(g *gin.Context, crID string) bool { + id, err := uuid.Parse(crID) + if err != nil { + return false + } + isAnon, found, err := m.CampaignRepository.IsAnonymousByCampaignRecipientID(g.Request.Context(), &id) + if err != nil { + m.Logger.Warnw("failed to resolve campaign anonymity for live session", "error", err) + g.AbortWithStatus(http.StatusNotFound) + return true + } + if found && isAnon { + g.AbortWithStatus(http.StatusNotFound) + return true + } + return false +} + // CloseLiveSession terminates an active victim session by cancelling its context. func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { if !m.isEnabled(g) { @@ -1050,6 +1098,13 @@ func (m *RemoteBrowserController) CloseLiveSession(g *gin.Context) { return } crID := g.Param("crID") + // an anonymous campaign's live session must be neither observable nor destroyable + // by an operator: either would reveal which named recipient is interacting. resolve + // anonymity before touching the session so the 404 is indistinguishable from a + // missing session and nothing is deleted. + if m.hideIfAnonByCRID(g, crID) { + return + } val, loaded := m.RemoteBrowserService.LoadAndDeleteSession(crID) if !loaded { g.AbortWithStatus(http.StatusNotFound) @@ -1080,12 +1135,32 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { return } crIDStr := g.Param("crID") + // resolve anonymity before the session lookup so an anonymous campaign returns the + // same 404 with the same timing whether or not a session exists (no existence + // oracle, including via the extra query a post-lookup check would add). + if m.hideIfAnonByCRID(g, crIDStr) { + return + } val, exists := m.RemoteBrowserService.LoadSession(crIDStr) if !exists { g.AbortWithStatus(http.StatusNotFound) return } sess := val.(*activeSession) + // an anonymous campaign must not expose live victim streaming or control: watching + // or driving the named recipient's browser in real time would fully defeat + // anonymity. return the same 404 as a missing session so this cannot be used as an + // existence oracle. fail closed if anonymity cannot be confirmed. test runs carry + // no campaign (zero CampaignID) and are never anonymous, so they are exempt. + if !sess.isTest { + if isAnon, err := m.CampaignRepository.IsAnonymousByID(g.Request.Context(), &sess.CampaignID); err != nil || isAnon { + if err != nil { + m.Logger.Warnw("failed to check campaign anonymity for live stream", "error", err) + } + g.AbortWithStatus(http.StatusNotFound) + return + } + } page := sess.getBrowserPage() if page == nil { // newSession() has not been called yet in the script @@ -1199,7 +1274,7 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) { setActivePage(p) // Foreground the tab so Chrome doesn't throttle its rendering pipeline. proto.TargetActivateTarget{TargetID: p.TargetID}.Call(p.Browser()) //nolint:errcheck - proto.PageBringToFront{}.Call(p) //nolint:errcheck + proto.PageBringToFront{}.Call(p) //nolint:errcheck get, release := sess.scAcquire(p) setFrameSource(get, release) var pageCtx context.Context @@ -1599,7 +1674,7 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { X float64 `json:"x"` Y float64 `json:"y"` Button string `json:"button"` - Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4) + Buttons int64 `json:"buttons"` // bitmask of held buttons (left=1, right=2, middle=4) DeltaX float64 `json:"deltaX"` DeltaY float64 `json:"deltaY"` Key string `json:"key"` @@ -1735,6 +1810,41 @@ func (m *RemoteBrowserController) dispatchInput(page *rod.Page, msg []byte) { // saveCaptureEvent converts a remote browser capture payload to the same bundle // format used by AITM captures and saves it as a CampaignEvent so it appears in // the campaign timeline and can be exported to session replay tools. +// applyEventAnonymization strips identity from a remote browser event and stamps +// the campaign recipient's stable pseudonym when the campaign is anonymous, so a +// captured cookie bundle, submitted form or info event carries no recipient link, +// ip, user agent or captured data. No-op for a normal campaign. +func (m *RemoteBrowserController) applyEventAnonymization( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + event *model.CampaignEvent, +) { + if campaignID == nil || recipientID == nil { + return + } + // decide from the authoritative campaign flag, not the presence of a pseudonym. + // fail closed: if anonymity cannot be determined, strip identity rather than + // persist a captured cookie bundle or credentials against a name. + isAnon, err := m.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + m.Logger.Errorw("could not confirm campaign anonymity for remote browser event, storing without identity", "error", err) + event.Anonymize(nil) + return + } + if !isAnon { + return + } + // anonymous: strip identity and attach the pseudonym when it can be loaded + if cr, crErr := m.CampaignRecipientRepository.GetByCampaignAndRecipientID(ctx, campaignID, recipientID, &repository.CampaignRecipientOption{}); crErr == nil { + if aid, aerr := cr.AnonymizedID.Get(); aerr == nil { + event.Anonymize(&aid) + return + } + } + event.Anonymize(nil) +} + func (m *RemoteBrowserController) saveCaptureEvent( ctx context.Context, req *http.Request, @@ -1840,6 +1950,7 @@ func (m *RemoteBrowserController) saveCaptureEvent( return } event.Data = eventData + m.applyEventAnonymization(ctx, campaignID, recipientID, event) if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } @@ -1883,6 +1994,7 @@ func (m *RemoteBrowserController) saveInfoEvent( IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } + m.applyEventAnonymization(ctx, campaignID, recipientID, event) m.CampaignRepository.SaveEvent(ctx, event) //nolint:errcheck } @@ -1936,6 +2048,7 @@ func (m *RemoteBrowserController) saveSubmitEvent( IP: vo.NewOptionalString64Must(clientIP), UserAgent: vo.NewOptionalString255Must(userAgent), } + m.applyEventAnonymization(ctx, campaignID, recipientID, event) if err := m.CampaignRepository.SaveEvent(ctx, event); err != nil { return } diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 4c4d3d3..a566656 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -55,8 +55,9 @@ type Campaign struct { ConstraintEndTime *string `gorm:"index;"` SaveSubmittedData bool `gorm:"not null;default:false"` SaveBrowserMetadata bool `gorm:"not null;default:false"` - // IsAnonymous is reserved for a campaign mode that records events without - // a recipient relation. nothing reads it and the model rejects true. + // IsAnonymous marks a campaign that records events against a stable pseudonym + // instead of the recipient, never storing identity or submitted data. The + // recipient link is kept only while the campaign is active and severed at close. IsAnonymous bool `gorm:"not null;default:false"` IsTest bool `gorm:"not null;default:false"` Obfuscate bool `gorm:"not null;default:false"` diff --git a/backend/database/campaignRecipient.go b/backend/database/campaignRecipient.go index 389f838..ad78960 100644 --- a/backend/database/campaignRecipient.go +++ b/backend/database/campaignRecipient.go @@ -40,12 +40,21 @@ type CampaignRecipient struct { // self-managed SelfManaged bool `gorm:"not null;default:false;"` - // AnonymizedID is set when the recipient has been anonymized + // AnonymizedID is the stable pseudonym for this recipient in this campaign. + // For an anonymous campaign it is assigned at materialization and stamped on + // every event so events carry no identity. For a normal campaign it is + // assigned at close by the anonymization sweep. AnonymizedID *uuid.UUID `gorm:"type:uuid;"` Recipient *Recipient // A null recipientID means that the data has been anonymized RecipientID *uuid.UUID `gorm:"type:uuid;index;uniqueIndex:idx_campaign_recipients_campaign_id_recipient_id;"` + // Position and Department are snapshotted from the recipient at + // materialization so grouped statistics survive after the recipient relation + // is severed at anonymization. Populated only for anonymous campaigns. + Position string `gorm:";"` + Department string `gorm:";"` + // NotableEventID is the most notable event for this recipient NotableEvent *Event `gorm:"foreignKey:NotableEventID;references:ID"` NotableEventID *uuid.UUID `gorm:"type:uuid;index"` diff --git a/backend/embedded/default_report.html b/backend/embedded/default_report.html index e1def85..9a77cb3 100644 --- a/backend/embedded/default_report.html +++ b/backend/embedded/default_report.html @@ -528,5 +528,50 @@ {{end}} +{{if .Groups}} + +
| {{.GroupsBy}} | +Targets | +Visited | +Submitted | +Reported | +|||
|---|---|---|---|---|---|---|---|
| {{.Group}} | +{{.Total}} | + {{if .Suppressed}} +Hidden (group too small) | + {{else}} +{{if lt .Clicked 0}}—{{else}}{{.Clicked}} ({{.ClickedPercent}}%){{end}} | +{{if lt .Submitted 0}}—{{else}}{{.Submitted}} ({{.SubmittedPercent}}%){{end}} | +{{if lt .Reported 0}}—{{else}}{{.Reported}} ({{.ReportedPercent}}%){{end}} | + {{end}} +||
A dash means the result is not shown, to keep individuals anonymous. Anonymous campaigns show group sizes only; on other campaigns a group too small to show on its own is folded into Other.
+ + +