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 "
- Cookies can be imported using the StorageAceSession Sushi extension.
Import cookie
+ These rows were skipped during CSV parsing (before import) +
++ These recipients failed to import (backend errors) +
++ These rows were skipped during CSV parsing (before import) +
++ These recipients failed to import (backend errors) +
+Read about the difference between whitebox and blackbox phishingphishing simulation and red team phishing