diff --git a/backend/data/events.go b/backend/data/events.go index b1e288d..8da3ca7 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -40,3 +40,35 @@ var Events = []string{ EVENT_CAMPAIGN_RECIPIENT_REPORTED, EVENT_CAMPAIGN_RECIPIENT_CANCELLED, } + +// webhook event bit flags for storing selected events as int +// includes all events that call HandleWebhook (campaign.go + proxy.go) +const ( + WEBHOOK_EVENT_BIT_CAMPAIGN_CLOSED = 1 << 0 // 1 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_SENT = 1 << 1 // 2 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED = 1 << 2 // 4 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_READ = 1 << 3 // 8 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = 1 << 4 // 16 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED = 1 << 5 // 32 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED = 1 << 6 // 64 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_PAGE_VISITED = 1 << 7 // 128 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = 1 << 8 // 256 + WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED = 1 << 9 // 512 +) + +// WEBHOOK_EVENT_ALL_BITS represents all events selected +const WEBHOOK_EVENT_ALL_BITS = 1023 // 2^10 - 1 + +// map event names to their bit positions +var WebhookEventToBit = map[string]int{ + EVENT_CAMPAIGN_CLOSED: WEBHOOK_EVENT_BIT_CAMPAIGN_CLOSED, + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_SENT: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_SENT, + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_FAILED, + EVENT_CAMPAIGN_RECIPIENT_MESSAGE_READ: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_MESSAGE_READ, + EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED, + EVENT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_BEFORE_PAGE_VISITED, + EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_PAGE_VISITED, + EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED, + EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED: WEBHOOK_EVENT_BIT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED, +} diff --git a/backend/database/campaign.go b/backend/database/campaign.go index 3791706..2e2c814 100644 --- a/backend/database/campaign.go +++ b/backend/database/campaign.go @@ -44,7 +44,21 @@ type Campaign struct { IsAnonymous bool `gorm:"not null;default:false"` IsTest bool `gorm:"not null;default:false"` Obfuscate bool `gorm:"not null;default:false"` - WebhookIncludeData bool `gorm:"not null;default:false"` + WebhookIncludeData string `gorm:"not null;default:'full'"` + // WebhookEvents is a binary format storing selected events as bits (10 events) + // 0 = all events (default, backward compatible) + // non-zero = only selected events trigger webhooks + // bit 0 (1): campaign_closed + // bit 1 (2): campaign_recipient_message_sent + // bit 2 (4): campaign_recipient_message_failed + // bit 3 (8): campaign_recipient_message_read + // bit 4 (16): campaign_recipient_submitted_data + // bit 5 (32): campaign_recipient_evasion_page_visited + // bit 6 (64): campaign_recipient_before_page_visited + // bit 7 (128): campaign_recipient_page_visited + // bit 8 (256): campaign_recipient_after_page_visited + // bit 9 (512): campaign_recipient_deny_page_visited + WebhookEvents int `gorm:"not null;default:0"` // has one CampaignTemplateID *uuid.UUID `gorm:"index;type:uuid;"` diff --git a/backend/model/campaign.go b/backend/model/campaign.go index 317b8a9..39c60d9 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/validate" @@ -42,7 +43,8 @@ type Campaign struct { IsAnonymous nullable.Nullable[bool] `json:"isAnonymous"` IsTest nullable.Nullable[bool] `json:"isTest"` Obfuscate nullable.Nullable[bool] `json:"obfuscate"` - WebhookIncludeData nullable.Nullable[bool] `json:"webhookIncludeData"` + WebhookIncludeData nullable.Nullable[string] `json:"webhookIncludeData"` + WebhookEvents nullable.Nullable[int] `json:"webhookEvents"` TemplateID nullable.Nullable[uuid.UUID] `json:"templateID"` Template *CampaignTemplate `json:"template"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` @@ -63,6 +65,53 @@ type Campaign struct { } // Validate checks if the campaign has a valid state +const ( + WebhookDataLevelNone = "none" + WebhookDataLevelBasic = "basic" + WebhookDataLevelFull = "full" +) + +// WebhookEventsToBinary converts array of event names to bitwise int +func WebhookEventsToBinary(events []string) int { + if len(events) == 0 { + return 0 // 0 means all events + } + binary := 0 + for _, event := range events { + if bit, ok := data.WebhookEventToBit[event]; ok { + binary |= bit + } + } + return binary +} + +// WebhookEventsFromBinary converts bitwise int to array of event names +func WebhookEventsFromBinary(binary int) []string { + if binary == 0 { + return []string{} // empty array means all events + } + events := []string{} + for eventName, bit := range data.WebhookEventToBit { + if binary&bit != 0 { + events = append(events, eventName) + } + } + return events +} + +// IsWebhookEventEnabled checks if an event should trigger webhook +func IsWebhookEventEnabled(webhookEvents int, eventName string) bool { + // 0 means all events enabled (backward compatible) + if webhookEvents == 0 { + return true + } + // check if the event bit is set + if bit, ok := data.WebhookEventToBit[eventName]; ok { + return webhookEvents&bit != 0 + } + return false +} + func (c *Campaign) Validate() error { if err := validate.NullableFieldRequired("name", c.Name); err != nil { return err @@ -216,6 +265,32 @@ func (c *Campaign) Validate() error { c.NotableEventID.SetNull() } + // validate webhookIncludeData enum + if c.WebhookIncludeData.IsSpecified() { + if level, err := c.WebhookIncludeData.Get(); err == nil { + if level != WebhookDataLevelNone && + level != WebhookDataLevelBasic && + level != WebhookDataLevelFull { + return validate.WrapErrorWithField( + errors.New("must be 'none', 'basic', or 'full'"), + "webhookIncludeData", + ) + } + } + } + + // validate webhookEvents is non-negative + if c.WebhookEvents.IsSpecified() && !c.WebhookEvents.IsNull() { + if events, err := c.WebhookEvents.Get(); err == nil { + if events < 0 { + return validate.WrapErrorWithField( + errors.New("must be non-negative"), + "webhookEvents", + ) + } + } + } + return nil } @@ -379,11 +454,17 @@ func (c *Campaign) ToDBMap() map[string]any { } } if c.WebhookIncludeData.IsSpecified() { - m["webhook_include_data"] = false + m["webhook_include_data"] = WebhookDataLevelFull if v, err := c.WebhookIncludeData.Get(); err == nil { m["webhook_include_data"] = v } } + if c.WebhookEvents.IsSpecified() { + m["webhook_events"] = 0 + if v, err := c.WebhookEvents.Get(); err == nil { + m["webhook_events"] = v + } + } if c.TemplateID.IsSpecified() { m["campaign_template_id"] = nil if v, err := c.TemplateID.Get(); err == nil { diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index d7be788..1b165de 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -1488,6 +1488,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { isTest := nullable.NewNullableWithValue(row.IsTest) obfuscate := nullable.NewNullableWithValue(row.Obfuscate) webhookIncludeData := nullable.NewNullableWithValue(row.WebhookIncludeData) + webhookEvents := nullable.NewNullableWithValue(row.WebhookEvents) var templateID nullable.Nullable[uuid.UUID] if row.CampaignTemplateID != nil { templateID = nullable.NewNullableWithValue(*row.CampaignTemplateID) @@ -1617,6 +1618,7 @@ func ToCampaign(row *database.Campaign) (*model.Campaign, error) { IsTest: isTest, Obfuscate: obfuscate, WebhookIncludeData: webhookIncludeData, + WebhookEvents: webhookEvents, TemplateID: templateID, Template: template, RecipientGroups: recipientGroups, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 7886dab..07bc7a3 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -77,6 +77,14 @@ func (c *Campaign) Create( if len(campaign.RecipientGroupIDs) == 0 { return nil, validate.WrapErrorWithField(errors.New("no groups provided"), "Recipient Groups") } + // set default webhookIncludeData for backward compatibility + if !campaign.WebhookIncludeData.IsSpecified() { + campaign.WebhookIncludeData.Set(model.WebhookDataLevelFull) + } + // set default webhookEvents (0 means all events) for backward compatibility + if !campaign.WebhookEvents.IsSpecified() { + campaign.WebhookEvents.Set(0) + } // if the schedule type is scheduled, set the start time to start of day and end to the end of the last day if campaign.ConstraintWeekDays.IsSpecified() && !campaign.ConstraintWeekDays.IsNull() { if err := campaign.ValidateSendTimesSet(); err != nil { @@ -1338,6 +1346,12 @@ func (c *Campaign) UpdateByID( if v, err := incoming.WebhookIncludeData.Get(); err == nil { current.WebhookIncludeData.Set(v) } + if v, err := incoming.WebhookEvents.Get(); err == nil { + current.WebhookEvents.Set(v) + } + if v, err := incoming.TemplateID.Get(); err == nil { + current.TemplateID.Set(v) + } if v, err := incoming.SortField.Get(); err == nil { current.SortField.Set(v) } @@ -3149,27 +3163,50 @@ func (c *Campaign) HandleWebhook( return errs.Wrap(err) } - // check if campaign has webhookIncludeData enabled + // check campaign webhook data level campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) if err != nil { return errs.Wrap(err) } - // only include captured data if webhookIncludeData is enabled - var dataToSend map[string]interface{} - if campaign.WebhookIncludeData.MustGet() { - dataToSend = capturedData + // check if this event should trigger webhook notification + webhookEvents := 0 + if events, err := campaign.WebhookEvents.Get(); err == nil { + webhookEvents = events + } + // 0 means all events (backward compatibility) + // otherwise check if the event bit is set + if !model.IsWebhookEventEnabled(webhookEvents, eventName) { + // event not in selected events, skip webhook + return nil + } + + // determine what data to send based on webhookIncludeData level + dataLevel := model.WebhookDataLevelFull + if level, err := campaign.WebhookIncludeData.Get(); err == nil { + dataLevel = level } now := time.Now() webhookReq := WebhookRequest{ - Time: &now, - CampaignName: campaignName, - Event: eventName, - Data: dataToSend, + Time: &now, + Event: eventName, } - if email != nil { - webhookReq.Email = email.String() + + // apply data level filters + switch dataLevel { + case model.WebhookDataLevelNone: + // only time and event - no campaign name, no email, no data + case model.WebhookDataLevelBasic: + // include campaign name but no email or captured data + webhookReq.CampaignName = campaignName + case model.WebhookDataLevelFull: + // include everything + webhookReq.CampaignName = campaignName + webhookReq.Data = capturedData + if email != nil { + webhookReq.Email = email.String() + } } // the webhook is handles as a different go routine // so we don't block the campaign handling thread diff --git a/backend/service/webhook.go b/backend/service/webhook.go index 152d6f1..086de29 100644 --- a/backend/service/webhook.go +++ b/backend/service/webhook.go @@ -386,10 +386,33 @@ func (w *Webhook) Send( return data, nil } +// WebhookRequest represents the payload sent to webhook endpoints. +// webhooks are sent based on the campaign's webhookEvents setting (stored as bitwise int): +// - 0: all events trigger webhooks (default, backward compatible) +// - non-zero: only events with their bit set trigger webhooks +// +// webhook events (10 total - events that call HandleWebhook): +// from campaign.go (4 events): +// - campaign_closed: when a campaign finishes +// - campaign_recipient_message_sent: when an email is successfully sent +// - campaign_recipient_message_failed: when an email fails to send +// - campaign_recipient_message_read: when tracking pixel is loaded +// from proxy.go (6 events): +// - campaign_recipient_submitted_data: when user submits data on phishing page +// - campaign_recipient_evasion_page_visited: when evasion page is visited +// - campaign_recipient_before_page_visited: when before page is visited +// - campaign_recipient_page_visited: when landing page is visited +// - campaign_recipient_after_page_visited: when after page is visited +// - campaign_recipient_deny_page_visited: when deny page is visited +// +// the fields included depend on the campaign's webhookIncludeData setting: +// - "none": only Time and Event are sent (maximum privacy) +// - "basic": Time, Event, and CampaignName are sent (no PII) +// - "full": all fields including Email and Data are sent (complete information) type WebhookRequest struct { Time *time.Time `json:"time"` - CampaignName string `json:"campaignName"` - Email string `json:"email"` + CampaignName string `json:"campaignName,omitempty"` + Email string `json:"email,omitempty"` Event string `json:"event"` Data map[string]interface{} `json:"data,omitempty"` } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 9d0e19d..0ec90ac 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -512,7 +512,8 @@ export class API { * @param {string} campaign.denyPageID uuid * @param {string} campaign.evasionPageID uuid * @param {string} campaign.webhookID uuid - * @param {boolean} [campaign.webhookIncludeData] + * @param {string} [campaign.webhookIncludeData] + * @param {number} [campaign.webhookEvents] * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] @@ -541,6 +542,7 @@ export class API { evasionPageID, webhookID, webhookIncludeData, + webhookEvents, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -568,6 +570,7 @@ export class API { evasionPageID, webhookID, webhookIncludeData, + webhookEvents, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -597,7 +600,8 @@ export class API { * @param {string} campaign.denyPageID uuid * @param {string} campaign.evasionPageID uuid * @param {string} campaign.webhookID uuid - * @param {boolean} [campaign.webhookIncludeData] + * @param {string} [campaign.webhookIncludeData] + * @param {number} [campaign.webhookEvents] * @param {Array} [campaign.constraintWeekDays] * @param {string} [campaign.constraintStartTime] * @param {string} [campaign.constraintEndTime] @@ -626,6 +630,7 @@ export class API { evasionPageID, webhookID, webhookIncludeData, + webhookEvents, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -652,6 +657,7 @@ export class API { evasionPageID, webhookID, webhookIncludeData, + webhookEvents, constraintWeekDays, constraintStartTime, constraintEndTime, diff --git a/frontend/src/lib/components/TextFieldMultiSelect.svelte b/frontend/src/lib/components/TextFieldMultiSelect.svelte index 15b161c..eaf1530 100644 --- a/frontend/src/lib/components/TextFieldMultiSelect.svelte +++ b/frontend/src/lib/components/TextFieldMultiSelect.svelte @@ -413,18 +413,19 @@ {/if} {#if value.length > 0} -