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} -
+
{#each value as option} {/each}
diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 7df3006..d8ba318 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -130,6 +130,92 @@ } ]; + // webhook data level descriptions: + // - none: only send event type and timestamp - no campaign info, emails, or data + // - basic: send campaign name and event, but exclude emails and submission data + // - full: include all information: emails, captured credentials, and submission data + const webhookDataLevelOptions = [ + { + label: 'Minimum', + value: 'none' + }, + { + label: 'Basic', + value: 'basic' + }, + { + label: 'Full', + value: 'full' + } + ]; + + // webhook event options - 0 means all events will trigger webhook (default) + const webhookEventOptions = [ + 'campaign_recipient_message_sent', + 'campaign_recipient_message_failed', + 'campaign_recipient_message_read', + 'campaign_recipient_submitted_data', + 'campaign_recipient_page_visited', + 'campaign_recipient_before_page_visited', + 'campaign_recipient_after_page_visited', + 'campaign_recipient_evasion_page_visited', + 'campaign_recipient_deny_page_visited', + 'campaign_closed' + ]; + + // human-readable display names for webhook events + const webhookEventDisplayNames = { + campaign_closed: 'Campaign Closed', + campaign_recipient_message_sent: 'Message Sent', + campaign_recipient_message_failed: 'Message Failed', + campaign_recipient_message_read: 'Message Read', + campaign_recipient_submitted_data: 'Submitted Data', + campaign_recipient_evasion_page_visited: 'Evasion Page Visited', + campaign_recipient_before_page_visited: 'Before Page Visited', + campaign_recipient_page_visited: 'Page Visited', + campaign_recipient_after_page_visited: 'After Page Visited', + campaign_recipient_deny_page_visited: 'Deny Page Visited' + }; + + // create display options array with nice names + const webhookEventDisplayOptions = webhookEventOptions.map((event) => ({ + value: event, + label: webhookEventDisplayNames[event] || event + })); + + // map event names to bit positions (must match backend data.WebhookEventToBit) + const webhookEventToBit = { + campaign_closed: 1 << 0, // 1 + campaign_recipient_message_sent: 1 << 1, // 2 + campaign_recipient_message_failed: 1 << 2, // 4 + campaign_recipient_message_read: 1 << 3, // 8 + campaign_recipient_submitted_data: 1 << 4, // 16 + campaign_recipient_evasion_page_visited: 1 << 5, // 32 + campaign_recipient_before_page_visited: 1 << 6, // 64 + campaign_recipient_page_visited: 1 << 7, // 128 + campaign_recipient_after_page_visited: 1 << 8, // 256 + campaign_recipient_deny_page_visited: 1 << 9 // 512 + }; + + const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected + + // convert array of event names to bitwise int + const webhookEventsToBinary = (events) => { + if (!events?.length) return 0; // 0 means all events + return events.reduce((binary, event) => binary | (webhookEventToBit[event] || 0), 0); + }; + + // convert bitwise int to array of event names (internal values, not display names) + const webhookEventsFromBinary = (binary) => { + if (binary === 0) return [...webhookEventOptions]; // 0 means all events, return all + return webhookEventOptions.filter((event) => binary & webhookEventToBit[event]); + }; + + // helper to get display name for an event + const getEventDisplayName = (eventValue) => { + return webhookEventDisplayNames[eventValue] || eventValue; + }; + let deleteValues = { id: null, name: null @@ -292,7 +378,8 @@ obfuscate: false, selectedCount: 0, webhookValue: null, - webhookIncludeData: false, + webhookIncludeData: 'full', + webhookEvents: [...webhookEventOptions], // all events selected by default jitterMin: 0, jitterMax: 0 }; @@ -305,7 +392,6 @@ let isValidatingName = false; let weekDaysAvailable = []; let isDeleteAlertVisible = false; - let webhookIncludeDataCheckbox = null; $: { modalText = getModalText('campaign', modalMode); @@ -675,6 +761,7 @@ constraintEndTime: contraintEndTimeUTC, webhookID: webhookMap.byValueOrNull(formValues.webhookValue), webhookIncludeData: formValues.webhookIncludeData, + webhookEvents: webhookEventsToBinary(formValues.webhookEvents), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); @@ -741,6 +828,7 @@ evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), webhookID: webhookMap.byValueOrNull(formValues.webhookValue), webhookIncludeData: formValues.webhookIncludeData, + webhookEvents: webhookEventsToBinary(formValues.webhookEvents), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null }); @@ -868,7 +956,8 @@ obfuscate: false, selectedCount: 0, webhookValue: null, - webhookIncludeData: false, + webhookIncludeData: 'full', + webhookEvents: [...webhookEventOptions], // all events selected by default jitterMin: 0, jitterMax: 0 }; @@ -976,7 +1065,8 @@ obfuscate: campaign.obfuscate || false, template: templateMap.byKey(campaign.templateID), webhookValue: webhookMap.byKey(campaign.webhookID), - webhookIncludeData: campaign.webhookIncludeData || false + webhookIncludeData: campaign.webhookIncludeData || 'full', + webhookEvents: webhookEventsFromBinary(campaign.webhookEvents || 0) }; if (copyMode) { @@ -1756,13 +1846,57 @@
{#if formValues.webhookValue}
- - Include captured data - + options={webhookDataLevelOptions} + label="Webhook Data Level" + toolTipText="Control what campaign and recipient information is sent to the webhook endpoint" + /> +
+
+
{/if} @@ -2171,10 +2305,16 @@ Webhook: {formValues.webhookValue} - Include Data: - {formValues.webhookIncludeData ? 'Yes' : 'No'}Data Level: + {formValues.webhookIncludeData} + Webhook Events: + + {formValues.webhookEvents.length === webhookEventOptions.length + ? 'All Events' + : formValues.webhookEvents.length + ' selected'} + {/if} {#if formValues.denyPageValue}