Release catalyst

This commit is contained in:
Jonas Plum
2021-12-13 00:39:15 +01:00
commit 15cf0ebd49
339 changed files with 111677 additions and 0 deletions

101
database/artifact.go Normal file
View File

@@ -0,0 +1,101 @@
package database
import (
"context"
"fmt"
"time"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func (db *Database) ArtifactGet(ctx context.Context, id int64, name string) (*models.Artifact, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
FOR a in NOT_NULL(d.artifacts, [])
FILTER a.name == @name
RETURN a`
cursor, _, err := db.Query(ctx, query, mergeMaps(ticketFilterVars, map[string]interface{}{
"@collection": TicketCollectionName,
"ID": fmt.Sprint(id),
"name": name,
}), busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var doc models.Artifact
_, err = cursor.ReadDocument(ctx, &doc)
if err != nil {
return nil, err
}
return &doc, nil
}
func (db *Database) ArtifactUpdate(ctx context.Context, id int64, name string, artifact *models.Artifact) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
FOR a IN NOT_NULL(d.artifacts, [])
FILTER a.name == @name
LET newartifacts = APPEND(REMOVE_VALUE(d.artifacts, a), @artifact)
UPDATE d WITH { "artifacts": newartifacts } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"@collection": TicketCollectionName,
"ID": id,
"name": name,
"artifact": artifact,
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: fmt.Sprintf("Update artifact %s", name),
})
}
func (db *Database) EnrichArtifact(ctx context.Context, id int64, name string, enrichmentForm *models.EnrichmentForm) (*models.TicketWithTickets, error) {
enrichment := models.Enrichment{time.Now().UTC(), enrichmentForm.Data, enrichmentForm.Name}
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
FOR a IN d.artifacts
FILTER a.name == @name
LET enrichments = NOT_NULL(a.enrichments, {})
LET newenrichments = MERGE(enrichments, ZIP( [@enrichmentname], [@enrichment]) )
LET newartifacts = APPEND(REMOVE_VALUE(d.artifacts, a), MERGE(a, { "enrichments": newenrichments }))
UPDATE d WITH { "artifacts": newartifacts } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"@collection": TicketCollectionName,
"ID": id,
"name": name,
"enrichmentname": enrichment.Name,
"enrichment": enrichment,
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: fmt.Sprintf("Run %s on artifact", enrichment.Name),
})
}

99
database/automation.go Normal file
View File

@@ -0,0 +1,99 @@
package database
import (
"context"
"errors"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func toAutomation(doc *models.AutomationForm) interface{} {
return &models.Automation{
Image: doc.Image,
Script: doc.Script,
Schema: doc.Schema,
Type: doc.Type,
}
}
func toAutomationResponse(id string, doc models.Automation) *models.AutomationResponse {
return &models.AutomationResponse{
ID: id,
Image: doc.Image,
Script: doc.Script,
Schema: doc.Schema,
Type: doc.Type,
}
}
func (db *Database) AutomationCreate(ctx context.Context, automation *models.AutomationForm) (*models.AutomationResponse, error) {
if automation == nil {
return nil, errors.New("requires automation")
}
if automation.ID == "" {
return nil, errors.New("requires automation ID")
}
var doc models.Automation
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.automationCollection.CreateDocument(ctx, newctx, automation.ID, toAutomation(automation))
if err != nil {
return nil, err
}
return toAutomationResponse(meta.Key, doc), nil
}
func (db *Database) AutomationGet(ctx context.Context, id string) (*models.AutomationResponse, error) {
var doc models.Automation
meta, err := db.automationCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toAutomationResponse(meta.Key, doc), nil
}
func (db *Database) AutomationUpdate(ctx context.Context, id string, automation *models.AutomationForm) (*models.AutomationResponse, error) {
var doc models.Automation
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.automationCollection.ReplaceDocument(ctx, id, toAutomation(automation))
if err != nil {
return nil, err
}
return toAutomationResponse(meta.Key, doc), nil
}
func (db *Database) AutomationDelete(ctx context.Context, id string) error {
_, err := db.automationCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) AutomationList(ctx context.Context) ([]*models.AutomationResponse, error) {
query := "FOR d IN @@collection SORT d._key ASC RETURN UNSET(d, 'script')"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": AutomationCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.AutomationResponse
for {
var doc models.Automation
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, toAutomationResponse(meta.Key, doc))
}
return docs, err
}

182
database/busdb/busdb.go Normal file
View File

@@ -0,0 +1,182 @@
package busdb
import (
"context"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/bus"
"github.com/SecurityBrewery/catalyst/generated/models"
)
type Hook interface {
PublishAction(action string, context, msg map[string]interface{}) error
PublishUpdate(col, id string) error
}
// BusDatabase
// 1. Save entry to log
// 2. Send update ticket to bus
// 3. Add document to index
type BusDatabase struct {
internal driver.Database
logCollection driver.Collection
bus *bus.Bus
// index *index.Index
}
func NewDatabase(ctx context.Context, internal driver.Database, b *bus.Bus) (*BusDatabase, error) {
logCollection, err := internal.Collection(ctx, LogCollectionName)
if err != nil {
return nil, err
}
return &BusDatabase{
internal: internal,
logCollection: logCollection,
bus: b,
}, nil
}
type OperationType int
const (
Create OperationType = iota
Read = iota
Update = iota
)
type Operation struct {
OperationType OperationType
Ids []driver.DocumentID
Msg string
}
var CreateOperation = &Operation{OperationType: Create}
var ReadOperation = &Operation{OperationType: Read}
func (db BusDatabase) Query(ctx context.Context, query string, vars map[string]interface{}, operation *Operation) (driver.Cursor, *models.LogEntry, error) {
cur, err := db.internal.Query(ctx, query, vars)
if err != nil {
return nil, nil, err
}
var logs *models.LogEntry
switch {
case operation.OperationType == Update:
if err := db.LogAndNotify(ctx, operation.Ids, operation.Msg); err != nil {
return nil, nil, err
}
}
return cur, logs, err
}
func (db BusDatabase) LogAndNotify(ctx context.Context, ids []driver.DocumentID, msg string) error {
var logEntries []*models.LogEntry
for _, i := range ids {
logEntries = append(logEntries, &models.LogEntry{Reference: i.String(), Message: msg})
}
if err := db.LogBatchCreate(ctx, logEntries); err != nil {
return err
}
return db.bus.PublishUpdate(ids)
}
func (db BusDatabase) Remove(ctx context.Context) error {
return db.internal.Remove(ctx)
}
func (db BusDatabase) Collection(ctx context.Context, name string) (driver.Collection, error) {
return db.internal.Collection(ctx, name)
}
type Collection struct {
internal driver.Collection
db *BusDatabase
}
func NewCollection(internal driver.Collection, db *BusDatabase) *Collection {
return &Collection{internal: internal, db: db}
}
func (c Collection) CreateDocument(ctx, newctx context.Context, key string, document interface{}) (driver.DocumentMeta, error) {
meta, err := c.internal.CreateDocument(newctx, &Keyed{Key: key, Doc: document})
if err != nil {
return meta, err
}
err = c.db.LogAndNotify(ctx, []driver.DocumentID{meta.ID}, "Document created")
if err != nil {
return meta, err
}
return meta, nil
}
func (c Collection) CreateEdge(ctx, newctx context.Context, edge *driver.EdgeDocument) (driver.DocumentMeta, error) {
meta, err := c.internal.CreateDocument(newctx, edge)
if err != nil {
return meta, err
}
err = c.db.LogAndNotify(ctx, []driver.DocumentID{meta.ID}, "Document created")
if err != nil {
return meta, err
}
return meta, nil
}
func (c Collection) CreateEdges(ctx context.Context, edges []*driver.EdgeDocument) (driver.DocumentMetaSlice, error) {
metas, errs, err := c.internal.CreateDocuments(ctx, edges)
if err != nil {
return nil, err
}
if errs.FirstNonNil() != nil {
return nil, errs.FirstNonNil()
}
var ids []driver.DocumentID
for _, meta := range metas {
ids = append(ids, meta.ID)
}
err = c.db.LogAndNotify(ctx, ids, "Document created")
if err != nil {
return metas, err
}
return metas, nil
}
func (c Collection) DocumentExists(ctx context.Context, id string) (bool, error) {
return c.internal.DocumentExists(ctx, id)
}
func (c Collection) ReadDocument(ctx context.Context, key string, result interface{}) (driver.DocumentMeta, error) {
return c.internal.ReadDocument(ctx, key, result)
}
func (c Collection) UpdateDocument(ctx context.Context, key string, update interface{}) (driver.DocumentMeta, error) {
meta, err := c.internal.UpdateDocument(ctx, key, update)
if err != nil {
return meta, err
}
return meta, c.db.bus.PublishUpdate([]driver.DocumentID{meta.ID})
}
func (c Collection) ReplaceDocument(ctx context.Context, key string, document interface{}) (driver.DocumentMeta, error) {
meta, err := c.internal.ReplaceDocument(ctx, key, document)
if err != nil {
return meta, err
}
return meta, c.db.bus.PublishUpdate([]driver.DocumentID{meta.ID})
}
func (c Collection) RemoveDocument(ctx context.Context, formatInt string) (driver.DocumentMeta, error) {
return c.internal.RemoveDocument(ctx, formatInt)
}

34
database/busdb/context.go Normal file
View File

@@ -0,0 +1,34 @@
package busdb
import (
"context"
"github.com/gin-gonic/gin"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/role"
)
const (
userContextKey = "user"
groupContextKey = "groups"
)
func SetContext(ctx *gin.Context, user *models.UserResponse) {
user.Roles = role.Strings(role.Explodes(user.Roles))
ctx.Set(userContextKey, user)
}
func SetGroupContext(ctx *gin.Context, groups []string) {
ctx.Set(groupContextKey, groups)
}
func UserContext(ctx context.Context, user *models.UserResponse) context.Context {
user.Roles = role.Strings(role.Explodes(user.Roles))
return context.WithValue(ctx, userContextKey, user)
}
func UserFromContext(ctx context.Context) (*models.UserResponse, bool) {
u, ok := ctx.Value(userContextKey).(*models.UserResponse)
return u, ok
}

25
database/busdb/keyed.go Normal file
View File

@@ -0,0 +1,25 @@
package busdb
import "encoding/json"
type Keyed struct {
Key string
Doc interface{}
}
func (p Keyed) MarshalJSON() ([]byte, error) {
b, err := json.Marshal(p.Doc)
if err != nil {
panic(err)
}
var m map[string]interface{}
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
m["_key"] = p.Key
return json.Marshal(m)
}

92
database/busdb/log.go Normal file
View File

@@ -0,0 +1,92 @@
package busdb
import (
"context"
"errors"
"time"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/generated/models"
)
const LogCollectionName = "logs"
func (db *BusDatabase) LogCreate(ctx context.Context, id, message string) (*models.LogEntry, error) {
user, ok := UserFromContext(ctx)
if !ok {
return nil, errors.New("no user in context")
}
logentry := &models.LogEntry{
Reference: id,
Created: time.Now(),
Creator: user.ID,
Message: message,
}
doc := models.LogEntry{}
_, err := db.logCollection.CreateDocument(driver.WithReturnNew(ctx, &doc), logentry)
if err != nil {
return nil, err
}
return &doc, db.bus.PublishUpdate([]driver.DocumentID{driver.DocumentID(logentry.Reference)})
}
func (db *BusDatabase) LogBatchCreate(ctx context.Context, logEntryForms []*models.LogEntry) error {
user, ok := UserFromContext(ctx)
if !ok {
return errors.New("no user in context")
}
var ids []driver.DocumentID
var logentries []*models.LogEntry
for _, logEntryForm := range logEntryForms {
logentry := &models.LogEntry{
Reference: logEntryForm.Reference,
Created: time.Now(),
Creator: user.ID,
Message: logEntryForm.Message,
}
logentries = append(logentries, logentry)
ids = append(ids, driver.DocumentID(logentry.Reference))
}
_, errs, err := db.logCollection.CreateDocuments(ctx, logentries)
if err != nil {
return err
}
err = errs.FirstNonNil()
if err != nil {
return err
}
return db.bus.PublishUpdate(ids)
}
func (db *BusDatabase) LogList(ctx context.Context, reference string) ([]*models.LogEntry, error) {
query := "FOR d IN @@collection FILTER d.reference == @reference SORT d.created DESC RETURN d"
cursor, err := db.internal.Query(ctx, query, map[string]interface{}{
"@collection": LogCollectionName,
"reference": reference,
})
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.LogEntry
for {
var doc models.LogEntry
_, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, &doc)
}
return docs, err
}

177
database/db.go Normal file
View File

@@ -0,0 +1,177 @@
package database
import (
"context"
"fmt"
"log"
"github.com/arangodb/go-driver"
"github.com/arangodb/go-driver/http"
"github.com/SecurityBrewery/catalyst/bus"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/database/migrations"
"github.com/SecurityBrewery/catalyst/hooks"
"github.com/SecurityBrewery/catalyst/index"
)
const (
Name = "catalyst"
TicketCollectionName = "tickets"
TemplateCollectionName = "templates"
PlaybookCollectionName = "playbooks"
AutomationCollectionName = "automations"
UserDataCollectionName = "userdata"
UserCollectionName = "users"
TicketTypeCollectionName = "tickettypes"
JobCollectionName = "jobs"
TicketArtifactsGraphName = "Graph"
RelatedTicketsCollectionName = "related"
)
type Database struct {
*busdb.BusDatabase
Index *index.Index
bus *bus.Bus
Hooks *hooks.Hooks
templateCollection *busdb.Collection
ticketCollection *busdb.Collection
playbookCollection *busdb.Collection
automationCollection *busdb.Collection
userdataCollection *busdb.Collection
userCollection *busdb.Collection
tickettypeCollection *busdb.Collection
jobCollection *busdb.Collection
relatedCollection *busdb.Collection
containsCollection *busdb.Collection
}
type Config struct {
Host string
User string
Password string
Name string
}
func New(ctx context.Context, index *index.Index, bus *bus.Bus, hooks *hooks.Hooks, config *Config) (*Database, error) {
name := config.Name
if config.Name == "" {
name = Name
}
conn, err := http.NewConnection(http.ConnectionConfig{Endpoints: []string{config.Host}})
if err != nil {
return nil, err
}
client, err := driver.NewClient(driver.ClientConfig{
Connection: conn,
Authentication: driver.BasicAuthentication(config.User, config.Password),
})
if err != nil {
return nil, err
}
hooks.DatabaseAfterConnect(ctx, client, name)
db, err := setupDB(ctx, client, name)
if err != nil {
return nil, fmt.Errorf("DB setup failed: %w", err)
}
if err = migrations.PerformMigrations(ctx, db); err != nil {
return nil, fmt.Errorf("migrations failed: %w", err)
}
ticketCollection, err := db.Collection(ctx, TicketCollectionName)
if err != nil {
return nil, err
}
templateCollection, err := db.Collection(ctx, TemplateCollectionName)
if err != nil {
return nil, err
}
playbookCollection, err := db.Collection(ctx, PlaybookCollectionName)
if err != nil {
return nil, err
}
relatedCollection, err := db.Collection(ctx, RelatedTicketsCollectionName)
if err != nil {
return nil, err
}
automationCollection, err := db.Collection(ctx, AutomationCollectionName)
if err != nil {
return nil, err
}
userdataCollection, err := db.Collection(ctx, UserDataCollectionName)
if err != nil {
return nil, err
}
userCollection, err := db.Collection(ctx, UserCollectionName)
if err != nil {
return nil, err
}
tickettypeCollection, err := db.Collection(ctx, TicketTypeCollectionName)
if err != nil {
return nil, err
}
jobCollection, err := db.Collection(ctx, JobCollectionName)
if err != nil {
return nil, err
}
hookedDB, err := busdb.NewDatabase(ctx, db, bus)
if err != nil {
return nil, err
}
return &Database{
BusDatabase: hookedDB,
bus: bus,
Index: index,
Hooks: hooks,
templateCollection: busdb.NewCollection(templateCollection, hookedDB),
ticketCollection: busdb.NewCollection(ticketCollection, hookedDB),
playbookCollection: busdb.NewCollection(playbookCollection, hookedDB),
automationCollection: busdb.NewCollection(automationCollection, hookedDB),
relatedCollection: busdb.NewCollection(relatedCollection, hookedDB),
userdataCollection: busdb.NewCollection(userdataCollection, hookedDB),
userCollection: busdb.NewCollection(userCollection, hookedDB),
tickettypeCollection: busdb.NewCollection(tickettypeCollection, hookedDB),
jobCollection: busdb.NewCollection(jobCollection, hookedDB),
}, nil
}
func setupDB(ctx context.Context, client driver.Client, dbName string) (driver.Database, error) {
databaseExists, err := client.DatabaseExists(ctx, dbName)
if err != nil {
return nil, err
}
var db driver.Database
if !databaseExists {
db, err = client.CreateDatabase(ctx, dbName, nil)
} else {
db, err = client.Database(ctx, dbName)
}
if err != nil {
return nil, err
}
collectionExists, err := db.CollectionExists(ctx, migrations.MigrationCollection)
if err != nil {
return nil, err
}
if !collectionExists {
if _, err := db.CreateCollection(ctx, migrations.MigrationCollection, &driver.CreateCollectionOptions{
KeyOptions: &driver.CollectionKeyOptions{AllowUserKeys: true},
}); err != nil {
log.Println(err)
}
}
return db, nil
}

256
database/job.go Normal file
View File

@@ -0,0 +1,256 @@
package database
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/arangodb/go-driver"
"github.com/docker/docker/client"
"github.com/xeipuuv/gojsonschema"
"github.com/SecurityBrewery/catalyst/caql"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func toJob(doc *models.JobForm) *models.Job {
return &models.Job{
Automation: doc.Automation,
Payload: doc.Payload,
Origin: doc.Origin,
Running: true,
Status: "created",
}
}
func (db *Database) toJobResponse(ctx context.Context, key string, doc *models.Job, update bool) (*models.JobResponse, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, err
}
defer cli.Close()
status := doc.Status
if doc.Running {
inspect, err := cli.ContainerInspect(ctx, key)
if err != nil || inspect.State == nil {
doc.Running = false
if update {
db.JobUpdate(ctx, key, doc)
}
} else if doc.Status != inspect.State.Status {
status = inspect.State.Status
doc.Status = inspect.State.Status
if update {
db.JobUpdate(ctx, key, doc)
}
}
}
return &models.JobResponse{
Automation: doc.Automation,
ID: key,
Log: doc.Log,
Payload: doc.Payload,
Origin: doc.Origin,
Output: doc.Output,
Status: status,
Container: doc.Container,
}, nil
}
func (db *Database) JobCreate(ctx context.Context, id string, job *models.JobForm) (*models.JobResponse, error) {
if job == nil {
return nil, errors.New("requires job")
}
var doc models.Job
newctx := driver.WithReturnNew(ctx, &doc)
/* Start validation */
j := toJob(job)
b, _ := json.Marshal(j)
r, err := models.JobSchema.Validate(gojsonschema.NewBytesLoader(b))
if err != nil {
return nil, err
}
if !r.Valid() {
var errs []string
for _, e := range r.Errors() {
errs = append(errs, e.String())
}
return nil, errors.New(strings.Join(errs, ", "))
}
/* End validation */
meta, err := db.jobCollection.CreateDocument(ctx, newctx, id, j)
if err != nil {
return nil, err
}
return db.toJobResponse(ctx, meta.Key, &doc, true)
}
func (db *Database) JobGet(ctx context.Context, id string) (*models.JobResponse, error) {
var doc models.Job
meta, err := db.jobCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return db.toJobResponse(ctx, meta.Key, &doc, true)
}
func (db *Database) JobUpdate(ctx context.Context, id string, job *models.Job) (*models.JobResponse, error) {
var doc models.Job
ctx = driver.WithReturnNew(ctx, &doc)
/* Start validation */
b, _ := json.Marshal(job)
r, err := models.JobSchema.Validate(gojsonschema.NewBytesLoader(b))
if err != nil {
return nil, err
}
if !r.Valid() {
var errs []string
for _, e := range r.Errors() {
errs = append(errs, e.String())
}
return nil, errors.New(strings.Join(errs, ", "))
}
/* End validation */
meta, err := db.jobCollection.ReplaceDocument(ctx, id, job)
if err != nil {
return nil, err
}
return db.toJobResponse(ctx, meta.Key, &doc, true)
}
func (db *Database) JobLogAppend(ctx context.Context, id string, logLine string) error {
query := `LET d = DOCUMENT(@@collection, @ID)
UPDATE d WITH { "log": CONCAT(NOT_NULL(d.log, ""), @logline) } IN @@collection`
cur, _, err := db.Query(ctx, query, map[string]interface{}{
"@collection": JobCollectionName,
"ID": id,
"logline": logLine,
}, &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%s", JobCollectionName, id)),
},
Msg: fmt.Sprintf("Append logline"),
})
if err != nil {
return err
}
defer cur.Close()
return nil
}
func (db *Database) JobComplete(ctx context.Context, id string, out interface{}) error {
query := `LET d = DOCUMENT(@@collection, @ID)
UPDATE d WITH { "output": @out, "status": "completed", "running": false } IN @@collection`
cur, _, err := db.Query(ctx, query, map[string]interface{}{
"@collection": JobCollectionName,
"ID": id,
"out": out,
}, &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%s", JobCollectionName, id)),
},
Msg: fmt.Sprintf("Set output"),
})
if err != nil {
return err
}
defer cur.Close()
return nil
}
func (db *Database) JobDelete(ctx context.Context, id string) error {
_, err := db.jobCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) JobList(ctx context.Context) ([]*models.JobResponse, error) {
query := "FOR d IN @@collection RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": JobCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.JobResponse
for {
var doc models.Job
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
job, err := db.toJobResponse(ctx, meta.Key, &doc, false)
if err != nil {
return nil, err
}
docs = append(docs, job)
}
return docs, err
}
func publishJobMapping(id, automation string, contextStructs *models.Context, origin *models.Origin, payloadMapping map[string]string, db *Database) error {
msg, err := generatePayload(payloadMapping, contextStructs)
if err != nil {
return fmt.Errorf("message generation failed: %w", err)
}
return publishJob(id, automation, contextStructs, origin, msg, db)
}
func publishJob(id, automation string, contextStructs *models.Context, origin *models.Origin, payload map[string]interface{}, db *Database) error {
return db.bus.PublishJob(id, automation, payload, contextStructs, origin)
}
func generatePayload(msgMapping map[string]string, contextStructs *models.Context) (map[string]interface{}, error) {
contextJson, err := json.Marshal(contextStructs)
if err != nil {
return nil, err
}
automationContext := map[string]interface{}{}
err = json.Unmarshal(contextJson, &automationContext)
if err != nil {
return nil, err
}
parser := caql.Parser{}
msg := map[string]interface{}{}
for arg, expr := range msgMapping {
tree, err := parser.Parse(expr)
if err != nil {
return nil, err
}
v, err := tree.Eval(automationContext)
if err != nil {
return nil, err
}
msg[arg] = v
}
return msg, nil
}

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python
import subprocess
import sys
subprocess.call(
[sys.executable, "-m", "pip", "install", "requests"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
import json
import requests
def run(msg):
if "ticket" in msg["context"]:
headers = {"PRIVATE-TOKEN": msg["secrets"]["catalyst_apikey"]}
url = "%s/tickets/%d/comments" % (msg["secrets"]["catalyst_apiurl"], msg["context"]["ticket"]["id"])
data = {'message': msg["payload"]["default"], 'creator': 'automation'}
requests.post(url, json=data, headers=headers).json()
return {"done": True}
print(json.dumps(run(json.loads(sys.argv[1]))))

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python
import sys
import json
import hashlib
def run(msg):
sha1 = hashlib.sha1(msg['payload']['default'].encode('utf-8'))
return {"hash": sha1.hexdigest()}
print(json.dumps(run(json.loads(sys.argv[1]))))

View File

@@ -0,0 +1,630 @@
#!/usr/bin/env python
import subprocess
import sys
import json
from datetime import datetime
import io
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "thehive4py", "requests", "minio"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
defaultschema = {
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.com/object1618746510.json",
"title": "Default",
"type": "object",
"required": [
"severity",
"description",
"summary",
"tlp",
"pap"
],
"properties": {
"severity": {
"$id": "#root/severity",
"title": "Severity",
"type": "string",
"default": "Medium",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "Unknown",
"title": "Unknown",
"icon": "mdi-help"
},
{
"const": "Low",
"title": "Low",
"icon": "mdi-chevron-up"
},
{
"const": "Medium",
"title": "Medium",
"icon": "mdi-chevron-double-up"
},
{
"const": "High",
"title": "High",
"icon": "mdi-chevron-triple-up"
},
{
"const": "Very High",
"title": "Very High",
"icon": "mdi-exclamation"
}
]
},
"flag": {
"title": "Flag",
"type": "boolean",
"x-cols": 6,
},
"tlp": {
"$id": "#root/tlp",
"title": "TLP",
"type": "string",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "White",
"title": "White",
"icon": "mdi-alpha-w"
},
{
"const": "Green",
"title": "Green",
"icon": "mdi-alpha-g"
},
{
"const": "Amber",
"title": "Amber",
"icon": "mdi-alpha-a"
},
{
"const": "Red",
"title": "Red",
"icon": "mdi-alpha-r"
}
]
},
"pap": {
"$id": "#root/pap",
"title": "PAP",
"type": "string",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "White",
"title": "White",
"icon": "mdi-alpha-w"
},
{
"const": "Green",
"title": "Green",
"icon": "mdi-alpha-g"
},
{
"const": "Amber",
"title": "Amber",
"icon": "mdi-alpha-a"
},
{
"const": "Red",
"title": "Red",
"icon": "mdi-alpha-r"
}
]
},
"tags": {
"$id": "#root/tags",
"title": "Tags",
"type": "array",
"items": {
"type": "string"
}
},
"description": {
"$id": "#root/description",
"title": "Description",
"type": "string",
"x-display": "textarea",
"x-class": "pr-2"
},
"resolutionStatus": {
"$id": "#root/resolutionStatus",
"title": "Resolution Status",
"type": "string",
"x-cols": 6,
"x-class": "pr-2",
},
"endDate": {
"$id": "#root/endDate",
"title": "End Data",
"type": "string",
"format": "date-time",
"x-cols": 6,
"x-class": "pr-2",
},
"summary": {
"$id": "#root/summary",
"title": "Summary",
"type": "string",
"x-display": "textarea",
"x-class": "pr-2"
}
}
}
defaultalertschema = {
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.com/object1618746510.json",
"title": "Default",
"type": "object",
"required": [
"severity",
"description",
"summary",
"tlp",
"pap"
],
"properties": {
"severity": {
"$id": "#root/severity",
"title": "Severity",
"type": "string",
"default": "Medium",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "Unknown",
"title": "Unknown",
"icon": "mdi-help"
},
{
"const": "Low",
"title": "Low",
"icon": "mdi-chevron-up"
},
{
"const": "Medium",
"title": "Medium",
"icon": "mdi-chevron-double-up"
},
{
"const": "High",
"title": "High",
"icon": "mdi-chevron-triple-up"
},
{
"const": "Very High",
"title": "Very High",
"icon": "mdi-exclamation"
}
]
},
"tlp": {
"$id": "#root/tlp",
"title": "TLP",
"type": "string",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "White",
"title": "White",
"icon": "mdi-alpha-w"
},
{
"const": "Green",
"title": "Green",
"icon": "mdi-alpha-g"
},
{
"const": "Amber",
"title": "Amber",
"icon": "mdi-alpha-a"
},
{
"const": "Red",
"title": "Red",
"icon": "mdi-alpha-r"
}
]
},
"source": {
"$id": "#root/source",
"title": "Source",
"type": "string",
"x-cols": 4,
"x-class": "pr-2",
},
"sourceRef": {
"$id": "#root/sourceRef",
"title": "Source Ref",
"type": "string",
"x-cols": 4,
"x-class": "pr-2",
},
"type": {
"$id": "#root/type",
"title": "Type",
"type": "string",
"x-cols": 4,
"x-class": "pr-2",
},
"description": {
"$id": "#root/description",
"title": "Description",
"type": "string",
"x-display": "textarea",
"x-class": "pr-2"
}
}
}
class schema:
def __init__(self):
self.schema = defaultschema
def add_string(self, title):
self.schema["properties"][title] = { "type": "string", "x-cols": 6, "x-class": "pr-2" }
def add_boolean(self, title):
self.schema["properties"][title] = { "type": "boolean", "x-cols": 6, "x-class": "pr-2" }
def add_date(self, title):
self.schema["properties"][title] = { "type": "string", "format": "date-time", "x-cols": 6, "x-class": "pr-2" }
def add_integer(self, title):
self.schema["properties"][title] = { "type": "integer", "x-cols": 6, "x-class": "pr-2" }
def add_float(self, title):
self.schema["properties"][title] = { "type": "number", "x-cols": 6, "x-class": "pr-2" }
class alertschema:
def __init__(self):
self.schema = defaultalertschema
def maptime(hivetime):
if hivetime is None:
return None
return datetime.fromtimestamp(hivetime/1000).isoformat() + "Z"
def mapstatus(hivestatus):
if hivestatus == "Open" or hivestatus == "New":
return "open"
return "closed"
def maptlp(hivetlp):
if hivetlp == 0:
return "White"
if hivetlp == 1:
return "Green"
if hivetlp == 2:
return "Amber"
if hivetlp == 3:
return "Red"
return "White"
def mapseverity(hiveseverity):
if hiveseverity == 1:
return "Low"
if hiveseverity == 2:
return "Medium"
if hiveseverity == 3:
return "High"
if hiveseverity == 4:
return "Very High"
return "Unknown"
# {
# "_id": "~16416",
# "id": "~16416",
# "createdBy": "jonas@thehive.local",
# "updatedBy": "jonas@thehive.local",
# "createdAt": 1638704013583,
# "updatedAt": 1638704061151,
# "_type": "case",
# "caseId": 1,
# "title": "My Test 1",
# "description": "My Testcase",
# "severity": 2,
# "startDate": 1638703980000,
# "endDate": null,
# "impactStatus": null,
# "resolutionStatus": null,
# "tags": [],
# "flag": false,
# "tlp": 2,
# "pap": 2,
# "status": "Open",
# "summary": null,
# "owner": "jonas@thehive.local",
# "customFields": {},
# "stats": {},
# "permissions": [ "manageShare", "manageAnalyse", "manageTask", "manageCaseTemplate", "manageCase", "manageUser", "manageProcedure", "managePage", "manageObservable", "manageTag", "manageConfig", "manageAlert", "accessTheHiveFS", "manageAction" ]
# }
def mapcase(hivecase, url, keep_ids):
s = schema()
details = {}
for name, data in hivecase["customFields"].items():
if "string" in data and data["string"] is not None:
s.add_string(name)
details[name] = data["string"]
if "boolean" in data and data["boolean"] is not None:
s.add_boolean(name)
details[name] = data["boolean"]
if "date" in data and data["date"] is not None:
s.add_date(name)
details[name] = maptime(data["date"])
if "integer" in data and data["integer"] is not None:
s.add_integer(name)
details[name] = data["integer"]
if "float" in data and data["float"] is not None:
s.add_float(name)
details[name] = data["float"]
case = {}
if keep_ids:
case["id"] = hivecase["caseId"]
return {
"name": hivecase["title"],
"type": "incident",
"status": mapstatus(hivecase["status"]),
"owner": hivecase["owner"],
# "write": hivecase["write"],
# "read": hivecase["read"],
"schema": json.dumps(s.schema),
"details": {
"tlp": maptlp(hivecase["tlp"]),
"pap": maptlp(hivecase["pap"]),
"severity": mapseverity(hivecase["severity"]),
"description": hivecase["description"],
"summary": hivecase["summary"],
"tags": hivecase["tags"],
"endDate": maptime(hivecase["endDate"]),
"resolutionStatus": hivecase["resolutionStatus"],
"flag": hivecase["flag"],
} | details,
"references": [
{ "name": "TheHive #%d" % hivecase["caseId"], "href": "%s/index.html#!/case/~%s/details" % (url, hivecase["id"]) }
],
#
# "playbooks": hivecase["playbooks"],
#
"files": [],
"comments": [],
# creator, created, message
#
"artifacts": [],
# name, type, status, enrichment
# name, data
"created": maptime(hivecase["createdAt"]),
"modified": maptime(hivecase["updatedAt"]),
} | case
# {
# "_id": "ce2c00f17132359cb3c50dfbb1901810",
# "_type": "alert",
# "artifacts": [],
# "createdAt": 1495012062014,
# "createdBy": "myuser",
# "date": 1495012062016,
# "description": "N/A",
# "follow": true,
# "id": "ce2c00f17132359cb3c50dfbb1901810",
# "lastSyncDate": 1495012062016,
# "severity": 2,
# "source": "instance1",
# "sourceRef": "alert-ref",
# "status": "New",
# "title": "New Alert",
# "tlp": 2,
# "type": "external",
# "user": "myuser"
# }
def mapalert(hivealert, url):
s = alertschema()
details = {}
return {
"name": hivealert["title"],
"type": "alert",
"status": mapstatus(hivealert["status"]),
"owner": hivealert["user"],
"schema": json.dumps(s.schema),
"details": {
"tlp": maptlp(hivealert["tlp"]),
"severity": mapseverity(hivealert["severity"]),
"description": hivealert["description"],
"source": hivealert["source"],
"sourceRef": hivealert["sourceRef"],
"type": hivealert["type"],
} | details,
"references": [
{ "name": "TheHive Alerts", "href": "%s/index.html#!/alert/list" % url }
],
"files": [],
"comments": [],
"artifacts": [],
"created": maptime(hivealert["createdAt"]),
"modified": maptime(hivealert["lastSyncDate"]),
}
# {
# "_id": "~41152",
# "id": "~41152",
# "createdBy": "jonas@thehive.local",
# "createdAt": 1638723814523,
# "_type": "case_artifact",
# "dataType": "ip",
# "data": "2.2.2.2",
# "startDate": 1638723814523,
# "tlp": 2,
# "tags": [],
# "ioc": false,
# "sighted": false,
# "message": ".",
# "reports": {},
# "stats": {},
# "ignoreSimilarity": false
# }
def mapobservable(hiveobservable):
status = "unknown"
if hiveobservable["ioc"]:
status = "malicious"
return {
"name": hiveobservable["data"],
"type": hiveobservable["dataType"],
"status": status,
}
# {
# "id": "~12296",
# "_id": "~12296",
# "createdBy": "jonas@thehive.local",
# "createdAt": 1638704029800,
# "_type": "case_task",
# "title": "Start",
# "group": "MyTaskGroup1",
# "owner": "jonas@thehive.local",
# "status": "InProgress",
# "flag": false,
# "startDate": 1638704115667,
# "order": 0
# }
# {
# "_id": "~24656",
# "id": "~24656",
# "createdBy": "jonas@thehive.local",
# "createdAt": 1638729992590,
# "_type": "case_task_log",
# "message": "asd",
# "startDate": 1638729992590,
# "attachment": {
# "name": "Chemistry Vector.eps",
# "hashes": [
# "adf2d4cd72f4141fe7f8eb4af035596415a29c048d3039be6449008f291258e9",
# "180f66a6d22b1f09ed198afd814f701e42440e7c",
# "b28ae347371df003b76cbb8c6199c97e"
# ],
# "size": 3421842,
# "contentType": "application/postscript",
# "id": "adf2d4cd72f4141fe7f8eb4af035596415a29c048d3039be6449008f291258e9"
# },
# "status": "Ok",
# "owner": "jonas@thehive.local"
# }
def maptasklog(hivetask, hivetasklog):
message = "**" + hivetask["group"] + ": " + hivetask["title"] + "** (" + hivetask["status"] + ")\n\n"
message += hivetasklog["message"]
if 'attachment' in hivetasklog:
message += "\n\n*Attachment*: " + hivetasklog['attachment']["name"]
return {
"creator": hivetasklog["createdBy"],
"created": maptime(hivetasklog["createdAt"]),
"message": message,
}
def run(msg):
skip_files = msg["payload"]["skip_files"]
keep_ids = msg["payload"]["keep_ids"]
from thehive4py.api import TheHiveApi
import requests
from minio import Minio
headers = {"PRIVATE-TOKEN": msg["secrets"]["catalyst_apikey"]}
# minioclient = Minio("try.catalyst-soar.com:9000", access_key="minio", secret_key="password")
if not skip_files:
minioclient = Minio(
msg["secrets"]["minio_host"],
access_key=msg["secrets"]["minio_access_key"],
secret_key=msg["secrets"]["minio_secret_key"])
# url = "http://localhost:9000"
url = msg["payload"]["thehiveurl"]
# api = TheHiveApi(url, "dtUCnzY4h291GIFHJKW/Z2I2SgjTRQqo")
api = TheHiveApi(url, msg["payload"]["thehivekey"])
print("find alerts", file=sys.stderr)
alerts = []
resp = api.find_alerts(query={}, sort=['-createdAt'], range='all')
resp.raise_for_status()
for alert in resp.json():
alerts.append(mapalert(alert, url))
if alerts:
print("create %s alerts" % len(alerts), file=sys.stderr)
response = requests.post(msg["secrets"]["catalyst_apiurl"] + "/tickets/batch", json=alerts, headers=headers)
response.raise_for_status()
print("find incidents", file=sys.stderr)
incidents = []
resp = api.find_cases(query={}, sort=['-createdAt'], range='all')
resp.raise_for_status()
for case in resp.json():
incident = mapcase(case, url, keep_ids)
for observable in api.get_case_observables(case["id"]).json():
incident["artifacts"].append(mapobservable(observable))
for task in api.get_case_tasks(case["id"]).json():
for log in api.get_task_logs(task["id"]).json():
incident["comments"].append(maptasklog(task, log))
if 'attachment' in log and not skip_files:
incident["files"].append({ "key": log['attachment']["id"], "name": log['attachment']["name"] })
bucket_name = "catalyst-%d" % incident["id"]
if not minioclient.bucket_exists(bucket_name):
minioclient.make_bucket(bucket_name)
response = api.download_attachment(log["attachment"]["id"])
data = io.BytesIO(response.content)
minioclient.put_object(bucket_name, log["attachment"]["id"], data, length=-1, part_size=10*1024*1024)
incidents.append(incident)
if incidents:
if keep_ids:
print("delete incidents", file=sys.stderr)
for incident in incidents:
requests.delete(msg["secrets"]["catalyst_apiurl"] + "/tickets/%d" % incident["id"], headers=headers)
print("create %d incidents" % len(incidents), file=sys.stderr)
response = requests.post(msg["secrets"]["catalyst_apiurl"] + "/tickets/batch", json=incidents, headers=headers)
response.raise_for_status()
return {"done": True}
print(json.dumps(run(json.loads(sys.argv[1]))))

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
import subprocess
import sys
subprocess.call(
[sys.executable, "-m", "pip", "install", "requests"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
import json
import requests
def run(msg):
api_key = msg['secrets']['vt_api_key'].encode('utf-8')
resource = msg['payload']['default'].encode('utf-8')
params = {'apikey': api_key, 'resource': resource}
return requests.get("https://www.virustotal.com/vtapi/v2/file/report", params=params).json()
print(json.dumps(run(json.loads(sys.argv[1]))))

View File

@@ -0,0 +1,27 @@
package migrations
import _ "embed"
//go:embed templates/default.json
var DefaultTemplateSchema string
//go:embed automations/hash.sha1.py
var SHA1HashAutomation string
//go:embed automations/vt.hash.py
var VTHashAutomation string
//go:embed automations/thehive.py
var TheHiveAutomation string
//go:embed automations/comment.py
var CommentAutomation string
//go:embed playbooks/malware.yml
var MalwarePlaybook string
//go:embed playbooks/phishing.yml
var PhishingPlaybook string
//go:embed playbooks/simple.yaml
var SimplePlaybook string

View File

@@ -0,0 +1,217 @@
package migrations
import (
"context"
"fmt"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/pointer"
)
const MigrationCollection string = "migrations"
type Migration interface {
MID() string
Migrate(ctx context.Context, driver driver.Database) error
}
func generateMigrations() ([]Migration, error) {
// content here should never change
return []Migration{
&createCollection{ID: "create-log-collection", Name: "logs", DataType: "log", Schema: `{"properties":{"created":{"format":"date-time","type":"string"},"creator":{"type":"string"},"message":{"type":"string"},"reference":{"type":"string"}},"required":["created","creator","message","reference"],"type":"object"}`},
&createCollection{ID: "create-ticket-collection", Name: "tickets", DataType: "ticket", Schema: `{"properties":{"artifacts":{"items":{"properties":{"enrichments":{"additionalProperties":{"properties":{"created":{"format":"date-time","type":"string"},"data":{"example":{"hash":"b7a067a742c20d07a7456646de89bc2d408a1153"},"properties":{},"type":"object"},"name":{"example":"hash.sha1","type":"string"}},"required":["created","data","name"],"type":"object"},"type":"object"},"name":{"example":"2.2.2.2","type":"string"},"status":{"example":"Unknown","type":"string"},"type":{"type":"string"}},"required":["name"],"type":"object"},"type":"array"},"comments":{"items":{"properties":{"created":{"format":"date-time","type":"string"},"creator":{"type":"string"},"message":{"type":"string"}},"required":["created","creator","message"],"type":"object"},"type":"array"},"created":{"format":"date-time","type":"string"},"details":{"example":{"description":"my little incident"},"properties":{},"type":"object"},"files":{"items":{"properties":{"key":{"example":"myfile","type":"string"},"name":{"example":"notes.docx","type":"string"}},"required":["key","name"],"type":"object"},"type":"array"},"modified":{"format":"date-time","type":"string"},"name":{"example":"WannyCry","type":"string"},"owner":{"example":"bob","type":"string"},"playbooks":{"additionalProperties":{"properties":{"name":{"example":"Phishing","type":"string"},"tasks":{"additionalProperties":{"properties":{"automation":{"type":"string"},"closed":{"format":"date-time","type":"string"},"created":{"format":"date-time","type":"string"},"data":{"properties":{},"type":"object"},"done":{"type":"boolean"},"join":{"example":false,"type":"boolean"},"payload":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"example":"Inform user","type":"string"},"next":{"additionalProperties":{"type":"string"},"type":"object"},"owner":{"type":"string"},"schema":{"properties":{},"type":"object"},"type":{"enum":["task","input","automation"],"example":"task","type":"string"}},"required":["created","done","name","type"],"type":"object"},"type":"object"}},"required":["name","tasks"],"type":"object"},"type":"object"},"read":{"example":["bob"],"items":{"type":"string"},"type":"array"},"references":{"items":{"properties":{"href":{"example":"https://cve.mitre.org/cgi-bin/cvename.cgi?name=cve-2017-0144","type":"string"},"name":{"example":"CVE-2017-0144","type":"string"}},"required":["href","name"],"type":"object"},"type":"array"},"schema":{"example":"{}","type":"string"},"status":{"example":"open","type":"string"},"type":{"example":"incident","type":"string"},"write":{"example":["alice"],"items":{"type":"string"},"type":"array"}},"required":["created","modified","name","schema","status","type"],"type":"object"}`},
&createCollection{ID: "create-template-collection", Name: "templates", DataType: "template", Schema: `{"properties":{"name":{"type":"string"},"schema":{"type":"string"}},"required":["name","schema"],"type":"object"}`},
&createCollection{ID: "create-playbook-collection", Name: "playbooks", DataType: "playbook", Schema: `{"properties":{"name":{"type":"string"},"yaml":{"type":"string"}},"required":["name","yaml"],"type":"object"}`},
&createCollection{ID: "create-automation-collection", Name: "automations", DataType: "automation", Schema: `{"properties":{"image":{"type":"string"},"script":{"type":"string"}},"required":["image","script"],"type":"object"}`},
&createCollection{ID: "create-userdata-collection", Name: "userdata", DataType: "userdata", Schema: `{"properties":{"email":{"type":"string"},"image":{"type":"string"},"name":{"type":"string"},"timeformat":{"title":"Time Format (https://moment.github.io/luxon/docs/manual/formatting.html#table-of-tokens)","type":"string"}},"type":"object"}`},
&createCollection{ID: "create-tickettype-collection", Name: "tickettypes", DataType: "tickettype", Schema: `{"properties":{"default_groups":{"items":{"type":"string"},"type":"array"},"default_playbooks":{"items":{"type":"string"},"type":"array"},"default_template":{"type":"string"},"icon":{"type":"string"},"name":{"type":"string"}},"required":["default_playbooks","default_template","icon","name"],"type":"object"}`},
&createCollection{ID: "create-user-collection", Name: "users", DataType: "user", Schema: `{"properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"roles":{"items":{"type":"string"},"type":"array"},"sha256":{"type":"string"}},"required":["apikey","blocked","roles"],"type":"object"}`},
&createGraph{ID: "create-ticket-graph", Name: "Graph", EdgeDefinitions: []driver.EdgeDefinition{{Collection: "related", From: []string{"tickets"}, To: []string{"tickets"}}}},
&createDocument{ID: "create-template-default", Collection: "templates", Document: &busdb.Keyed{Key: "default", Doc: models.TicketTemplate{Schema: DefaultTemplateSchema, Name: "Default"}}},
&createDocument{ID: "create-automation-vt.hash", Collection: "automations", Document: &busdb.Keyed{Key: "vt.hash", Doc: models.Automation{Image: "docker.io/python:3", Script: VTHashAutomation}}},
&createDocument{ID: "create-automation-comment", Collection: "automations", Document: &busdb.Keyed{Key: "comment", Doc: models.Automation{Image: "docker.io/python:3", Script: CommentAutomation}}},
&createDocument{ID: "create-automation-thehive", Collection: "automations", Document: &busdb.Keyed{Key: "thehive", Doc: models.Automation{Image: "docker.io/python:3", Script: TheHiveAutomation}}},
&createDocument{ID: "create-automation-hash.sha1", Collection: "automations", Document: &busdb.Keyed{Key: "hash.sha1", Doc: models.Automation{Image: "docker.io/python:3", Script: SHA1HashAutomation}}},
&createDocument{ID: "create-playbook-malware", Collection: "playbooks", Document: &busdb.Keyed{Key: "malware", Doc: models.PlaybookTemplate{Name: "Malware", Yaml: MalwarePlaybook}}},
&createDocument{ID: "create-playbook-phishing", Collection: "playbooks", Document: &busdb.Keyed{Key: "phishing", Doc: models.PlaybookTemplate{Name: "Phishing", Yaml: PhishingPlaybook}}},
&createDocument{ID: "create-tickettype-alert", Collection: "tickettypes", Document: &busdb.Keyed{Key: "alert", Doc: models.TicketType{Name: "Alerts", Icon: "mdi-alert", DefaultTemplate: "default", DefaultPlaybooks: []string{}, DefaultGroups: nil}}},
&createDocument{ID: "create-tickettype-incident", Collection: "tickettypes", Document: &busdb.Keyed{Key: "incident", Doc: models.TicketType{Name: "Incidents", Icon: "mdi-radioactive", DefaultTemplate: "default", DefaultPlaybooks: []string{}, DefaultGroups: nil}}},
&createDocument{ID: "create-tickettype-investigation", Collection: "tickettypes", Document: &busdb.Keyed{Key: "investigation", Doc: models.TicketType{Name: "Forensic Investigations", Icon: "mdi-fingerprint", DefaultTemplate: "default", DefaultPlaybooks: []string{}, DefaultGroups: nil}}},
&createDocument{ID: "create-tickettype-hunt", Collection: "tickettypes", Document: &busdb.Keyed{Key: "hunt", Doc: models.TicketType{Name: "Threat Hunting", Icon: "mdi-target", DefaultTemplate: "default", DefaultPlaybooks: []string{}, DefaultGroups: nil}}},
&updateSchema{ID: "update-automation-collection-1", Name: "automations", DataType: "automation", Schema: `{"properties":{"image":{"type":"string"},"script":{"type":"string"}},"required":["image","script"],"type":"object"}`},
&updateDocument{ID: "update-automation-vt.hash-1", Collection: "automations", Key: "vt.hash", Document: models.Automation{Image: "docker.io/python:3", Script: VTHashAutomation, Schema: pointer.String(`{"title":"Input","type":"object","properties":{"default":{"type":"string","title":"Value"}},"required":["default"]}`), Type: []string{"global", "artifact", "playbook"}}},
&updateDocument{ID: "update-automation-comment-1", Collection: "automations", Key: "comment", Document: models.Automation{Image: "docker.io/python:3", Script: CommentAutomation, Type: []string{"playbook"}}},
&updateDocument{ID: "update-automation-thehive-1", Collection: "automations", Key: "thehive", Document: models.Automation{Image: "docker.io/python:3", Script: TheHiveAutomation, Schema: pointer.String(`{"title":"TheHive credentials","type":"object","properties":{"thehiveurl":{"type":"string","title":"TheHive URL (e.g. 'https://thehive.example.org')"},"thehivekey":{"type":"string","title":"TheHive API Key"},"skip_files":{"type":"boolean", "default": true, "title":"Skip Files (much faster)"},"keep_ids":{"type":"boolean", "default": true, "title":"Keep IDs and overwrite existing IDs"}},"required":["thehiveurl", "thehivekey", "skip_files", "keep_ids"]}`), Type: []string{"global"}}},
&updateDocument{ID: "update-automation-hash.sha1-1", Collection: "automations", Key: "hash.sha1", Document: models.Automation{Image: "docker.io/python:3", Script: SHA1HashAutomation, Schema: pointer.String(`{"title":"Input","type":"object","properties":{"default":{"type":"string","title":"Value"}},"required":["default"]}`), Type: []string{"global", "artifact", "playbook"}}},
&createCollection{ID: "create-job-collection", Name: "jobs", DataType: "job", Schema: `{"properties":{"automation":{"type":"string"},"log":{"type":"string"},"payload":{},"origin":{"properties":{"artifact_origin":{"properties":{"artifact":{"type":"string"},"ticket_id":{"format":"int64","type":"integer"}},"required":["artifact","ticket_id"],"type":"object"},"task_origin":{"properties":{"playbook_id":{"type":"string"},"task_id":{"type":"string"},"ticket_id":{"format":"int64","type":"integer"}},"required":["playbook_id","task_id","ticket_id"],"type":"object"}},"type":"object"},"output":{"properties":{},"type":"object"},"running":{"type":"boolean"},"status":{"type":"string"}},"required":["automation","running","status"],"type":"object"}`},
}, nil
}
func loadSchema(dataType, jsonschema string) (*driver.CollectionSchemaOptions, error) {
ticketCollectionSchema := &driver.CollectionSchemaOptions{Level: driver.CollectionSchemaLevelStrict, Message: fmt.Sprintf("Validation of %s failed", dataType)}
err := ticketCollectionSchema.LoadRule([]byte(jsonschema))
return ticketCollectionSchema, err
}
type migration struct {
Key string `json:"_key"`
}
func PerformMigrations(ctx context.Context, db driver.Database) error {
collection, err := db.Collection(ctx, MigrationCollection)
if err != nil {
return err
}
migrations, err := generateMigrations()
if err != nil {
return fmt.Errorf("could not generate migrations: %w", err)
}
for _, m := range migrations {
migrationRan, err := collection.DocumentExists(ctx, m.MID())
if err != nil {
return err
}
if !migrationRan {
if err := m.Migrate(ctx, db); err != nil {
return fmt.Errorf("migration %s failed: %w", m.MID(), err)
}
if _, err := collection.CreateDocument(ctx, &migration{Key: m.MID()}); err != nil {
return fmt.Errorf("could not save %s migration document: %w", m.MID(), err)
}
}
}
return nil
}
type createCollection struct {
ID string
Name string
DataType string
Schema string
}
func (m *createCollection) MID() string {
return m.ID
}
func (m *createCollection) Migrate(ctx context.Context, db driver.Database) error {
schema, err := loadSchema(m.DataType, m.Schema)
if err != nil {
return err
}
_, err = db.CreateCollection(ctx, m.Name, &driver.CreateCollectionOptions{
Schema: schema,
})
return err
}
type updateSchema struct {
ID string
Name string
DataType string
Schema string
}
func (m *updateSchema) MID() string {
return m.ID
}
func (m *updateSchema) Migrate(ctx context.Context, db driver.Database) error {
schema, err := loadSchema(m.DataType, m.Schema)
if err != nil {
return err
}
col, err := db.Collection(ctx, m.Name)
if err != nil {
return err
}
err = col.SetProperties(ctx, driver.SetCollectionPropertiesOptions{
Schema: schema,
})
return err
}
type createGraph struct {
ID string
Name string
EdgeDefinitions []driver.EdgeDefinition
}
func (m *createGraph) MID() string {
return m.ID
}
func (m *createGraph) Migrate(ctx context.Context, db driver.Database) error {
_, err := db.CreateGraph(ctx, m.Name, &driver.CreateGraphOptions{
EdgeDefinitions: m.EdgeDefinitions,
})
return err
}
type createDocument struct {
ID string
Collection string
Document interface{}
}
func (m *createDocument) MID() string {
return m.ID
}
func (m *createDocument) Migrate(ctx context.Context, driver driver.Database) error {
collection, err := driver.Collection(ctx, m.Collection)
if err != nil {
return err
}
_, err = collection.CreateDocument(ctx, m.Document)
return err
}
type updateDocument struct {
ID string
Collection string
Key string
Document interface{}
}
func (m *updateDocument) MID() string {
return m.ID
}
func (m *updateDocument) Migrate(ctx context.Context, driver driver.Database) error {
collection, err := driver.Collection(ctx, m.Collection)
if err != nil {
return err
}
exists, err := collection.DocumentExists(ctx, m.Key)
if err != nil {
return err
}
if !exists {
_, err = collection.CreateDocument(ctx, m.Document)
return err
}
_, err = collection.ReplaceDocument(ctx, m.Key, m.Document)
return err
}

View File

@@ -0,0 +1,63 @@
name: Malware
tasks:
file-or-hash:
name: Do you have the file or the hash?
type: input
schema:
title: Malware
type: object
properties:
file:
type: string
title: "I have the"
enum: [ "File", "Hash" ]
next:
enter-hash: "file == 'Hash'"
upload: "file == 'File'"
enter-hash:
name: Please enter the hash
type: input
schema:
title: Malware
type: object
properties:
hash:
type: string
title: Please enter the hash value
minlength: 32
next:
virustotal: "hash != ''"
upload:
name: Upload the malware
type: input
schema:
title: Malware
type: object
properties:
malware:
type: object
x-display: file
title: Please upload the malware
next:
hash: "malware"
hash:
name: Hash the malware
type: automation
automation: hash.sha1
payload:
default: "playbook.tasks['upload'].data['malware']"
next:
virustotal:
virustotal:
name: Send hash to VirusTotal
type: automation
automation: vt.hash
args:
hash: "playbook.tasks['enter-hash'].data['hash'] || playbook.tasks['hash'].data['hash']"
# next:
# known-malware: "score > 5"
# sandbox: "score < 6" # unknown-malware

View File

@@ -0,0 +1,85 @@
name: Phishing
tasks:
board:
name: Board Involvement?
description: Is a board member involved?
type: input
schema:
properties:
boardInvolved:
default: false
title: A board member is involved.
type: boolean
required:
- boardInvolved
title: Board Involvement?
type: object
next:
escalate: "boardInvolved == true"
mail-available: "boardInvolved == false"
escalate:
name: Escalate to CISO
description: Please escalate the task to the CISO
type: task
mail-available:
name: Mail available
type: input
schema:
oneOf:
- properties:
mail:
title: Mail
type: string
x-display: textarea
schemaKey:
const: 'yes'
type: string
required:
- mail
title: 'Yes'
- properties:
schemaKey:
const: 'no'
type: string
title: 'No'
title: Mail available
type: object
next:
block-sender: "schemaKey == 'yes'"
extract-iocs: "schemaKey == 'yes'"
search-email-gateway: "schemaKey == 'no'"
search-email-gateway:
name: Search email gateway
description: Please search email-gateway for the phishing mail.
type: task
next:
extract-iocs:
block-sender:
name: Block sender
type: task
next:
extract-iocs:
extract-iocs:
name: Extract IOCs
description: Please insert the IOCs
type: input
schema:
properties:
iocs:
items:
type: string
title: IOCs
type: array
title: Extract IOCs
type: object
next:
block-iocs:
block-iocs:
name: Block IOCs
type: task

View File

@@ -0,0 +1,37 @@
name: Simple
tasks:
input:
name: Enter something to hash
type: input
schema:
title: Something
type: object
properties:
something:
type: string
title: Something
default: ""
next:
hash: "something != ''"
hash:
name: Hash the something
type: automation
automation: hash.sha1
payload:
default: "playbook.tasks['input'].data['something']"
next:
comment: "hash != ''"
comment:
name: Comment the hash
type: automation
automation: comment
payload:
default: "playbook.tasks['hash'].data['hash']"
next:
done: "done"
done:
name: You can close this case now
type: task

View File

@@ -0,0 +1,208 @@
{
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.com/object1618746510.json",
"title": "Advanced",
"type": "object",
"properties": {
"severity": {
"$id": "#root/severity",
"title": "Severity",
"type": "string",
"default": "Medium",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "Low",
"title": "Low",
"icon": "mdi-chevron-up"
},
{
"const": "Medium",
"title": "Medium",
"icon": "mdi-chevron-double-up"
},
{
"const": "High",
"title": "High",
"icon": "mdi-chevron-triple-up"
}
]
},
"tlp": {
"$id": "#root/tlp",
"title": "TLP",
"type": "string",
"nx-enum": [
"White",
"Green",
"Amber",
"Red"
],
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "White",
"title": "White",
"icon": "mdi-alpha-w"
},
{
"const": "Green",
"title": "Green",
"icon": "mdi-alpha-g"
},
{
"const": "Amber",
"title": "Amber",
"icon": "mdi-alpha-a"
},
{
"const": "Red",
"title": "Red",
"icon": "mdi-alpha-r"
}
]
},
"description": {
"$id": "#root/description",
"title": "Description",
"type": "string",
"x-display": "textarea",
"x-class": "pr-2"
},
"type": {
"type": "object",
"title": "Select an incident type",
"oneOf": [
{
"title": "Malware",
"properties": {
"schemaKey": {
"type": "string",
"const": "malware"
},
"malware_type": {
"type": "string",
"title": "Malware Type",
"enum": ["Ransomware", "Worm", "Virus"]
}
}
},
{
"title": "Phishing",
"properties": {
"schemaKey": {
"type": "string",
"const": "phishing"
},
"phishing_type": {
"type": "string",
"title": "Phishing Type",
"enum": ["Normal", "Spear", "Whale"]
}
}
}
],
"x-cols": 12
},
"apt": {
"type": "boolean",
"x-display": "switch",
"title": "APT involved?",
"x-cols": 6
},
"apt-group": {
"type": "string",
"title": "Select APT",
"enum": ["Lazarus Group", "Equation Group", "Fancy Bear (APT 28)", "OceanLotus (APT 32)", "Other"],
"x-if": "apt",
"x-cols": 6
},
"tactics": {
"type": "array",
"title": "MITRE Att&ck",
"description": "This description is used as a help message.",
"items": {
"type": "object",
"oneOf": [
{
"title": "Reconnaissance",
"properties": {
"tactic": {
"type": "string",
"const": "reconnaissance",
"title": "Tactic",
"description": "The adversary is trying to gather information they can use to plan future operations."
},
"techniques": {
"type": "array",
"title": "Techniques",
"items": {
"type": "string",
"oneOf": [
{
"const": "T1595",
"title": "Active Scanning",
"description": "Adversaries may execute active reconnaissance scans to gather information that can be used during targeting. Active scans are those where the adversary probes victim infrastructure via network traffic, as opposed to other forms of reconnaissance that do not involve direct interaction."
},
{
"const": "T1592",
"title": "Gather Victim Host Information"
}
]
},
"minItems": 1,
"uniqueItems": true
}
}
},
{
"title": "Persistence",
"properties": {
"tactic": {
"type": "string",
"const": "persistence"
},
"techniques": {
"type": "string",
"title": "Techniques",
"oneOf": [
{
"const": "T1098",
"title": "Account Manipulation"
},
{
"const": "T1197",
"title": "BITS Jobs"
}
]
}
}
}
]
},
"uniqueItems": true
},
"tags": {
"type": "array",
"title": "Tags",
"items": {
"type": "string",
"examples": [
"misp",
"external report",
"internal report"
]
}
}
},
"required": ["severity", "description", "tactics", "type"]
}

View File

@@ -0,0 +1,79 @@
{
"definitions": {},
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.com/object1618746510.json",
"title": "Default",
"type": "object",
"required": [
"severity",
"description",
"tlp"
],
"properties": {
"severity": {
"$id": "#root/severity",
"title": "Severity",
"type": "string",
"default": "Medium",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "Low",
"title": "Low",
"icon": "mdi-chevron-up"
},
{
"const": "Medium",
"title": "Medium",
"icon": "mdi-chevron-double-up"
},
{
"const": "High",
"title": "High",
"icon": "mdi-chevron-triple-up"
}
]
},
"tlp": {
"$id": "#root/tlp",
"title": "TLP",
"type": "string",
"x-cols": 6,
"x-class": "pr-2",
"x-display": "icon",
"x-itemIcon": "icon",
"oneOf": [
{
"const": "White",
"title": "White",
"icon": "mdi-alpha-w"
},
{
"const": "Green",
"title": "Green",
"icon": "mdi-alpha-g"
},
{
"const": "Amber",
"title": "Amber",
"icon": "mdi-alpha-a"
},
{
"const": "Red",
"title": "Red",
"icon": "mdi-alpha-r"
}
]
},
"description": {
"$id": "#root/description",
"title": "Description",
"type": "string",
"x-display": "textarea",
"x-class": "pr-2"
}
}
}

File diff suppressed because it is too large Load Diff

152
database/playbook.go Normal file
View File

@@ -0,0 +1,152 @@
package database
import (
"context"
"errors"
"time"
"github.com/arangodb/go-driver"
"github.com/iancoleman/strcase"
"github.com/icza/dyno"
"gopkg.in/yaml.v3"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
type PlaybookYAML struct {
Name string `yaml:"name"`
Tasks map[string]TaskYAML `yaml:"tasks"`
}
type TaskYAML struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Schema interface{} `yaml:"schema"`
Automation string `yaml:"automation"`
Payload map[string]string `yaml:"payload"`
Next map[string]string `yaml:"next"`
Join bool `yaml:"join"`
}
func toPlaybooks(docs []*models.PlaybookTemplateForm) (map[string]*models.Playbook, error) {
playbooks := map[string]*models.Playbook{}
for _, doc := range docs {
playbook, err := toPlaybook(doc)
if err != nil {
return nil, err
}
if doc.ID != nil {
playbooks[*doc.ID] = playbook
} else {
playbooks[strcase.ToKebab(playbook.Name)] = playbook
}
}
return playbooks, nil
}
func toPlaybook(doc *models.PlaybookTemplateForm) (*models.Playbook, error) {
ticketPlaybook := &models.Playbook{}
err := yaml.Unmarshal([]byte(doc.Yaml), ticketPlaybook)
if err != nil {
return nil, err
}
for idx, task := range ticketPlaybook.Tasks {
if task.Schema != nil {
task.Schema = dyno.ConvertMapI2MapS(task.Schema.(map[string]interface{}))
}
task.Created = time.Now().UTC()
ticketPlaybook.Tasks[idx] = task
}
return ticketPlaybook, nil
}
func toPlaybookTemplateResponse(key string, doc *models.PlaybookTemplate) *models.PlaybookTemplateResponse {
return &models.PlaybookTemplateResponse{ID: key, Name: doc.Name, Yaml: doc.Yaml}
}
func (db *Database) PlaybookCreate(ctx context.Context, playbook *models.PlaybookTemplateForm) (*models.PlaybookTemplateResponse, error) {
if playbook == nil {
return nil, errors.New("requires playbook")
}
var playbookYAML PlaybookYAML
err := yaml.Unmarshal([]byte(playbook.Yaml), &playbookYAML)
if err != nil {
return nil, err
}
if playbookYAML.Name == "" {
return nil, errors.New("requires template name")
}
p := models.PlaybookTemplate{Name: playbookYAML.Name, Yaml: playbook.Yaml}
var doc models.PlaybookTemplate
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.playbookCollection.CreateDocument(ctx, newctx, strcase.ToKebab(playbookYAML.Name), p)
if err != nil {
return nil, err
}
return toPlaybookTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) PlaybookGet(ctx context.Context, id string) (*models.PlaybookTemplateResponse, error) {
doc := models.PlaybookTemplate{}
meta, err := db.playbookCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toPlaybookTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) PlaybookDelete(ctx context.Context, id string) error {
_, err := db.playbookCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) PlaybookUpdate(ctx context.Context, id string, playbook *models.PlaybookTemplateForm) (*models.PlaybookTemplateResponse, error) {
var pb PlaybookYAML
err := yaml.Unmarshal([]byte(playbook.Yaml), &pb)
if err != nil {
return nil, err
}
if pb.Name == "" {
return nil, errors.New("requires template name")
}
var doc models.PlaybookTemplate
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.playbookCollection.ReplaceDocument(ctx, id, models.PlaybookTemplate{Name: pb.Name, Yaml: playbook.Yaml})
if err != nil {
return nil, err
}
return toPlaybookTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) PlaybookList(ctx context.Context) ([]*models.PlaybookTemplateResponse, error) {
query := "FOR d IN @@collection RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": PlaybookCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.PlaybookTemplateResponse
for {
var doc models.PlaybookTemplate
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, toPlaybookTemplateResponse(meta.Key, &doc))
}
return docs, err
}

182
database/playbookutils.go Normal file
View File

@@ -0,0 +1,182 @@
package database
import (
"errors"
"fmt"
"log"
"sort"
"github.com/SecurityBrewery/catalyst/caql"
"github.com/SecurityBrewery/catalyst/dag"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func playbookGraph(playbook *models.Playbook) (*dag.Graph, error) {
d := dag.NewGraph()
var taskIDs []string
for taskID := range playbook.Tasks {
taskIDs = append(taskIDs, taskID)
}
sort.Strings(taskIDs)
for _, taskID := range taskIDs {
if err := d.AddNode(taskID); err != nil {
return nil, errors.New("could not add node")
}
}
for _, taskID := range taskIDs {
task := playbook.Tasks[taskID]
for next := range task.Next {
if err := d.AddEdge(taskID, next); err != nil {
return nil, errors.New("could not add edge")
}
}
}
return d, nil
}
func toTaskResponse(playbook *models.Playbook, taskID string, order int, graph *dag.Graph) (*models.TaskResponse, error) {
task, ok := playbook.Tasks[taskID]
if !ok {
return nil, fmt.Errorf("task %s not found", taskID)
}
tr := &models.TaskResponse{
Automation: task.Automation,
Closed: task.Closed,
Created: task.Created,
Data: task.Data,
Done: task.Done,
Join: task.Join,
Payload: task.Payload,
Name: task.Name,
Next: task.Next,
Owner: task.Owner,
Schema: task.Schema,
Type: task.Type,
// Active: active,
// Order: v.Order,
}
tr.Order = int64(order)
taskActive, _ := active(playbook, taskID, graph, task)
tr.Active = taskActive
return tr, nil
}
func activePlaybook(playbook *models.Playbook, taskID string) (bool, error) {
task, ok := playbook.Tasks[taskID]
if !ok {
return false, fmt.Errorf("playbook does not contain tasks %s", taskID)
}
d, err := playbookGraph(playbook)
if err != nil {
return false, err
}
return active(playbook, taskID, d, task)
}
func active(playbook *models.Playbook, taskID string, d *dag.Graph, task *models.Task) (bool, error) {
if task.Done {
return false, nil
}
parents := d.GetParents(taskID)
if len(parents) == 0 {
return true, nil // valid(&task)
}
if task.Join != nil && *task.Join {
for _, parent := range parents {
parentTask := playbook.Tasks[parent]
if !parentTask.Done {
return false, nil
}
requirement := parentTask.Next[taskID]
b, err := evalRequirement(requirement, parentTask.Data)
if err != nil {
return false, err
}
if !b {
return false, nil
}
}
return true, nil
}
for _, parent := range parents {
parentTask := playbook.Tasks[parent]
if !parentTask.Done {
// return false, nil
continue
}
requirement := parentTask.Next[taskID]
b, err := evalRequirement(requirement, parentTask.Data)
if err != nil {
continue
}
if b {
return true, nil
}
}
return false, nil
}
func evalRequirement(aql string, data interface{}) (bool, error) {
if aql == "" {
return true, nil
}
parser := caql.Parser{}
tree, err := parser.Parse(aql)
if err != nil {
return false, err
}
var dataMap map[string]interface{}
if data != nil {
if dataMapX, ok := data.(map[string]interface{}); ok {
dataMap = dataMapX
} else {
log.Println("wrong data type for task data")
}
}
v, err := tree.Eval(dataMap)
if err != nil {
return false, err
}
if b, ok := v.(bool); ok {
return b, nil
}
return false, err
}
/*
// "github.com/qri-io/jsonschema"
func valid(task *models.Task) (bool, error) {
schema, err := json.Marshal(task.Schema)
if err != nil {
return false, err
}
rs := &jsonschema.Schema{}
if err := json.Unmarshal(schema, rs); err != nil {
return false, err
}
state := rs.Validate(context.Background(), task.Data)
return len(*state.Errs) > 0, nil
}
*/

View File

@@ -0,0 +1,135 @@
package database
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SecurityBrewery/catalyst/generated/models"
)
var playbook2 = &models.Playbook{
Name: "Phishing",
Tasks: map[string]*models.Task{
"board": {Next: map[string]string{
"escalate": "boardInvolved == true",
"aquire-mail": "boardInvolved == false",
}},
"escalate": {},
"aquire-mail": {Next: map[string]string{
"extract-iocs": "schemaKey == 'yes'",
"block-sender": "schemaKey == 'yes'",
"search-email-gateway": "schemaKey == 'no'",
}},
"extract-iocs": {Next: map[string]string{"fetch-iocs": ""}},
"fetch-iocs": {Next: map[string]string{"block-iocs": ""}},
"search-email-gateway": {Next: map[string]string{"block-iocs": ""}},
"block-sender": {Next: map[string]string{"block-iocs": ""}},
"block-iocs": {Next: map[string]string{"block-ioc": ""}},
"block-ioc": {},
},
}
var playbook3 = &models.Playbook{
Name: "Phishing",
Tasks: map[string]*models.Task{
"board": {Next: map[string]string{
"escalate": "boardInvolved == true",
"aquire-mail": "boardInvolved == false",
}, Data: map[string]interface{}{"boardInvolved": true}, Done: true},
"escalate": {},
"aquire-mail": {Next: map[string]string{
"extract-iocs": "schemaKey == 'yes'",
"block-sender": "schemaKey == 'yes'",
"search-email-gateway": "schemaKey == 'no'",
}},
"extract-iocs": {Next: map[string]string{"fetch-iocs": ""}},
"fetch-iocs": {Next: map[string]string{"block-iocs": ""}},
"search-email-gateway": {Next: map[string]string{"block-iocs": ""}},
"block-sender": {Next: map[string]string{"block-iocs": ""}},
"block-iocs": {Next: map[string]string{"block-ioc": ""}},
"block-ioc": {},
},
}
var playbook4 = &models.Playbook{
Name: "Malware",
Tasks: map[string]*models.Task{
"file-or-hash": {Next: map[string]string{
"enter-hash": "file == 'Hash'",
"upload": "file == 'File'",
}},
"enter-hash": {Next: map[string]string{
"virustotal": "hash != ''",
}},
"upload": {Next: map[string]string{
"hash": "malware",
}},
"hash": {Next: map[string]string{"virustotal": ""}},
"virustotal": {},
},
}
func Test_canBeCompleted(t *testing.T) {
type args struct {
playbook *models.Playbook
taskID string
}
tests := []struct {
name string
args args
want bool
wantErr bool
}{
{"playbook2 board", args{playbook: playbook2, taskID: "board"}, true, false},
{"playbook2 escalate", args{playbook: playbook2, taskID: "escalate"}, false, false},
{"playbook2 aquire-mail", args{playbook: playbook2, taskID: "aquire-mail"}, false, false},
{"playbook2 block-ioc", args{playbook: playbook2, taskID: "block-ioc"}, false, false},
{"playbook3 board", args{playbook: playbook3, taskID: "board"}, false, false},
{"playbook3 escalate", args{playbook: playbook3, taskID: "escalate"}, true, false},
{"playbook3 aquire-mail", args{playbook: playbook3, taskID: "aquire-mail"}, false, false},
{"playbook3 block-ioc", args{playbook: playbook3, taskID: "block-ioc"}, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := activePlaybook(tt.args.playbook, tt.args.taskID)
if (err != nil) != tt.wantErr {
t.Errorf("activePlaybook() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("activePlaybook() got = %v, want %v", got, tt.want)
}
})
}
}
func Test_playbookOrder(t *testing.T) {
type args struct {
playbook *models.Playbook
}
tests := []struct {
name string
args args
want []string
wantErr bool
}{
{"playbook4", args{playbook: playbook4}, []string{"file-or-hash", "enter-hash", "upload", "hash", "virustotal"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := toPlaybookResponse(tt.args.playbook)
if (err != nil) != tt.wantErr {
t.Errorf("activePlaybook() error = %v, wantErr %v", err, tt.wantErr)
return
}
names := make([]string, len(got.Tasks))
for name, task := range got.Tasks {
names[task.Order] = name
}
assert.Equal(t, tt.want, names)
})
}
}

48
database/relationships.go Normal file
View File

@@ -0,0 +1,48 @@
package database
import (
"context"
"errors"
"strconv"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/database/busdb"
)
func (db *Database) RelatedCreate(ctx context.Context, id, id2 int64) error {
if id == id2 {
return errors.New("tickets cannot relate to themself")
}
_, err := db.relatedCollection.CreateEdge(ctx, ctx, &driver.EdgeDocument{
From: driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id))),
To: driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id2))),
})
return err
}
func (db *Database) RelatedBatchCreate(ctx context.Context, edges []*driver.EdgeDocument) error {
_, err := db.relatedCollection.CreateEdges(ctx, edges)
return err
}
func (db *Database) RelatedRemove(ctx context.Context, id, id2 int64) error {
q := `
FOR d in @@collection
FILTER (d._from == @id && d._to == @id2) || (d._to == @id && d._from == @id2)
REMOVE d in @@collection`
_, _, err := db.Query(ctx, q, map[string]interface{}{
"@collection": RelatedTicketsCollectionName,
"id": driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id))),
"id2": driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id2))),
}, &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id))),
driver.DocumentID(TicketCollectionName + "/" + strconv.Itoa(int(id2))),
},
Msg: "Removed ticket/artifact relation",
})
return err
}

86
database/settings.go Normal file
View File

@@ -0,0 +1,86 @@
package database
import (
"context"
"errors"
"github.com/arangodb/go-driver"
"github.com/gin-gonic/gin"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func toUserDataResponse(key string, doc *models.UserData) *models.UserDataResponse {
return &models.UserDataResponse{
Email: doc.Email,
ID: key,
Image: doc.Image,
Name: doc.Name,
Timeformat: doc.Timeformat,
}
}
func (db *Database) UserDataCreate(ctx context.Context, id string, userdata *models.UserData) error {
if userdata == nil {
return errors.New("requires setting")
}
if id == "" {
return errors.New("requires username")
}
_, err := db.userdataCollection.CreateDocument(ctx, ctx, id, userdata)
return err
}
func (db *Database) UserDataGetOrCreate(ctx *gin.Context, id string, newUserData *models.UserData) (*models.UserDataResponse, error) {
setting, err := db.UserDataGet(ctx, id)
if err != nil {
return toUserDataResponse(id, newUserData), db.UserDataCreate(ctx, id, newUserData)
}
return setting, nil
}
func (db *Database) UserDataGet(ctx context.Context, id string) (*models.UserDataResponse, error) {
var doc models.UserData
meta, err := db.userdataCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toUserDataResponse(meta.Key, &doc), err
}
func (db *Database) UserDataList(ctx context.Context) ([]*models.UserDataResponse, error) {
query := "FOR d IN @@collection SORT d.username ASC RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": UserDataCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.UserDataResponse
for {
var doc models.UserData
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, toUserDataResponse(meta.Key, &doc))
}
return docs, err
}
func (db *Database) UserDataUpdate(ctx context.Context, id string, userdata *models.UserData) (*models.UserDataResponse, error) {
var doc models.UserData
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.userdataCollection.ReplaceDocument(ctx, id, userdata)
if err != nil {
return nil, err
}
return toUserDataResponse(meta.Key, &doc), nil
}

159
database/settings_test.go Normal file
View File

@@ -0,0 +1,159 @@
package database_test
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/pointer"
"github.com/SecurityBrewery/catalyst/test"
)
func init() {
gin.SetMode(gin.TestMode)
}
var bob = &models.UserData{
Email: pointer.String("bob@example.org"),
Name: pointer.String("Bob"),
}
var bobResponse = &models.UserDataResponse{
ID: "bob",
Email: pointer.String("bob@example.org"),
Name: pointer.String("Bob"),
}
func TestDatabase_UserDataCreate(t *testing.T) {
type args struct {
id string
setting *models.UserData
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "Normal setting", args: args{id: "bob", setting: bob}, wantErr: false},
{name: "Nil setting", args: args{id: "bob"}, wantErr: true},
{name: "UserData without settingname", args: args{id: ""}, wantErr: true},
{name: "Only settingname", args: args{id: "bob"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := db.UserDataCreate(test.Context(), tt.args.id, tt.args.setting); (err != nil) != tt.wantErr {
t.Errorf("settingCreate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestDatabase_UserDataGet(t *testing.T) {
type args struct {
id string
}
tests := []struct {
name string
args args
want *models.UserDataResponse
wantErr bool
}{
{name: "Normal get", args: args{id: "bob"}, want: bobResponse},
{name: "Not existing", args: args{id: "foo"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := db.UserDataCreate(test.Context(), "bob", bob); err != nil {
t.Errorf("settingCreate() error = %v", err)
}
got, err := db.UserDataGet(test.Context(), tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("UserDataGet() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestDatabase_UserDataList(t *testing.T) {
tests := []struct {
name string
want []*models.UserDataResponse
wantErr bool
}{
{name: "Normal list", want: []*models.UserDataResponse{bobResponse}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := db.UserDataCreate(test.Context(), "bob", bob); err != nil {
t.Errorf("settingCreate() error = %v", err)
}
got, err := db.UserDataList(test.Context())
if (err != nil) != tt.wantErr {
t.Errorf("UserDataList() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestDatabase_UserDataUpdate(t *testing.T) {
type args struct {
id string
setting *models.UserData
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "Normal", args: args{id: "bob", setting: bob}},
{name: "Not existing", args: args{id: "foo"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := db.UserDataCreate(test.Context(), "bob", bob); err != nil {
t.Errorf("settingCreate() error = %v", err)
}
if _, err := db.UserDataUpdate(test.Context(), tt.args.id, tt.args.setting); (err != nil) != tt.wantErr {
t.Errorf("UserDataUpdate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

43
database/statistics.go Normal file
View File

@@ -0,0 +1,43 @@
package database
import (
"context"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func (db *Database) Statistics(ctx context.Context) (*models.Statistics, error) {
query := `RETURN {
tickets_per_type: MERGE(FOR d in tickets
COLLECT type = d.type WITH COUNT INTO typecount
RETURN ZIP([type], [typecount])),
unassigned: FIRST(FOR d in tickets
FILTER d.status == "open" AND !d.owner
COLLECT WITH COUNT INTO length
RETURN length),
open_tickets_per_user: MERGE(FOR d in tickets
FILTER d.status == "open"
COLLECT user = d.owner WITH COUNT INTO usercount
RETURN ZIP([user], [usercount])),
tickets_per_week: MERGE(FOR d in tickets
COLLECT week = CONCAT(DATE_YEAR(d.created), "-", DATE_ISOWEEK(d.created) < 10 ? "0" : "", DATE_ISOWEEK(d.created)) WITH COUNT INTO weekcount
RETURN ZIP([week], [weekcount])),
}`
cur, _, err := db.Query(ctx, query, nil, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cur.Close()
statistics := models.Statistics{}
if _, err := cur.ReadDocument(ctx, &statistics); err != nil {
return nil, err
}
return &statistics, nil
}

68
database/task.go Normal file
View File

@@ -0,0 +1,68 @@
package database
import (
"context"
"github.com/arangodb/go-driver"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
type playbookResponse struct {
PlaybookId string `json:"playbook_id"`
PlaybookName string `json:"playbook_name"`
Playbook models.Playbook `json:"playbook"`
TicketId int64 `json:"ticket_id"`
TicketName string `json:"ticket_name"`
}
func (db *Database) TaskList(ctx context.Context) ([]*models.TaskWithContext, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `FOR d IN @@collection
` + ticketFilterQuery + `
FILTER d.status == 'open'
FOR playbook IN NOT_NULL(VALUES(d.playbooks), [])
RETURN { ticket_id: TO_NUMBER(d._key), ticket_name: d.name, playbook_id: POSITION(d.playbooks, playbook, true), playbook_name: playbook.name, playbook: playbook }`
cursor, _, err := db.Query(ctx, query, mergeMaps(ticketFilterVars, map[string]interface{}{
"@collection": TicketCollectionName,
}), busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
docs := []*models.TaskWithContext{}
for {
var doc playbookResponse
_, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
playbook, err := toPlaybookResponse(&doc.Playbook)
if err != nil {
return nil, err
}
for _, task := range playbook.Tasks {
if task.Active {
docs = append(docs, &models.TaskWithContext{
PlaybookId: doc.PlaybookId,
PlaybookName: doc.PlaybookName,
Task: *task,
TicketId: doc.TicketId,
TicketName: doc.TicketName,
})
}
}
}
return docs, err
}

88
database/template.go Normal file
View File

@@ -0,0 +1,88 @@
package database
import (
"context"
"errors"
"github.com/arangodb/go-driver"
"github.com/iancoleman/strcase"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func toTicketTemplate(doc *models.TicketTemplateForm) *models.TicketTemplate {
return &models.TicketTemplate{Name: doc.Name, Schema: doc.Schema}
}
func toTicketTemplateResponse(key string, doc *models.TicketTemplate) *models.TicketTemplateResponse {
return &models.TicketTemplateResponse{ID: key, Name: doc.Name, Schema: doc.Schema}
}
func (db *Database) TemplateCreate(ctx context.Context, template *models.TicketTemplateForm) (*models.TicketTemplateResponse, error) {
if template == nil {
return nil, errors.New("requires template")
}
if template.Name == "" {
return nil, errors.New("requires template name")
}
var doc models.TicketTemplate
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.templateCollection.CreateDocument(ctx, newctx, strcase.ToKebab(template.Name), toTicketTemplate(template))
if err != nil {
return nil, err
}
return toTicketTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) TemplateGet(ctx context.Context, id string) (*models.TicketTemplateResponse, error) {
var doc models.TicketTemplate
meta, err := db.templateCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toTicketTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) TemplateUpdate(ctx context.Context, id string, template *models.TicketTemplateForm) (*models.TicketTemplateResponse, error) {
var doc models.TicketTemplate
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.templateCollection.ReplaceDocument(ctx, id, toTicketTemplate(template))
if err != nil {
return nil, err
}
return toTicketTemplateResponse(meta.Key, &doc), nil
}
func (db *Database) TemplateDelete(ctx context.Context, id string) error {
_, err := db.templateCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) TemplateList(ctx context.Context) ([]*models.TicketTemplateResponse, error) {
query := "FOR d IN @@collection RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": TemplateCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.TicketTemplateResponse
for {
var doc models.TicketTemplate
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, toTicketTemplateResponse(meta.Key, &doc))
}
return docs, err
}

182
database/template_test.go Normal file
View File

@@ -0,0 +1,182 @@
package database_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SecurityBrewery/catalyst/database/migrations"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/test"
)
var template1 = &models.TicketTemplateForm{
Schema: migrations.DefaultTemplateSchema,
Name: "Template 1",
}
var default1 = &models.TicketTemplateForm{
Schema: migrations.DefaultTemplateSchema,
Name: "Default",
}
func TestDatabase_TemplateCreate(t *testing.T) {
type args struct {
template *models.TicketTemplateForm
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "Normal", args: args{template: template1}},
{name: "Duplicate", args: args{template: default1}, wantErr: true},
{name: "Nil template", args: args{}, wantErr: true},
{name: "Template without fields", args: args{template: &models.TicketTemplateForm{}}, wantErr: true},
{name: "Only name", args: args{template: &models.TicketTemplateForm{Name: "name"}}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := db.TemplateCreate(test.Context(), tt.args.template); (err != nil) != tt.wantErr {
t.Errorf("TemplateCreate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestDatabase_TemplateDelete(t *testing.T) {
type args struct {
id string
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "Normal", args: args{"default"}},
{name: "Not existing", args: args{"foobar"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := db.TemplateCreate(test.Context(), template1); err != nil {
t.Errorf("TemplateCreate() error = %v", err)
}
if err := db.TemplateDelete(test.Context(), tt.args.id); (err != nil) != tt.wantErr {
t.Errorf("TemplateDelete() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestDatabase_TemplateGet(t *testing.T) {
type args struct {
id string
}
tests := []struct {
name string
args args
want *models.TicketTemplateResponse
wantErr bool
}{
{name: "Normal", args: args{id: "default"}, want: &models.TicketTemplateResponse{ID: "default", Name: "Default", Schema: migrations.DefaultTemplateSchema}},
{name: "Not existing", args: args{id: "foobar"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := db.TemplateCreate(test.Context(), template1); err != nil {
t.Errorf("TemplateCreate() error = %v", err)
}
got, err := db.TemplateGet(test.Context(), tt.args.id)
if (err != nil) != tt.wantErr {
t.Errorf("TemplateGet() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
assert.Equal(t, got, tt.want)
})
}
}
func TestDatabase_TemplateList(t *testing.T) {
tests := []struct {
name string
want []*models.TicketTemplateResponse
wantErr bool
}{
{name: "Normal", want: []*models.TicketTemplateResponse{{ID: "default", Name: "Default", Schema: migrations.DefaultTemplateSchema}, {ID: "template-1", Name: template1.Name, Schema: template1.Schema}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := db.TemplateCreate(test.Context(), template1); err != nil {
t.Errorf("TemplateCreate() error = %v", err)
}
got, err := db.TemplateList(test.Context())
if (err != nil) != tt.wantErr {
t.Errorf("TemplateList() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, got, tt.want)
})
}
}
func TestDatabase_TemplateUpdate(t *testing.T) {
type args struct {
id string
template *models.TicketTemplateForm
}
tests := []struct {
name string
args args
wantErr bool
}{
{name: "Normal", args: args{"default", template1}},
{name: "Not existing", args: args{"foobar", template1}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, _, _, db, cleanup, err := test.DB(t)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := db.TemplateCreate(test.Context(), template1); err != nil {
t.Errorf("TemplateCreate() error = %v", err)
}
if _, err := db.TemplateUpdate(test.Context(), tt.args.id, tt.args.template); (err != nil) != tt.wantErr {
t.Errorf("TemplateUpdate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

604
database/ticket.go Normal file
View File

@@ -0,0 +1,604 @@
package database
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/arangodb/go-driver"
"github.com/xeipuuv/gojsonschema"
"github.com/SecurityBrewery/catalyst/caql"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/index"
)
func toTicket(ticketForm *models.TicketForm) (interface{}, error) {
playbooks, err := toPlaybooks(ticketForm.Playbooks)
if err != nil {
return nil, err
}
ticket := &models.Ticket{
Artifacts: ticketForm.Artifacts,
Comments: ticketForm.Comments,
Details: ticketForm.Details,
Files: ticketForm.Files,
Name: ticketForm.Name,
Owner: ticketForm.Owner,
Playbooks: playbooks,
Read: ticketForm.Read,
References: ticketForm.References,
Status: ticketForm.Status,
Type: ticketForm.Type,
Write: ticketForm.Write,
// ID: ticketForm.ID,
// Created: ticketForm.Created,
// Modified: ticketForm.Modified,
// Schema: ticketForm.Schema,
}
if ticketForm.Created != nil {
ticket.Created = *ticketForm.Created
} else {
ticket.Created = time.Now().UTC()
}
if ticketForm.Modified != nil {
ticket.Modified = *ticketForm.Modified
} else {
ticket.Modified = time.Now().UTC()
}
if ticketForm.Schema != nil {
ticket.Schema = *ticketForm.Schema
} else {
ticket.Schema = "{}"
}
if ticketForm.Status == "" {
ticket.Status = "open"
}
if ticketForm.ID != nil {
return &busdb.Keyed{Key: strconv.FormatInt(*ticketForm.ID, 10), Doc: ticket}, nil
}
return ticket, nil
}
func toTicketResponses(tickets []*models.TicketSimpleResponse) ([]*models.TicketResponse, error) {
var extendedTickets []*models.TicketResponse
for _, simple := range tickets {
tr, err := toTicketResponse(simple)
if err != nil {
return nil, err
}
extendedTickets = append(extendedTickets, tr)
}
return extendedTickets, nil
}
func toTicketResponse(ticket *models.TicketSimpleResponse) (*models.TicketResponse, error) {
playbooks, err := toPlaybookResponses(ticket.Playbooks)
if err != nil {
return nil, err
}
return &models.TicketResponse{
ID: ticket.ID,
Artifacts: ticket.Artifacts,
Comments: ticket.Comments,
Created: ticket.Created,
Details: ticket.Details,
Files: ticket.Files,
Modified: ticket.Modified,
Name: ticket.Name,
Owner: ticket.Owner,
Playbooks: playbooks,
Read: ticket.Read,
References: ticket.References,
Schema: ticket.Schema,
Status: ticket.Status,
Type: ticket.Type,
Write: ticket.Write,
}, nil
}
func toTicketSimpleResponse(key string, ticket *models.Ticket) (*models.TicketSimpleResponse, error) {
id, err := strconv.ParseInt(key, 10, 64)
if err != nil {
return nil, err
}
return &models.TicketSimpleResponse{
Artifacts: ticket.Artifacts,
Comments: ticket.Comments,
Created: ticket.Created,
Details: ticket.Details,
Files: ticket.Files,
ID: id,
Modified: ticket.Modified,
Name: ticket.Name,
Owner: ticket.Owner,
Playbooks: ticket.Playbooks,
Read: ticket.Read,
References: ticket.References,
Schema: ticket.Schema,
Status: ticket.Status,
Type: ticket.Type,
Write: ticket.Write,
}, nil
}
func toTicketWithTickets(ticketResponse *models.TicketResponse, tickets []*models.TicketSimpleResponse) *models.TicketWithTickets {
return &models.TicketWithTickets{
Artifacts: ticketResponse.Artifacts,
Comments: ticketResponse.Comments,
Created: ticketResponse.Created,
Details: ticketResponse.Details,
Files: ticketResponse.Files,
ID: ticketResponse.ID,
Modified: ticketResponse.Modified,
Name: ticketResponse.Name,
Owner: ticketResponse.Owner,
Playbooks: ticketResponse.Playbooks,
Read: ticketResponse.Read,
References: ticketResponse.References,
Schema: ticketResponse.Schema,
Status: ticketResponse.Status,
Type: ticketResponse.Type,
Write: ticketResponse.Write,
Tickets: tickets,
}
}
func toPlaybookResponses(playbooks map[string]*models.Playbook) (map[string]*models.PlaybookResponse, error) {
pr := map[string]*models.PlaybookResponse{}
var err error
for k, v := range playbooks {
pr[k], err = toPlaybookResponse(v)
if err != nil {
return nil, err
}
}
return pr, nil
}
func toPlaybookResponse(playbook *models.Playbook) (*models.PlaybookResponse, error) {
graph, err := playbookGraph(playbook)
if err != nil {
return nil, err
}
re := &models.PlaybookResponse{
Name: playbook.Name,
Tasks: map[string]*models.TaskResponse{},
}
results, err := graph.Toposort()
if err != nil {
return nil, err
}
i := 0
for _, taskID := range results {
rootTask, err := toTaskResponse(playbook, taskID, i, graph)
if err != nil {
return nil, err
}
re.Tasks[taskID] = rootTask
i++
}
return re, nil
}
func (db *Database) TicketBatchCreate(ctx context.Context, ticketForms []*models.TicketForm) ([]*models.TicketResponse, error) {
update, err := db.Hooks.IngestionFilter(ctx, db.Index)
if err != nil {
return nil, err
}
var dbTickets []interface{}
for _, ticketForm := range ticketForms {
ticket, err := toTicket(ticketForm)
if err != nil {
return nil, err
}
if err := validate(ticket, models.TicketSchema); err != nil {
return nil, err
}
dbTickets = append(dbTickets, ticket)
}
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `FOR d IN @tickets
` + ticketFilterQuery + `
LET updates = ` + update + `
LET newdoc = LENGTH(updates) != 0 ? APPLY("MERGE_RECURSIVE", APPEND([d], updates)) : d
LET keyeddoc = HAS(newdoc, "id") ? MERGE(newdoc, {"_key": TO_STRING(newdoc.id)}) : newdoc
LET noiddoc = UNSET(keyeddoc, "id")
INSERT noiddoc INTO @@collection
RETURN NEW`
apiTickets, _, err := db.ticketListQuery(ctx, query, mergeMaps(map[string]interface{}{
"tickets": dbTickets,
}, ticketFilterVars), busdb.CreateOperation)
if err != nil {
return nil, err
}
if err = batchIndex(db.Index, apiTickets); err != nil {
return nil, err
}
var ids []driver.DocumentID
for _, apiTicket := range apiTickets {
ids = append(ids, driver.NewDocumentID(TicketCollectionName, fmt.Sprint(apiTicket.ID)))
}
if err := db.BusDatabase.LogAndNotify(ctx, ids, "Ticket created"); err != nil {
return nil, err
}
ticketResponses, err := toTicketResponses(apiTickets)
if err != nil {
return nil, err
}
for _, ticketResponse := range ticketResponses {
for playbookID := range ticketResponse.Playbooks {
if err := runRootTask(ticketResponse, playbookID, db); err != nil {
return nil, err
}
}
}
return ticketResponses, nil
}
func (db *Database) IndexRebuild(ctx context.Context) error {
if err := db.Index.Truncate(); err != nil {
return err
}
tickets, _, err := db.ticketListQuery(ctx, "FOR d IN @@collection RETURN d", nil, busdb.ReadOperation)
if err != nil {
return err
}
return batchIndex(db.Index, tickets)
}
func batchIndex(index *index.Index, tickets []*models.TicketSimpleResponse) error {
var wg sync.WaitGroup
var batch []*models.TicketSimpleResponse
for _, ticket := range tickets {
batch = append(batch, ticket)
if len(batch) > 100 {
wg.Add(1)
go func(docs []*models.TicketSimpleResponse) {
index.Index(docs)
wg.Done()
}(batch)
batch = []*models.TicketSimpleResponse{}
}
}
wg.Wait()
return nil
}
func (db *Database) TicketGet(ctx context.Context, ticketID int64) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketReadFilter(ctx)
if err != nil {
return nil, err
}
return db.ticketGetQuery(ctx, ticketID, `LET d = DOCUMENT(@@collection, @ID) `+ticketFilterQuery+` RETURN d`, ticketFilterVars, busdb.ReadOperation)
}
func (db *Database) ticketGetQuery(ctx context.Context, ticketID int64, query string, bindVars map[string]interface{}, operation *busdb.Operation) (*models.TicketWithTickets, error) {
if bindVars == nil {
bindVars = map[string]interface{}{}
}
bindVars["@collection"] = TicketCollectionName
if ticketID != 0 {
bindVars["ID"] = fmt.Sprint(ticketID)
}
cur, _, err := db.Query(ctx, query, bindVars, operation)
if err != nil {
return nil, err
}
defer cur.Close()
ticket := models.Ticket{}
meta, err := cur.ReadDocument(ctx, &ticket)
if err != nil {
return nil, err
}
ticketSimpleResponse, err := toTicketSimpleResponse(meta.Key, &ticket)
if err != nil {
return nil, err
}
// index
go db.Index.Index([]*models.TicketSimpleResponse{ticketSimpleResponse})
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketReadFilter(ctx)
if err != nil {
return nil, err
}
// tickets
ticketsQuery := `FOR vertex, edge IN OUTBOUND
DOCUMENT(@@tickets, @ID)
GRAPH @graph
FILTER IS_SAME_COLLECTION(@@collection, vertex)
FILTER vertex != null
LET d = DOCUMENT(@@collection, edge["_to"])
` + ticketFilterQuery + `
RETURN d`
outTickets, _, err := db.ticketListQuery(ctx, ticketsQuery, mergeMaps(map[string]interface{}{
"ID": fmt.Sprint(ticketID),
"graph": TicketArtifactsGraphName,
"@tickets": TicketCollectionName,
}, ticketFilterVars), busdb.ReadOperation)
if err != nil {
return nil, err
}
ticketsQuery = `FOR vertex, edge IN INBOUND
DOCUMENT(@@tickets, @ID)
GRAPH @graph
FILTER IS_SAME_COLLECTION(@@collection, vertex)
FILTER vertex != null
LET d = DOCUMENT(@@collection, edge["_from"])
` + ticketFilterQuery + `
RETURN d`
inTickets, _, err := db.ticketListQuery(ctx, ticketsQuery, mergeMaps(map[string]interface{}{
"ID": fmt.Sprint(ticketID),
"graph": TicketArtifactsGraphName,
"@tickets": TicketCollectionName,
}, ticketFilterVars), busdb.ReadOperation)
if err != nil {
return nil, err
}
var artifactNames []string
for _, artifact := range ticketSimpleResponse.Artifacts {
artifactNames = append(artifactNames, artifact.Name)
}
ticketsQuery = `FOR d IN @@collection
FILTER d._key != @ID
` + ticketFilterQuery + `
FOR a IN NOT_NULL(d.artifacts, [])
FILTER POSITION(@artifacts, a.name)
RETURN d`
sameArtifactTickets, _, err := db.ticketListQuery(ctx, ticketsQuery, mergeMaps(map[string]interface{}{
"ID": fmt.Sprint(ticketID),
"artifacts": artifactNames,
}, ticketFilterVars), busdb.ReadOperation)
if err != nil {
return nil, err
}
tickets := append(outTickets, inTickets...)
tickets = append(tickets, sameArtifactTickets...)
sort.Slice(tickets, func(i, j int) bool {
return tickets[i].ID < tickets[j].ID
})
ticketResponse, err := toTicketResponse(ticketSimpleResponse)
if err != nil {
return nil, err
}
return toTicketWithTickets(ticketResponse, tickets), nil
}
func (db *Database) TicketUpdate(ctx context.Context, ticketID int64, ticket *models.Ticket) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
REPLACE d WITH @ticket IN @@collection
RETURN NEW`
ticket.Modified = time.Now().UTC() // TODO make setable?
return db.ticketGetQuery(ctx, ticketID, query, mergeMaps(map[string]interface{}{"ticket": ticket}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update, Ids: []driver.DocumentID{
driver.NewDocumentID(TicketCollectionName, strconv.FormatInt(ticketID, 10)),
},
Msg: "Ticket updated",
})
}
func (db *Database) TicketDelete(ctx context.Context, ticketID int64) error {
_, err := db.TicketGet(ctx, ticketID)
if err != nil {
return err
}
_, err = db.ticketCollection.RemoveDocument(ctx, strconv.FormatInt(ticketID, 10))
if err != nil {
return err
}
return nil
}
func (db *Database) TicketList(ctx context.Context, ticketType string, query string, sorts []string, desc []bool, offset, count int64) (*models.TicketList, error) {
binVars := map[string]interface{}{}
parser := &caql.Parser{Searcher: db.Index, Prefix: "d."}
var typeString = ""
if ticketType != "" {
typeString = "FILTER d.type == @type "
binVars["type"] = ticketType
}
var filterString = ""
if query != "" {
queryTree, err := parser.Parse(query)
if err != nil {
return nil, errors.New("invalid filter query: syntax error")
}
filterString, err = queryTree.String()
if err != nil {
return nil, fmt.Errorf("invalid filter query: %w", err)
}
filterString = "FILTER " + filterString
}
documentCount, err := db.TicketCount(ctx, typeString, filterString, binVars)
if err != nil {
return nil, err
}
sortQ := sortQuery(sorts, desc, binVars)
binVars["offset"] = offset
binVars["count"] = count
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketReadFilter(ctx)
if err != nil {
return nil, err
}
q := `FOR d IN @@collection
` + ticketFilterQuery + `
` + sortQ + `
` + typeString + `
` + filterString + `
LIMIT @offset, @count
SORT d._key ASC
RETURN d`
// RETURN KEEP(d, "_key", "id", "name", "type", "created")`
ticketList, _, err := db.ticketListQuery(ctx, q, mergeMaps(binVars, ticketFilterVars), busdb.ReadOperation)
return &models.TicketList{
Count: documentCount,
Tickets: ticketList,
}, err
// return map[string]interface{}{"tickets": ticketList, "count": documentCount}, err
}
func (db *Database) ticketListQuery(ctx context.Context, query string, bindVars map[string]interface{}, operation *busdb.Operation) ([]*models.TicketSimpleResponse, *models.LogEntry, error) {
if bindVars == nil {
bindVars = map[string]interface{}{}
}
bindVars["@collection"] = TicketCollectionName
cursor, logEntry, err := db.Query(ctx, query, bindVars, operation)
if err != nil {
return nil, nil, err
}
defer cursor.Close()
var docs []*models.TicketSimpleResponse
for {
doc := models.Ticket{}
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, nil, err
}
resp, err := toTicketSimpleResponse(meta.Key, &doc)
if err != nil {
return nil, nil, err
}
docs = append(docs, resp)
}
return docs, logEntry, nil
}
func (db *Database) TicketCount(ctx context.Context, typequery, filterquery string, bindVars map[string]interface{}) (int, error) {
if bindVars == nil {
bindVars = map[string]interface{}{}
}
bindVars["@collection"] = TicketCollectionName
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketReadFilter(ctx)
if err != nil {
return 0, err
}
countQuery := `RETURN LENGTH(FOR d IN @@collection ` + ticketFilterQuery + " " + typequery + " " + filterquery + ` RETURN 1)`
cursor, _, err := db.Query(ctx, countQuery, mergeMaps(bindVars, ticketFilterVars), busdb.ReadOperation)
if err != nil {
return 0, err
}
documentCount := 0
_, err = cursor.ReadDocument(ctx, &documentCount)
if err != nil {
return 0, err
}
cursor.Close()
return documentCount, nil
}
func sortQuery(paramsSort []string, paramsDesc []bool, bindVars map[string]interface{}) string {
sort := ""
if len(paramsSort) > 0 {
var sorts []string
for i, column := range paramsSort {
colsort := fmt.Sprintf("d.@column%d", i)
if len(paramsDesc) > i && paramsDesc[i] {
colsort += " DESC"
}
sorts = append(sorts, colsort)
bindVars[fmt.Sprintf("column%d", i)] = column
}
sort = "SORT " + strings.Join(sorts, ", ")
}
return sort
}
func mergeMaps(a map[string]interface{}, b map[string]interface{}) map[string]interface{} {
merged := map[string]interface{}{}
for k, v := range a {
merged[k] = v
}
for k, v := range b {
merged[k] = v
}
return merged
}
func validate(e interface{}, schema *gojsonschema.Schema) error {
b, err := json.Marshal(e)
if err != nil {
return err
}
res, err := schema.Validate(gojsonschema.NewStringLoader(string(b)))
if err != nil {
return err
}
if len(res.Errors()) > 0 {
var l []string
for _, e := range res.Errors() {
l = append(l, e.String())
}
return fmt.Errorf("validation failed: %v", strings.Join(l, ", "))
}
return nil
}

291
database/ticket_field.go Normal file
View File

@@ -0,0 +1,291 @@
package database
import (
"context"
"errors"
"fmt"
"time"
"github.com/arangodb/go-driver"
"github.com/iancoleman/strcase"
"github.com/mingrammer/commonregex"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/pointer"
)
func (db *Database) AddArtifact(ctx context.Context, id int64, artifact *models.Artifact) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
if artifact.Status == nil {
artifact.Status = pointer.String("unknown")
}
if artifact.Type == nil {
artifact.Type = pointer.String(inferType(artifact.Name))
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "artifacts": PUSH(NOT_NULL(d.artifacts, []), @artifact) } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"artifact": artifact}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Add artifact",
})
}
func inferType(name string) string {
switch {
case commonregex.IPRegex.MatchString(name):
return "ip"
case commonregex.LinkRegex.MatchString(name):
return "url"
case commonregex.EmailRegex.MatchString(name):
return "email"
case commonregex.MD5HexRegex.MatchString(name):
return "md5"
case commonregex.SHA1HexRegex.MatchString(name):
return "sha1"
case commonregex.SHA256HexRegex.MatchString(name):
return "sha256"
}
return "unknown"
}
func (db *Database) RemoveArtifact(ctx context.Context, id int64, name string) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
FOR a IN NOT_NULL(d.artifacts, [])
FILTER a.name == @name
LET newartifacts = REMOVE_VALUE(d.artifacts, a)
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "artifacts": newartifacts } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"name": name}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Remove artifact",
})
}
func (db *Database) SetTemplate(ctx context.Context, id int64, schema string) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "schema": @schema } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"schema": schema}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Set Template",
})
}
func (db *Database) AddComment(ctx context.Context, id int64, comment *models.CommentForm) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
if comment.Creator == nil || *comment.Creator == "" {
user, exists := busdb.UserFromContext(ctx)
if !exists {
return nil, errors.New("no user in context")
}
comment.Creator = pointer.String(user.ID)
}
if comment.Created == nil {
comment.Created = pointer.Time(time.Now().UTC())
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "comments": PUSH(NOT_NULL(d.comments, []), @comment) } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"comment": comment}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Add comment",
})
}
func (db *Database) RemoveComment(ctx context.Context, id int64, commentID int64) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "comments": REMOVE_NTH(d.comments, @commentID) } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"commentID": commentID}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Remove comment",
})
}
func (db *Database) SetReferences(ctx context.Context, id int64, references []*models.Reference) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "references": @references } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"references": references}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Changed references",
})
}
func (db *Database) LinkFiles(ctx context.Context, id int64, files []*models.File) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "files": @files } IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{"files": files}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.DocumentID(fmt.Sprintf("%s/%d", TicketCollectionName, id)),
},
Msg: "Linked files",
})
}
func (db *Database) AddTicketPlaybook(ctx context.Context, id int64, playbookTemplate *models.PlaybookTemplateForm) (*models.TicketWithTickets, error) {
pb, err := toPlaybook(playbookTemplate)
if err != nil {
return nil, err
}
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
playbookID := strcase.ToKebab(pb.Name)
if playbookTemplate.ID != nil {
playbookID = *playbookTemplate.ID
}
parentTicket, err := db.TicketGet(ctx, id)
if err != nil {
return nil, err
}
query := `FOR d IN @@collection
` + ticketFilterQuery + `
FILTER d._key == @ID
LET newplaybook = ZIP( [@playbookID], [@playbook] )
LET newplaybooks = MERGE(NOT_NULL(d.playbooks, {}), newplaybook)
LET newticket = MERGE(d, { "modified": DATE_ISO8601(DATE_NOW()), "playbooks": newplaybooks })
REPLACE d WITH newticket IN @@collection
RETURN NEW`
ticket, err := db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"playbook": pb,
"playbookID": findName(parentTicket.Playbooks, playbookID),
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.NewDocumentID(TicketCollectionName, fmt.Sprintf("%d", id)),
},
Msg: "Added playbook",
})
if err != nil {
return nil, err
}
if err := runRootTask(extractTicketResponse(ticket), playbookID, db); err != nil {
return nil, err
}
return ticket, nil
}
func findName(playbooks map[string]*models.PlaybookResponse, name string) string {
if _, ok := playbooks[name]; !ok {
return name
}
for i := 0; ; i++ {
try := fmt.Sprintf("%s%d", name, i)
if _, ok := playbooks[try]; !ok {
return try
}
}
}
func runRootTask(ticket *models.TicketResponse, playbookID string, db *Database) error {
playbook := ticket.Playbooks[playbookID]
var root *models.TaskResponse
for _, task := range playbook.Tasks {
if task.Order == 0 {
root = task
}
}
runNextTasks(ticket.ID, playbookID, root.Next, root.Data, ticket, db)
return nil
}
func (db *Database) RemoveTicketPlaybook(ctx context.Context, id int64, playbookID string) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `FOR d IN @@collection
` + ticketFilterQuery + `
FILTER d._key == @ID
LET newplaybooks = UNSET(d.playbooks, @playbookID)
REPLACE d WITH MERGE(d, { "modified": DATE_ISO8601(DATE_NOW()), "playbooks": newplaybooks }) IN @@collection
RETURN NEW`
return db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"playbookID": playbookID,
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.NewDocumentID(TicketCollectionName, fmt.Sprintf("%d", id)),
},
Msg: fmt.Sprintf("Removed playbook %s", playbookID),
})
}

186
database/ticket_task.go Normal file
View File

@@ -0,0 +1,186 @@
package database
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/arangodb/go-driver"
"github.com/google/uuid"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func (db *Database) TaskGet(ctx context.Context, id int64, playbookID string, taskID string) (*models.TicketWithTickets, *models.PlaybookResponse, *models.TaskWithContext, error) {
inc, err := db.TicketGet(ctx, id)
if err != nil {
return nil, nil, nil, err
}
playbook, ok := inc.Playbooks[playbookID]
if !ok {
return nil, nil, nil, errors.New("playbook does not exist")
}
task, ok := playbook.Tasks[taskID]
if !ok {
return nil, nil, nil, errors.New("task does not exist")
}
return inc, playbook, &models.TaskWithContext{
PlaybookId: playbookID,
PlaybookName: playbook.Name,
TaskId: taskID,
Task: *task,
TicketId: id,
TicketName: inc.Name,
}, nil
}
func (db *Database) TaskComplete(ctx context.Context, id int64, playbookID string, taskID string, data interface{}) (*models.TicketWithTickets, error) {
inc, err := db.TicketGet(ctx, id)
if err != nil {
return nil, err
}
completable := inc.Playbooks[playbookID].Tasks[taskID].Active
if !completable {
return nil, errors.New("cannot be completed")
}
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
LET playbook = d.playbooks[@playbookID]
LET task = playbook.tasks[@taskID]
LET newtask = MERGE(task, {"data": NOT_NULL(@data, {}), "done": true, closed: @closed })
LET newtasks = MERGE(playbook.tasks, { @taskID: newtask } )
LET newplaybook = MERGE(playbook, {"tasks": newtasks})
LET newplaybooks = MERGE(d.playbooks, { @playbookID: newplaybook } )
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "playbooks": newplaybooks } IN @@collection
RETURN NEW`
ticket, err := db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"playbookID": playbookID,
"taskID": taskID,
"data": data,
"closed": time.Now().UTC(),
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.NewDocumentID(TicketCollectionName, fmt.Sprintf("%d", id)),
},
Msg: fmt.Sprintf("Completed task %s in playbook %s", taskID, playbookID),
})
if err != nil {
return nil, err
}
playbook := ticket.Playbooks[playbookID]
task := playbook.Tasks[taskID]
runNextTasks(id, playbookID, task.Next, task.Data, extractTicketResponse(ticket), db)
return ticket, nil
}
func extractTicketResponse(ticket *models.TicketWithTickets) *models.TicketResponse {
return &models.TicketResponse{
Artifacts: ticket.Artifacts,
Comments: ticket.Comments,
Created: ticket.Created,
Details: ticket.Details,
Files: ticket.Files,
ID: ticket.ID,
Modified: ticket.Modified,
Name: ticket.Name,
Owner: ticket.Owner,
Playbooks: ticket.Playbooks,
Read: ticket.Read,
References: ticket.References,
Schema: ticket.Schema,
Status: ticket.Status,
Type: ticket.Type,
Write: ticket.Write,
}
}
func (db *Database) TaskUpdate(ctx context.Context, id int64, playbookID string, taskID string, task *models.Task) (*models.TicketWithTickets, error) {
ticketFilterQuery, ticketFilterVars, err := db.Hooks.TicketWriteFilter(ctx)
if err != nil {
return nil, err
}
query := `LET d = DOCUMENT(@@collection, @ID)
` + ticketFilterQuery + `
LET playbook = d.playbooks[@playbookID]
LET newtasks = MERGE(playbook.tasks, { @taskID: @task } )
LET newplaybook = MERGE(playbook, {"tasks": newtasks})
LET newplaybooks = MERGE(d.playbooks, { @playbookID: newplaybook } )
UPDATE d WITH { "modified": DATE_ISO8601(DATE_NOW()), "playbooks": newplaybooks } IN @@collection
RETURN NEW`
ticket, err := db.ticketGetQuery(ctx, id, query, mergeMaps(map[string]interface{}{
"playbookID": playbookID,
"taskID": taskID,
"task": task,
}, ticketFilterVars), &busdb.Operation{
OperationType: busdb.Update,
Ids: []driver.DocumentID{
driver.NewDocumentID(TicketCollectionName, fmt.Sprintf("%d", id)),
},
Msg: fmt.Sprintf("Saved task %s in playbook %s", taskID, playbookID),
})
if err != nil {
return nil, err
}
return ticket, nil
}
func (db *Database) TaskRun(ctx context.Context, id int64, playbookID string, taskID string) error {
ticket, _, task, err := db.TaskGet(ctx, id, playbookID, taskID)
if err != nil {
return err
}
if task.Task.Type == models.TaskTypeAutomation {
if err := runTask(id, playbookID, taskID, &task.Task, extractTicketResponse(ticket), db); err != nil {
return err
}
}
return nil
}
func runNextTasks(id int64, playbookID string, next map[string]string, data interface{}, ticket *models.TicketResponse, db *Database) {
for nextTaskID, requirement := range next {
nextTask := ticket.Playbooks[playbookID].Tasks[nextTaskID]
if nextTask.Type == models.TaskTypeAutomation {
b, err := evalRequirement(requirement, data)
if err != nil {
continue
}
if b {
if err := runTask(id, playbookID, nextTaskID, nextTask, ticket, db); err != nil {
log.Println(err)
}
}
}
}
}
func runTask(ticketID int64, playbookID string, taskID string, task *models.TaskResponse, ticket *models.TicketResponse, db *Database) error {
playbook := ticket.Playbooks[playbookID]
msgContext := &models.Context{Playbook: playbook, Task: task, Ticket: ticket}
origin := &models.Origin{TaskOrigin: &models.TaskOrigin{TaskId: taskID, PlaybookId: playbookID, TicketId: ticketID}}
jobID := uuid.NewString()
return publishJobMapping(jobID, *task.Automation, msgContext, origin, task.Payload, db)
}

101
database/tickettype.go Normal file
View File

@@ -0,0 +1,101 @@
package database
import (
"context"
"errors"
"github.com/arangodb/go-driver"
"github.com/iancoleman/strcase"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
)
func toTicketType(doc *models.TicketTypeForm) *models.TicketType {
return &models.TicketType{
Name: doc.Name,
Icon: doc.Icon,
DefaultPlaybooks: doc.DefaultPlaybooks,
DefaultTemplate: doc.DefaultTemplate,
DefaultGroups: doc.DefaultGroups,
}
}
func toTicketTypeResponse(key string, doc *models.TicketType) *models.TicketTypeResponse {
return &models.TicketTypeResponse{
ID: key,
Name: doc.Name,
Icon: doc.Icon,
DefaultPlaybooks: doc.DefaultPlaybooks,
DefaultTemplate: doc.DefaultTemplate,
DefaultGroups: doc.DefaultGroups,
}
}
func (db *Database) TicketTypeCreate(ctx context.Context, tickettype *models.TicketTypeForm) (*models.TicketTypeResponse, error) {
if tickettype == nil {
return nil, errors.New("requires ticket type")
}
if tickettype.Name == "" {
return nil, errors.New("requires ticket type name")
}
var doc models.TicketType
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.tickettypeCollection.CreateDocument(ctx, newctx, strcase.ToKebab(tickettype.Name), toTicketType(tickettype))
if err != nil {
return nil, err
}
return toTicketTypeResponse(meta.Key, &doc), nil
}
func (db *Database) TicketTypeGet(ctx context.Context, id string) (*models.TicketTypeResponse, error) {
var doc models.TicketType
meta, err := db.tickettypeCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toTicketTypeResponse(meta.Key, &doc), nil
}
func (db *Database) TicketTypeUpdate(ctx context.Context, id string, tickettype *models.TicketTypeForm) (*models.TicketTypeResponse, error) {
var doc models.TicketType
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.tickettypeCollection.ReplaceDocument(ctx, id, toTicketType(tickettype))
if err != nil {
return nil, err
}
return toTicketTypeResponse(meta.Key, &doc), nil
}
func (db *Database) TicketTypeDelete(ctx context.Context, id string) error {
_, err := db.tickettypeCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) TicketTypeList(ctx context.Context) ([]*models.TicketTypeResponse, error) {
query := "FOR d IN @@collection RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": TicketTypeCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.TicketTypeResponse
for {
var doc models.TicketType
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
docs = append(docs, toTicketTypeResponse(meta.Key, &doc))
}
return docs, err
}

201
database/user.go Normal file
View File

@@ -0,0 +1,201 @@
package database
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"math/rand"
"time"
"github.com/arangodb/go-driver"
"github.com/gin-gonic/gin"
"github.com/iancoleman/strcase"
"github.com/SecurityBrewery/catalyst/database/busdb"
"github.com/SecurityBrewery/catalyst/generated/models"
"github.com/SecurityBrewery/catalyst/pointer"
"github.com/SecurityBrewery/catalyst/role"
)
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
func init() {
rand.Seed(time.Now().UnixNano())
}
func generateKey() string {
b := make([]rune, 32)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func toUser(user *models.UserForm, sha256 *string) *models.User {
roles := []string{}
roles = append(roles, role.Strings(role.Explodes(user.Roles))...)
u := &models.User{
Blocked: user.Blocked,
Roles: roles,
Sha256: sha256,
Apikey: user.Apikey,
}
// log.Println(u)
// b, _ := json.Marshal(u)
// loader := gojsonschema.NewBytesLoader(b)
// res, err := models.UserSchema.Validate(loader)
// if err != nil {
// log.Println(err)
// }
// log.Println(res.Errors())
return u
}
func toUserResponse(key string, user *models.User) *models.UserResponse {
return &models.UserResponse{
ID: key,
Roles: user.Roles,
Blocked: user.Blocked,
Apikey: user.Apikey,
}
}
func toNewUserResponse(key string, user *models.User, secret *string) *models.NewUserResponse {
return &models.NewUserResponse{
ID: key,
Roles: user.Roles,
Secret: secret,
Blocked: user.Blocked,
}
}
func (db *Database) UserGetOrCreate(ctx *gin.Context, newUser *models.UserForm) (*models.UserResponse, error) {
user, err := db.UserGet(ctx, newUser.ID)
if err != nil {
newUser, err := db.UserCreate(ctx, newUser)
if err != nil {
return nil, err
}
return &models.UserResponse{ID: newUser.ID, Roles: newUser.Roles, Blocked: newUser.Blocked}, nil
}
return user, nil
}
func (db *Database) UserCreate(ctx context.Context, newUser *models.UserForm) (*models.NewUserResponse, error) {
var key string
var hash *string
if newUser.Apikey {
key = generateKey()
hash = pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(key))))
}
var doc models.User
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, hash))
if err != nil {
return nil, err
}
return toNewUserResponse(meta.Key, &doc, pointer.String(key)), nil
}
func (db *Database) UserCreateSetupAPIKey(ctx context.Context, key string) (*models.UserResponse, error) {
newUser := &models.UserForm{
ID: "setup",
Roles: []string{role.Admin},
Apikey: true,
Blocked: false,
}
hash := pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(key))))
var doc models.User
newctx := driver.WithReturnNew(ctx, &doc)
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, hash))
if err != nil {
return nil, err
}
return toUserResponse(meta.Key, &doc), nil
}
func (db *Database) UserGet(ctx context.Context, id string) (*models.UserResponse, error) {
var doc models.User
meta, err := db.userCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
return toUserResponse(meta.Key, &doc), nil
}
func (db *Database) UserDelete(ctx context.Context, id string) error {
_, err := db.userCollection.RemoveDocument(ctx, id)
return err
}
func (db *Database) UserList(ctx context.Context) ([]*models.UserResponse, error) {
query := "FOR d IN @@collection RETURN d"
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": UserCollectionName}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var docs []*models.UserResponse
for {
var doc models.User
meta, err := cursor.ReadDocument(ctx, &doc)
if driver.IsNoMoreDocuments(err) {
break
} else if err != nil {
return nil, err
}
doc.Sha256 = nil
docs = append(docs, toUserResponse(meta.Key, &doc))
}
return docs, err
}
func (db *Database) UserByHash(ctx context.Context, sha256 string) (*models.UserResponse, error) {
query := `FOR d in @@collection
FILTER d.sha256 == @sha256
RETURN d`
cursor, _, err := db.Query(ctx, query, map[string]interface{}{"@collection": UserCollectionName, "sha256": sha256}, busdb.ReadOperation)
if err != nil {
return nil, err
}
defer cursor.Close()
var doc models.User
meta, err := cursor.ReadDocument(ctx, &doc)
if err != nil {
return nil, err
}
return toUserResponse(meta.Key, &doc), err
}
func (db *Database) UserUpdate(ctx context.Context, id string, user *models.UserForm) (*models.UserResponse, error) {
var doc models.User
_, err := db.userCollection.ReadDocument(ctx, id, &doc)
if err != nil {
return nil, err
}
if doc.Sha256 != nil {
return nil, errors.New("cannot update an API key")
}
ctx = driver.WithReturnNew(ctx, &doc)
meta, err := db.userCollection.ReplaceDocument(ctx, id, toUser(user, nil))
if err != nil {
return nil, err
}
return toUserResponse(meta.Key, &doc), nil
}