mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-07 15:52:47 +01:00
Compare commits
12 Commits
v0.13.4
...
v0.13.8-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d0cd3428 | ||
|
|
baba5b7a45 | ||
|
|
d1cf75ab79 | ||
|
|
38a89f2c94 | ||
|
|
8c36ea5243 | ||
|
|
a2bdeecb0d | ||
|
|
42797509f7 | ||
|
|
70ba16a6bd | ||
|
|
f42de34780 | ||
|
|
88f56a2bdb | ||
|
|
88cc02b350 | ||
|
|
46f7815699 |
7
.github/workflows/goreleaser.yml
vendored
7
.github/workflows/goreleaser.yml
vendored
@@ -7,6 +7,8 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
goreleaser:
|
goreleaser:
|
||||||
@@ -21,6 +23,11 @@ jobs:
|
|||||||
|
|
||||||
- run: make build-ui
|
- run: make build-ui
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: "securitybrewery"
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: goreleaser/goreleaser-action@v6
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- ids: [ catalyst ]
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
image_templates:
|
||||||
|
- "ghcr.io/securitybrewery/catalyst:main"
|
||||||
|
- "{{if not .Prerelease}}ghcr.io/securitybrewery/catalyst:latest{{end}}"
|
||||||
|
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- format: tar.gz
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
|||||||
22
Makefile
22
Makefile
@@ -39,23 +39,37 @@ build-ui:
|
|||||||
cd ui && bun install
|
cd ui && bun install
|
||||||
cd ui && bun build-only
|
cd ui && bun build-only
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: build-ui
|
||||||
|
@echo "Building..."
|
||||||
|
go build -o catalyst .
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: build-linux
|
||||||
|
build-linux: build-ui
|
||||||
|
@echo "Building..."
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o catalyst .
|
||||||
|
|
||||||
|
.PHONY: docker
|
||||||
|
docker: build-linux
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -f docker/Dockerfile -t catalyst .
|
||||||
|
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
dev:
|
dev:
|
||||||
@echo "Running..."
|
@echo "Running..."
|
||||||
rm -rf catalyst_data
|
rm -rf catalyst_data
|
||||||
go run . admin create admin@catalyst-soar.com 1234567890
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
go run . set-feature-flags dev
|
|
||||||
go run . fake-data
|
go run . fake-data
|
||||||
go run . serve
|
go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
|
||||||
.PHONY: dev-10000
|
.PHONY: dev-10000
|
||||||
dev-10000:
|
dev-10000:
|
||||||
@echo "Running..."
|
@echo "Running..."
|
||||||
rm -rf catalyst_data
|
rm -rf catalyst_data
|
||||||
go run . admin create admin@catalyst-soar.com 1234567890
|
go run . admin create admin@catalyst-soar.com 1234567890
|
||||||
go run . set-feature-flags dev
|
|
||||||
go run . fake-data --users 100 --tickets 10000
|
go run . fake-data --users 100 --tickets 10000
|
||||||
go run . serve
|
go run . serve --app-url http://localhost:8090 --flags dev
|
||||||
|
|
||||||
.PHONY: serve-ui
|
.PHONY: serve-ui
|
||||||
serve-ui:
|
serve-ui:
|
||||||
|
|||||||
46
app/app.go
46
app/app.go
@@ -23,31 +23,47 @@ func App(dir string, test bool) (*pocketbase.PocketBase, error) {
|
|||||||
DefaultDataDir: dir,
|
DefaultDataDir: dir,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var appURL string
|
||||||
|
|
||||||
|
app.RootCmd.PersistentFlags().StringVar(&appURL, "app-url", "", "the app's URL")
|
||||||
|
|
||||||
|
var flags []string
|
||||||
|
|
||||||
|
app.RootCmd.PersistentFlags().StringSliceVar(&flags, "flags", nil, "feature flags")
|
||||||
|
|
||||||
|
_ = app.RootCmd.ParseFlags(os.Args[1:])
|
||||||
|
|
||||||
|
app.RootCmd.AddCommand(fakeDataCmd(app))
|
||||||
|
|
||||||
webhook.BindHooks(app)
|
webhook.BindHooks(app)
|
||||||
reaction.BindHooks(app, test)
|
reaction.BindHooks(app, test)
|
||||||
|
|
||||||
app.OnBeforeServe().Add(addRoutes())
|
|
||||||
|
|
||||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||||
|
if err := MigrateDBs(e.App); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetFlags(e.App, flags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if HasFlag(e.App, "demo") {
|
if HasFlag(e.App, "demo") {
|
||||||
bindDemoHooks(e.App)
|
bindDemoHooks(e.App)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if appURL != "" {
|
||||||
|
s := e.App.Settings()
|
||||||
|
s.Meta.AppUrl = appURL
|
||||||
|
|
||||||
|
if err := e.App.Dao().SaveSettings(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.App.RefreshSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register additional commands
|
app.OnBeforeServe().Add(addRoutes())
|
||||||
app.RootCmd.AddCommand(fakeDataCmd(app))
|
|
||||||
app.RootCmd.AddCommand(setFeatureFlagsCmd(app))
|
|
||||||
app.RootCmd.AddCommand(setAppURL(app))
|
|
||||||
|
|
||||||
if err := app.Bootstrap(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := MigrateDBs(app); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return app, nil
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ func fakeDataCmd(app core.App) *cobra.Command {
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "fake-data",
|
Use: "fake-data",
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
if err := fakedata.Generate(app, userCount, ticketCount); err != nil {
|
return fakedata.Generate(app, userCount, ticketCount)
|
||||||
app.Logger().Error(err.Error())
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
app/flags.go
38
app/flags.go
@@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/models"
|
"github.com/pocketbase/pocketbase/models"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/SecurityBrewery/catalyst/migrations"
|
"github.com/SecurityBrewery/catalyst/migrations"
|
||||||
)
|
)
|
||||||
@@ -85,40 +84,3 @@ func SetFlags(app core.App, args []string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFeatureFlagsCmd(app core.App) *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "set-feature-flags",
|
|
||||||
Run: func(_ *cobra.Command, args []string) {
|
|
||||||
if err := SetFlags(app, args); err != nil {
|
|
||||||
app.Logger().Error(err.Error())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setAppURL(app core.App) *cobra.Command {
|
|
||||||
return &cobra.Command{
|
|
||||||
Use: "set-app-url",
|
|
||||||
Run: func(_ *cobra.Command, args []string) {
|
|
||||||
if len(args) != 1 {
|
|
||||||
app.Logger().Error("missing app url")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := app.Settings().Clone()
|
|
||||||
if err != nil {
|
|
||||||
app.Logger().Error(err.Error())
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.Meta.AppUrl = args[0]
|
|
||||||
|
|
||||||
if err := app.Dao().SaveSettings(settings); err != nil {
|
|
||||||
app.Logger().Error(err.Error())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ func addRoutes() func(*core.ServeEvent) error {
|
|||||||
return c.Redirect(http.StatusFound, "/ui/")
|
return c.Redirect(http.StatusFound, "/ui/")
|
||||||
})
|
})
|
||||||
e.Router.GET("/ui/*", staticFiles())
|
e.Router.GET("/ui/*", staticFiles())
|
||||||
|
e.Router.GET("/health", func(c echo.Context) error {
|
||||||
|
if _, err := Flags(e.App); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.String(http.StatusOK, "OK")
|
||||||
|
})
|
||||||
|
|
||||||
e.Router.GET("/api/config", func(c echo.Context) error {
|
e.Router.GET("/api/config", func(c echo.Context) error {
|
||||||
flags, err := Flags(e.App)
|
flags, err := Flags(e.App)
|
||||||
|
|||||||
16
docker/Dockerfile
Normal file
16
docker/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y curl python3 python3-pip python3-venv
|
||||||
|
|
||||||
|
COPY catalyst /usr/local/bin/catalyst
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
VOLUME /usr/local/bin/catalyst_data
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
15
docker/entrypoint.sh
Normal file
15
docker/entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Set the flags
|
||||||
|
FLAGS=""
|
||||||
|
if [ -n "$CATALYST_FLAGS" ]; then
|
||||||
|
FLAGS="$CATALYST_FLAGS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set the app url
|
||||||
|
APP_URL=""
|
||||||
|
if [ -n "$CATALYST_APP_URL" ]; then
|
||||||
|
APP_URL="$CATALYST_APP_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
/usr/local/bin/catalyst serve --http 0.0.0.0:8080 --flags "$FLAGS" --app-url "$APP_URL"
|
||||||
@@ -248,6 +248,28 @@ func linkRecords(dao *daos.Dao, created time.Time, record *models.Record) []*mod
|
|||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createTicketPy = `import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pocketbase import PocketBase
|
||||||
|
|
||||||
|
# Connect to the PocketBase server
|
||||||
|
client = PocketBase(os.environ["CATALYST_APP_URL"])
|
||||||
|
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
|
||||||
|
|
||||||
|
newtickets = client.collection("tickets").get_list(1, 200, {"filter": 'name = "New Ticket"'})
|
||||||
|
for ticket in newtickets.items:
|
||||||
|
client.collection("tickets").delete(ticket.id)
|
||||||
|
|
||||||
|
# Create a new ticket
|
||||||
|
client.collection("tickets").create({
|
||||||
|
"name": "New Ticket",
|
||||||
|
"type": "alert",
|
||||||
|
"open": True,
|
||||||
|
})`
|
||||||
|
|
||||||
const alertIngestPy = `import sys
|
const alertIngestPy = `import sys
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
@@ -294,8 +316,9 @@ client.collection("tickets").update(ticket["record"]["id"], {
|
|||||||
})`
|
})`
|
||||||
|
|
||||||
const (
|
const (
|
||||||
triggerWebhook = `{"token":"1234567890","path":"webhook"}`
|
triggerSchedule = `{"expression":"12 * * * *"}`
|
||||||
triggerHook = `{"collections":["tickets"],"events":["create"]}`
|
triggerWebhook = `{"token":"1234567890","path":"webhook"}`
|
||||||
|
triggerHook = `{"collections":["tickets"],"events":["create"]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
func reactionRecords(dao *daos.Dao) []*models.Record {
|
func reactionRecords(dao *daos.Dao) []*models.Record {
|
||||||
@@ -306,6 +329,24 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTicketActionData, err := json.Marshal(map[string]interface{}{
|
||||||
|
"requirements": "pocketbase",
|
||||||
|
"script": createTicketPy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record := models.NewRecord(collection)
|
||||||
|
record.SetId("w_" + security.PseudorandomString(10))
|
||||||
|
record.Set("name", "Create New Ticket")
|
||||||
|
record.Set("trigger", "schedule")
|
||||||
|
record.Set("triggerdata", triggerSchedule)
|
||||||
|
record.Set("action", "python")
|
||||||
|
record.Set("actiondata", string(createTicketActionData))
|
||||||
|
|
||||||
|
records = append(records, record)
|
||||||
|
|
||||||
alertIngestActionData, err := json.Marshal(map[string]interface{}{
|
alertIngestActionData, err := json.Marshal(map[string]interface{}{
|
||||||
"requirements": "pocketbase",
|
"requirements": "pocketbase",
|
||||||
"script": alertIngestPy,
|
"script": alertIngestPy,
|
||||||
@@ -314,7 +355,7 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
record := models.NewRecord(collection)
|
record = models.NewRecord(collection)
|
||||||
record.SetId("w_" + security.PseudorandomString(10))
|
record.SetId("w_" + security.PseudorandomString(10))
|
||||||
record.Set("name", "Alert Ingest Webhook")
|
record.Set("name", "Alert Ingest Webhook")
|
||||||
record.Set("trigger", "webhook")
|
record.Set("trigger", "webhook")
|
||||||
|
|||||||
28
migrations/9_reactions_update.go
Normal file
28
migrations/9_reactions_update.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
|
"github.com/pocketbase/pocketbase/models/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func reactionsUpdateUp(db dbx.Builder) error {
|
||||||
|
dao := daos.New(db)
|
||||||
|
|
||||||
|
triggers := []string{"webhook", "hook", "schedule"}
|
||||||
|
|
||||||
|
col, err := dao.FindCollectionByNameOrId(ReactionCollectionName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
field := col.Schema.GetFieldByName("trigger")
|
||||||
|
|
||||||
|
field.Options = &schema.SelectOptions{MaxSelect: 1, Values: triggers}
|
||||||
|
|
||||||
|
col.Schema.AddField(field)
|
||||||
|
|
||||||
|
return dao.SaveCollection(col)
|
||||||
|
}
|
||||||
@@ -13,4 +13,5 @@ func Register() {
|
|||||||
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
|
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
|
||||||
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
|
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
|
||||||
migrations.Register(dashboardCountsViewUpdateUp, dashboardCountsViewUpdateDown, "1700000008_dashboardview.go")
|
migrations.Register(dashboardCountsViewUpdateUp, dashboardCountsViewUpdateDown, "1700000008_dashboardview.go")
|
||||||
|
migrations.Register(reactionsUpdateUp, nil, "1700000009_reactions_update.go")
|
||||||
}
|
}
|
||||||
|
|||||||
101
reaction/schedule/schedule.go
Normal file
101
reaction/schedule/schedule.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package schedule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/daos"
|
||||||
|
"github.com/pocketbase/pocketbase/models"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/cron"
|
||||||
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/migrations"
|
||||||
|
"github.com/SecurityBrewery/catalyst/reaction/action"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Schedule struct {
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Start(pb *pocketbase.PocketBase) {
|
||||||
|
scheduler := cron.New()
|
||||||
|
|
||||||
|
if err := scheduler.Add("reactions", "* * * * *", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
moment := cron.NewMoment(time.Now())
|
||||||
|
|
||||||
|
if err := runSchedule(ctx, pb.App, moment); err != nil {
|
||||||
|
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error(fmt.Sprintf("failed to add cron job: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSchedule(ctx context.Context, app core.App, moment *cron.Moment) error {
|
||||||
|
var errs error
|
||||||
|
|
||||||
|
records, err := findByScheduleTrigger(app.Dao(), moment)
|
||||||
|
if err != nil {
|
||||||
|
errs = multierr.Append(errs, fmt.Errorf("failed to find schedule reaction: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hook := range records {
|
||||||
|
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), "{}")
|
||||||
|
if err != nil {
|
||||||
|
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func findByScheduleTrigger(dao *daos.Dao, moment *cron.Moment) ([]*models.Record, error) {
|
||||||
|
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "schedule"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to find schedule reaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs error
|
||||||
|
|
||||||
|
var matchedRecords []*models.Record
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
var schedule Schedule
|
||||||
|
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &schedule); err != nil {
|
||||||
|
errs = multierr.Append(errs, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := cron.NewSchedule(schedule.Expression)
|
||||||
|
if err != nil {
|
||||||
|
errs = multierr.Append(errs, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.IsDue(moment) {
|
||||||
|
matchedRecords = append(matchedRecords, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedRecords, errs
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ package reaction
|
|||||||
import (
|
import (
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
|
|
||||||
|
"github.com/SecurityBrewery/catalyst/reaction/schedule"
|
||||||
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
|
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
|
||||||
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
|
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BindHooks(pb *pocketbase.PocketBase, test bool) {
|
func BindHooks(pb *pocketbase.PocketBase, test bool) {
|
||||||
|
schedule.Start(pb)
|
||||||
hook.BindHooks(pb, test)
|
hook.BindHooks(pb, test)
|
||||||
webhook.BindHooks(pb)
|
webhook.BindHooks(pb)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func App(t *testing.T) (*pocketbase.PocketBase, *Counter, func()) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := baseApp.Bootstrap(); err != nil {
|
||||||
|
t.Fatal(fmt.Errorf("failed to bootstrap: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
baseApp.Settings().Logs.MaxDays = 0
|
baseApp.Settings().Logs.MaxDays = 0
|
||||||
|
|
||||||
defaultTestData(t, baseApp)
|
defaultTestData(t, baseApp)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
|
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
|
||||||
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
|
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
|
||||||
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
|
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
|
||||||
|
import TriggerScheduleFormFields from '@/components/reaction/TriggerScheduleFormFields.vue'
|
||||||
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
|
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -67,6 +68,25 @@ defineRule('required', (value: string) => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineRule('triggerdata.expression', (value: string) => {
|
||||||
|
if (values.trigger !== 'schedule') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
return 'This field is required'
|
||||||
|
}
|
||||||
|
const macros = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly']
|
||||||
|
if (macros.includes(value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const expression =
|
||||||
|
/^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/
|
||||||
|
if (value.match(expression)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return 'Invalid cron expression'
|
||||||
|
})
|
||||||
|
|
||||||
defineRule('triggerdata.token', (value: string) => {
|
defineRule('triggerdata.token', (value: string) => {
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -160,6 +180,7 @@ const { handleSubmit, validate, values } = useForm({
|
|||||||
validationSchema: {
|
validationSchema: {
|
||||||
name: 'required',
|
name: 'required',
|
||||||
trigger: 'required',
|
trigger: 'required',
|
||||||
|
'triggerdata.expression': 'triggerdata.expression',
|
||||||
'triggerdata.token': 'triggerdata.token',
|
'triggerdata.token': 'triggerdata.token',
|
||||||
'triggerdata.path': 'triggerdata.path',
|
'triggerdata.path': 'triggerdata.path',
|
||||||
'triggerdata.collections': 'triggerdata.collections',
|
'triggerdata.collections': 'triggerdata.collections',
|
||||||
@@ -263,6 +284,7 @@ const curlExample = computed(() => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
<SelectItem value="schedule">Schedule</SelectItem>
|
||||||
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
|
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
|
||||||
<SelectItem value="hook">Collection Hook</SelectItem>
|
<SelectItem value="hook">Collection Hook</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
@@ -277,7 +299,8 @@ const curlExample = computed(() => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
|
<TriggerScheduleFormFields v-if="values.trigger === 'schedule'" />
|
||||||
|
<TriggerWebhookFormFields v-else-if="values.trigger === 'webhook'" />
|
||||||
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
|
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
|
||||||
|
|
||||||
<div v-if="values.trigger === 'webhook'">
|
<div v-if="values.trigger === 'webhook'">
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ const subtitle = (reaction: Reaction) =>
|
|||||||
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
|
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
|
||||||
|
|
||||||
const triggerNiceName = (reaction: Reaction) => {
|
const triggerNiceName = (reaction: Reaction) => {
|
||||||
if (reaction.trigger === 'hook') {
|
if (reaction.trigger === 'schedule') {
|
||||||
|
return 'Schedule'
|
||||||
|
} else if (reaction.trigger === 'hook') {
|
||||||
return 'Collection Hook'
|
return 'Collection Hook'
|
||||||
} else if (reaction.trigger === 'webhook') {
|
} else if (reaction.trigger === 'webhook') {
|
||||||
return 'HTTP / Webhook'
|
return 'HTTP / Webhook'
|
||||||
|
|||||||
42
ui/src/components/reaction/TriggerScheduleFormFields.vue
Normal file
42
ui/src/components/reaction/TriggerScheduleFormFields.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField name="triggerdata.expression" v-slot="{ componentField }" validate-on-input>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel for="expression" class="text-right"> Cron Expression </FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
id="expression"
|
||||||
|
class="col-span-3"
|
||||||
|
v-bind="componentField"
|
||||||
|
placeholder="Enter a cron expression"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
A cron expression or macro to schedule the trigger. Example: <code>0 * * * *</code> (every
|
||||||
|
hour) or <code>@daily</code> (every day).
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/Cron#CRON_expression"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-600 underline"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
@@ -15,15 +15,11 @@ import { Input } from '@/components/ui/input'
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel for="token" class="text-right">Token</FormLabel>
|
<FormLabel for="token" class="text-right">Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input id="token" class="col-span-3" v-bind="componentField" placeholder="Enter a token" />
|
||||||
id="token"
|
|
||||||
class="col-span-3"
|
|
||||||
v-bind="componentField"
|
|
||||||
placeholder="Enter a token (e.g. 'xyz...')"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Optional. Include an authorization token in the request headers.
|
Optional. Include an authorization token in the request headers. Example:
|
||||||
|
<code>Bearer YOUR_TOKEN</code>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -33,14 +29,11 @@ import { Input } from '@/components/ui/input'
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel for="path" class="text-right">Path</FormLabel>
|
<FormLabel for="path" class="text-right">Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input id="path" class="col-span-3" v-bind="componentField" placeholder="Enter a path" />
|
||||||
id="path"
|
|
||||||
class="col-span-3"
|
|
||||||
v-bind="componentField"
|
|
||||||
placeholder="Enter a path (e.g. 'action1')"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
|
<FormDescription>
|
||||||
|
Specify the path to trigger the reaction. Example: <code>action1</code>
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|||||||
Reference in New Issue
Block a user