diff --git a/backend/app/administration.go b/backend/app/administration.go index f760a1a6..103e0dc2 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -230,6 +230,10 @@ const ( ROUTE_V1_WEBHOOK = "/api/v1/webhook" ROUTE_V1_WEBHOOK_ID = "/api/v1/webhook/:id" ROUTE_V1_WEBHOOK_ID_TEST = "/api/v1/webhook/:id/test" + + ROUTE_V1_SCRIPT = "/api/v1/script" + ROUTE_V1_SCRIPT_ID = "/api/v1/script/:id" + ROUTE_V1_SCRIPT_TEST = "/api/v1/script/test" // identifiers ROUTE_V1_IDENTIFIER = "/api/v1/identifier" // oauth providers @@ -593,6 +597,13 @@ func setupRoutes( PATCH(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.UpdateByID). DELETE(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.DeleteByID). POST(ROUTE_V1_WEBHOOK_ID_TEST, middleware.SessionHandler, controllers.Webhook.SendTest). + // scripts + GET(ROUTE_V1_SCRIPT, middleware.SessionHandler, controllers.Script.GetAll). + GET(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.GetByID). + POST(ROUTE_V1_SCRIPT, middleware.SessionHandler, controllers.Script.Create). + POST(ROUTE_V1_SCRIPT_TEST, middleware.SessionHandler, controllers.Script.Test). + PATCH(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.UpdateByID). + DELETE(ROUTE_V1_SCRIPT_ID, middleware.SessionHandler, controllers.Script.DeleteByID). // identifiers GET(ROUTE_V1_IDENTIFIER, middleware.SessionHandler, controllers.Identifier.GetAll). // version diff --git a/backend/app/controllers.go b/backend/app/controllers.go index bd0638f0..f7352610 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -9,43 +9,44 @@ import ( // Controllers is a collection of controllers type Controllers struct { - Asset *controller.Asset - Attachment *controller.Attachment - Company *controller.Company - Health *controller.Health - Installer *controller.Install - InitialSetup *controller.InitialSetup - Page *controller.Page - Proxy *controller.Proxy - Log *controller.Log - Option *controller.Option - User *controller.User - Domain *controller.Domain - Recipient *controller.Recipient - RecipientGroup *controller.RecipientGroup - SMTPConfiguration *controller.SMTPConfiguration - Email *controller.Email - CampaignTemplate *controller.CampaignTemplate - Campaign *controller.Campaign - QR *controller.QRGenerator - APISender *controller.APISender - AllowDeny *controller.AllowDeny - GeoIP *controller.GeoIP - Webhook *controller.Webhook - Identifier *controller.Identifier - Version *controller.Version - SSO *controller.SSO - Update *controller.Update - Import *controller.Import - Backup *controller.Backup - IPAllowList *controller.IPAllowList - OAuthProvider *controller.OAuthProvider + Asset *controller.Asset + Attachment *controller.Attachment + Company *controller.Company + Health *controller.Health + Installer *controller.Install + InitialSetup *controller.InitialSetup + Page *controller.Page + Proxy *controller.Proxy + Log *controller.Log + Option *controller.Option + User *controller.User + Domain *controller.Domain + Recipient *controller.Recipient + RecipientGroup *controller.RecipientGroup + SMTPConfiguration *controller.SMTPConfiguration + Email *controller.Email + CampaignTemplate *controller.CampaignTemplate + Campaign *controller.Campaign + QR *controller.QRGenerator + APISender *controller.APISender + AllowDeny *controller.AllowDeny + GeoIP *controller.GeoIP + Webhook *controller.Webhook + Identifier *controller.Identifier + Version *controller.Version + SSO *controller.SSO + Update *controller.Update + Import *controller.Import + Backup *controller.Backup + IPAllowList *controller.IPAllowList + OAuthProvider *controller.OAuthProvider CompanyScimConfig *controller.CompanyScimConfig CompanyReportConfig *controller.CompanyReportConfig Scim *controller.Scim RemoteBrowser *controller.RemoteBrowserController ReportTemplate *controller.ReportTemplate Branding *controller.Branding + Script *controller.Script } // NewControllers creates a collection of controllers @@ -173,6 +174,11 @@ func NewControllers( Common: common, WebhookService: services.Webhook, } + script := &controller.Script{ + Common: common, + ScriptService: services.Script, + Enabled: conf.Script.Enabled, + } identifier := &controller.Identifier{ Common: common, IdentifierService: services.Identifier, @@ -242,42 +248,43 @@ func NewControllers( } return &Controllers{ - Asset: asset, - Attachment: attachment, - Company: company, - Installer: installer, - InitialSetup: initialSetup, - Health: health, - Page: page, - Proxy: proxy, - Log: log, - Option: option, - User: user, - Domain: domain, - Recipient: recipient, - RecipientGroup: recipientGroup, - SMTPConfiguration: smtpConfiguration, - Email: email, - CampaignTemplate: campaignTemplate, - Campaign: campaign, - QR: qr, - APISender: apiSender, - AllowDeny: allowDeny, - GeoIP: geoIP, - Webhook: webhook, - Identifier: identifier, - Version: version, - SSO: sso, - Update: update, - Import: importController, - Backup: backup, - IPAllowList: ipAllowList, - OAuthProvider: oauthProvider, + Asset: asset, + Attachment: attachment, + Company: company, + Installer: installer, + InitialSetup: initialSetup, + Health: health, + Page: page, + Proxy: proxy, + Log: log, + Option: option, + User: user, + Domain: domain, + Recipient: recipient, + RecipientGroup: recipientGroup, + SMTPConfiguration: smtpConfiguration, + Email: email, + CampaignTemplate: campaignTemplate, + Campaign: campaign, + QR: qr, + APISender: apiSender, + AllowDeny: allowDeny, + GeoIP: geoIP, + Webhook: webhook, + Identifier: identifier, + Version: version, + SSO: sso, + Update: update, + Import: importController, + Backup: backup, + IPAllowList: ipAllowList, + OAuthProvider: oauthProvider, CompanyScimConfig: companyScimConfig, CompanyReportConfig: companyReportConfig, Scim: scim, RemoteBrowser: remoteBrowser, ReportTemplate: reportTemplate, Branding: branding, + Script: script, } } diff --git a/backend/app/repositories.go b/backend/app/repositories.go index 2342f72c..d94e5949 100644 --- a/backend/app/repositories.go +++ b/backend/app/repositories.go @@ -36,6 +36,7 @@ type Repositories struct { ReportSendLog *repository.ReportSendLog RemoteBrowser *repository.RemoteBrowser ReportTemplate *repository.ReportTemplate + Script *repository.Script } // NewRepositories creates a collection of repositories @@ -73,5 +74,6 @@ func NewRepositories( ReportSendLog: &repository.ReportSendLog{DB: db}, RemoteBrowser: &repository.RemoteBrowser{DB: db}, ReportTemplate: &repository.ReportTemplate{DB: db}, + Script: &repository.Script{DB: db}, } } diff --git a/backend/app/services.go b/backend/app/services.go index 848c376a..78da8d1c 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -5,6 +5,7 @@ import ( "time" "github.com/caddyserver/certmagic" + "github.com/phishingclub/phishingclub/script" "github.com/phishingclub/phishingclub/service" "go.uber.org/zap" "gorm.io/gorm" @@ -49,6 +50,10 @@ type Services struct { RemoteBrowser *service.RemoteBrowser ReportTemplate *service.ReportTemplate Branding *service.Branding + Script *service.Script + // ScriptDispatcher is the worker pool for event triggered scripts, exposed so + // graceful shutdown can drain in flight jobs. Nil when the feature is disabled. + ScriptDispatcher *script.Dispatcher } // NewServices creates a collection of services @@ -67,10 +72,23 @@ func NewServices( trustedProxies []string, remoteBrowserExecPath string, brandingPath string, + scriptEnabled bool, ) *Services { common := service.Common{ Logger: logger, } + // the script dispatcher only exists when the feature is enabled at the + // server level. When nil the campaign service skips script dispatch. + var scriptDispatcher *script.Dispatcher + if scriptEnabled { + scriptDispatcher = script.NewDispatcher( + logger, + script.DefaultWorkers, + script.DefaultQueueSize, + script.DefaultTimeout, + ) + scriptDispatcher.Start() + } microsoftDeviceCodeService := &service.MicrosoftDeviceCode{ Common: common, MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode, @@ -149,6 +167,21 @@ func NewServices( CampaignRepository: repositories.Campaign, WebhookRepository: repositories.Webhook, } + // a standalone runner powers the editor test panel (capture mode) + var scriptTestRunner *script.Runner + if scriptEnabled { + scriptTestRunner = &script.Runner{ + Logger: logger, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Timeout: 30 * time.Second, + } + } + scriptSvc := &service.Script{ + Common: common, + CampaignRepository: repositories.Campaign, + ScriptRepository: repositories.Script, + TestRunner: scriptTestRunner, + } campaignTemplate := &service.CampaignTemplate{ Common: common, @@ -227,6 +260,8 @@ func NewServices( RecipientGroupRepository: repositories.RecipientGroup, AllowDenyRepository: repositories.AllowDeny, WebhookRepository: repositories.Webhook, + ScriptRepository: repositories.Script, + ScriptDispatcher: scriptDispatcher, CampaignTemplateService: campaignTemplate, DomainService: domain, RecipientService: recipient, @@ -269,6 +304,7 @@ func NewServices( CampaignTemplate: campaignTemplate, AllowDenyService: allowDeny, WebhookService: webhook, + ScriptService: scriptSvc, AssetService: asset, CompanyRepository: repositories.Company, } @@ -379,5 +415,7 @@ func NewServices( RemoteBrowser: remoteBrowser, ReportTemplate: reportTemplate, Branding: brandingService, + Script: scriptSvc, + ScriptDispatcher: scriptDispatcher, } } diff --git a/backend/config.docker.json b/backend/config.docker.json index 7a3adc44..24e0d9f3 100644 --- a/backend/config.docker.json +++ b/backend/config.docker.json @@ -19,5 +19,8 @@ }, "remote_browser": { "enabled": true + }, + "script": { + "enabled": true } } diff --git a/backend/config.example.json b/backend/config.example.json index f0ae40d9..af3a1663 100644 --- a/backend/config.example.json +++ b/backend/config.example.json @@ -24,5 +24,8 @@ "remote_browser": { "enabled": false, "exec_path": "" + }, + "script": { + "enabled": false } } diff --git a/backend/config/config.go b/backend/config/config.go index 91bed65b..8c630c5c 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -80,6 +80,7 @@ type ( IPSecurity IPSecurityConfig RemoteBrowser RemoteBrowserServerConfig + Script ScriptServerConfig Authentication AuthenticationConfig } @@ -92,6 +93,7 @@ type ( Log Log `json:"log"` IPSecurity IPSecurityConfig `json:"ip_security"` RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"` + Script ScriptServerConfig `json:"script"` Authentication AuthenticationConfig `json:"authentication"` } @@ -147,6 +149,16 @@ type ( // its own auto-downloaded Chromium. Set at the server level only. ExecPath string `json:"exec_path"` } + + // ScriptServerConfig holds server side script settings. + ScriptServerConfig struct { + // Enabled controls whether the script feature is available. + // Defaults to false. When false all script endpoints return 404 and + // no scripts run. When true admin authored scripts run in a sandboxed + // engine with outbound network access, so only enable on instances where + // every operator is trusted as a server admin. + Enabled bool `json:"enabled"` + } ) type IPSecurityConfig struct { @@ -506,6 +518,7 @@ func FromDTO(dto *ConfigDTO) (*Config, error) { return nil, err } cfg.RemoteBrowser = dto.RemoteBrowser + cfg.Script = dto.Script cfg.Authentication = dto.Authentication return cfg, nil } @@ -537,6 +550,7 @@ func (c *Config) ToDTO() *ConfigDTO { }, IPSecurity: c.IPSecurity, RemoteBrowser: c.RemoteBrowser, + Script: c.Script, Authentication: c.Authentication, } } diff --git a/backend/config/config_test.go b/backend/config/config_test.go index 1ea94a64..0d4652a6 100644 --- a/backend/config/config_test.go +++ b/backend/config/config_test.go @@ -420,17 +420,30 @@ func TestNewDefaultConfig(t *testing.T) { { name: "happypath", want: &Config{ + tlsHost: "phish.test", tlsCertPath: adminPublicCertPath, tlsKeyPath: adminPrivateCertPath, adminNetAddress: net.TCPAddr{ - IP: net.IPv4(127, 0, 0, 1), + IP: net.IPv4(0, 0, 0, 0), Port: DefaultDevAdministrationPort, }, + phishingHTTPNetAddress: net.TCPAddr{ + IP: net.IPv4(0, 0, 0, 0), + Port: DefaultDevHTTPPhishingPort, + }, + phishingHTTPSNetAddress: net.TCPAddr{ + IP: net.IPv4(0, 0, 0, 0), + Port: DefaultDevHTTPSPhishingPort, + }, database: Database{ Engine: DefaultAdministrationUseSqlite, DSN: DefaultAdministrationDSN, }, fileWriter: &file.FileWriter{}, + IPSecurity: IPSecurityConfig{ + AdminAllowed: []string{}, + TrustedProxies: []string{}, + }, }, }, } diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 441dbed4..1855ba7e 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -199,6 +199,7 @@ func (c *Campaign) GetByID(g *gin.Context) { WithDenyPage: true, WithEvasionPage: true, WithWebhooks: true, + WithScripts: true, }, ) // handle responses diff --git a/backend/controller/script.go b/backend/controller/script.go new file mode 100644 index 00000000..3bf9e919 --- /dev/null +++ b/backend/controller/script.go @@ -0,0 +1,232 @@ +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/script" + "github.com/phishingclub/phishingclub/service" +) + +// ScriptColumnsMap is a map between the frontend and the backend +// so the frontend has user friendly names instead of direct references +// to the database schema +var ScriptColumnsMap = map[string]string{ + "created_at": repository.TableColumn(database.SCRIPT_TABLE, "created_at"), + "updated_at": repository.TableColumn(database.SCRIPT_TABLE, "updated_at"), + "name": repository.TableColumn(database.SCRIPT_TABLE, "name"), +} + +// Script is a controller +type Script struct { + Common + ScriptService *service.Script + + // Enabled mirrors config.ScriptServerConfig.Enabled. When false every + // endpoint returns 404, matching the remote browser gate. The script + // engine runs admin authored scripts with outbound network access, so it is + // only enabled on instances where every operator is trusted as a server admin. + Enabled bool +} + +// isEnabled returns true when the feature is enabled, otherwise it aborts the +// request with 404 so the feature is invisible when turned off. +func (a *Script) isEnabled(g *gin.Context) bool { + if !a.Enabled { + g.AbortWithStatus(http.StatusNotFound) + return false + } + return true +} + +// Create creates a new script +func (a *Script) Create(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + var req model.Script + if ok := a.handleParseRequest(g, &req); !ok { + return + } + // save script + id, err := a.ScriptService.Create(g.Request.Context(), session, &req) + // handle response + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK( + g, + gin.H{ + "id": id.String(), + }, + ) +} + +// GetAll gets the scripts +func (a *Script) GetAll(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + queryArgs, ok := a.handleQueryArgs(g) + if !ok { + return + } + queryArgs.DefaultSortByUpdatedAt() + queryArgs.RemapOrderBy(ScriptColumnsMap) + companyID := companyIDFromRequestQuery(g) + // get + scripts, err := a.ScriptService.GetAll( + g.Request.Context(), + session, + companyID, + &repository.ScriptOption{ + QueryArgs: queryArgs, + }, + ) + // handle response + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK( + g, + scripts, + ) +} + +// GetByID gets a script by id +func (a *Script) GetByID(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + // get + script, err := a.ScriptService.GetByID( + g.Request.Context(), + session, + id, + ) + // handle response + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, script) +} + +// UpdateByID updates a script +func (a *Script) UpdateByID(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + + var req model.Script + if ok := a.handleParseRequest(g, &req); !ok { + return + } + // save + err := a.ScriptService.Update(g.Request.Context(), session, id, &req) + // handle response + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, nil) +} + +// scriptTestRequest is the body for a test run: a script plus a simulated +// campaign event to feed it. +type scriptTestRequest struct { + Script string `json:"script"` + Event struct { + Name string `json:"name"` + CampaignName string `json:"campaignName"` + Email string `json:"email"` + CampaignID string `json:"campaignId"` + RecipientID string `json:"recipientId"` + Data map[string]interface{} `json:"data"` + } `json:"event"` +} + +// Test runs a script against a simulated event and returns what it did, without +// touching any campaign (log/info/emitEvent are captured, not applied). +func (a *Script) Test(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + var req scriptTestRequest + if ok := a.handleParseRequest(g, &req); !ok { + return + } + result, err := a.ScriptService.Test( + g.Request.Context(), + session, + req.Script, + script.EventContext{ + CampaignID: req.Event.CampaignID, + RecipientID: req.Event.RecipientID, + Event: req.Event.Name, + CampaignName: req.Event.CampaignName, + Email: req.Event.Email, + Data: req.Event.Data, + }, + ) + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, result) +} + +// DeleteByID deletes a script by id +func (a *Script) DeleteByID(g *gin.Context) { + if !a.isEnabled(g) { + return + } + session, _, ok := a.handleSession(g) + if !ok { + return + } + // parse request + id, ok := a.handleParseIDParam(g) + if !ok { + return + } + // delete + err := a.ScriptService.DeleteByID(g, session, id) + // handle response + if ok := a.handleErrors(g, err); !ok { + return + } + a.Response.OK(g, nil) +} diff --git a/backend/database/campaignScript.go b/backend/database/campaignScript.go new file mode 100644 index 00000000..d846ce8f --- /dev/null +++ b/backend/database/campaignScript.go @@ -0,0 +1,35 @@ +package database + +import ( + "github.com/google/uuid" +) + +const ( + CAMPAIGN_SCRIPT_TABLE = "campaign_scripts" +) + +// CampaignScript is a gorm data model. +// It is a junction table for the campaign to script many to many relationship +// and stores the per script configuration (events and data level). +// It mirrors CampaignWebhook so scripts are configured on a campaign the +// same way webhooks are. +type CampaignScript struct { + CampaignID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_script;primaryKey;"` + Campaign *Campaign + + ScriptID *uuid.UUID `gorm:"not null;index;type:uuid;uniqueIndex:idx_campaign_script;primaryKey;"` + Script *Script + + // scriptincludedata is the data level handed to the script. + // values: "none", "basic", "full" + ScriptIncludeData string `gorm:"not null;default:'full'"` + + // scriptevents is a binary format storing selected events as bits. + // 0 = all events (default). Uses the same bit map as webhooks + // (data.WebhookEventToBit) so the two features subscribe identically. + ScriptEvents int `gorm:"not null;default:0"` +} + +func (CampaignScript) TableName() string { + return CAMPAIGN_SCRIPT_TABLE +} diff --git a/backend/database/script.go b/backend/database/script.go new file mode 100644 index 00000000..19649a3c --- /dev/null +++ b/backend/database/script.go @@ -0,0 +1,34 @@ +package database + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + SCRIPT_TABLE = "scripts" +) + +// Script is a gorm data model for scripts. +// An script holds a JavaScript program that runs when a subscribed +// campaign event fires. It is the scripting counterpart to a webhook. +type Script struct { + ID *uuid.UUID `gorm:"primary_key;not null;unique;type:uuid"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;index;"` + CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_scripts_unique_name_and_company_id;type:uuid"` + Name string `gorm:"not null;uniqueIndex:idx_scripts_unique_name_and_company_id;"` + Script string `gorm:"not null;"` +} + +func (e *Script) Migrate(db *gorm.DB) error { + // SQLITE + // ensure name + company id is unique + return UniqueIndexNameAndNullCompanyID(db, SCRIPT_TABLE) +} + +func (Script) TableName() string { + return SCRIPT_TABLE +} diff --git a/backend/main.go b/backend/main.go index 6b4a0d84..a8725840 100644 --- a/backend/main.go +++ b/backend/main.go @@ -251,6 +251,7 @@ func main() { conf.IPSecurity.TrustedProxies, conf.RemoteBrowser.ExecPath, brandingPath, + conf.Script.Enabled, ) // get entra-id options and setup msal client ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) @@ -538,6 +539,24 @@ func main() { logger.Errorw("HTTPS server shutdown error", "error", err) } + // Drain in flight scripts before closing the database, since their write + // back funnel needs the DB. Nil when the feature is disabled. Bounded so a + // full queue cannot overrun the graceful shutdown window: in flight jobs + // finish, jobs still queued past the window are abandoned (best effort). + if services.ScriptDispatcher != nil { + logger.Debugf("Stopping script dispatcher") + drained := make(chan struct{}) + go func() { + services.ScriptDispatcher.Stop() + close(drained) + }() + select { + case <-drained: + case <-time.After(20 * time.Second): + logger.Warnw("script dispatcher drain exceeded its window, abandoning queued jobs") + } + } + // Close database connections sqlDB, err := db.DB() if err != nil { diff --git a/backend/model/campaign.go b/backend/model/campaign.go index cce57d47..2b416b2c 100644 --- a/backend/model/campaign.go +++ b/backend/model/campaign.go @@ -76,6 +76,10 @@ type Campaign struct { // webhooks configuration with per-webhook settings Webhooks nullable.Nullable[[]*CampaignWebhook] `json:"webhooks,omitempty"` + // scripts configuration with per-script settings. + // attached to a campaign the same way webhooks are. + Scripts nullable.Nullable[[]*CampaignScript] `json:"scripts,omitempty"` + // snapshotted from the campaign template while the campaign holds no // recipients. read only, see ToDBMap. LureURLMode nullable.Nullable[string] `json:"lureURLMode"` diff --git a/backend/model/campaignScript.go b/backend/model/campaignScript.go new file mode 100644 index 00000000..b997eabe --- /dev/null +++ b/backend/model/campaignScript.go @@ -0,0 +1,72 @@ +package model + +import ( + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/validate" +) + +// CampaignScript represents a script configuration for a campaign. +// It allows per script event and data level settings and mirrors +// CampaignWebhook so scripts attach to a campaign exactly like webhooks. +type CampaignScript struct { + ScriptID nullable.Nullable[uuid.UUID] `json:"scriptID"` + ScriptIncludeData nullable.Nullable[string] `json:"scriptIncludeData"` + ScriptEvents nullable.Nullable[int] `json:"scriptEvents"` +} + +// Validate checks if the campaign script has valid configuration +func (ca *CampaignScript) Validate() error { + if err := validate.NullableFieldRequired("scriptID", ca.ScriptID); err != nil { + return err + } + + // validate scriptincludedata is one of the allowed values + if ca.ScriptIncludeData.IsSpecified() && !ca.ScriptIncludeData.IsNull() { + dataLevel := ca.ScriptIncludeData.MustGet() + if dataLevel != WebhookDataLevelNone && + dataLevel != WebhookDataLevelBasic && + dataLevel != WebhookDataLevelFull { + return validate.WrapErrorWithField( + errors.New("must be 'none', 'basic', or 'full'"), + "scriptIncludeData", + ) + } + } + + // validate scriptevents is a valid binary value + if ca.ScriptEvents.IsSpecified() && !ca.ScriptEvents.IsNull() { + events := ca.ScriptEvents.MustGet() + // check if any invalid bits are set, the valid ones are the mapped events + maxValidBits := 0 + for _, bit := range data.WebhookEventToBit { + maxValidBits |= bit + } + if events < 0 || (events > 0 && events&^maxValidBits != 0) { + return validate.WrapErrorWithField( + errors.New("invalid script events binary value"), + "scriptEvents", + ) + } + } + + return nil +} + +// GetScriptIncludeDataOrDefault returns the data level or default to "full" +func (ca *CampaignScript) GetScriptIncludeDataOrDefault() string { + if ca.ScriptIncludeData.IsSpecified() && !ca.ScriptIncludeData.IsNull() { + return ca.ScriptIncludeData.MustGet() + } + return WebhookDataLevelFull +} + +// GetScriptEventsOrDefault returns the script events binary or default to 0 (all events) +func (ca *CampaignScript) GetScriptEventsOrDefault() int { + if ca.ScriptEvents.IsSpecified() && !ca.ScriptEvents.IsNull() { + return ca.ScriptEvents.MustGet() + } + return 0 // 0 means all events +} diff --git a/backend/model/script.go b/backend/model/script.go new file mode 100644 index 00000000..6a25a87a --- /dev/null +++ b/backend/model/script.go @@ -0,0 +1,56 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/validate" + "github.com/phishingclub/phishingclub/vo" +) + +// Script is a gorm data model for scripts. +// The Script is a JavaScript program run in a sandboxed engine when a +// subscribed campaign event fires. +type Script struct { + ID nullable.Nullable[uuid.UUID] `json:"id"` + CreatedAt *time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` + Name nullable.Nullable[vo.String127] `json:"name"` + Script nullable.Nullable[vo.String1MB] `json:"script"` +} + +// Validate runs the validations for this struct +func (a *Script) Validate() error { + if err := validate.NullableFieldRequired("name", a.Name); err != nil { + return err + } + if err := validate.NullableFieldRequired("script", a.Script); err != nil { + return err + } + return nil +} + +// ToDBMap converts the fields that can be stored or updated to a map +// if the value is nullable and not set, it is not included +// if the value is nullable and set, it is included, if it is null, it is set to nil +func (a *Script) ToDBMap() map[string]any { + m := map[string]any{} + if a.Name.IsSpecified() { + m["name"] = nil + if name, err := a.Name.Get(); err == nil { + m["name"] = name.String() + } + } + if a.Script.IsSpecified() { + m["script"] = nil + if script, err := a.Script.Get(); err == nil { + m["script"] = script.String() + } + } + if v, err := a.CompanyID.Get(); err == nil { + m["company_id"] = v.String() + } + return m +} diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 3885acca..d145eee7 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -63,6 +63,7 @@ type CampaignOption struct { WithDenyPage bool WithEvasionPage bool WithWebhooks bool + WithScripts bool IncludeTestCampaigns bool } @@ -482,6 +483,101 @@ func (r *Campaign) RemoveWebhookFromJunctionByWebhookID( return nil } +// AddScripts adds scripts to a campaign with per script configuration +func (r *Campaign) AddScripts( + ctx context.Context, + campaignID *uuid.UUID, + scripts []*model.CampaignScript, +) error { + batch := []database.CampaignScript{} + // deduplicate by script id to prevent unique constraint violations + seen := map[string]struct{}{} + for _, ca := range scripts { + scriptID := ca.ScriptID.MustGet() + key := scriptID.String() + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + batch = append(batch, database.CampaignScript{ + CampaignID: campaignID, + ScriptID: &scriptID, + ScriptIncludeData: ca.GetScriptIncludeDataOrDefault(), + ScriptEvents: ca.GetScriptEventsOrDefault(), + }) + } + if len(batch) == 0 { + return nil + } + res := r.DB.Create(&batch) + + if res.Error != nil { + return res.Error + } + return nil +} + +// RemoveScriptsByCampaignID removes all scripts from a campaign +func (r *Campaign) RemoveScriptsByCampaignID( + ctx context.Context, + campaignID *uuid.UUID, +) error { + res := r.DB. + Where("campaign_id = ?", campaignID). + Delete(&database.CampaignScript{}) + + if res.Error != nil { + return res.Error + } + return nil +} + +// GetCampaignScripts fetches script configurations for a campaign from the junction table +func (r *Campaign) GetCampaignScripts( + ctx context.Context, + campaignID *uuid.UUID, +) ([]*model.CampaignScript, error) { + var rows []database.CampaignScript + res := r.DB. + Where("campaign_id = ?", campaignID.String()). + Find(&rows) + + if res.Error != nil { + return nil, res.Error + } + + scripts := []*model.CampaignScript{} + for _, row := range rows { + if row.ScriptID == nil { + continue + } + ca := &model.CampaignScript{ + ScriptID: nullable.NewNullableWithValue(*row.ScriptID), + ScriptIncludeData: nullable.NewNullableWithValue(row.ScriptIncludeData), + ScriptEvents: nullable.NewNullableWithValue(row.ScriptEvents), + } + scripts = append(scripts, ca) + } + + return scripts, nil +} + +// RemoveScriptFromJunctionByScriptID removes all campaign_scripts rows for a given script id +// must be called before deleting a script to avoid orphaned junction rows +func (r *Campaign) RemoveScriptFromJunctionByScriptID( + ctx context.Context, + scriptID *uuid.UUID, +) error { + res := r.DB. + Where("script_id = ?", scriptID.String()). + Delete(&database.CampaignScript{}) + + if res.Error != nil { + return res.Error + } + return nil +} + // RemoveWebhookByCampaignIDs removes the webhook from campaigns by ids func (r *Campaign) RemoveWebhookByCampaignIDs( ctx context.Context, @@ -1297,6 +1393,13 @@ func (r *Campaign) GetByID( } campaign.Webhooks = nullable.NewNullableWithValue(webhooks) } + if options.WithScripts { + scripts, err := r.GetCampaignScripts(ctx, id) + if err != nil { + return nil, err + } + campaign.Scripts = nullable.NewNullableWithValue(scripts) + } return campaign, nil } diff --git a/backend/repository/script.go b/backend/repository/script.go new file mode 100644 index 00000000..6b47a5a1 --- /dev/null +++ b/backend/repository/script.go @@ -0,0 +1,208 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +var scriptAllowedColumns = assignTableToColumns(database.SCRIPT_TABLE, []string{ + "created_at", + "updated_at", + "name", +}) + +type ScriptOption struct { + *vo.QueryArgs +} + +type Script struct { + DB *gorm.DB +} + +// Insert inserts a new script +func (r *Script) Insert( + ctx context.Context, + script *model.Script, +) (*uuid.UUID, error) { + id := uuid.New() + row := script.ToDBMap() + row["id"] = id + AddTimestamps(row) + + res := r.DB. + Model(&database.Script{}). + Create(row) + + if res.Error != nil { + return nil, res.Error + } + return &id, nil +} + +// GetAll gets all scripts +func (r *Script) GetAll( + ctx context.Context, + companyID *uuid.UUID, + options *ScriptOption, +) (*model.Result[model.Script], error) { + result := model.NewEmptyResult[model.Script]() + db := withCompanyIncludingNullContext(r.DB, companyID, database.SCRIPT_TABLE) + db, err := useQuery(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + var rows []*database.Script + res := db. + Find(&rows) + + if res.Error != nil { + return result, res.Error + } + + hasNextPage, err := useHasNextPage(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...) + if err != nil { + return result, errs.Wrap(err) + } + result.HasNextPage = hasNextPage + + for _, row := range rows { + result.Rows = append(result.Rows, ToScript(row)) + } + return result, nil +} + +// GetAllByCompanyID gets all scripts for a company +func (r *Script) GetAllByCompanyID( + ctx context.Context, + companyID *uuid.UUID, + options *ScriptOption, +) ([]*model.Script, error) { + out := []*model.Script{} + db := whereCompany(r.DB, database.SCRIPT_TABLE, companyID) + db, err := useQuery(db, database.SCRIPT_TABLE, options.QueryArgs, scriptAllowedColumns...) + if err != nil { + return out, errs.Wrap(err) + } + var rows []*database.Script + res := db. + Find(&rows) + + if res.Error != nil { + return out, res.Error + } + for _, row := range rows { + out = append(out, ToScript(row)) + } + return out, nil +} + +// GetByID gets a script by id +func (r *Script) GetByID( + ctx context.Context, + id *uuid.UUID, +) (*model.Script, error) { + var row database.Script + res := r.DB. + Where( + fmt.Sprintf( + "%s = ?", + TableColumnID(database.SCRIPT_TABLE), + ), + id.String(), + ). + First(&row) + + if res.Error != nil { + return nil, res.Error + } + + return ToScript(&row), nil +} + +// GetByIDs fetches multiple scripts by their IDs in a single query +func (r *Script) GetByIDs( + ctx context.Context, + ids []*uuid.UUID, +) ([]*model.Script, error) { + out := []*model.Script{} + if len(ids) == 0 { + return out, nil + } + idStrings := make([]string, 0, len(ids)) + for _, id := range ids { + idStrings = append(idStrings, id.String()) + } + var rows []*database.Script + res := r.DB. + Where( + fmt.Sprintf("%s IN ?", TableColumnID(database.SCRIPT_TABLE)), + idStrings, + ). + Find(&rows) + + if res.Error != nil { + return nil, res.Error + } + for _, row := range rows { + out = append(out, ToScript(row)) + } + return out, nil +} + +// UpdateByID updates a script by id +func (r *Script) UpdateByID( + ctx context.Context, + id *uuid.UUID, + script *model.Script, +) error { + row := script.ToDBMap() + AddUpdatedAt(row) + + res := r.DB. + Model(&database.Script{}). + Where("id = ?", id). + Updates(row) + + return res.Error +} + +// DeleteByID deletes a script by id +func (r *Script) DeleteByID( + ctx context.Context, + id *uuid.UUID, +) error { + res := r.DB. + Where("id = ?", id). + Delete(&database.Script{}) + + return res.Error +} + +func ToScript( + row *database.Script, +) *model.Script { + id := nullable.NewNullableWithValue(*row.ID) + companyID := nullable.NewNullNullable[uuid.UUID]() + if row.CompanyID != nil { + companyID.Set(*row.CompanyID) + } + name := nullable.NewNullableWithValue(*vo.NewString127Must(row.Name)) + script := nullable.NewNullableWithValue(*vo.NewString1MBMust(row.Script)) + + return &model.Script{ + ID: id, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + CompanyID: companyID, + Name: name, + Script: script, + } +} diff --git a/backend/script/codec.go b/backend/script/codec.go new file mode 100644 index 00000000..91b5e2a2 --- /dev/null +++ b/backend/script/codec.go @@ -0,0 +1,299 @@ +package script + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base32" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "hash" + "html" + "io" + "net/url" + "strings" + + "github.com/dop251/goja" + "github.com/google/uuid" +) + +// registerCodec installs the encode/decode/hash/hmac/jwt/random helpers on the +// VM. These are the data-transform toolkit for scripts: base and binary +// encodings, hashing and keyed HMAC signing, JWT inspection, and secure random +// (nonces, PKCE, OAuth state). Output encoding for hash/hmac/random is selectable +// as "hex" (default), "base64", "base64url" or "base32". +func registerCodec(vm *goja.Runtime) { + vm.Set("encode", map[string]interface{}{ + // text and url + "base64": func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }, + "base64url": func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }, + "base32": func(s string) string { return base32.StdEncoding.EncodeToString([]byte(s)) }, + "hex": func(s string) string { return hex.EncodeToString([]byte(s)) }, + "url": func(s string) string { return url.QueryEscape(s) }, + "urlPath": func(s string) string { return url.PathEscape(s) }, + "html": func(s string) string { return html.EscapeString(s) }, + "json": func(call goja.FunctionCall) goja.Value { + b, err := json.Marshal(call.Argument(0).Export()) + if err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(string(b)) + }, + // object -> application/x-www-form-urlencoded + "form": func(call goja.FunctionCall) goja.Value { + return vm.ToValue(formEncode(call.Argument(0))) + }, + // compression, output as base64 so it survives as a JS string + "gzip": func(s string) (string, error) { return deflateEncode(s, true) }, + "deflate": func(s string) (string, error) { + return deflateEncode(s, false) + }, + }) + + vm.Set("decode", map[string]interface{}{ + "base64": func(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + return string(b), err + }, + "base64url": func(s string) (string, error) { + b, err := decodeBase64URL(s) + return string(b), err + }, + "base32": func(s string) (string, error) { + b, err := base32.StdEncoding.DecodeString(s) + return string(b), err + }, + "hex": func(s string) (string, error) { + b, err := hex.DecodeString(s) + return string(b), err + }, + "url": func(s string) (string, error) { return url.QueryUnescape(s) }, + "urlPath": func(s string) (string, error) { return url.PathUnescape(s) }, + "html": func(s string) string { return html.UnescapeString(s) }, + "json": func(s string) (interface{}, error) { + var v interface{} + err := json.Unmarshal([]byte(s), &v) + return v, err + }, + // application/x-www-form-urlencoded -> object + "form": func(s string) (map[string]interface{}, error) { return formDecode(s) }, + "gzip": func(s string) (string, error) { return inflateDecode(s, true) }, + "deflate": func(s string) (string, error) { + return inflateDecode(s, false) + }, + }) + + // hashing: hash.sha256(input, enc?) -> string + vm.Set("hash", map[string]interface{}{ + "md5": hashFn(vm, func() hash.Hash { return md5.New() }), + "sha1": hashFn(vm, func() hash.Hash { return sha1.New() }), + "sha256": hashFn(vm, func() hash.Hash { return sha256.New() }), + "sha384": hashFn(vm, func() hash.Hash { return sha512.New384() }), + "sha512": hashFn(vm, func() hash.Hash { return sha512.New() }), + }) + + // keyed HMAC: hmac.sha256(key, message, enc?) -> string + vm.Set("hmac", map[string]interface{}{ + "sha1": hmacFn(vm, func() hash.Hash { return sha1.New() }), + "sha256": hmacFn(vm, func() hash.Hash { return sha256.New() }), + "sha384": hmacFn(vm, func() hash.Hash { return sha512.New384() }), + "sha512": hmacFn(vm, func() hash.Hash { return sha512.New() }), + }) + + // jwt.decode(token) -> { header, payload, signature } (NOT verified) + vm.Set("jwt", map[string]interface{}{ + "decode": func(token string) (map[string]interface{}, error) { return jwtDecode(token) }, + }) + + // random: nonces, PKCE verifiers, OAuth state + vm.Set("random", map[string]interface{}{ + "bytes": func(call goja.FunctionCall) goja.Value { + n := int(call.Argument(0).ToInteger()) + if n <= 0 || n > 4096 { + panic(vm.NewTypeError("random.bytes: length must be 1..4096")) + } + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(vm.NewGoError(err)) + } + return vm.ToValue(codecOutput(b, argString(call, 1, "hex"))) + }, + "uuid": func() string { return uuid.NewString() }, + }) +} + +// codecOutput renders bytes in the requested encoding, defaulting to hex. +func codecOutput(b []byte, enc string) string { + switch strings.ToLower(enc) { + case "base64": + return base64.StdEncoding.EncodeToString(b) + case "base64url": + return base64.RawURLEncoding.EncodeToString(b) + case "base32": + return base32.StdEncoding.EncodeToString(b) + default: + return hex.EncodeToString(b) + } +} + +// argString reads an optional string argument, falling back to def. +func argString(call goja.FunctionCall, i int, def string) string { + if len(call.Arguments) > i { + v := call.Argument(i) + if !goja.IsUndefined(v) && !goja.IsNull(v) { + return v.String() + } + } + return def +} + +func hashFn(vm *goja.Runtime, newH func() hash.Hash) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + h := newH() + h.Write([]byte(call.Argument(0).String())) + return vm.ToValue(codecOutput(h.Sum(nil), argString(call, 1, "hex"))) + } +} + +func hmacFn(vm *goja.Runtime, newH func() hash.Hash) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + m := hmac.New(newH, []byte(call.Argument(0).String())) + m.Write([]byte(call.Argument(1).String())) + return vm.ToValue(codecOutput(m.Sum(nil), argString(call, 2, "hex"))) + } +} + +// decodeBase64URL decodes url-safe base64 with or without padding. +func decodeBase64URL(s string) ([]byte, error) { + s = strings.TrimRight(s, "=") + return base64.RawURLEncoding.DecodeString(s) +} + +func deflateEncode(s string, gz bool) (string, error) { + var buf bytes.Buffer + var w io.WriteCloser + var err error + if gz { + w = gzip.NewWriter(&buf) + } else { + w, err = flate.NewWriter(&buf, flate.DefaultCompression) + if err != nil { + return "", err + } + } + if _, err := w.Write([]byte(s)); err != nil { + return "", err + } + if err := w.Close(); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +func inflateDecode(b64 string, gz bool) (string, error) { + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", err + } + var r io.ReadCloser + if gz { + r, err = gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return "", err + } + } else { + r = flate.NewReader(bytes.NewReader(data)) + } + defer r.Close() + out, err := io.ReadAll(r) + if err != nil { + return "", err + } + return string(out), nil +} + +// formEncode turns an object into an application/x-www-form-urlencoded string. +func formEncode(val goja.Value) string { + values := url.Values{} + if m, ok := val.Export().(map[string]interface{}); ok { + for k, v := range m { + switch vv := v.(type) { + case []interface{}: + for _, item := range vv { + values.Add(k, fmt.Sprint(item)) + } + default: + values.Set(k, fmt.Sprint(v)) + } + } + } + return values.Encode() +} + +// formDecode parses a form-encoded string into an object. A key with one value +// becomes a string, a repeated key becomes an array. +func formDecode(s string) (map[string]interface{}, error) { + vals, err := url.ParseQuery(s) + if err != nil { + return nil, err + } + out := map[string]interface{}{} + for k, v := range vals { + if len(v) == 1 { + out[k] = v[0] + continue + } + arr := make([]interface{}, len(v)) + for i, x := range v { + arr[i] = x + } + out[k] = arr + } + return out, nil +} + +// jwtDecode splits a JWT and returns its header and payload as objects plus the +// raw signature. It does NOT verify the signature: it is for inspecting tokens. +func jwtDecode(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid jwt: expected header.payload.signature") + } + header, err := decodeB64URLJSON(parts[0]) + if err != nil { + return nil, fmt.Errorf("jwt header: %w", err) + } + payload, err := decodeB64URLJSON(parts[1]) + if err != nil { + return nil, fmt.Errorf("jwt payload: %w", err) + } + sig := "" + if len(parts) >= 3 { + sig = parts[2] + } + return map[string]interface{}{ + "header": header, + "payload": payload, + "signature": sig, + }, nil +} + +func decodeB64URLJSON(s string) (interface{}, error) { + b, err := decodeBase64URL(s) + if err != nil { + return nil, err + } + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return nil, err + } + return v, nil +} diff --git a/backend/script/script.go b/backend/script/script.go new file mode 100644 index 00000000..d609eb94 --- /dev/null +++ b/backend/script/script.go @@ -0,0 +1,579 @@ +// Package script runs admin authored JavaScript when a subscribed campaign +// event fires. It is the scripting counterpart to webhooks: a script receives +// the event payload, can call out over HTTP, transform data, and create a new +// campaign event in the same context. +// +// Trust model: scripts run only when the feature is enabled at the server level +// (config.Script.Enabled), which is the operator acknowledgement that every +// admin is trusted as a server admin. The engine is a goja VM with a small, +// explicit binding set (no require, no filesystem, no process access). Scripts +// are bounded by a wall clock timeout, run one per fresh VM, and execute on a +// bounded worker pool so a burst of events cannot exhaust memory or goroutines. +// +// All bindings are synchronous: http.fetch blocks and returns the response. +// Each run owns its VM on its own worker goroutine, so a blocking call stalls +// only that run. +package script + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/dop251/goja" + "github.com/phishingclub/phishingclub/data" + "go.uber.org/zap" + "golang.org/x/net/proxy" +) + +const ( + // DefaultTimeout is the wall clock budget for a single script run. + DefaultTimeout = 10 * time.Second + // DefaultWorkers is the number of scripts that can run at once. + DefaultWorkers = 4 + // DefaultQueueSize is how many pending jobs are buffered before new ones + // are dropped. + DefaultQueueSize = 256 + + // maxResponseBytes caps a fetch response body so a large download cannot + // exhaust memory. + maxResponseBytes = 5 << 20 // 5 MB + // defaultFetchTimeout and maxFetchTimeout bound a single outbound request. + defaultFetchTimeout = 10 * time.Second + maxFetchTimeout = 30 * time.Second + + // maxCallStackSize bounds JS recursion depth so a runaway recursive script + // throws a StackOverflowError instead of exhausting the Go stack. + maxCallStackSize = 2000 +) + +// scriptStopError is thrown by the stop() binding to end a run cleanly. It is +// detected by identity via errors.As (which traverses goja.Exception.Unwrap to +// the wrapped Go error), so a script that catches stop() and then throws a real +// error later is never mistaken for a clean stop. +type scriptStopError struct{} + +func (scriptStopError) Error() string { return "script stopped" } + +// emittableEvents are the only events a script may create via emitEvent: data the +// script itself authored. Server-detected outcome events (message delivery, +// opens, clicks, page visits, reports, training) are deliberately excluded so a +// script cannot fabricate a campaign's statistics. info() emits the info event +// through its own binding, not emitEvent. +var emittableEvents = map[string]bool{ + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: true, + data.EVENT_CAMPAIGN_RECIPIENT_INFO: true, +} + +// EventContext is the payload handed to a script. The caller fills it after +// applying the anonymization guard and the none/basic/full data level, so a +// script never sees more than its configuration allows. +type EventContext struct { + CampaignID string + RecipientID string + Event string + CampaignName string + Email string + Data map[string]interface{} +} + +// EmitFunc lets a script create a new campaign event in the same context. +// The caller implements it so the write goes through the native event +// chokepoint (SaveSubmittedData plus anonymization) and does not re-trigger +// scripts. +type EmitFunc func(eventName string, data map[string]interface{}) error + +// Job is a single script run. +type Job struct { + ScriptID string + Script string + Event EventContext + Emit EmitFunc + + // test, when set, puts the run in capture mode: log/info/emitEvent are + // recorded into it instead of applied, and errors are captured. Set only by + // RunTest. + test *TestResult +} + +// Runner executes one job in a fresh goja VM. +type Runner struct { + Logger *zap.SugaredLogger + HTTPClient *http.Client + Timeout time.Duration +} + +// run executes a single job. It never returns an error to the caller: a script +// failure is logged, not propagated, because scripts are out of band. +func (r *Runner) run(job Job) { + // a broken script or a panic in a native binding must never take down the + // server, so catch anything that escapes the VM. + defer func() { + if rec := recover(); rec != nil { + msg := fmt.Sprintf("%v", rec) + // a test run captures failures in its own run log; keep the server log + // quiet so the test output is the single source of truth + if job.test == nil { + r.Logger.Errorw("script panicked", + "scriptID", job.ScriptID, + "recover", msg, + ) + } + r.reportError(job, "panic", msg) + } + }() + + timeout := r.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + vm := goja.New() + // bound recursion so a deeply recursive script throws a JS StackOverflowError + // instead of growing the Go stack until the process dies. + vm.SetMaxCallStackSize(maxCallStackSize) + + // interrupt the VM when the run times out. Guard with recover so this third + // goroutine can never take down the process, upholding the crash isolation + // invariant even if a future change does more work here. + go func() { + defer func() { _ = recover() }() + <-ctx.Done() + vm.Interrupt(ctx.Err()) + }() + + r.registerBindings(vm, job, ctx) + + // wrap in an IIFE so the script can use return, matching the remote browser + _, err := vm.RunString("(function(){\n" + job.Script + "\n})()") + if err != nil { + // a clean stop() exit is detected by identity: errors.As traverses the + // goja exception to the wrapped Go error. A caught and rethrown error is + // therefore never mistaken for a stop. + var stopErr scriptStopError + if errors.As(err, &stopErr) { + return + } + if _, ok := err.(*goja.InterruptedError); ok { + if job.test == nil { + r.Logger.Warnw("script timed out", "scriptID", job.ScriptID) + } + r.reportError(job, "timeout", "script exceeded its time budget") + return + } + // a native call cancelled by the run budget (for example http.fetch + // blocked when the deadline passed) surfaces as a thrown exception, not + // an InterruptedError; classify it as a timeout too + if ctx.Err() == context.DeadlineExceeded { + if job.test == nil { + r.Logger.Warnw("script timed out", "scriptID", job.ScriptID) + } + r.reportError(job, "timeout", "script exceeded its time budget") + return + } + // a test run surfaces the error in its own run log; don't also spam the + // server log + if job.test == nil { + r.Logger.Errorw("script error", + "scriptID", job.ScriptID, + "error", err.Error(), + ) + } + r.reportError(job, "exception", err.Error()) + } +} + +// reportError records an uncaught script failure as a campaign info event so +// it is visible beyond the server logs. It goes through the same event funnel as +// emitEvent, so the detail follows the campaign's data-retention and anonymity +// rules (the full message is always in the server logs). Best effort: a failure +// to record is only logged. +func (r *Runner) reportError(job Job, phase, message string) { + if job.test != nil { + job.test.setError(phase, message) + return + } + if job.Emit == nil { + return + } + // reportError is called from the deferred panic recover; a panic in Emit (a DB + // write plus webhook fan out) would escape that recover and kill the worker, so + // isolate it here. + defer func() { + if rec := recover(); rec != nil { + r.Logger.Errorw("panic recording script error event", + "scriptID", job.ScriptID, + "recover", fmt.Sprintf("%v", rec), + ) + } + }() + err := job.Emit(data.EVENT_CAMPAIGN_RECIPIENT_INFO, map[string]interface{}{ + "source": "script", + "level": "error", + "scriptId": job.ScriptID, + "phase": phase, // "exception" | "timeout" | "panic" + "error": message, + }) + if err != nil { + r.Logger.Errorw("failed to record script error event", + "scriptID", job.ScriptID, + "error", err, + ) + } +} + +// registerBindings installs the script API on the VM. This is the entire +// capability surface: an event payload, outbound http, encode/decode helpers, +// log, emitEvent and stop. No require, no filesystem, no process access. +func (r *Runner) registerBindings(vm *goja.Runtime, job Job, ctx context.Context) { + // event payload, already filtered by the caller + vm.Set("event", map[string]interface{}{ + "name": job.Event.Event, + "campaignId": job.Event.CampaignID, + "recipientId": job.Event.RecipientID, + "campaignName": job.Event.CampaignName, + "email": job.Event.Email, + "data": job.Event.Data, + }) + + vm.Set("stop", func(call goja.FunctionCall) goja.Value { + panic(vm.NewGoError(scriptStopError{})) + }) + + vm.Set("log", func(call goja.FunctionCall) goja.Value { + msg := call.Argument(0).String() + var extra interface{} + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Argument(1)) && !goja.IsNull(call.Argument(1)) { + extra = call.Argument(1).Export() + } + if job.test != nil { + job.test.addLog(msg, extra) + return goja.Undefined() + } + if extra != nil { + r.Logger.Infow("script log", "scriptID", job.ScriptID, "message", msg, "data", extra) + } else { + r.Logger.Infow("script log", "scriptID", job.ScriptID, "message", msg) + } + return goja.Undefined() + }) + + // info records a campaign_recipient_info event, visible in the campaign + // timeline. Unlike log (server logs only) this is observable in the app; the + // detail follows the campaign's data-retention and anonymity rules. + vm.Set("info", func(call goja.FunctionCall) goja.Value { + msg := call.Argument(0).String() + // the optional second argument is extra structured data + var extra map[string]interface{} + if len(call.Arguments) > 1 { + if m, ok := call.Argument(1).Export().(map[string]interface{}); ok { + extra = m + } + } + if job.test != nil { + job.test.addInfo(msg, extra) + return goja.Undefined() + } + if job.Emit == nil { + return goja.Undefined() + } + payload := map[string]interface{}{ + "source": "script", + "level": "info", + "message": msg, + } + for k, v := range extra { + payload[k] = v + } + if err := job.Emit(data.EVENT_CAMPAIGN_RECIPIENT_INFO, payload); err != nil { + panic(vm.NewGoError(err)) + } + return goja.Undefined() + }) + + vm.Set("http", map[string]interface{}{ + "fetch": r.makeFetch(vm, ctx, job), + }) + + // encode/decode/hash/hmac/jwt/random data transform toolkit + registerCodec(vm) + + vm.Set("emitEvent", func(call goja.FunctionCall) goja.Value { + name := call.Argument(0).String() + // a script may only author its own data events; it must not be able to + // fabricate server-detected outcomes (opens, clicks, reports, delivery, + // training) and skew a campaign's statistics. + if !emittableEvents[name] { + panic(vm.NewTypeError(fmt.Sprintf( + "emitEvent: %q cannot be created by a script (allowed: %s, %s)", + name, + data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + data.EVENT_CAMPAIGN_RECIPIENT_INFO, + ))) + } + var d map[string]interface{} + if exp := call.Argument(1).Export(); exp != nil { + if m, ok := exp.(map[string]interface{}); ok { + d = m + } + } + if job.test != nil { + job.test.addEvent(name, d) + return goja.Undefined() + } + if job.Emit == nil { + panic(vm.NewTypeError("emitEvent is not available for this event")) + } + if err := job.Emit(name, d); err != nil { + panic(vm.NewGoError(err)) + } + return goja.Undefined() + }) +} + +// makeFetch builds the synchronous http.fetch binding. +func (r *Runner) makeFetch(vm *goja.Runtime, ctx context.Context, job Job) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + urlStr := call.Argument(0).String() + method := "GET" + var bodyReader io.Reader + headers := map[string]string{} + fetchTimeout := defaultFetchTimeout + proxyStr := "" + + if exp := call.Argument(1).Export(); exp != nil { + if opts, ok := exp.(map[string]interface{}); ok { + if v, ok := opts["method"].(string); ok && v != "" { + method = strings.ToUpper(v) + } + if v, ok := opts["body"].(string); ok { + bodyReader = strings.NewReader(v) + } + if v, ok := opts["proxy"].(string); ok { + proxyStr = v + } + if h, ok := opts["headers"].(map[string]interface{}); ok { + for k, val := range h { + headers[k] = fmt.Sprint(val) + } + } + if ms := coerceMillis(opts["timeoutMs"]); ms > 0 { + fetchTimeout = time.Duration(ms) * time.Millisecond + if fetchTimeout > maxFetchTimeout { + fetchTimeout = maxFetchTimeout + } + } + } + } + + reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, method, urlStr, bodyReader) + if err != nil { + panic(vm.NewTypeError(err.Error())) + } + for k, v := range headers { + req.Header.Set(k, v) + } + + // route through a proxy for this request when the script asks for one + client := r.HTTPClient + if proxyStr != "" { + pc, perr := proxyClient(proxyStr) + if perr != nil { + panic(vm.NewTypeError("http.fetch: " + perr.Error())) + } + client = pc + } + + resp, err := client.Do(req) + if err != nil { + if job.test != nil { + job.test.addFetchErr(method, urlStr, err.Error()) + } + panic(vm.NewGoError(err)) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + if job.test != nil { + job.test.addFetchErr(method, urlStr, err.Error()) + } + panic(vm.NewGoError(err)) + } + + if job.test != nil { + job.test.addFetchOK(method, urlStr, resp.StatusCode) + } + + respHeaders := map[string]interface{}{} + for k := range resp.Header { + respHeaders[k] = resp.Header.Get(k) + } + + return vm.ToValue(map[string]interface{}{ + "status": resp.StatusCode, + "headers": respHeaders, + "body": string(body), + }) + } +} + +// proxyClient builds an http.Client that routes a request through the given +// proxy. Supports http/https and socks5. Keep-alives are disabled so a per +// request proxy client does not accumulate idle connections. +func proxyClient(proxyStr string) (*http.Client, error) { + u, err := url.Parse(proxyStr) + if err != nil { + return nil, fmt.Errorf("invalid proxy url: %w", err) + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + return &http.Client{ + Timeout: maxFetchTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(u), + DisableKeepAlives: true, + }, + }, nil + case "socks5", "socks5h": + dialer, err := proxy.FromURL(u, proxy.Direct) + if err != nil { + return nil, err + } + tr := &http.Transport{DisableKeepAlives: true} + if cd, ok := dialer.(proxy.ContextDialer); ok { + tr.DialContext = cd.DialContext + } else { + tr.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + return &http.Client{Timeout: maxFetchTimeout, Transport: tr}, nil + default: + return nil, fmt.Errorf("unsupported proxy scheme %q (use http, https or socks5)", u.Scheme) + } +} + +// coerceMillis reads a JS number that goja may export as int64 or float64. +func coerceMillis(v interface{}) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + default: + return 0 + } +} + +// Dispatcher runs jobs on a bounded worker pool. +type Dispatcher struct { + jobs chan Job + runner *Runner + logger *zap.SugaredLogger + workers int + wg sync.WaitGroup + stopOnce sync.Once + // mu guards closed so Enqueue never sends on a channel Stop has closed. + mu sync.RWMutex + closed bool +} + +// NewDispatcher builds a dispatcher. Zero values fall back to the defaults. +func NewDispatcher( + logger *zap.SugaredLogger, + workers int, + queueSize int, + timeout time.Duration, +) *Dispatcher { + if workers <= 0 { + workers = DefaultWorkers + } + if queueSize <= 0 { + queueSize = DefaultQueueSize + } + if timeout <= 0 { + timeout = DefaultTimeout + } + return &Dispatcher{ + jobs: make(chan Job, queueSize), + runner: &Runner{ + Logger: logger, + HTTPClient: &http.Client{Timeout: maxFetchTimeout}, + Timeout: timeout, + }, + logger: logger, + workers: workers, + } +} + +// Start launches the worker goroutines. +func (d *Dispatcher) Start() { + for i := 0; i < d.workers; i++ { + d.wg.Add(1) + go func() { + defer d.wg.Done() + for job := range d.jobs { + d.runOne(job) + } + }() + } +} + +// runOne isolates a single job. run() has its own recover, but the error +// reporting path runs inside that recover, so a panic there could still escape. +// This last line of defence guarantees one bad job can never crash the worker +// goroutine (and with it the process). +func (d *Dispatcher) runOne(job Job) { + defer func() { + if rec := recover(); rec != nil { + d.logger.Errorw("script worker recovered from panic", + "scriptID", job.ScriptID, + "recover", fmt.Sprintf("%v", rec), + ) + } + }() + d.runner.run(job) +} + +// Enqueue submits a job. It returns false when the queue is full, in which case +// the job is dropped rather than blocking the caller on the event capture path. +func (d *Dispatcher) Enqueue(job Job) bool { + // hold the read lock across the send so Stop cannot close the channel + // between the closed check and the send + d.mu.RLock() + defer d.mu.RUnlock() + if d.closed { + return false + } + select { + case d.jobs <- job: + return true + default: + return false + } +} + +// Stop closes the queue and waits for in flight jobs to finish. +func (d *Dispatcher) Stop() { + d.stopOnce.Do(func() { + // take the write lock so no Enqueue is mid send when the channel closes + d.mu.Lock() + d.closed = true + close(d.jobs) + d.mu.Unlock() + }) + d.wg.Wait() +} diff --git a/backend/script/script_test.go b/backend/script/script_test.go new file mode 100644 index 00000000..e6f640cc --- /dev/null +++ b/backend/script/script_test.go @@ -0,0 +1,431 @@ +package script + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "go.uber.org/zap" +) + +// newTestRunner builds a Runner with a no-op logger for tests. +func newTestRunner() *Runner { + return &Runner{ + Logger: zap.NewNop().Sugar(), + HTTPClient: &http.Client{Timeout: maxFetchTimeout}, + Timeout: 5 * time.Second, + } +} + +// TestScriptFetchEncodeEmit proves the engine can read the event payload, call +// out over http.fetch, use encode/decode, and write back through emitEvent. +func TestScriptFetchEncodeEmit(t *testing.T) { + // a server that echoes the request body back + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + gotBody = string(buf) + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"ok":true,"token":"abc123"}`)) + })) + defer srv.Close() + + var mu sync.Mutex + emitted := map[string]map[string]interface{}{} + emit := func(name string, data map[string]interface{}) error { + mu.Lock() + defer mu.Unlock() + emitted[name] = data + return nil + } + + script := ` + log('starting', { event: event.name }); + // send the campaign name to the echo server, base64 encoded + var res = http.fetch('` + srv.URL + `', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: encode.json({ name: event.campaignName, b: encode.base64('hi') }) + }); + if (res.status !== 200) { throw new Error('bad status ' + res.status); } + var parsed = decode.json(res.body); + // write the returned token back as a submit event + emitEvent('campaign_recipient_submitted_data', { token: parsed.token }); + ` + + r := newTestRunner() + r.run(Job{ + ScriptID: "test-1", + Script: script, + Event: EventContext{ + CampaignID: "c1", + Event: "campaign_recipient_page_visited", + CampaignName: "Q3 Phish", + }, + Emit: emit, + }) + + mu.Lock() + defer mu.Unlock() + + // the server must have received the base64 of "hi" = "aGk=" + if gotBody == "" { + t.Fatalf("server received no body") + } + if want := "aGk="; !contains(gotBody, want) { + t.Fatalf("request body %q did not contain %q", gotBody, want) + } + // emitEvent must have fired with the decoded token + ev, ok := emitted["campaign_recipient_submitted_data"] + if !ok { + t.Fatalf("emitEvent was not called; emitted=%v", emitted) + } + if ev["token"] != "abc123" { + t.Fatalf("emit token = %v, want abc123", ev["token"]) + } +} + +// TestCodec exercises the encode/decode/hash/hmac/jwt/random toolkit. +func TestCodec(t *testing.T) { + var mu sync.Mutex + var got map[string]interface{} + emit := func(name string, data map[string]interface{}) error { + mu.Lock() + defer mu.Unlock() + got = data + return nil + } + + script := ` + var r = {}; + r.b64url = encode.base64url('hi'); + r.b64urlRound = decode.base64url(encode.base64url('héllo')); + r.sha256abc = hash.sha256('abc'); + r.hmacLen = hmac.sha256('key', 'msg').length; + r.hmacDet = hmac.sha256('key', 'msg') === hmac.sha256('key', 'msg'); + r.hmacB64Len = hmac.sha256('key', 'msg', 'base64').length > 0; + r.gzipRound = decode.gzip(encode.gzip('the quick brown fox')); + r.deflateRound = decode.deflate(encode.deflate('deflate me')); + r.form = encode.form({ a: '1', b: 'two' }); + var f = decode.form('a=1&b=two&b=three'); + r.formA = f.a; + r.formBArr = Array.isArray(f.b) ? f.b.length : 0; + var tok = encode.base64url('{"alg":"HS256"}') + '.' + encode.base64url('{"sub":"123"}') + '.xxx'; + var jd = jwt.decode(tok); + r.jwtSub = jd.payload.sub; + r.uuidLen = random.uuid().length; + r.randHexLen = random.bytes(16, 'hex').length; + r.b32 = encode.base32('foo'); + r.htmlRound = decode.html(encode.html('&"x"')); + emitEvent('campaign_recipient_submitted_data', r); + ` + + r := newTestRunner() + r.run(Job{ScriptID: "codec", Script: script, Emit: emit}) + + mu.Lock() + defer mu.Unlock() + if got == nil { + t.Fatalf("codec script emitted nothing") + } + want := map[string]string{ + "b64url": "aGk", + "b64urlRound": "héllo", + "sha256abc": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "hmacLen": "64", + "hmacDet": "true", + "hmacB64Len": "true", + "gzipRound": "the quick brown fox", + "deflateRound": "deflate me", + "form": "a=1&b=two", + "formA": "1", + "formBArr": "2", + "jwtSub": "123", + "uuidLen": "36", + "randHexLen": "32", + "b32": "MZXW6===", + "htmlRound": `&"x"`, + } + for k, w := range want { + if g := fmt.Sprint(got[k]); g != w { + t.Errorf("codec %s = %q, want %q", k, g, w) + } + } +} + +// TestUncaughtErrorRecordsEvent proves an uncaught exception is captured as an +// info event (level=error) through the Emit funnel, not just the server logs. +func TestUncaughtErrorRecordsEvent(t *testing.T) { + var mu sync.Mutex + var name string + var payload map[string]interface{} + emit := func(n string, d map[string]interface{}) error { + mu.Lock() + defer mu.Unlock() + name = n + payload = d + return nil + } + r := newTestRunner() + r.run(Job{ScriptID: "boom", Script: `throw new Error('kaboom');`, Emit: emit}) + + mu.Lock() + defer mu.Unlock() + if name != "campaign_recipient_info" { + t.Fatalf("error event name = %q, want campaign_recipient_info", name) + } + if payload["level"] != "error" || payload["phase"] != "exception" { + t.Fatalf("error payload = %v", payload) + } + if s, _ := payload["error"].(string); s == "" || !contains(s, "kaboom") { + t.Fatalf("error detail did not include the message: %v", payload["error"]) + } +} + +// TestInfoBinding proves info() records a visible info event distinct from log(). +func TestInfoBinding(t *testing.T) { + var mu sync.Mutex + var name string + var payload map[string]interface{} + emit := func(n string, d map[string]interface{}) error { + mu.Lock() + defer mu.Unlock() + name = n + payload = d + return nil + } + r := newTestRunner() + r.run(Job{ScriptID: "info", Script: `info('hello', { step: 3 });`, Emit: emit}) + + mu.Lock() + defer mu.Unlock() + if name != "campaign_recipient_info" { + t.Fatalf("info event name = %q", name) + } + if payload["message"] != "hello" || payload["level"] != "info" { + t.Fatalf("info payload = %v", payload) + } + if fmt.Sprint(payload["step"]) != "3" { + t.Fatalf("info extra field lost: %v", payload["step"]) + } +} + +// TestRunTestCapture proves RunTest records log/info/emitEvent instead of +// applying them, and captures an uncaught error. +func TestRunTestCapture(t *testing.T) { + r := newTestRunner() + res := r.RunTest(` + log('a log', { x: 1 }); + info('an info'); + emitEvent('campaign_recipient_submitted_data', { token: hmac.sha256('k', 'm') }); + `, EventContext{Event: "campaign_recipient_submitted_data", CampaignName: "Demo"}) + + if !res.OK { + t.Fatalf("expected ok, got error %q", res.Error) + } + // ordered run log: log, info, event(emitEvent), done + types := []string{} + for _, e := range res.Entries { + types = append(types, e.Type) + } + if len(res.Entries) != 4 || + res.Entries[0].Type != "log" || res.Entries[0].Message != "a log" || + res.Entries[1].Type != "info" || + res.Entries[2].Type != "event" || res.Entries[2].Key != "campaign_recipient_submitted_data" || + res.Entries[3].Type != "done" { + t.Fatalf("entries = %+v (types %v)", res.Entries, types) + } + if v, _ := res.Entries[2].Value.(map[string]interface{}); v["token"] == "" || v["token"] == nil { + t.Fatalf("emitEvent data not captured: %+v", res.Entries[2].Value) + } + + // an uncaught throw is captured, not panicked + bad := r.RunTest(`throw new Error('nope');`, EventContext{}) + if bad.OK || bad.ErrorPhase != "exception" || bad.Error == "" { + t.Fatalf("expected captured exception, got %+v", bad) + } +} + +// TestEmitPanicDoesNotCrash proves that a panic in the Emit callback (e.g. a DB +// failure) during the error-reporting path is contained, not propagated. A +// script throws (triggering reportError), and Emit panics; the run must return. +func TestEmitPanicDoesNotCrash(t *testing.T) { + panicEmit := func(string, map[string]interface{}) error { + panic("emit blew up") + } + r := newTestRunner() + done := make(chan struct{}) + go func() { + // throw -> reportError -> Emit panics; must not escape run() + r.run(Job{ScriptID: "emit-panic", Script: `throw new Error('x');`, Emit: panicEmit}) + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatalf("run() did not return after a panicking Emit") + } +} + +// TestWorkerSurvivesPanickingJob proves the dispatcher keeps working after a job +// whose Emit panics: a later job still runs. +func TestWorkerSurvivesPanickingJob(t *testing.T) { + d := NewDispatcher(zap.NewNop().Sugar(), 1, 8, time.Second) + d.Start() + defer d.Stop() + + panicEmit := func(string, map[string]interface{}) error { panic("boom") } + d.Enqueue(Job{ScriptID: "bad", Script: `throw new Error('x');`, Emit: panicEmit}) + + ran := make(chan struct{}, 1) + okEmit := func(string, map[string]interface{}) error { ran <- struct{}{}; return nil } + d.Enqueue(Job{ScriptID: "good", Script: `emitEvent('x', {});`, Emit: okEmit}) + + select { + case <-ran: + case <-time.After(3 * time.Second): + t.Fatalf("worker died after a panicking job; later job never ran") + } +} + +// TestDeepRecursionBounded proves runaway recursion throws instead of crashing. +func TestDeepRecursionBounded(t *testing.T) { + r := newTestRunner() + done := make(chan struct{}) + go func() { + r.run(Job{ScriptID: "recurse", Script: `function f(){ return f(); } f();`}) + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatalf("deep recursion was not bounded") + } +} + +// TestTestCaptureCapped proves a looping test script cannot grow the run log +// without bound. +func TestTestCaptureCapped(t *testing.T) { + r := newTestRunner() + res := r.RunTest(`for (var i = 0; i < 100000; i++) { log('x'); }`, EventContext{}) + if len(res.Entries) > maxTestEntries+2 { + t.Fatalf("captured %d entries, want <= %d", len(res.Entries), maxTestEntries+2) + } +} + +// TestEmitEventAllowlist proves a script may only emit its own data events and +// cannot fabricate a server-detected outcome (e.g. a report). +func TestEmitEventAllowlist(t *testing.T) { + bad := newTestRunner().RunTest(`emitEvent('campaign_recipient_reported', {});`, EventContext{}) + if bad.OK { + t.Fatalf("emitEvent('campaign_recipient_reported') should be rejected") + } + good := newTestRunner().RunTest(`emitEvent('campaign_recipient_submitted_data', { a: 1 });`, EventContext{}) + if !good.OK { + t.Fatalf("emitEvent('campaign_recipient_submitted_data') should be allowed: %s", good.Error) + } +} + +// TestScriptTimeout proves a runaway script is interrupted, not hung. +func TestScriptTimeout(t *testing.T) { + r := newTestRunner() + r.Timeout = 300 * time.Millisecond + + done := make(chan struct{}) + go func() { + r.run(Job{ScriptID: "loop", Script: `while (true) {}`}) + close(done) + }() + + select { + case <-done: + // interrupted and returned + case <-time.After(3 * time.Second): + t.Fatalf("runaway script was not interrupted") + } +} + +// TestScriptStop proves stop() exits cleanly and code after it does not run. +func TestScriptStop(t *testing.T) { + var called bool + emit := func(name string, data map[string]interface{}) error { + called = true + return nil + } + r := newTestRunner() + r.run(Job{ + ScriptID: "stop", + Script: `stop(); emitEvent('should_not_fire', {});`, + Emit: emit, + }) + if called { + t.Fatalf("code after stop() ran") + } +} + +// TestScriptStopCaughtDoesNotSwallowLaterError proves stop() is detected by +// identity, not a latched flag: a script that catches stop() and then throws a +// real error still has that error reported instead of being treated as a clean +// stop. +func TestScriptStopCaughtDoesNotSwallowLaterError(t *testing.T) { + var reported bool + var phase string + emit := func(name string, data map[string]interface{}) error { + if lvl, _ := data["level"].(string); lvl == "error" { + reported = true + phase, _ = data["phase"].(string) + } + return nil + } + r := newTestRunner() + r.run(Job{ + ScriptID: "caught-stop", + Script: `try { stop(); } catch (e) {} throw new Error('boom');`, + Emit: emit, + }) + if !reported { + t.Fatalf("a real error after a caught stop() was swallowed") + } + if phase != "exception" { + t.Fatalf("expected phase 'exception', got %q", phase) + } +} + +// TestDispatcherDropsWhenFull proves the pool bounds work: a full queue drops. +func TestDispatcherDropsWhenFull(t *testing.T) { + // one worker, tiny queue, a slow script so the queue fills + d := NewDispatcher(zap.NewNop().Sugar(), 1, 1, time.Second) + d.Start() + defer d.Stop() + + block := make(chan struct{}) + // fill the single worker with a job that blocks via a slow emit + slow := func(string, map[string]interface{}) error { + <-block + return nil + } + // occupy the worker + d.Enqueue(Job{Script: `emitEvent('x', {});`, Emit: slow}) + time.Sleep(50 * time.Millisecond) + // fill the queue (depth 1) + d.Enqueue(Job{Script: `1;`}) + // next enqueue should be dropped + dropped := !d.Enqueue(Job{Script: `1;`}) + close(block) + if !dropped { + t.Fatalf("expected a full queue to drop the job") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/backend/script/testrun.go b/backend/script/testrun.go new file mode 100644 index 00000000..12edb532 --- /dev/null +++ b/backend/script/testrun.go @@ -0,0 +1,102 @@ +package script + +import ( + "fmt" + "sync" + "time" +) + +// TestEntry is one line in the run log. It mirrors the remote browser editor's +// runLog entry shape so the editor renders both the same way. +// - type "log": Message (+ optional Data) +// - type "info": Message +// - type "event": Key (event name) + Value (event data) — an emitEvent call +// - type "error": Message +// - type "done": end marker +type TestEntry struct { + Type string `json:"type"` + Time string `json:"time"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` + Key string `json:"key,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +// TestResult is the captured output of a test run: an ordered run log plus the +// terminal error, if any. +type TestResult struct { + OK bool `json:"ok"` + DurationMs int64 `json:"durationMs"` + Entries []TestEntry `json:"entries"` + Error string `json:"error,omitempty"` + ErrorPhase string `json:"errorPhase,omitempty"` + + mu sync.Mutex +} + +// maxTestEntries caps the captured run log so a script that logs in a tight loop +// cannot grow the response unboundedly and exhaust memory in the request handler. +const maxTestEntries = 2000 + +func (t *TestResult) add(e TestEntry) { + e.Time = time.Now().UTC().Format(time.RFC3339Nano) + t.mu.Lock() + defer t.mu.Unlock() + if len(t.Entries) >= maxTestEntries { + if len(t.Entries) == maxTestEntries { + t.Entries = append(t.Entries, TestEntry{ + Type: "log", + Time: e.Time, + Message: "… output truncated (too many entries)", + }) + } + return + } + t.Entries = append(t.Entries, e) +} + +func (t *TestResult) addLog(msg string, data interface{}) { + t.add(TestEntry{Type: "log", Message: msg, Data: data}) +} + +func (t *TestResult) addInfo(msg string, data interface{}) { + t.add(TestEntry{Type: "info", Message: msg, Data: data}) +} + +func (t *TestResult) addEvent(name string, data map[string]interface{}) { + t.add(TestEntry{Type: "event", Key: name, Value: data}) +} + +func (t *TestResult) addFetchOK(method, url string, status int) { + t.add(TestEntry{Type: "log", Message: fmt.Sprintf("%s %s → %d", method, url, status)}) +} + +func (t *TestResult) addFetchErr(method, url, errMsg string) { + t.add(TestEntry{Type: "log", Message: fmt.Sprintf("%s %s ✗ %s", method, url, errMsg)}) +} + +func (t *TestResult) setError(phase, msg string) { + t.mu.Lock() + if t.Error == "" { + t.Error = msg + t.ErrorPhase = phase + } + t.mu.Unlock() + t.add(TestEntry{Type: "error", Message: msg}) +} + +// RunTest runs a script against a simulated event and captures what it does as +// an ordered run log, without touching the campaign: log, info and emitEvent are +// recorded rather than applied, and no webhooks fire. http.fetch runs for real. +// The Runner's Timeout bounds the run. +func (r *Runner) RunTest(script string, event EventContext) *TestResult { + res := &TestResult{Entries: []TestEntry{}} + start := time.Now() + r.run(Job{ScriptID: "test", Script: script, Event: event, test: res}) + res.DurationMs = time.Since(start).Milliseconds() + res.OK = res.Error == "" + if res.OK { + res.add(TestEntry{Type: "done"}) + } + return res +} diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index 8cc838d0..31921a5d 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -54,6 +54,8 @@ func initialInstallAndSeed( &database.CampaignAllowDeny{}, &database.Webhook{}, &database.CampaignWebhook{}, + &database.Script{}, + &database.CampaignScript{}, &database.Identifier{}, &database.CampaignStats{}, &database.OAuthProvider{}, diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 3703e46c..5ef29809 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/tls" + "encoding/json" "errors" "fmt" "math/rand" @@ -33,6 +34,7 @@ import ( "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/remotebrowser" "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/script" "github.com/phishingclub/phishingclub/utils" "github.com/phishingclub/phishingclub/validate" "github.com/phishingclub/phishingclub/vo" @@ -50,6 +52,8 @@ type Campaign struct { RecipientGroupRepository *repository.RecipientGroup AllowDenyRepository *repository.AllowDeny WebhookRepository *repository.Webhook + ScriptRepository *repository.Script + ScriptDispatcher *script.Dispatcher CampaignTemplateService *CampaignTemplate TemplateService *Template DomainService *Domain @@ -247,6 +251,28 @@ func (c *Campaign) Create( } } + // save script configurations if present + if campaign.Scripts.IsSpecified() && !campaign.Scripts.IsNull() { + scripts := campaign.Scripts.MustGet() + if len(scripts) > 0 { + // validate before AddScripts so a missing scriptID fails as a clean + // error instead of panicking on the MustGet inside the repository + for _, ca := range scripts { + if ca == nil { + return nil, errs.Wrap(errors.New("script entry cannot be null")) + } + if err := ca.Validate(); err != nil { + return nil, errs.Wrap(err) + } + } + err = c.CampaignRepository.AddScripts(ctx, id, scripts) + if err != nil { + c.Logger.Errorw("failed to add scripts to campaign", "error", err) + return nil, errs.Wrap(err) + } + } + } + createdCampaign, err := c.CampaignRepository.GetByID( ctx, id, @@ -2271,6 +2297,24 @@ func (c *Campaign) UpdateByID( } } + // handle scripts array, mirroring webhooks + if incoming.Scripts.IsSpecified() { + if incoming.Scripts.IsNull() || len(incoming.Scripts.MustGet()) == 0 { + current.Scripts.SetNull() + } else { + scripts := incoming.Scripts.MustGet() + for _, ca := range scripts { + if ca == nil { + return errs.Wrap(errors.New("script entry cannot be null")) + } + if err := ca.Validate(); err != nil { + return errs.Wrap(err) + } + } + current.Scripts.Set(scripts) + } + } + // check there is atleast one valid group // and remove any empty groups validGroups := []*uuid.UUID{} @@ -2380,6 +2424,27 @@ func (c *Campaign) UpdateByID( } } } + + // update script configurations + if current.Scripts.IsSpecified() { + // remove all existing scripts + err = c.CampaignRepository.RemoveScriptsByCampaignID(ctx, id) + if err != nil { + c.Logger.Errorw("failed to remove scripts from campaign", "error", err) + return errs.Wrap(err) + } + // add new scripts if present + if !current.Scripts.IsNull() { + scripts := current.Scripts.MustGet() + if len(scripts) > 0 { + err = c.CampaignRepository.AddScripts(ctx, id, scripts) + if err != nil { + c.Logger.Errorw("failed to add scripts to campaign", "error", err) + return errs.Wrap(err) + } + } + } + } // re-schedule the campaign // TODO should this all be in the schedule method // remove all existing schedules if the campaign is not self-managed @@ -2535,6 +2600,12 @@ func (c *Campaign) DeleteByID( c.Logger.Errorw("failed to delete campaign webhooks by campaign id", "error", err) return errs.Wrap(err) } + // delete all campaign-script junction records + err = c.CampaignRepository.RemoveScriptsByCampaignID(ctx, id) + if err != nil { + c.Logger.Errorw("failed to delete campaign scripts by campaign id", "error", err) + return errs.Wrap(err) + } // delete all microsoft device codes for the campaign if err = c.MicrosoftDeviceCodeRepository.DeleteByCampaignID(ctx, id); err != nil { c.Logger.Errorw("failed to delete microsoft device codes by campaign id", "error", err) @@ -4614,6 +4685,16 @@ func (c *Campaign) HandleWebhooks( eventName string, capturedData map[string]interface{}, ) error { + // run scripts from the same seam so every event that fires a webhook can + // also run a script. Skipped when this call originates from a script + // write back, which breaks the emitEvent recursion. Script failures never + // fail the webhook path. + if !isScriptOrigin(ctx) { + if err := c.handleScripts(ctx, campaignID, recipientID, eventName, capturedData); err != nil { + c.Logger.Errorw("failed to handle scripts", "error", err) + } + } + // get campaign webhooks from junction table webhooks, err := c.CampaignRepository.GetCampaignWebhooks(ctx, campaignID) if err != nil { @@ -4772,6 +4853,240 @@ func (c *Campaign) HandleWebhook( return c.HandleWebhooks(ctx, campaignID, recipientID, eventName, capturedData) } +// scriptOriginKey marks a context whose event came from a script +// write back, so HandleWebhooks does not run scripts again for it. +type scriptOriginKey struct{} + +func withScriptOrigin(ctx context.Context) context.Context { + return context.WithValue(ctx, scriptOriginKey{}, true) +} + +func isScriptOrigin(ctx context.Context) bool { + v, _ := ctx.Value(scriptOriginKey{}).(bool) + return v +} + +// handleScripts enqueues scripts subscribed to a campaign event. +// It mirrors the webhook gating: the same event bitmask, the same none/basic/full +// data level, the same anonymization guard (no per recipient dispatch for an +// anonymous campaign, full capped to basic). The payload is filtered here so a +// script never receives more than its configuration and the campaign's anonymity +// allow. +func (c *Campaign) handleScripts( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + eventName string, + capturedData map[string]interface{}, +) error { + // feature disabled: no dispatcher wired + if c.ScriptDispatcher == nil { + return nil + } + scripts, err := c.CampaignRepository.GetCampaignScripts(ctx, campaignID) + if err != nil { + return errs.Wrap(err) + } + if len(scripts) == 0 { + return nil + } + + isAnon, err := c.CampaignRepository.IsAnonymousByID(ctx, campaignID) + if err != nil { + return errs.Wrap(err) + } + // same privacy rule as webhooks: an anonymous campaign must not push per + // recipient events, the exact timing can single out who acted. Campaign level + // events (recipientID nil) still run. + if isAnon && recipientID != nil { + return nil + } + + campaignName, err := c.CampaignRepository.GetNameByID(ctx, campaignID) + if err != nil { + return errs.Wrap(err) + } + + var email string + if recipientID != nil && !isAnon { + if e, err := c.RecipientRepository.GetEmailByID(ctx, recipientID); err == nil && e != nil { + email = e.String() + } + } + + // batch fetch the scripts in one query + scriptIDs := make([]*uuid.UUID, 0, len(scripts)) + for _, ac := range scripts { + id := ac.ScriptID.MustGet() + scriptIDs = append(scriptIDs, &id) + } + details, err := c.ScriptRepository.GetByIDs(ctx, scriptIDs) + if err != nil { + return errs.Wrap(err) + } + byID := make(map[string]*model.Script, len(details)) + for _, d := range details { + byID[d.ID.MustGet().String()] = d + } + + for _, ac := range scripts { + scriptID := ac.ScriptID.MustGet() + events := ac.GetScriptEventsOrDefault() + if !model.IsWebhookEventEnabled(events, eventName) { + continue + } + detail, ok := byID[scriptID.String()] + if !ok { + continue + } + source, err := detail.Script.Get() + if err != nil { + continue + } + + dataLevel := ac.GetScriptIncludeDataOrDefault() + // cap the level for an anonymous campaign, matching webhooks + if isAnon && dataLevel == model.WebhookDataLevelFull { + dataLevel = model.WebhookDataLevelBasic + } + + evCtx := script.EventContext{ + CampaignID: campaignID.String(), + Event: eventName, + } + if recipientID != nil { + evCtx.RecipientID = recipientID.String() + } + switch dataLevel { + case model.WebhookDataLevelNone: + // only the event name + case model.WebhookDataLevelBasic: + evCtx.CampaignName = campaignName + case model.WebhookDataLevelFull: + evCtx.CampaignName = campaignName + evCtx.Data = capturedData + evCtx.Email = email + } + + // bind the write back callback to this campaign and recipient so a script + // cannot target another recipient or company + cid := *campaignID + var rid *uuid.UUID + if recipientID != nil { + r := *recipientID + rid = &r + } + aid := scriptID.String() + job := script.Job{ + ScriptID: aid, + Script: source.String(), + Event: evCtx, + Emit: func(name string, d map[string]interface{}) error { + // bound the write back so a stuck database cannot pin the worker + // slot indefinitely (the run itself is already out of band) + emitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return c.emitScriptEvent(emitCtx, &cid, rid, name, d) + }, + } + if !c.ScriptDispatcher.Enqueue(job) { + c.Logger.Warnw("script job dropped, queue full", + "campaignID", cid.String(), + "scriptID", aid, + "event", eventName, + ) + } + } + + return nil +} + +// emitScriptEvent creates a campaign event from a script. It +// funnels through the same chokepoint as native capture: submitted data is +// stored only when the campaign allows it, the event is anonymized for an +// anonymous campaign, and webhooks fire while scripts do not (the context +// origin flag breaks the emitEvent recursion). +func (c *Campaign) emitScriptEvent( + ctx context.Context, + campaignID *uuid.UUID, + recipientID *uuid.UUID, + eventName string, + dataMap map[string]interface{}, +) error { + eventID, ok := cache.EventIDByName[eventName] + if !ok { + return errs.Wrap(errors.New("unknown script event name")) + } + // defense in depth: a script may only author its own data events, never + // fabricate a server-detected outcome. The script binding enforces this too. + if eventName != data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA && + eventName != data.EVENT_CAMPAIGN_RECIPIENT_INFO { + return errs.Wrap(errors.New("script may not create event: " + eventName)) + } + campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) + if err != nil { + return errs.Wrap(err) + } + if !campaign.IsActive() { + return nil + } + + // load the campaign recipient so the event can be anonymized against its + // pseudonym when the campaign is anonymous + var campaignRecipient *model.CampaignRecipient + if recipientID != nil { + campaignRecipient, err = c.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + campaignID, + recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + return errs.Wrap(err) + } + } + + // store submitted data only when the campaign is configured to keep it, + // matching the native capture paths. Anonymization strips it regardless. + dataVO := vo.NewEmptyOptionalString1MB() + saveData := false + if sd, err := campaign.SaveSubmittedData.Get(); err == nil && sd { + saveData = true + } + if saveData && len(dataMap) > 0 { + b, err := json.Marshal(dataMap) + if err != nil { + return errs.Wrap(err) + } + // script controlled data: use the non panicking constructor so a payload + // over the 1MB cap fails as a clean error, not a panic + d, err := vo.NewOptionalString1MB(string(b)) + if err != nil { + return errs.Wrap(err) + } + dataVO = d + } + + newEventID := uuid.New() + campaignEvent := &model.CampaignEvent{ + ID: &newEventID, + CampaignID: campaignID, + RecipientID: recipientID, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: eventID, + Data: dataVO, + } + anonymizeEventForRecipient(campaignAnonymous(campaign), campaignRecipient, campaignEvent) + if err := c.CampaignRepository.SaveEvent(ctx, campaignEvent); err != nil { + return errs.Wrap(err) + } + + // fire webhooks for the new event, but not scripts: the origin flag makes + // HandleWebhooks skip the script dispatch so a script cannot loop. + return c.HandleWebhooks(withScriptOrigin(ctx), campaignID, recipientID, eventName, dataMap) +} + // anonymizeCampaignRecipients severs the recipient relation for a campaign and // finalizes anonymization. Each recipient keeps its existing pseudonym (an // anonymous campaign assigned one at materialization) or is given a fresh one and diff --git a/backend/service/company.go b/backend/service/company.go index 2cd45eb4..f0ffcc0d 100644 --- a/backend/service/company.go +++ b/backend/service/company.go @@ -30,6 +30,7 @@ type Company struct { CampaignTemplate *CampaignTemplate AllowDenyService *AllowDeny WebhookService *Webhook + ScriptService *Script AssetService *Asset CompanyRepository *repository.Company } @@ -466,6 +467,32 @@ func (s *Company) DeleteByID( } } + // delete scripts + affectedScripts, err := s.ScriptService.GetByCompanyID( + g, + session, + companyID, + ) + if err != nil { + s.Logger.Errorw( + "failed get scripts that should be deleted due to company deletion", + "error", err, + ) + return 0, errs.Wrap(err) + } + for _, script := range affectedScripts { + scriptID := script.ID.MustGet() + err = s.ScriptService.DeleteByID( + g, + session, + &scriptID, + ) + if err != nil { + s.Logger.Errorw("failed to delete scripts related to company", "error", err) + return 0, errs.Wrap(err) + } + } + // delete allow deny affectedAllowDenies, err := s.AllowDenyService.GetByCompanyID( g, diff --git a/backend/service/script.go b/backend/service/script.go new file mode 100644 index 00000000..e1a78f14 --- /dev/null +++ b/backend/service/script.go @@ -0,0 +1,298 @@ +package service + +import ( + "context" + + "github.com/go-errors/errors" + + "github.com/google/uuid" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/script" + "github.com/phishingclub/phishingclub/validate" +) + +type Script struct { + Common + CampaignRepository *repository.Campaign + ScriptRepository *repository.Script + // TestRunner runs scripts in capture mode for the editor test panel. Nil when + // the feature is disabled. + TestRunner *script.Runner +} + +// Test runs a script against a simulated event and returns what it did, without +// touching any campaign. Gated on the global admin permission. +func (a *Script) Test( + ctx context.Context, + session *model.Session, + script string, + event script.EventContext, +) (*script.TestResult, error) { + ae := NewAuditEvent("Script.Test", session) + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + if a.TestRunner == nil { + return nil, errs.Wrap(errors.New("script is not enabled")) + } + // match the saved script cap (vo.String1MB) so a test run cannot submit an + // unbounded script + if len(script) > 1_000_000 { + return nil, validate.WrapErrorWithField(errors.New("script is too large"), "script") + } + result := a.TestRunner.RunTest(script, event) + a.AuditLogAuthorized(ae) + return result, nil +} + +// Create creates a new script +func (a *Script) Create( + ctx context.Context, + session *model.Session, + script *model.Script, +) (*uuid.UUID, error) { + ae := NewAuditEvent("Script.Create", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return nil, errors.New("unauthorized") + } + // validate data + if err := script.Validate(); err != nil { + return nil, errs.Wrap(err) + } + // check uniqueness + var companyID *uuid.UUID + if cid, err := script.CompanyID.Get(); err == nil { + companyID = &cid + } + name := script.Name.MustGet() + isOK, err := repository.CheckNameIsUnique( + ctx, + a.ScriptRepository.DB, + "scripts", + name.String(), + companyID, + nil, + ) + if err != nil { + a.Logger.Errorw("failed to check script uniqueness", "error", err) + return nil, errs.Wrap(err) + } + if !isOK { + a.Logger.Debugw("script name is already taken", "name", name.String()) + return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + // insert + id, err := a.ScriptRepository.Insert(ctx, script) + if err != nil { + a.Logger.Errorw("failed to insert script", "error", err) + return nil, errs.Wrap(err) + } + ae.Details["id"] = id.String() + a.AuditLogAuthorized(ae) + + return id, nil +} + +// GetAll gets all scripts +func (a *Script) GetAll( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, + options *repository.ScriptOption, +) (*model.Result[model.Script], error) { + result := model.NewEmptyResult[model.Script]() + ae := NewAuditEvent("Script.GetAll", session) + if companyID != nil { + ae.Details["companyId"] = companyID.String() + } + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + a.LogAuthError(err) + return result, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return result, errs.ErrAuthorizationFailed + } + // get + result, err = a.ScriptRepository.GetAll(ctx, companyID, options) + if err != nil { + a.Logger.Errorw("failed to get scripts", "error", err) + return result, errs.Wrap(err) + } + a.AuditLogAuthorized(ae) + + return result, nil +} + +// GetByID gets a script by id +func (a *Script) GetByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) (*model.Script, error) { + ae := NewAuditEvent("Script.GetByID", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // get + out, err := a.ScriptRepository.GetByID(ctx, id) + if err != nil { + a.Logger.Errorw("failed to get script", "error", err) + return out, errs.Wrap(err) + } + // no audit on read + + return out, nil +} + +// GetByCompanyID gets scripts by company id +func (a *Script) GetByCompanyID( + ctx context.Context, + session *model.Session, + companyID *uuid.UUID, +) ([]*model.Script, error) { + ae := NewAuditEvent("Script.GetByCompanyID", session) + if companyID != nil { + ae.Details["companyId"] = companyID.String() + } + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return nil, errs.Wrap(err) + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return nil, errs.ErrAuthorizationFailed + } + // get + models, err := a.ScriptRepository.GetAllByCompanyID(ctx, companyID, &repository.ScriptOption{}) + if err != nil { + a.Logger.Errorw("failed to get scripts", "error", err) + return models, errs.Wrap(err) + } + // no audit on read + + return models, nil +} + +// Update updates a script +func (a *Script) Update( + ctx context.Context, + session *model.Session, + id *uuid.UUID, + script *model.Script, +) error { + ae := NewAuditEvent("Script.Update", session) + ae.Details["id"] = id.String() + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return err + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return errors.New("unauthorized") + } + // confirm the script exists before updating + if _, err := a.ScriptRepository.GetByID(ctx, id); err != nil { + a.Logger.Errorw("failed to get script", "error", err) + return err + } + // the repository update reads the changed fields from the incoming script, + // so only the name uniqueness needs checking here + if v, err := script.Name.Get(); err == nil { + // check uniqueness + var companyID *uuid.UUID + if cid, err := script.CompanyID.Get(); err == nil { + companyID = &cid + } + + isOK, err := repository.CheckNameIsUnique( + ctx, + a.ScriptRepository.DB, + "scripts", + v.String(), + companyID, + id, + ) + if err != nil { + a.Logger.Errorw("failed to check script uniqueness", "error", err) + return err + } + if !isOK { + a.Logger.Debugw("script name is already taken", "name", v.String()) + return validate.WrapErrorWithField(errors.New("is not unique"), "name") + } + } + // update + err = a.ScriptRepository.UpdateByID(ctx, id, script) + if err != nil { + a.Logger.Errorw("failed to update script", "error", err) + return err + } + a.AuditLogAuthorized(ae) + + return nil +} + +// DeleteByID deletes a script +func (a *Script) DeleteByID( + ctx context.Context, + session *model.Session, + id *uuid.UUID, +) error { + ae := NewAuditEvent("Script.DeleteByID", session) + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil { + a.LogAuthError(err) + return err + } + if !isAuthorized { + a.AuditLogNotAuthorized(ae) + return errors.New("unauthorized") + } + // remove junction table rows for this script so no campaign retains + // a dangling reference + err = a.CampaignRepository.RemoveScriptFromJunctionByScriptID(ctx, id) + if err != nil { + a.Logger.Errorw("failed to remove script from campaign_scripts junction", "error", err) + return err + } + // delete + err = a.ScriptRepository.DeleteByID(ctx, id) + if err != nil { + a.Logger.Errorw("failed to delete script", "error", err) + return err + } + a.AuditLogAuthorized(ae) + + return nil +} diff --git a/backend/utils/json.go b/backend/utils/json.go index 897baf79..c900463f 100644 --- a/backend/utils/json.go +++ b/backend/utils/json.go @@ -28,7 +28,11 @@ func Unmarshal(data []byte, v any) error { return err } var typ *json.UnmarshalTypeError - if errors.As(err, &typ) { + // the standard library annotates Field for a plain type mismatch, but a + // mismatch thrown from inside a custom UnmarshalJSON has no Field. Only + // short circuit when the field is already known, otherwise fall through to + // locate so the dotted path can be recovered. + if errors.As(err, &typ) && typ.Field != "" { return sanitizeTypeError(typ, nil) } t, ok := targetType(v) diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index d1b726f0..a4458b01 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -595,6 +595,7 @@ export class API { denyPageID, evasionPageID, webhooks, + scripts, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -623,6 +624,7 @@ export class API { denyPageID, evasionPageID, webhooks, + scripts, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -683,6 +685,7 @@ export class API { denyPageID, evasionPageID, webhooks, + scripts, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -710,6 +713,7 @@ export class API { denyPageID, evasionPageID, webhooks, + scripts, constraintWeekDays, constraintStartTime, constraintEndTime, @@ -3355,6 +3359,96 @@ export class API { } }; + /** + * script is the API for script related operations. + * Scripts attach to a campaign like webhooks but run a script. + */ + script = { + /** + * Create a new script. + * + * @param {Object} script + * @param {string} script.name + * @param {string} script.script + * @param {string} [script.companyID] + * @returns {Promise} + */ + create: async ({ name, script, companyID }) => { + return await postJSON(this.getPath('/script'), { + name: name, + script: script, + // send null (global scope) rather than an empty string, which the + // backend would try to parse as a UUID + companyID: companyID || null + }); + }, + + /** + * GetAll scripts. + * + * @param {TableURLParams} options + * @param {string|null} companyID + * @returns {Promise} + */ + getAll: async (options, companyID = null) => { + return await getJSON( + this.getPath(`/script?${appendQuery(options)}${this.appendCompanyQuery(companyID)}`) + ); + }, + + /** + * Get a script by its ID. + * + * @param {string} id + * @returns {Promise} + */ + getByID: async (id) => { + return await getJSON(this.getPath(`/script/${id}`)); + }, + + /** + * Update a script. + * + * @param {Object} script + * @param {string} script.id + * @param {string} script.name + * @param {string} script.script + * @param {string} [script.companyID] + * @returns {Promise} + */ + update: async ({ id, name, script, companyID }) => { + return await patchJSON(this.getPath(`/script/${id}`), { + name: name, + script: script, + companyID: companyID || null + }); + }, + + /** + * Delete a script by its ID. + * + * @param {string} id + * @returns {Promise} + */ + delete: async (id) => { + return await deleteJSON(this.getPath(`/script/${id}`)); + }, + + /** + * Test-run a script against a simulated campaign event. Captures what the + * script does (logs, info/emitEvent, fetches, errors) without touching a + * campaign. + * + * @param {Object} args + * @param {string} args.script + * @param {Object} args.event + * @returns {Promise} + */ + test: async ({ script, event }) => { + return await postJSON(this.getPath('/script/test'), { script, event }); + } + }; + /** * identifier is for campaign identifiers, ala. 'rid' in gophish */ diff --git a/frontend/src/lib/components/TextFieldSelect.svelte b/frontend/src/lib/components/TextFieldSelect.svelte index 56574af9..29ae5fb5 100644 --- a/frontend/src/lib/components/TextFieldSelect.svelte +++ b/frontend/src/lib/components/TextFieldSelect.svelte @@ -310,6 +310,7 @@ class="flex items-center relative" class:w-28={size == 'small'} class:w-60={size == 'normal'} + class:w-full={size == 'full'} > +`, + + script: ` + + ` }; @@ -176,6 +181,7 @@ '/campaign-template/': 'campaign_templates', '/filter/': 'filters', '/webhook/': 'webhooks', + '/script/': 'script', '/recipient/': 'recipients_overview', '/recipient/group/': 'recipient_groups', '/domain/': 'domains_overview', diff --git a/frontend/src/lib/components/header/MobileMenu.svelte b/frontend/src/lib/components/header/MobileMenu.svelte index 123c9589..be9b4e02 100644 --- a/frontend/src/lib/components/header/MobileMenu.svelte +++ b/frontend/src/lib/components/header/MobileMenu.svelte @@ -107,6 +107,11 @@ remote_browser: ` +`, + + script: ` + + `, tools: ` @@ -129,6 +134,7 @@ '/campaign-template/': 'campaign_templates', '/filter/': 'filters', '/webhook/': 'webhooks', + '/script/': 'script', '/recipient/': 'recipients_overview', '/recipient/group/': 'recipient_groups', '/domain/': 'domains_overview', diff --git a/frontend/src/lib/components/script/ScriptEditor.svelte b/frontend/src/lib/components/script/ScriptEditor.svelte new file mode 100644 index 00000000..df2b0587 --- /dev/null +++ b/frontend/src/lib/components/script/ScriptEditor.svelte @@ -0,0 +1,595 @@ + + +
+ +
+
+ Name +
+
+ + +
+
+ + +
+ +
+
+ JavaScript + +
+
+
+
+ + {#if view === 'test'} + +
+ +
+
+ + Event + + Campaign name + Recipient email + + + {#if eventHasData} +
+

Event data (JSON)

+ +
+ {/if} + + {#if testError} +
+ {testError} +
+ {/if} +
+ + +
+ + +
+
+
+ Run log + {#if testing} + + {/if} +
+ {#if testResult || testError} + + {/if} +
+ + +
+ {#if !testResult} + No events yet. Click Run test to execute the script. + {:else} + {#each testResult.entries ?? [] as entry} +
+ {#if entry.type === 'event'} + [{entry.time?.slice(11, 23)}] + emit + {entry.key} + = + {JSON.stringify(entry.value)} + {:else if entry.type === 'info'} + [{entry.time?.slice(11, 23)}] + ℹ info + {entry.message} + {#if entry.data !== undefined && entry.data !== null} + {JSON.stringify(entry.data)} + {/if} + {:else if entry.type === 'done'} + [{entry.time?.slice(11, 23)}] + ✓ done + {:else} + [{entry.time?.slice(11, 23)}] + {entry.message} + {#if entry.data !== undefined && entry.data !== null} + {JSON.stringify(entry.data)} + {/if} + {/if} +
+ {/each} + {/if} +
+
+
+ {/if} +
+
diff --git a/frontend/src/lib/consts/events.js b/frontend/src/lib/consts/events.js new file mode 100644 index 00000000..38ba70be --- /dev/null +++ b/frontend/src/lib/consts/events.js @@ -0,0 +1,22 @@ +// Human-readable display names for campaign events, shared so the campaign +// wizard and the script editor label events the same way instead of showing +// the raw internal names. +export const eventDisplayNames = { + 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_reported: 'Reported', + 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', + campaign_recipient_training_started: 'Training Started', + campaign_recipient_training_completed: 'Training Completed' +}; + +// eventDisplayLabel returns the friendly label for an event name, falling back +// to the raw name for anything unmapped. +export const eventDisplayLabel = (name) => eventDisplayNames[name] || name; diff --git a/frontend/src/lib/consts/navigation.js b/frontend/src/lib/consts/navigation.js index 22c8bad5..dbecb880 100644 --- a/frontend/src/lib/consts/navigation.js +++ b/frontend/src/lib/consts/navigation.js @@ -101,6 +101,11 @@ export const route = { label: 'Webhooks', route: '/webhook/' }, + script: { + label: 'Scripts', + route: '/script/', + blackbox: true + }, userGuide: { label: 'User Guide', route: 'https://phishing.club/guide/introduction/', @@ -118,7 +123,13 @@ export const menu = [ { label: 'Campaigns', type: 'submenu', - items: [route.campaigns, route.campaignTemplates, route.allowDeny, route.webhook] + items: [ + route.campaigns, + route.campaignTemplates, + route.allowDeny, + route.webhook, + route.script + ] }, { diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 6917a5d9..f2130452 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { newTableURLParams } from '$lib/service/tableURLParams.js'; + import { eventDisplayNames } from '$lib/consts/events.js'; import Headline from '$lib/components/Headline.svelte'; import TextField from '$lib/components/TextField.svelte'; import TableRow from '$lib/components/table/TableRow.svelte'; @@ -178,21 +179,8 @@ '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', - campaign_recipient_training_started: 'Training Started', - campaign_recipient_training_completed: 'Training Completed' - }; + // human-readable display names for webhook events (shared with the script editor) + const webhookEventDisplayNames = eventDisplayNames; // create display options array with nice names const webhookEventDisplayOptions = webhookEventOptions.map((event) => ({ @@ -279,6 +267,11 @@ let denyPageMap = new BiMap({}); let allowDenyMap = new BiMap({}); let webhookMap = new BiMap({}); + // scripts reuse the webhook event bit map and data levels. The feature is + // only shown when the server has it enabled (the list endpoint returns 404 + // otherwise). + let scriptMap = new BiMap({}); + let scriptsEnabled = false; let modalMode = null; let scheduleType = 'basic'; let allowDenyType = 'none'; @@ -493,6 +486,7 @@ obfuscate: false, selectedCount: 0, webhooks: [], // array of {id, includeData, events} + scripts: [], // array of {id, includeData, events} jitterMin: 0, jitterMax: 0 }; @@ -768,6 +762,21 @@ return api.webhook.getAll(options, contextCompanyID); }); webhookMap = BiMap.FromArrayOfObjects(webhooks); + + // load scripts only when the feature is enabled; the endpoint returns + // 404 when disabled, in which case the scripts UI stays hidden. + try { + const res = await api.script.getAll({}, contextCompanyID); + if (res.success) { + scriptsEnabled = true; + const scripts = await fetchAllRows((options) => { + return api.script.getAll(options, contextCompanyID); + }); + scriptMap = BiMap.FromArrayOfObjects(scripts); + } + } catch (e) { + scriptsEnabled = false; + } }; // Parse a YYYY-MM-DD string as local midnight, not UTC midnight. @@ -924,12 +933,19 @@ constraintStartTime: contraintStartTimeUTC, constraintEndTime: contraintEndTimeUTC, webhooks: formValues.webhooks - .filter((wh) => wh.id !== null) + .filter((wh) => wh.id) .map((wh) => ({ webhookID: wh.id, webhookIncludeData: wh.includeData, webhookEvents: webhookEventsToBinary(wh.events) })), + scripts: formValues.scripts + .filter((a) => a.id) + .map((a) => ({ + scriptID: a.id, + scriptIncludeData: a.includeData, + scriptEvents: webhookEventsToBinary(a.events) + })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null, scheduleAt: scheduleAtUTC @@ -1003,12 +1019,19 @@ denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue), evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue), webhooks: formValues.webhooks - .filter((wh) => wh.id !== null) + .filter((wh) => wh.id) .map((wh) => ({ webhookID: wh.id, webhookIncludeData: wh.includeData, webhookEvents: webhookEventsToBinary(wh.events) })), + scripts: formValues.scripts + .filter((a) => a.id) + .map((a) => ({ + scriptID: a.id, + scriptIncludeData: a.includeData, + scriptEvents: webhookEventsToBinary(a.events) + })), jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null, jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null, scheduleAt: scheduleAtUTC @@ -1160,6 +1183,7 @@ obfuscate: false, selectedCount: 0, webhooks: [], + scripts: [], jitterMin: 0, jitterMax: 0 }; @@ -1297,6 +1321,16 @@ } // no webhooks return []; + })(), + scripts: (() => { + if (campaign.scripts && campaign.scripts.length > 0) { + return campaign.scripts.map((a) => ({ + id: a.scriptID, + includeData: a.scriptIncludeData ?? 'full', + events: webhookEventsFromBinary(a.scriptEvents ?? 0) + })); + } + return []; })() }; @@ -1345,6 +1379,7 @@ showAdvancedOptionsStep4 = !!( campaign.webhookID || campaign.webhooks?.length || + campaign.scripts?.length || campaign.denyPage || campaign.evasionPage || campaign.allowDeny?.length || @@ -1504,6 +1539,37 @@ formValues.webhooks = [...formValues.webhooks]; // trigger reactivity }; + // script helper functions, mirroring the webhook ones + const addScript = () => { + formValues.scripts = [ + ...formValues.scripts, + { + id: null, + includeData: 'full', + events: [...webhookEventOptions] // all events by default + } + ]; + }; + + const removeScript = (index) => { + formValues.scripts = formValues.scripts.filter((_, i) => i !== index); + }; + + const toggleScriptEvent = (scriptIndex, eventValue) => { + const script = formValues.scripts[scriptIndex]; + const isSelected = script.events.includes(eventValue); + + if (isSelected) { + // prevent unselecting the last item + if (script.events.length > 1) { + script.events = script.events.filter((e) => e !== eventValue); + } + } else { + script.events = [...script.events, eventValue]; + } + formValues.scripts = [...formValues.scripts]; // trigger reactivity + }; + // check if user is in the correct context for campaign actions const isContextMismatch = (campaign) => { const context = appStateService.getContext(); @@ -2368,6 +2434,110 @@ + + {#if scriptsEnabled} +
+
+
+

Scripts

+ + Run a script when a campaign event fires. Each script has its own + data level and event filters, the same as webhooks. + +
+

+ optional +

+
+
+
+ {#each formValues.scripts as script, index} +
+
+
+ ({ value: k, label: scriptMap.byKey(k) }))} + > + Script + +
+
+ +
+
+ +
+ +
+ +
+
+

+ Events +

+ + {script.events.length === webhookEventOptions.length + ? 'All' + : script.events.length} / {webhookEventOptions.length} + +
+
+ {#each webhookEventDisplayOptions as eventOption} + {@const isSelected = script.events.includes(eventOption.value)} + + {/each} +
+
+
+ {/each} + +
+
+
+ {/if} +
+
{/if} + + {#if scriptsEnabled && formValues.scripts.length > 0} + Scripts: +
+ {#each formValues.scripts as script, index} +
+
+ {scriptMap.byKey(script.id) || 'Not selected'} +
+
+ Data Level: {script.includeData} +
+
+ Events: {script.events.length === webhookEventOptions.length + ? 'All Events' + : script.events.length + ' selected'} +
+
+ {/each} +
+ {/if} +
+ {#if formValues.denyPageValue} Deny Page: webhook name for display let webhookMap = new BiMap({}); + // map of script id -> script name for display + let scriptMap = new BiMap({}); let allowedFilter = null; let campaignRecipients = []; let campaignRecipientsHasNextPage = false; @@ -378,6 +381,7 @@ campaign.evasionPage = t.evasionPage; campaign.webhookID = t.webhookID; campaign.webhooks = t.webhooks ?? []; + campaign.scripts = t.scripts ?? []; campaign.companyID = t.companyID; campaign.company = t.company; @@ -391,6 +395,19 @@ console.error('failed to load webhooks for display', e); } + // load script names for display; the endpoint 404s when the feature is + // off, which the catch swallows so the view still renders + if (campaign.scripts.length) { + try { + const allScripts = await fetchAllRows((options) => { + return api.script.getAll(options, campaign.companyID ?? null); + }); + scriptMap = BiMap.FromArrayOfObjects(allScripts); + } catch (e) { + console.error('failed to load scripts for display', e); + } + } + // if company exists but name is missing, fetch it if (campaign.companyID && !campaign.company?.name) { try { @@ -2217,6 +2234,21 @@
+ {#if campaign.scripts?.length} +
+ Scripts: +
+ {#each campaign.scripts as s, i} + {scriptMap.byKey(s.scriptID) || s.scriptID}{#if i < campaign.scripts.length - 1}, {/if} + {/each} +
+
+ {/if} +
Data Saving: diff --git a/frontend/src/routes/script/+page.svelte b/frontend/src/routes/script/+page.svelte new file mode 100644 index 00000000..56a5df87 --- /dev/null +++ b/frontend/src/routes/script/+page.svelte @@ -0,0 +1,365 @@ + + + +
+ Scripts + + {#if featureDisabled} +
+

Scripts are not enabled

+

+ This feature is disabled by default for security reasons. When enabled, any operator with + access can write scripts that run on the server when campaign events fire — including + making outbound HTTP requests. Only enable it on instances where every operator is trusted + as a server admin. +

+

+ To enable it, set enabled: true + in the script block of + config.json and restart the service. +

+
+ {:else} + New script + + {#each scripts as script} + + + + + {#if contextCompanyID} + + {/if} + + + + openEditModal(script.id)} + {...globalButtonDisabledAttributes(script, contextCompanyID)} + /> + openCopyModal(script.id)} + {...globalButtonDisabledAttributes(script, contextCompanyID)} + /> + openDeleteAlert(script)} + {...globalButtonDisabledAttributes(script, contextCompanyID)} + > + + + + {/each} +
+ {/if} + + + +
+ {#key editorKey} + + {/key} +
+ + + + +
+
+ + onClickDelete(deleteValues.id)} + bind:isVisible={isDeleteAlertVisible} + > +