From 1b33e451688bcf86b9995294940ddd458d47f184 Mon Sep 17 00:00:00 2001 From: Corey Ogburn Date: Fri, 2 Feb 2024 12:16:01 -0700 Subject: [PATCH] WIP: Detection History Previous versions of a Detection's history are now presented in the History tab. With Refresh, Sort, Search, and Details. --- html/index.html | 172 +++++++++++++++++- html/js/i18n.js | 7 +- html/js/routes/detection.js | 55 +++++- server/detectionhandler.go | 19 ++ server/detectionstore.go | 1 + server/mock/mock_detectionstore.go | 15 ++ .../modules/elastic/elasticdetectionstore.go | 7 + 7 files changed, 269 insertions(+), 7 deletions(-) diff --git a/html/index.html b/html/index.html index 5c1f0e0a..b1c7c440 100644 --- a/html/index.html +++ b/html/index.html @@ -1376,9 +1376,173 @@

-
- Coming Soon! -
+ + + + + fa-sync + + + + + + + + + + + +
+ +
+ @@ -3091,7 +3255,7 @@

{{ i18n.evidenceAdd }}

- + fa-sync diff --git a/html/js/i18n.js b/html/js/i18n.js index 4d4f7380..070f6dd3 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -169,6 +169,7 @@ const i18n = { commentHours: 'Work Hours', commentHoursHelp: 'Hours spent working on this update (leave blank for 0 hours)', commentRequired: 'Comments cannot be empty.', + community: 'Community', completed: 'Completed', config: 'Configuration', configTitle: 'Grid Configuration', @@ -207,6 +208,7 @@ const i18n = { configQLZeekThreads: 'Change number of Zeek workers (threads)', container: 'Container', containerStatus: 'Container Status', + content: 'Content', continue: 'Would you like to continue?', contributors: 'Contributors', copyEventToClipboardAsJson: 'Copy full event as JSON', @@ -293,6 +295,7 @@ const i18n = { emailRequired: 'An email address must be specified.', enable: 'Enable', enabled: 'Enabled', + enabledLabel: 'Enabled:', endTime: 'Filter End', endTimeHelp: 'Filter end time in RFC 3339 format (Ex: 2020-10-16 15:30:00.230-04:00). Unused for imported PCAPs.', engine: 'Engine', @@ -596,10 +599,11 @@ const i18n = { redisQueueSize: 'Redis Queue Size', refresh: 'Refresh', refreshAttachmentsHelp: 'Refresh to view all recently added attachments for this case.', + refreshCaseHistoryHelp: 'Refresh to view the latest history for this case.', + refreshDetectionHistoryHelp: 'Refresh to view the latest history for this detection.', refreshCommentsHelp: 'Refresh to view all recently added comments for this case.', refreshObservablesHelp: 'Refresh to view all recently added observables for this case.', refreshEventsHelp: 'Refresh to view all recently escalated events for this case.', - refreshHistoryHelp: 'Refresh to view the latest history for this case.', regex: 'Regex', reject: 'Reject', rejected: 'Rejected', @@ -755,6 +759,7 @@ const i18n = { timestampFormat: 'YYYY-MM-DD HH:mm:ss.SSS Z', timezone: 'Time Zone:', timezoneHelp: 'Time Zone', + title: 'Title', toggleLegend: 'Toggle Legend', toolCyberchef: 'CyberChef', toolCyberchefHelp: 'Data decoding and transformation tools', diff --git a/html/js/routes/detection.js b/html/js/routes/detection.js index 47f4d1a7..7178ec8e 100644 --- a/html/js/routes/detection.js +++ b/html/js/routes/detection.js @@ -72,6 +72,24 @@ routes.push({ path: '/detection/:id', name: 'detection', component: { { value: 'limit', text: 'Limit' }, { value: 'both', text: 'Both' } ], + historyTableOpts: { + sortBy: 'updateTime', + sortDesc: false, + search: '', + headers: [ + { text: this.$root.i18n.actions, width: '10.0em' }, + { text: this.$root.i18n.username, value: 'owner' }, + { text: this.$root.i18n.time, value: 'updateTime' }, + { text: this.$root.i18n.kind, value: 'kind' }, + { text: this.$root.i18n.operation, value: 'operation' }, + ], + itemsPerPage: 10, + footerProps: { 'items-per-page-options': [10,50,250,1000] }, + count: 500, + expanded: [], + loading: false, + }, + history: [], }}, created() { this.onDetectionChange = debounce(this.onDetectionChange, 300); @@ -126,6 +144,7 @@ routes.push({ path: '/detection/:id', name: 'detection', component: { const response = await this.$root.papi.get('detection/' + encodeURIComponent(this.$route.params.id)); this.detect = response.data; this.tagOverrides(); + this.loadAssociations(); } catch (error) { if (error.response != undefined && error.response.status == 404) { this.$root.showError(this.i18n.notFound); @@ -136,6 +155,17 @@ routes.push({ path: '/detection/:id', name: 'detection', component: { this.$root.stopLoading(); }, + loadAssociations() { + this.loadHistory(); + }, + async loadHistory() { + const route = this; + const id = route.$route.params.id; + const response = await this.$root.papi.get(`detection/${id}/history`); + if (response && response.data) { + this.history = response.data; + } + }, getDefaultPreset(preset) { if (this.presets) { const presets = this.presets[preset]; @@ -632,6 +662,8 @@ routes.push({ path: '/detection/:id', name: 'detection', component: { } } } + } else if (this.detect.engine === 'strelka') { + return false; } return true; @@ -641,10 +673,29 @@ routes.push({ path: '/detection/:id', name: 'detection', component: { for (let i = 0; i < this.detect.overrides.length; i++) { this.detect.overrides[i].index = i; } + } else { + this.detect.overrides = []; } }, - print(x) { - console.log(x); + isExpanded(row) { + const expanded = this.historyTableOpts.expanded; + for (var i = 0; i < expanded.length; i++) { + if (expanded[i].id == row.id) { + return true; + } + } + return false; }, + async expandRow(row) { + const expanded = this.historyTableOpts.expanded; + for (var i = 0; i < expanded.length; i++) { + if (expanded[i].id == row.id) { + expanded.splice(i, 1); + return; + } + } + + expanded.push(row); + } } }}); diff --git a/server/detectionhandler.go b/server/detectionhandler.go index e3c530de..c38d73f7 100644 --- a/server/detectionhandler.go +++ b/server/detectionhandler.go @@ -42,6 +42,8 @@ func RegisterDetectionRoutes(srv *Server, r chi.Router, prefix string) { r.Post("/", h.postDetection) r.Post("/{id}/duplicate", h.duplicateDetection) + r.Get("/{id}/history", h.getDetectionHistory) + r.Put("/", h.putDetection) r.Delete("/{id}", h.deleteDetection) @@ -115,6 +117,23 @@ func (h *DetectionHandler) postDetection(w http.ResponseWriter, r *http.Request) web.Respond(w, r, http.StatusOK, detect) } +func (h *DetectionHandler) getDetectionHistory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id := chi.URLParam(r, "id") + if id == "" { + id = r.URL.Query().Get("id") + } + + obj, err := h.server.Detectionstore.GetDetectionHistory(ctx, id) + if err != nil { + web.Respond(w, r, http.StatusNotFound, err) + return + } + + web.Respond(w, r, http.StatusOK, obj) +} + func (h *DetectionHandler) duplicateDetection(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/server/detectionstore.go b/server/detectionstore.go index af5184dc..c73416b7 100644 --- a/server/detectionstore.go +++ b/server/detectionstore.go @@ -20,6 +20,7 @@ type Detectionstore interface { DeleteDetection(ctx context.Context, detectID string) (*model.Detection, error) GetAllCommunitySIDs(ctx context.Context, engine *model.EngineName) (map[string]*model.Detection, error) // map[detection.PublicId]detection Query(ctx context.Context, query string, max int) ([]interface{}, error) + GetDetectionHistory(ctx context.Context, detectID string) ([]interface{}, error) } //go:generate mockgen -destination mock/mock_detectionstore.go -package mock . Detectionstore diff --git a/server/mock/mock_detectionstore.go b/server/mock/mock_detectionstore.go index 1d30b4a4..60cf7691 100644 --- a/server/mock/mock_detectionstore.go +++ b/server/mock/mock_detectionstore.go @@ -99,6 +99,21 @@ func (mr *MockDetectionstoreMockRecorder) GetDetection(arg0, arg1 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDetection", reflect.TypeOf((*MockDetectionstore)(nil).GetDetection), arg0, arg1) } +// GetDetectionHistory mocks base method. +func (m *MockDetectionstore) GetDetectionHistory(arg0 context.Context, arg1 string) ([]any, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDetectionHistory", arg0, arg1) + ret0, _ := ret[0].([]any) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDetectionHistory indicates an expected call of GetDetectionHistory. +func (mr *MockDetectionstoreMockRecorder) GetDetectionHistory(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDetectionHistory", reflect.TypeOf((*MockDetectionstore)(nil).GetDetectionHistory), arg0, arg1) +} + // Query mocks base method. func (m *MockDetectionstore) Query(arg0 context.Context, arg1 string, arg2 int) ([]any, error) { m.ctrl.T.Helper() diff --git a/server/modules/elastic/elasticdetectionstore.go b/server/modules/elastic/elasticdetectionstore.go index 842514c4..6cde993f 100644 --- a/server/modules/elastic/elasticdetectionstore.go +++ b/server/modules/elastic/elasticdetectionstore.go @@ -457,6 +457,13 @@ func (store *ElasticDetectionstore) GetAllCommunitySIDs(ctx context.Context, eng return sids, nil } +func (store *ElasticDetectionstore) GetDetectionHistory(ctx context.Context, detectID string) ([]interface{}, error) { + query := fmt.Sprintf(`_index:"%s" AND %s%s:"%s" | sortby @timestamp^`, store.auditIndex, store.schemaPrefix, AUDIT_DOC_ID, detectID) + history, err := store.Query(ctx, query, store.maxAssociations) + + return history, err +} + func (store *ElasticDetectionstore) audit(ctx context.Context, document map[string]interface{}, id string) error { var err error