diff --git a/README.md b/README.md index b55702c..f1037e3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ See [production docker compose example](https://github.com/phishingclub/phishing ### Blogs & Resources - [Covert red team phishing with Phishing Club by Phishing Club](http://phishing.club/blog/covert-red-team-phishing-with-phishing-club/) -- [Whitebox vs blackbox phishing by Phishing Club](http://phishing.club/blog/white-box-vs-black-box-phishing/) +- [Phishing Simulation vs Red Team Phishing: Understanding Different Approaches](https://phishing.club/blog/phishing-simulation-vs-red-team-phishing/) ### Students & Learning diff --git a/RELEASE.md b/RELEASE.md index 663ca54..4ac3657 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,20 @@ # Changelog -## [1.22.0] - 2025-12-03 +## [1.23.0] - 2025-12-13 +- Added Session Sushi as recommended handling of captured cookies +- Rename whitebox/blackbox to Simulation/Red Team +- Added support for capturing PUT, PATCH and more content types +- Added status modal after import recipients + +## [1.22.0 / 1.21.1] - 2025-12-03 - Added preview recipients modal to create campaign modal - Added readonly to recipient email on update modal - Added CTRL+s save on create modals that are primarily editors - Improved installer UI width - Added clientside validation to username and password on installer -- Added recipient CSV clientside parsing warnings and errors +- Added recipient CSV clientside parsing warnings and errors - Fix rewrite_urls query param mapping bug in proxies -- Fix CTRL+s save on update now keep position in editor +- Fix CTRL+s save on update now keep position in editor ## [1.21.0] - 2025-11-27 - Added new capture engines for json, formdata and urlencoded diff --git a/backend/app/server.go b/backend/app/server.go index 1c76a1d..9c2901b 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -4,7 +4,10 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" + "encoding/json" "fmt" + "io" "log" "mime" "net" @@ -967,14 +970,14 @@ func (s *Server) checkAndServePhishingPage( nextPageType = data.PAGE_TYPE_DONE } } - isPOSTRequest := c.Request.Method == http.MethodPost - // if this is a POST request, then save the submitted data - if isPOSTRequest { + // support POST, PUT, and PATCH methods for data submission + isDataSubmission := c.Request.Method == http.MethodPost || + c.Request.Method == http.MethodPut || + c.Request.Method == http.MethodPatch + + // if this is a data submission request, then save the submitted data + if isDataSubmission { submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] - err = c.Request.ParseForm() - if err != nil { - return true, fmt.Errorf("failed to parse submitted form data: %s", err) - } newEventID := uuid.New() campaignID := campaign.ID.MustGet() clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) @@ -983,20 +986,156 @@ func (s *Server) checkAndServePhishingPage( // prepare submitted data for webhook var webhookData map[string]interface{} + var rawData string + if campaign.SaveSubmittedData.MustGet() { - submittedData, err = vo.NewOptionalString1MB(c.Request.PostForm.Encode()) + // parse based on content type + contentType := c.Request.Header.Get("Content-Type") + mediaType, _, _ := mime.ParseMediaType(contentType) + + switch { + case strings.Contains(mediaType, "application/json"): + // handle json content type + body, err := io.ReadAll(c.Request.Body) + if err != nil { + return true, fmt.Errorf("failed to read json request body: %s", err) + } + c.Request.Body.Close() + + rawData = string(body) + + // parse json for webhook + webhookData = make(map[string]interface{}) + if err := json.Unmarshal(body, &webhookData); err != nil { + s.logger.Warnw("failed to parse json for webhook", "error", err) + // store raw data if json parsing fails + webhookData = map[string]interface{}{ + "_raw": rawData, + } + } + + case strings.Contains(mediaType, "multipart/form-data"): + // handle multipart form data + err = c.Request.ParseMultipartForm(32 << 20) // 32 MB max + if err != nil { + return true, fmt.Errorf("failed to parse multipart form data: %s", err) + } + + // encode multipart data + if c.Request.MultipartForm != nil { + values := url.Values{} + for key, vals := range c.Request.MultipartForm.Value { + for _, val := range vals { + values.Add(key, val) + } + } + // include file information and content in saved data + for key, files := range c.Request.MultipartForm.File { + for i, file := range files { + prefix := key + if len(files) > 1 { + prefix = fmt.Sprintf("%s[%d]", key, i) + } + values.Add(prefix+"[filename]", file.Filename) + values.Add(prefix+"[size]", fmt.Sprintf("%d", file.Size)) + values.Add(prefix+"[content_type]", file.Header.Get("Content-Type")) + + // read and encode file content + f, err := file.Open() + if err != nil { + s.logger.Warnw("failed to open uploaded file", "filename", file.Filename, "error", err) + continue + } + fileContent, err := io.ReadAll(f) + f.Close() + if err != nil { + s.logger.Warnw("failed to read uploaded file", "filename", file.Filename, "error", err) + continue + } + // encode file content as base64 + encodedContent := base64.StdEncoding.EncodeToString(fileContent) + values.Add(prefix+"[content]", encodedContent) + } + } + rawData = values.Encode() + + // convert to map for webhook + webhookData = make(map[string]interface{}) + for key, vals := range c.Request.MultipartForm.Value { + if len(vals) == 1 { + webhookData[key] = vals[0] + } else { + webhookData[key] = vals + } + } + // add file metadata and content for webhook + fileData := make(map[string]interface{}) + for key, files := range c.Request.MultipartForm.File { + fileList := make([]map[string]interface{}, 0, len(files)) + for _, file := range files { + fileInfo := map[string]interface{}{ + "filename": file.Filename, + "size": file.Size, + } + if contentType := file.Header.Get("Content-Type"); contentType != "" { + fileInfo["content_type"] = contentType + } + + // read and encode file content + f, err := file.Open() + if err != nil { + s.logger.Warnw("failed to open uploaded file for webhook", "filename", file.Filename, "error", err) + fileList = append(fileList, fileInfo) + continue + } + fileContent, err := io.ReadAll(f) + f.Close() + if err != nil { + s.logger.Warnw("failed to read uploaded file for webhook", "filename", file.Filename, "error", err) + fileList = append(fileList, fileInfo) + continue + } + // encode file content as base64 + fileInfo["content"] = base64.StdEncoding.EncodeToString(fileContent) + fileInfo["encoding"] = "base64" + + fileList = append(fileList, fileInfo) + } + if len(fileList) == 1 { + fileData[key] = fileList[0] + } else { + fileData[key] = fileList + } + } + if len(fileData) > 0 { + webhookData["_files"] = fileData + } + } + + default: + // handle url-encoded and other form data + err = c.Request.ParseForm() + if err != nil { + return true, fmt.Errorf("failed to parse submitted form data: %s", err) + } + + rawData = c.Request.PostForm.Encode() + + // convert form data to map for webhook + webhookData = make(map[string]interface{}) + for key, values := range c.Request.PostForm { + if len(values) == 1 { + webhookData[key] = values[0] + } else { + webhookData[key] = values + } + } + } + + submittedData, err = vo.NewOptionalString1MB(rawData) if err != nil { return true, fmt.Errorf("user submitted phishing data too large: %s", err) } - // convert form data to map for webhook - webhookData = make(map[string]interface{}) - for key, values := range c.Request.PostForm { - if len(values) == 1 { - webhookData[key] = values[0] - } else { - webhookData[key] = values - } - } } var event *model.CampaignEvent // only save data if red team flag is set @@ -1078,8 +1217,8 @@ func (s *Server) checkAndServePhishingPage( } } } - // if redirect && POST && final page - if isPOSTRequest { + // if redirect && data submission && final page + if isDataSubmission { if redirectURL, err := cTemplate.AfterLandingPageRedirectURL.Get(); err == nil { if v := redirectURL.String(); len(v) > 0 { // if the current page is landing and there is no after, redirect diff --git a/backend/controller/recipient.go b/backend/controller/recipient.go index 6029197..67d2add 100644 --- a/backend/controller/recipient.go +++ b/backend/controller/recipient.go @@ -490,7 +490,7 @@ func (r *Recipient) Import(g *gin.Context) { if !req.IgnoreOverwriteEmptyFields.IsSpecified() || req.IgnoreOverwriteEmptyFields.IsNull() { req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true) } - _, err := r.RecipientService.Import( + result, err := r.RecipientService.Import( g, session, req.Recipients, @@ -500,7 +500,7 @@ func (r *Recipient) Import(g *gin.Context) { if ok := r.handleErrors(g, err); !ok { return } - r.Response.OK(g, &gin.H{}) + r.Response.OK(g, result) } // DeleteByID deletes a recipient by id diff --git a/backend/controller/recipientGroup.go b/backend/controller/recipientGroup.go index e007858..1b24212 100644 --- a/backend/controller/recipientGroup.go +++ b/backend/controller/recipientGroup.go @@ -221,7 +221,7 @@ func (r *RecipientGroup) Import(g *gin.Context) { req.IgnoreOverwriteEmptyFields = nullable.NewNullableWithValue(true) } - err := r.RecipientGroupService.Import( + result, err := r.RecipientGroupService.Import( g, session, req.Recipients, @@ -232,7 +232,7 @@ func (r *RecipientGroup) Import(g *gin.Context) { if ok := r.handleErrors(g, err); !ok { return } - r.Response.OK(g, &gin.H{}) + r.Response.OK(g, result) } // AddRecipients adds recipients to a recipient group diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 26c09d3..be992fc 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -2092,13 +2092,13 @@ func (m *Proxy) validatePhishingDomainUniquenessForUpdate(ctx context.Context, p func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error { proxyConfig, err := proxy.ProxyConfig.Get() if err != nil { - return fmt.Errorf("failed to get proxy config: %w", err) + return errs.NewCustomError(fmt.Errorf("failed to get proxy config: %w", err)) } // parse complete YAML structure var config ProxyServiceConfigYAML if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil { - return fmt.Errorf("failed to parse proxy config YAML: %w", err) + return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML: %w", err)) } // set default values @@ -2125,7 +2125,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain) + return errs.NewCustomError(fmt.Errorf("'to' field is required for domain mapping '%s'", originalDomain)) } // check if domain already exists (might be from previous failed attempt) @@ -2138,7 +2138,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("invalid phishing domain format %s: %w", domainConfig.To, err)) } existingDomain, err := m.DomainRepository.GetByName(ctx, phishingDomainVO, &repository.DomainOption{}) @@ -2164,7 +2164,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To) + return errs.NewCustomError(fmt.Errorf("domain %s already exists and is incompatible", domainConfig.To)) } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { // database error m.Logger.Errorw("failed to check existing domain", @@ -2194,7 +2194,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("failed to create proxy target domain for %s: %w", domainConfig.To, err)) } domain.ProxyTargetDomain.Set(*proxyTargetDomain) @@ -2227,7 +2227,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("failed to create page content for %s: %w", domainConfig.To, err)) } domain.PageContent.Set(*pageContent) @@ -2240,7 +2240,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("failed to create page not found content for %s: %w", domainConfig.To, err)) } domain.PageNotFoundContent.Set(*pageNotFoundContent) @@ -2253,7 +2253,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("failed to create redirect URL for %s: %w", domainConfig.To, err)) } domain.RedirectURL.Set(*redirectURL) @@ -2270,7 +2270,7 @@ func (m *Proxy) createProxyDomains(ctx context.Context, session *model.Session, ) // rollback created domains on error m.rollbackCreatedDomains(ctx, session, createdDomains) - return fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err) + return errs.NewCustomError(fmt.Errorf("failed to create domain %s: %w", domainConfig.To, err)) } createdDomains = append(createdDomains, domainConfig.To) @@ -2333,7 +2333,7 @@ func (m *Proxy) rollbackCreatedDomains(ctx context.Context, session *model.Sessi func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, proxyID *uuid.UUID, proxy *model.Proxy) error { proxyConfig, err := proxy.ProxyConfig.Get() if err != nil { - return fmt.Errorf("failed to get proxy config for sync: %w", err) + return errs.NewCustomError(fmt.Errorf("failed to get proxy config for sync: %w", err)) } // get current proxy domains by proxy ID @@ -2365,7 +2365,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr // parse complete YAML structure var config ProxyServiceConfigYAML if err := yaml.Unmarshal([]byte(proxyConfig.String()), &config); err != nil { - return fmt.Errorf("failed to parse proxy config YAML for sync: %w", err) + return errs.NewCustomError(fmt.Errorf("failed to parse proxy config YAML for sync: %w", err)) } // set default values @@ -2628,7 +2628,7 @@ func (m *Proxy) syncProxyDomains(ctx context.Context, session *model.Session, pr if errorCount > 0 { errorDetails := strings.Join(syncErrors, "; ") - return fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails) + return errs.NewCustomError(fmt.Errorf("proxy domain sync failed with %d errors: %s", errorCount, errorDetails)) } return nil diff --git a/backend/service/recipient.go b/backend/service/recipient.go index 9b07518..0db3b6e 100644 --- a/backend/service/recipient.go +++ b/backend/service/recipient.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/go-errors/errors" + "github.com/oapi-codegen/nullable" "github.com/google/uuid" "github.com/phishingclub/phishingclub/data" @@ -495,37 +496,84 @@ func (r *Recipient) GetByEmail( // Import imports recipients // if the recipient does not exists, it will be created and added to the group // if the recipient exits, it will be updated and added to the group + +// RecipientImportResult contains the results of importing recipients +type RecipientImportResult struct { + SuccessIDs []*uuid.UUID `json:"successIDs"` + CreatedRecipients []RecipientImportSuccess `json:"createdRecipients"` + UpdatedRecipients []RecipientImportSuccess `json:"updatedRecipients"` + Failures []RecipientImportFailure `json:"failures"` + Summary RecipientImportSummary `json:"summary"` +} + +// RecipientImportSuccess contains information about a successful import +type RecipientImportSuccess struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Index int `json:"index"` +} + +// RecipientImportFailure contains information about a failed import +type RecipientImportFailure struct { + Email string `json:"email"` + Index int `json:"index"` + Reason string `json:"reason"` +} + +// RecipientImportSummary contains summary statistics +type RecipientImportSummary struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Created int `json:"created"` + Updated int `json:"updated"` +} + func (r *Recipient) Import( ctx context.Context, session *model.Session, recipients []*model.Recipient, ignoreOverwriteEmptyFields bool, companyID *uuid.UUID, -) ([]*uuid.UUID, error) { +) (*RecipientImportResult, error) { ae := NewAuditEvent("Recipient.Import", session) - recipientsIDs := []*uuid.UUID{} + result := &RecipientImportResult{ + SuccessIDs: []*uuid.UUID{}, + CreatedRecipients: []RecipientImportSuccess{}, + UpdatedRecipients: []RecipientImportSuccess{}, + Failures: []RecipientImportFailure{}, + Summary: RecipientImportSummary{ + Total: len(recipients), + }, + } // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { r.LogAuthError(err) - return recipientsIDs, errs.Wrap(err) + return result, errs.Wrap(err) } if !isAuthorized { r.AuditLogNotAuthorized(ae) - return recipientsIDs, errs.ErrAuthorizationFailed + return result, errs.ErrAuthorizationFailed } if len(recipients) == 0 { - return recipientsIDs, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") + return result, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") } - // first validate all the entries - for _, recipient := range recipients { - if err := recipient.Validate(); err != nil { - return recipientsIDs, errs.Wrap(err) + + // process each recipient individually, collecting successes and failures + for i, incoming := range recipients { + // validate the recipient + if err := incoming.Validate(); err != nil { + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: getEmailFromRecipient(incoming), + Index: i, + Reason: err.Error(), + }) + result.Summary.Failed++ + continue } - } - // if the recipient does not exist, create it - // if the recipient exists, update it - for _, incoming := range recipients { + // check if the recipient exists email := incoming.Email.MustGet() current, err := r.RecipientRepository.GetByEmail( @@ -534,8 +582,17 @@ func (r *Recipient) Import( "id", "email", "company_id", ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - r.Logger.Debugw("failed to import recipients - failed to get recipient", "error", err) - return recipientsIDs, errs.Wrap(err) + r.Logger.Debugw("failed to import recipient - failed to get recipient", + "error", err, + "email", email.String(), + ) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "database error: " + err.Error(), + }) + result.Summary.Failed++ + continue } if current == nil { // create recipient @@ -548,12 +605,27 @@ func (r *Recipient) Import( incoming, ) if err != nil { - r.Logger.Debugw("failed to import recipients - failed to create recipient", + r.Logger.Debugw("failed to import recipient - failed to create recipient", "error", err, + "email", email.String(), ) - return recipientsIDs, errs.Wrap(err) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "create failed: " + err.Error(), + }) + result.Summary.Failed++ + continue } - recipientsIDs = append(recipientsIDs, recipientID) + result.SuccessIDs = append(result.SuccessIDs, recipientID) + result.Summary.Success++ + result.Summary.Created++ + result.CreatedRecipients = append(result.CreatedRecipients, RecipientImportSuccess{ + Email: email.String(), + FirstName: getStringFromOptional(incoming.FirstName), + LastName: getStringFromOptional(incoming.LastName), + Index: i, + }) } else { // set the companyID to NOT SET, so it is not overwritten if supplied incoming.CompanyID.SetUnspecified() @@ -571,17 +643,47 @@ func (r *Recipient) Import( incoming, ) if err != nil { - r.Logger.Debugw("failed to import recipients - failed to update recipient", + r.Logger.Debugw("failed to import recipient - failed to update recipient", "error", err, + "email", email.String(), ) - return recipientsIDs, errs.Wrap(err) + result.Failures = append(result.Failures, RecipientImportFailure{ + Email: email.String(), + Index: i, + Reason: "update failed: " + err.Error(), + }) + result.Summary.Failed++ + continue } - recipientsIDs = append(recipientsIDs, &recipientID) + result.SuccessIDs = append(result.SuccessIDs, &recipientID) + result.Summary.Success++ + result.Summary.Updated++ + result.UpdatedRecipients = append(result.UpdatedRecipients, RecipientImportSuccess{ + Email: email.String(), + FirstName: getStringFromOptional(incoming.FirstName), + LastName: getStringFromOptional(incoming.LastName), + Index: i, + }) } } r.AuditLogAuthorized(ae) + return result, nil +} - return recipientsIDs, nil +// getEmailFromRecipient safely extracts email from recipient for error reporting +func getEmailFromRecipient(r *model.Recipient) string { + if r.Email.IsSpecified() && !r.Email.IsNull() { + return r.Email.MustGet().String() + } + return "" +} + +// getStringFromOptional safely extracts string from optional field +func getStringFromOptional(field nullable.Nullable[vo.OptionalString127]) string { + if field.IsSpecified() && !field.IsNull() { + return field.MustGet().String() + } + return "" } // Delete deletes a recipient diff --git a/backend/service/recipientGroup.go b/backend/service/recipientGroup.go index 35b9510..5b65264 100644 --- a/backend/service/recipientGroup.go +++ b/backend/service/recipientGroup.go @@ -92,7 +92,7 @@ func (r *RecipientGroup) Import( ignoreOverwriteEmptyFields bool, recipientGroupID *uuid.UUID, companyID *uuid.UUID, -) error { +) (*RecipientImportResult, error) { ae := NewAuditEvent("RecipientGroup.Import", session) ae.Details["recipientGroupId"] = recipientGroupID.String() if companyID != nil { @@ -102,14 +102,14 @@ func (r *RecipientGroup) Import( isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { r.LogAuthError(err) - return err + return nil, err } if !isAuthorized { r.AuditLogNotAuthorized(ae) - return errs.ErrAuthorizationFailed + return nil, errs.ErrAuthorizationFailed } if len(recipients) == 0 { - return validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") + return nil, validate.WrapErrorWithField(errors.New("no recipients"), "add recipients") } // check that the recipient group exists _, err = r.RecipientGroupRepository.GetByID( @@ -119,9 +119,9 @@ func (r *RecipientGroup) Import( ) if err != nil { r.Logger.Debugw("failed to import recipients - failed to get recipient group", "error", err) - return err + return nil, err } - recipientIDs, err := r.RecipientService.Import( + result, err := r.RecipientService.Import( ctx, session, recipients, @@ -129,24 +129,24 @@ func (r *RecipientGroup) Import( companyID, ) if err != nil { - return err + return result, err } // add recpients to group err = r.AddRecipients( ctx, session, recipientGroupID, - recipientIDs, + result.SuccessIDs, ) if err != nil { r.Logger.Debugw("failed to import recipients - failed to add recipients to group", "error", err, ) - return err + return result, err } r.AuditLogAuthorized(ae) - return nil + return result, nil } // GetByID returns a recipient group by ID diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 1f67e95..25678f2 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -132,7 +132,7 @@ let isAnonymizeModalVisible = false; let isSendMessageModalVisible = false; let isSetAsSentModalVisible = false; - let isStorageAceModalVisible = false; + let isSessionSushiModalVisible = false; let storedCookieData = ''; let sendMessageRecipient = null; let setAsSentRecipient = null; @@ -578,13 +578,13 @@ isAnonymizeModalVisible = false; }; - const closeStorageAceModal = () => { - isStorageAceModalVisible = false; + const closeSessionSushiModal = () => { + isSessionSushiModalVisible = false; storedCookieData = ''; }; - const onStorageAceModalOk = () => { - closeStorageAceModal(); + const onSessionSushiModalOk = () => { + closeSessionSushiModal(); }; /** @param {string} eventData @param {string} eventName */ @@ -596,7 +596,7 @@ if (eventName === 'campaign_recipient_submitted_data' && eventData.startsWith('🍪')) { storedCookieData = eventData; - isStorageAceModalVisible = true; + isSessionSushiModalVisible = true; } addToast('Copied to clipboard', 'Success'); @@ -2169,18 +2169,18 @@

Import cookie

- Cookies can be imported using the StorageAceSession Sushi extension.

@@ -2203,7 +2203,7 @@
- + @@ -2214,7 +2214,7 @@ > + + + +
+ {/if} diff --git a/frontend/src/routes/recipient/group/[id]/+page.svelte b/frontend/src/routes/recipient/group/[id]/+page.svelte index 4f6cfa4..8b5bbc5 100644 --- a/frontend/src/routes/recipient/group/[id]/+page.svelte +++ b/frontend/src/routes/recipient/group/[id]/+page.svelte @@ -24,6 +24,7 @@ import { parseCSVToRecipients } from '$lib/utils/csv'; import CheckboxField from '$lib/components/CheckboxField.svelte'; import BigButton from '$lib/components/BigButton.svelte'; + import Button from '$lib/components/Button.svelte'; import FormColumns from '$lib/components/FormColumns.svelte'; import FormColumn from '$lib/components/FormColumn.svelte'; import Table from '$lib/components/table/Table.svelte'; @@ -62,6 +63,8 @@ ignoreOverwriteEmptyFields: true }; let csvSkippedRows = []; + let importResult = null; + let isImportResultModalVisible = false; const tableImportParams = newTableParams({ sortBy: 'email' }); let selectedRecipientsImportPaginatedChunk = []; let isImportModalVisible = false; @@ -264,8 +267,29 @@ importError = res.error; return; } - addToast('Recipients imported to group', 'Success'); + + // store import result for display + importResult = res.data; + + // build summary message + const summary = res.data.summary; + let message = `Import complete: ${summary.success} succeeded (${summary.created} created, ${summary.updated} updated)`; + if (summary.failed > 0) { + message += `, ${summary.failed} failed`; + } + if (csvSkippedRows.length > 0) { + message += `, ${csvSkippedRows.length} skipped in CSV`; + } + + console.log(summary); + addToast( + 'Import finished', + summary.failed > 0 || csvSkippedRows.length > 0 ? 'Warning' : 'Success' + ); + + // show result modal closeImportModal(); + isImportResultModalVisible = true; refreshRecipients(); } catch (err) { addToast('Failed to import recipients to group', 'Error'); @@ -286,6 +310,7 @@ // track skipped rows if (result.skipped && result.skipped.length > 0) { + csvSkippedRows = csvSkippedRows.concat(result.skipped); console.info(`CSV import: ${result.skipped.length} rows skipped`, result.skipped); } @@ -353,6 +378,7 @@ const openImportModal = () => { csvSkippedRows = []; importError = ''; + importResult = null; isImportModalVisible = true; }; @@ -581,4 +607,232 @@ onClick={() => onClickRemoveRecipient(deleteValues.id)} bind:isVisible={isDeleteAlertVisible} > + + {#if isImportResultModalVisible && importResult} + +
+
+ +
+
+

Recipients

+
    +
  • Total: {importResult.summary.total}
  • +
  • Created: {importResult.summary.created}
  • +
  • Updated: {importResult.summary.updated}
  • +
  • Failed: {importResult.summary.failed}
  • + {#if csvSkippedRows.length > 0} +
  • Skipped in CSV: {csvSkippedRows.length}
  • + {/if} +
+
+
+ + +
+
+ {#if importResult.createdRecipients?.length > 0} +
+
+ + + + + Created ({importResult.createdRecipients.length}) + +
+
+ {#each importResult.createdRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.updatedRecipients?.length > 0} +
+
+ + + + + Updated ({importResult.updatedRecipients.length}) + +
+
+ {#each importResult.updatedRecipients as recipient} +
+ + {recipient.email} + + {#if recipient.firstName || recipient.lastName} + + {recipient.firstName || ''} + {recipient.lastName || ''} + + {/if} +
+ {/each} +
+
+
+
+ {/if} + + {#if csvSkippedRows.length > 0} +
+
+ + + + + Skipped in CSV ({csvSkippedRows.length}) + +
+

+ These rows were skipped during CSV parsing (before import) +

+
+ {#each csvSkippedRows as skip} +
+ + Line {skip.line}: {skip.reason} + {#if skip.row?.email} + ({skip.row.email}) + {/if} + +
+ {/each} +
+
+
+
+ {/if} + + {#if importResult.failures?.length > 0} +
+
+ + + + + Import Errors ({importResult.failures.length}) + +
+

+ These recipients failed to import (backend errors) +

+
+ {#each importResult.failures as err} +
+ + {err.email}: {err.reason} + +
+ {/each} +
+
+
+
+ {/if} +
+
+ +
+ +
+
+
+
+ {/if} diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index b1d4914..c8e635a 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -726,10 +726,7 @@ />
- Whitebox Mode - - - For Phishing Simulation + Phishing Simulation
@@ -747,18 +744,15 @@ />
- Blackbox Mode - - - For Red Teaming + Red Team Phishing

Read about the difference between whitebox and blackbox phishingphishing simulation and red team phishing