mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-13 18:50:47 +02:00
added anonymous campaigns and position/department stats across campaigns and reports.
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
1 parent
5824e52aee
commit
0a08a245fa
23 files changed
+2095
-298
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user