From 2c8a930d897652091ecf03009ffd27ef6a39557f Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 29 Nov 2021 09:49:05 -0500 Subject: [PATCH 01/98] Initial CM --- config/clientparameters.go | 5 + config/clientparameters_test.go | 29 +- html/css/app.css | 14 + html/index.html | 210 +++++++++++- html/js/app.js | 11 +- html/js/app.test.js | 22 ++ html/js/i18n.js | 47 ++- html/js/routes/case.js | 265 +++++++++++++++ html/js/routes/case.test.js | 314 ++++++++++++++++++ html/js/routes/hunt.js | 39 ++- html/js/routes/hunt.test.js | 50 +++ html/js/routes/job.js | 4 +- html/js/routes/jobs.js | 6 +- html/js/test_common.js | 41 +++ model/case.go | 48 ++- model/event.go | 19 +- model/query.go | 76 ++++- model/query_test.go | 22 ++ server/casehandler.go | 115 ++++++- server/casestore.go | 9 + server/eventstore.go | 6 +- server/eventstore_fake.go | 78 +++++ server/modules/elastic/converter.go | 240 +++++++++++-- server/modules/elastic/converter_test.go | 164 ++++++++- server/modules/elastic/elastic.go | 19 ++ server/modules/elastic/elastic_test.go | 42 ++- server/modules/elastic/elasticcasestore.go | 284 ++++++++++++++++ .../modules/elastic/elasticcasestore_test.go | 300 +++++++++++++++++ server/modules/elastic/elasticeventstore.go | 85 ++++- .../elasticcases/elasticcaseconverter.go | 6 +- .../elasticcases/elasticcaseconverter_test.go | 8 +- .../modules/elasticcases/elasticcasestore.go | 33 ++ .../generichttp/generichttpreader_test.go | 8 +- server/modules/generichttp/httpcasestore.go | 32 ++ server/modules/thehive/thehivecasestore.go | 33 ++ server/modules/thehive/thehiveconverter.go | 13 +- server/queryhandler.go | 3 + server/server.go | 2 +- 38 files changed, 2582 insertions(+), 120 deletions(-) create mode 100644 html/js/routes/case.js create mode 100644 html/js/routes/case.test.js create mode 100644 server/eventstore_fake.go create mode 100644 server/modules/elastic/elasticcasestore.go create mode 100644 server/modules/elastic/elasticcasestore_test.go diff --git a/config/clientparameters.go b/config/clientparameters.go index 0fcc73c1..dc8351ae 100644 --- a/config/clientparameters.go +++ b/config/clientparameters.go @@ -19,6 +19,7 @@ const DEFAULT_MOST_RECENTLY_USED_LIMIT = 5 type ClientParameters struct { HuntingParams HuntingParameters `json:"hunt"` AlertingParams HuntingParameters `json:"alerts"` + CasesParams HuntingParameters `json:"cases"` JobParams HuntingParameters `json:"job"` DocsUrl string `json:"docsUrl"` CheatsheetUrl string `json:"cheatsheetUrl"` @@ -39,6 +40,9 @@ func (config *ClientParameters) Verify() error { if err := config.AlertingParams.Verify(); err != nil { return err } + if err := config.CasesParams.Verify(); err != nil { + return err + } return config.JobParams.Verify() } @@ -97,6 +101,7 @@ type HuntingParameters struct { Advanced bool `json:"advanced"` AckEnabled bool `json:"ackEnabled"` EscalateEnabled bool `json:"escalateEnabled"` + ViewEnabled bool `json:"viewEnabled"` } type GridParameters struct { diff --git a/config/clientparameters_test.go b/config/clientparameters_test.go index bf88961e..db839e07 100644 --- a/config/clientparameters_test.go +++ b/config/clientparameters_test.go @@ -19,23 +19,28 @@ import ( func TestVerifyClientParameters(tester *testing.T) { params := &ClientParameters{} err := params.Verify() - if assert.Nil(tester, err) { - assert.Zero(tester, params.WebSocketTimeoutMs) - assert.Zero(tester, params.TipTimeoutMs) - assert.Zero(tester, params.ApiTimeoutMs) - assert.Zero(tester, params.CacheExpirationMs) - } + assert.Nil(tester, err) + assert.Zero(tester, params.WebSocketTimeoutMs) + assert.Zero(tester, params.TipTimeoutMs) + assert.Zero(tester, params.ApiTimeoutMs) + assert.Zero(tester, params.CacheExpirationMs) + verifyInitialHuntingParams(tester, ¶ms.HuntingParams) + verifyInitialHuntingParams(tester, ¶ms.AlertingParams) + verifyInitialHuntingParams(tester, ¶ms.CasesParams) } func TestVerifyHuntingParams(tester *testing.T) { params := &HuntingParameters{} err := params.Verify() - if assert.Nil(tester, err) { - assert.Equal(tester, DEFAULT_GROUP_FETCH_LIMIT, params.GroupFetchLimit) - assert.Equal(tester, DEFAULT_EVENT_FETCH_LIMIT, params.EventFetchLimit) - assert.Equal(tester, DEFAULT_RELATIVE_TIME_VALUE, params.RelativeTimeValue) - assert.Equal(tester, DEFAULT_RELATIVE_TIME_UNIT, params.RelativeTimeUnit) - } + assert.Nil(tester, err) + verifyInitialHuntingParams(tester, params) +} + +func verifyInitialHuntingParams(tester *testing.T, params *HuntingParameters) { + assert.Equal(tester, DEFAULT_GROUP_FETCH_LIMIT, params.GroupFetchLimit) + assert.Equal(tester, DEFAULT_EVENT_FETCH_LIMIT, params.EventFetchLimit) + assert.Equal(tester, DEFAULT_RELATIVE_TIME_VALUE, params.RelativeTimeValue) + assert.Equal(tester, DEFAULT_RELATIVE_TIME_UNIT, params.RelativeTimeUnit) } func TestCombineEmptyDeprecatedLinkIntoEmptyLinks(tester *testing.T) { diff --git a/html/css/app.css b/html/css/app.css index 54b34a2e..3694a2c3 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -164,6 +164,20 @@ a#title, a#title:visited, a#title:active, a#title:hover { white-space: nowrap; } +.case.label { + display: inline-block; + padding-right: 10px; + width: 15%; + text-align: right; + white-space: nowrap; +} + +.case.value { + text-align: left; + font-weight: bold; + white-space: wrap; +} + td { white-space: nowrap; } diff --git a/html/index.html b/html/index.html index eaa1c053..e6a26b91 100644 --- a/html/index.html +++ b/html/index.html @@ -56,6 +56,14 @@ + + + fa-briefcase + + + + + fa-stream @@ -391,7 +399,7 @@
- + {{ filter }} @@ -399,6 +407,9 @@
{{ i18n.groupedBy }} {{ groupBy }} + + {{ i18n.sortedBy }} {{ sortBy }} + @@ -547,6 +558,9 @@
:color="isFilterToggleEnabled('escalated') || item['event.escalated'] == true ? 'secondary' : 'primary'"> fa-exclamation-triangle + + fa-binoculars +
@@ -839,7 +853,7 @@

- + {{ i18n.add }} {{ i18n.user }} @@ -859,7 +873,7 @@

Cancel - +
@@ -908,7 +922,7 @@

- + {{ i18n.add }} {{ i18n.job }} @@ -928,7 +942,7 @@

- +
@@ -1156,6 +1170,191 @@

{{ i18n.viewJob }}

+ + + + diff --git a/html/js/app.js b/html/js/app.js index a0af1565..e4d0647e 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -467,6 +467,9 @@ $(document).ready(function() { showError(msg) { this.error = true; this.errorMessage = this.localizeMessage(msg); + if (this.debug) { + console.log(msg.stack); + } }, showWarning(msg) { this.warning = true; @@ -706,11 +709,11 @@ $(document).ready(function() { } return null; }, - async populateJobDetails(job) { - if (job.userId && job.userId.length > 0) { - const user = await this.$root.getUserById(job.userId); + async populateUserDetails(obj, idField, outputField) { + if (obj[idField] && obj[idField].length > 0) { + const user = await this.$root.getUserById(obj[idField]); if (user) { - job.owner = user.email; + obj[outputField] = user.email; } } }, diff --git a/html/js/app.test.js b/html/js/app.test.js index 93583930..d33033bd 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -66,4 +66,26 @@ test('generateDatePickerPreselects', () => { expect(preselects[app.i18n.datePreselect4dToNow].length).toBe(2); expect(preselects[app.i18n.datePreselect7dToNow].length).toBe(2); expect(preselects[app.i18n.datePreselect30dToNow].length).toBe(2); +}); + +test('populateUserDetailsEmpty', async () => { + const obj = {}; + await app.populateUserDetails(obj, "userId", "owner") + expect(obj.owner).toBe(undefined); +}); + +test('populateUserDetailsNonEmptyNoUser', async () => { + const obj = {userId:'123'} + app.users = [{id:'111',email:'hi@there.net'}]; + app.usersLoadedTime = new Date().time; + await app.populateUserDetails(obj, "userId", "owner") + expect(obj.owner).toBe(undefined); +}); + +test('populateUserDetails', async () => { + const obj = {userId:'123'}; + app.users = [{id:'123',email:'hi@there.net'}]; + app.usersLoadedTime = new Date().time; + await app.populateUserDetails(obj, "userId", "owner") + expect(obj.owner).toBe('hi@there.net'); }); \ No newline at end of file diff --git a/html/js/i18n.js b/html/js/i18n.js index 181222ed..63851138 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -35,21 +35,44 @@ const i18n = { actionSuccess: 'Action completed: ', actionVirusTotal: 'VirusTotal', actionVirusTotalHelp: 'Analyze this field at virustotal.com', + add: 'Add', address: 'Address', - addJob: 'Add Job', - addUser: 'Add User', admin: 'Administration', alertAcknowledge: 'Acknowledge', alertEscalated: 'This alert has already been escalated', alertUndoAcknowledge: 'Undo Acknowledge', alerts: 'Alerts', attempt: 'Attempt', + author: 'Author', autohunt: 'Automatically Hunt after changing filters, groupings, and date-ranges', autoRefresh: 'Automatic refresh interval', beginTime: 'Filter Begin', beginTimeHelp: 'Filter start time in RFC 3339 format (Ex: 2020-10-16 13:00:00.230-04:00). Unused for imported PCAPs.', blog: 'Blog', cancel: 'Cancel', + cases: 'Cases', + caseAssignee: 'Assigned To', + caseAssigneeHelp: 'Designate the assignee for this case', + caseCategory: 'Category', + caseCategoryHelp: 'Category used for organizing and grouping similar cases', + caseDescription: 'Description', + caseDescriptionHelp: 'Detailed description of the case', + caseId: 'Case Id', + casePap: 'PAP', + casePapHelp: 'Permissible Actions Protocol', + casePriority: 'Priority', + casePriorityHelp: 'The numeric priority level of this case. Lower values typically indicate increasing importance.', + caseSeverity: 'Severity', + caseSeverityHelp: 'The severity classification for this case', + caseStatus: 'Status', + caseStatusHelp: 'Indicates the state of the case', + caseTags: 'Tags', + caseTagsHelp: 'Separate multiple tags by comma (tbd)', + caseTitle: 'Title', + caseTitleHelp: 'Brief summary of the case', + caseTlp: 'TLP', + caseTlpHelp: 'Traffic Light Protocol', + chartTitleBottom: 'Fewest Occurrences', chartTitleTimeline: 'Timeline', chartTitleTop: 'Most Occurrences', @@ -57,6 +80,9 @@ const i18n = { clear: 'Clear', collapse: 'Collapse', collapseHelp: 'Collapse all packet data', + comments: 'Comments', + commentDescription: 'Comment', + commentDescriptionHelp: 'Provide follow-up information to this case', completed: 'Completed', continue: 'Would you like to continue?', copyEventToClipboardAsJson: 'Copy full event as JSON', @@ -66,9 +92,12 @@ const i18n = { copyToClipboard: 'Copy to clipboard', custom: 'Custom', darkMode: 'Dark Mode', - dateDataEpoch: 'Earliest PCAP', + dateClosed: 'Date Closed', dateCompleted: 'Date Completed', + dateCreated: 'Date Created', + dateDataEpoch: 'Earliest PCAP', dateFailed: 'Date Failed', + dateModified: 'Date Modified', dateOnline: 'Online Since:', dateQueued: 'Date Queued', datePreselectToday: 'Today', @@ -123,6 +152,7 @@ const i18n = { escalatedEventTip: 'Escalated event(s) to a new case.', escalatedMultipleTip: 'Escalating groups of alerts may take a while and will continue in the background.', escalatedSingleTip: 'Escalated alert and removed from view.', + event: 'Event', events: 'Events', eventCaseTitle: 'Event Escalation from SOC', eventExpandHelp: 'Show all event fields', @@ -138,6 +168,10 @@ const i18n = { fault: 'Fault', fetchLimit: 'Fetch Limit', + 'field_case.createTime': 'Create Date', + 'field_case.severity': 'Severity', + 'field_case.status': 'Status', + 'field_case.title': 'Title', field_count: 'Count', field_soc_id: 'Event ID', field_soc_timestamp: 'Timestamp', @@ -163,6 +197,7 @@ const i18n = { help: 'Help', hex: 'HEX', hexHelp: 'Include hexadecimal representation of packet data', + history: 'History', home: 'Overview', hours: 'hours', hunt: 'Hunt', @@ -190,7 +225,6 @@ const i18n = { job: 'Job', jobIncomplete: 'The job was unable to complete and will retry within a few minutes. Details are available below.', jobInProgress: 'This job is awaiting completion.', - jobNotFound: 'The selected job no longer exists', jobs: 'PCAP', last: 'Last', lastName: 'Last Name', @@ -222,6 +256,7 @@ const i18n = { nodeStatusRaid: 'Raid Status:', noSearchResults: 'No search results were found.', note: 'Note', + notFound: 'The selected item no longer exists', number: 'Num', ok: 'OK', offline: 'Offline', @@ -294,6 +329,9 @@ const i18n = { 'so-sensor-keywords': 'Forward, Sensor, Sensoroni, Stenographer', 'so-standalone': 'Standalone', 'so-standalone-keywords': 'Elastic, Elasticsearch, Fleet, Forward, Ingest, Manager, Master, Search, Sensor, Sensoroni, Soc, Stenographer, Web', + sortedBy: 'Sort:', + sortInclude: "Sort By", + sortIncludeHelp: "Add as a sort-by field", sponsorsIntro: 'Brought to you by:', srcIp: 'Source IP', srcIpHelp: 'Optional source IP address to include in this job filter', @@ -337,6 +375,7 @@ const i18n = { users: 'Users', version: 'Version', view: 'View', + viewCase: 'Case Details', weeks: 'weeks', whatsnew: 'What\'s New', diff --git a/html/js/routes/case.js b/html/js/routes/case.js new file mode 100644 index 00000000..c319837e --- /dev/null +++ b/html/js/routes/case.js @@ -0,0 +1,265 @@ +// Copyright 2019 Jason Ertel (jertel). All rights reserved. +// Copyright 2020-2021 Security Onion Solutions, LLC. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +routes.push({ path: '/case/:id', name: 'case', component: { + template: '#page-case', + data() { return { + i18n: this.$root.i18n, + caseObj: {}, + associationsLoading: false, + search: '', + associations: { + comments: [], + artifacts: [], + events: [], + tasks: [], + history: [] + }, + headers: { + comments: [], + artifacts: [], + events: [], + tasks: [], + history: [] + }, + sortBy: 'number', + sortDesc: false, + itemsPerPage: 10, + footerProps: { 'items-per-page-options': [10,50,250,1000] }, + count: 500, + form: { + valid: false, + id: null, + title: null, + description: null, + status: null, + severity: null, + priority: null, + assigneeId: null, + tags: null, + tlp: null, + pap: null, + category: null + }, + associatedForms: { + comments: { + id: "", + caseId: "", + description: "", + valid: false + }, + tasks: { + id: "", + caseId: "", + description: "", + status: "", + valid: false + }, + artifacts: { + id: "", + caseId: "", + description: "", + valid: false + } + }, + rules: { + required: value => (!!value) || this.$root.i18n.required, + }, + }}, + created() { + }, + mounted() { + this.loadData(); + this.$root.loadParameters('case', this.initActions); + }, + destroyed() { + this.$root.unsubscribe("case", this.updateCase); + }, + watch: { + '$route': 'loadData', + }, + methods: { + initActions(params) { + this.params = params; + }, + async loadAssociations() { + this.associationsLoading = true; + + this.associations["comments"] = []; + this.associatedForms["comments"].caseId = this.caseObj.id; + this.loadAssociation('comments'); + + this.associations["tasks"] = []; + this.associatedForms["tasks"].caseId = this.caseObj.id; + this.loadAssociation('tasks'); + + this.associations["artifacts"] = []; + this.associatedForms["artifacts"].caseId = this.caseObj.id; + this.loadAssociation('artifacts'); + + this.associations["events"] = []; + this.loadAssociation('events'); + + this.associations["history"] = []; + this.loadAssociation('history'); + + this.associationsLoading = false; + }, + async loadAssociation(dataType) { + try { + const response = await this.$root.papi.get('case/' + dataType, { params: { + id: this.$route.params.id, + offset: this.associations[dataType].length, + count: this.count, + }}); + if (response && response.data) { + for (var idx = 0; idx < response.data.length; idx++) { + const obj = response.data[idx]; + await this.$root.populateUserDetails(obj, "userId", "owner"); + this.associations[dataType].push(obj); + } + } + } catch (error) { + this.$root.showError(error); + } + }, + async loadData() { + this.$root.startLoading(); + + try { + const response = await this.$root.papi.get('case/', { params: { + id: this.$route.params.id + }}); + this.updateCaseDetails(response.data); + this.loadAssociations(); + } catch (error) { + if (error.response != undefined && error.response.status == 404) { + this.$root.showError(this.i18n.notFound); + } else { + this.$root.showError(error); + } + } + this.$root.stopLoading(); + this.$root.subscribe("case", this.updateCase); + }, + updateCaseDetails(caseObj) { + this.form.id = caseObj.id; + this.form.title = caseObj.title; + this.form.description = caseObj.description; + this.form.severity = caseObj.severity; + this.form.priority = caseObj.priority; + this.form.status = caseObj.status; + this.form.tags = caseObj.tags ? caseObj.tags.join(", ") : ""; + this.form.tlp = caseObj.tlp; + this.form.pap = caseObj.pap; + this.form.category = caseObj.category; + this.form.assigneeId = caseObj.assigneeId; + this.$root.populateUserDetails(caseObj, "userId", "owner"); + this.$root.populateUserDetails(caseObj, "assigneeId", "assignee"); + this.caseObj = caseObj; + }, + async modifyCase() { + this.$root.startLoading(); + try { + // Convert priority and severity to ints + this.form.severity = parseInt(this.form.severity, 10); + this.form.priority = parseInt(this.form.priority, 10); + const formattedTags = this.form.tags; + if (this.form.tags) { + this.form.tags = this.form.tags.split(",").map(tag => { + return tag.trim(); + }); + } + const json = JSON.stringify(this.form); + this.form.tags = formattedTags; + const response = await this.$root.papi.put('case/', json); + this.updateCaseDetails(response.data); + } catch (error) { + if (error.response != undefined && error.response.status == 404) { + this.$root.showError(this.i18n.notFound); + } else { + this.$root.showError(error); + } + } + this.$root.stopLoading(); + }, + async addAssociation(association) { + this.$root.startLoading(); + try { + const response = await this.$root.papi.post('case/' + association, JSON.stringify(this.associatedForms[association])); + this.$root.populateUserDetails(response.data, "userId", "owner"); + this.associations[association].push(response.data); + } catch (error) { + this.$root.showError(error); + } + this.$root.stopLoading(); + }, + async modifyAssociation(association) { + var idx = -1; + for (var i = 0; i < this.associations[association].length; i++) { + if (this.associations[association][i].id == this.associatedForms[association].id) { + idx = i; + break; + } + } + if (idx > -1) { + this.$root.startLoading(); + try { + const response = await this.$root.papi.put('case/' + association, JSON.stringify(this.associatedForms[association])); + this.$root.populateUserDetails(response.data, "userId", "owner"); + Vue.set(this.associations[association], idx, response.data); + } catch (error) { + if (error.response != undefined && error.response.status == 404) { + this.$root.showError(this.i18n.notFound); + } else { + this.$root.showError(error); + } + } + this.$root.stopLoading(); + } + }, + async deleteAssociation(association, obj) { + const idx = this.associations[association].indexOf(obj); + if (idx > -1) { + this.$root.startLoading(); + try { + const response = await this.$root.papi.delete('case/' + association, { params: { + id: obj.id + }}); + this.associations[association].splice(idx, 1); + } catch (error) { + if (error.response != undefined && error.response.status == 404) { + this.$root.showError(this.i18n.notFound); + } else { + this.$root.showError(error); + } + } + this.$root.stopLoading(); + } + }, + editComment(comment) { + this.associatedForms['comments'].id = comment.id; + this.associatedForms['comments'].description = comment.description; + }, + cancelComment() { + this.associatedForms['comments'].id = ""; + this.associatedForms['comments'].description = ""; + }, + updateCase(caseObj) { + // No-op until we can detect if the user has made any changes to the form. We don't + // want to wipe out a long description they might be working on typing. + + // if (!caseObj || caseObj.id != this.caseObj.id) return; + // this.updateCaseDetails(caseObj) + // this.loadAssociations(); + }, + } +}}); + diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js new file mode 100644 index 00000000..db84e7aa --- /dev/null +++ b/html/js/routes/case.test.js @@ -0,0 +1,314 @@ +// Copyright 2020,2021 Security Onion Solutions. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +require('../test_common.js'); +require('./case.js'); + +const fakePriority = 33; +const fakeSeverity = 31; +const fakeEmail = 'my@email.invalid'; +const fakeAssigneeEmail = 'assignee@email.invalid'; +const fakeCase = { + userId: 'myUserId', + id: 'myCaseId', + title: 'myTitle', + description: 'myDescription', + severity: fakeSeverity, + priority: fakePriority, + tlp: 'myTlp', + pap: 'myPap', + category: 'myCategory', + tags: ['tag1', 'tag2'], + assigneeId: 'myAssigneeId', + status: 'open', +}; +const fakeUsers = [ + {'id': 'myUserId', 'email': fakeEmail}, + {'id': 'myAssigneeId', 'email': fakeAssigneeEmail} + ]; +const fakeComment = { + userId: 'myUserId', + id: 'myCommentId', + description: 'myDescription', +}; + +var comp; + +beforeEach(() => { + comp = getComponent("case"); + resetPapi(); +}); + +test('initParams', () => { + comp.initActions({"foo":"bar"}); + expect(comp.params.foo).toBe("bar"); +}); + +test('loadAssociations', () => { + comp.caseObj = {id: 'myCaseId'}; + comp.loadAssociation = jest.fn(); + comp.loadAssociations(); + expect(comp.loadAssociation).toHaveBeenCalledWith('comments'); + expect(comp.associatedForms["comments"].caseId).toBe('myCaseId') + expect(comp.loadAssociation).toHaveBeenCalledWith('tasks'); + expect(comp.associatedForms["tasks"].caseId).toBe('myCaseId') + expect(comp.loadAssociation).toHaveBeenCalledWith('artifacts'); + expect(comp.associatedForms["artifacts"].caseId).toBe('myCaseId') + expect(comp.loadAssociation).toHaveBeenCalledWith('events'); + expect(comp.loadAssociation).toHaveBeenCalledWith('history'); + expect(comp.associationsLoading).toBe(false); +}); + +test('loadAssociation', async () => { + const params = { params: { + id: 'myUserId', + offset: 0, + count: comp.count, + }}; + + resetPapi(); + // API call #1 is to get the comment list + mockPapi("get", {'data':[fakeComment]}); + // API call #2 is to get the user list + mock = mockPapi("get", {'data':fakeUsers}); + + comp.$route.params.id = 'myUserId'; + const showErrorMock = mockShowError(); + + await comp.loadAssociation('comments'); + + expect(mock).toHaveBeenCalledWith('case/comments', params); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expect(comp.associations['comments'].length).toBe(1); + expect(comp.associations['comments'][0].owner).toBe(fakeEmail); + expect(comp.$root.loading).toBe(false); +}); + +test('loadAssociationError', async () => { + const showErrorMock = mockShowError(); + resetPapi().mockPapi("get", null, new Error("something bad")); + await comp.loadAssociation('comments'); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +expectCaseDetails = () => { + expect(comp.form.id).toBe(fakeCase.id); + expect(comp.form.title).toBe(fakeCase.title); + expect(comp.form.description).toBe(fakeCase.description); + expect(comp.form.severity).toBe(fakeCase.severity); + expect(comp.form.priority).toBe(fakeCase.priority); + expect(comp.form.status).toBe(fakeCase.status); + expect(comp.form.tlp).toBe(fakeCase.tlp); + expect(comp.form.pap).toBe(fakeCase.pap); + expect(comp.form.category).toBe(fakeCase.category); + expect(comp.form.tags).toBe(fakeCase.tags.join(", ")); + expect(comp.caseObj).toBe(fakeCase); + expect(comp.caseObj.owner).toBe(fakeEmail); + expect(comp.caseObj.assignee).toBe(fakeAssigneeEmail); +} + +test('loadData', async () => { + const params = { params: { + id: 'myCaseId' + }}; + + // API call #1 is to get the comment list + mockPapi("get", {'data':fakeCase}); + // API call #2 is to get the user list + mock = mockPapi("get", {'data':fakeUsers}); + + comp.$route.params.id = 'myCaseId'; + const showErrorMock = mockShowError(); + comp.loadAssociations = jest.fn(); + + await comp.loadData(); + + expect(mock).toHaveBeenCalledWith('case/', params); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expect(comp.loadAssociations).toHaveBeenCalledTimes(1); + expectCaseDetails(); + expect(comp.$root.loading).toBe(false); +}); + +test('loadDataNotFound', async () => { + const showErrorMock = mockShowError(); + const error = new Error("not found") + error.response = { status: 404 }; + mockPapi("get", null, error); + await comp.loadData(); + expect(showErrorMock).toHaveBeenCalledWith(comp.i18n.notFound); +}); + +test('loadDataError', async () => { + const showErrorMock = mockShowError(); + mockPapi("get", null, new Error("something bad")); + await comp.loadData(); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +test('modifyCase', async () => { + // API call #1 is to get the comment list + const putMock = mockPapi("put", {'data':fakeCase}); + // API call #2 is to get the user list + const getMock = mockPapi("get", {'data':fakeUsers}); + + comp.form.priority = '' + fakePriority; + comp.form.severity = '' + fakeSeverity; + comp.form.id = 'myCaseId'; + comp.form.title = 'myTitle'; + comp.form.description = 'myDescription'; + comp.form.status = 'open'; + comp.form.tlp = 'myTlp'; + comp.form.pap = 'myPap'; + comp.form.category = 'myCategory'; + comp.form.tags = 'tag1,tag2'; + comp.form.assigneeId = 'myAssigneeId'; + const showErrorMock = mockShowError(true); + + await comp.modifyCase(); + + const body = "{\"valid\":false,\"id\":\"myCaseId\",\"title\":\"myTitle\",\"description\":\"myDescription\",\"status\":\"open\",\"severity\":31,\"priority\":33,\"assigneeId\":\"myAssigneeId\",\"tags\":[\"tag1\",\"tag2\"],\"tlp\":\"myTlp\",\"pap\":\"myPap\",\"category\":\"myCategory\"}"; + expect(putMock).toHaveBeenCalledWith('case/', body); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expectCaseDetails(); + expect(comp.associations['history'].length).toBe(0); + expect(comp.$root.loading).toBe(false); +}); + +test('modifyCaseNotFound', async () => { + const showErrorMock = mockShowError(); + const error = new Error("not found") + error.response = { status: 404 }; + mockPapi("put", null, error); + await comp.modifyCase(); + expect(showErrorMock).toHaveBeenCalledWith(comp.i18n.notFound); +}); + +test('modifyCaseError', async () => { + const showErrorMock = mockShowError(); + mockPapi("put", null, new Error("something bad")); + await comp.modifyCase(); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +test('addAssociation', async () => { + const mock = mockPapi("post", {'data':fakeComment}); + + comp.associatedForms['comments'].id = 'myCommentId'; + comp.associatedForms['comments'].description = 'myDescription'; + const showErrorMock = mockShowError(); + expect(comp.associations['comments'].length).toBe(0); + + await comp.addAssociation('comments'); + + const body = "{\"id\":\"myCommentId\",\"caseId\":\"\",\"description\":\"myDescription\",\"valid\":false}"; + expect(mock).toHaveBeenCalledWith('case/comments', body); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expect(comp.associations['comments'].length).toBe(1); + expect(comp.$root.loading).toBe(false); +}); + +test('addAssociationError', async () => { + const showErrorMock = mockShowError(); + mockPapi("post", null, new Error("something bad")); + await comp.addAssociation('comments'); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +test('modifyAssociation', async () => { + const fakeComment2 = { + id: fakeComment.id, + description: 'myDescription2', + caseId: fakeCase.id, + userId: 'myUserId', + } + const mock = mockPapi("put", {'data':fakeComment2}); + const showErrorMock = mockShowError(); + + comp.associatedForms['comments'].id = fakeComment.id; + comp.associatedForms['comments'].description = 'myDescription2'; + comp.associations['comments'] = [fakeComment]; + await comp.modifyAssociation('comments'); + + const body = "{\"id\":\"myCommentId\",\"caseId\":\"\",\"description\":\"myDescription2\",\"valid\":false}"; + expect(mock).toHaveBeenCalledWith('case/comments', body); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expect(comp.associations['comments'].length).toBe(1); + expect(comp.associations['comments'][0].description).toBe('myDescription2'); + expect(comp.$root.loading).toBe(false); +}); + +test('modifyAssociationNotFound', async () => { + const showErrorMock = mockShowError(); + const error = new Error("not found") + error.response = { status: 404 }; + mockPapi("put", null, error); + comp.associatedForms['comments'].id = fakeComment.id; + comp.associations['comments'] = [fakeComment]; + await comp.modifyAssociation('comments'); + expect(showErrorMock).toHaveBeenCalledWith(comp.i18n.notFound); +}); + +test('modifyAssociationError', async () => { + const showErrorMock = mockShowError(); + mockPapi("put", null, new Error("something bad")); + comp.associatedForms['comments'].id = fakeComment.id; + comp.associations['comments'] = [fakeComment]; + await comp.modifyAssociation('comments'); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +test('deleteAssociation', async () => { + const params = { + params: { + id: 'myCommentId' + } + }; + const mock = mockPapi("delete"); + + const showErrorMock = mockShowError(); + + comp.associations['comments'] = [fakeComment]; + await comp.deleteAssociation('comments', fakeComment); + + expect(mock).toHaveBeenCalledWith('case/comments', params); + expect(showErrorMock).toHaveBeenCalledTimes(0); + expect(comp.associations['comments'].length).toBe(0); + expect(comp.$root.loading).toBe(false); +}); + +test('deleteAssociationNotFound', async () => { + const showErrorMock = mockShowError(); + const error = new Error("not found") + error.response = { status: 404 }; + mockPapi("delete", null, error); + comp.associations['comments'] = [fakeComment]; + await comp.deleteAssociation('comments', fakeComment); + expect(showErrorMock).toHaveBeenCalledWith(comp.i18n.notFound); +}); + +test('deleteAssociationError', async () => { + const showErrorMock = mockShowError(); + mockPapi("delete", null, new Error("something bad")); + comp.associations['comments'] = [fakeComment]; + await comp.deleteAssociation('comments', fakeComment); + expect(showErrorMock).toHaveBeenCalledTimes(1); +}); + +test('editComment', () => { + comp.editComment(fakeComment); + expect(comp.associatedForms['comments'].id).toBe(fakeComment.id); + expect(comp.associatedForms['comments'].description).toBe(fakeComment.description); +}); + +test('cancelComment', () => { + comp.cancelComment(fakeComment); + expect(comp.associatedForms['comments'].id).toBe(""); + expect(comp.associatedForms['comments'].description).toBe(""); +}); diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index b7f09331..abd1698b 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -30,6 +30,7 @@ const huntComponent = { queryName: '', queryFilters: [], queryGroupBys: [], + querySortBys: [], eventFields: {}, dateRange: '', relativeTimeEnabled: true, @@ -46,6 +47,7 @@ const huntComponent = { huntPending: false, ackEnabled: false, escalateEnabled: false, + viewEnabled: false, collapsedSections: [], filterToggles: [], @@ -190,6 +192,7 @@ const huntComponent = { this.advanced = params["advanced"]; this.ackEnabled = params["ackEnabled"]; this.escalateEnabled = params["escalateEnabled"]; + this.viewEnabled = params["viewEnabled"]; if (this.queries != null && this.queries.length > 0) { this.query = this.queries[0].query; } @@ -447,7 +450,7 @@ const huntComponent = { var template = 'rule.case_template' in item ? item['rule.case_template'] : ''; - const response = await this.$root.papi.post('case', { + const response = await this.$root.papi.post('case/', { title: title, description: description, severity: severity, @@ -536,6 +539,7 @@ const huntComponent = { this.queryName = ""; this.queryFilters = []; this.queryGroupBys = []; + this.querySortBys = []; var route = this; if (this.query) { var segments = this.query.split("|"); @@ -564,7 +568,13 @@ const huntComponent = { route.queryGroupBys.push(item); } }); - break; + } + if (segment.indexOf("sortby") == 0) { + segment.split(" ").forEach(function(item, index) { + if (index > 0 && item.trim().length > 0) { + route.querySortBys.push(item); + } + }); } } } @@ -601,7 +611,27 @@ const huntComponent = { } } if (segments[i].length > 0) { - newQuery = newQuery.trim() + " | " + segments[i]; + newQuery = newQuery.trim() + " | " + segments[i].trim(); + } + } + this.query = newQuery.trim(); + if (!this.notifyInputsChanged()) { + this.obtainQueryDetails(); + } + }, + removeSortBy(sortBy) { + var segments = this.query.split("|"); + var newQuery = segments[0]; + for (var i = 1; i < segments.length; i++) { + if (segments[i].trim().indexOf("sortby") == 0) { + segments[i].replace(/,/g, ' '); + segments[i] = segments[i].replace(" " + sortBy, ""); + if (segments[i].trim() == "sortby") { + segments[i] = ""; + } + } + if (segments[i].length > 0) { + newQuery = newQuery.trim() + " | " + segments[i].trim(); } } this.query = newQuery.trim(); @@ -1113,3 +1143,6 @@ routes.push({ path: '/hunt', name: 'hunt', component: huntComponent}); const alertsComponent = Object.assign({}, huntComponent); routes.push({ path: '/alerts', name: 'alerts', component: alertsComponent}); + +const casesComponent = Object.assign({}, huntComponent); +routes.push({ path: '/cases', name: 'cases', component: casesComponent}); diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index e2b43256..a2e7b41a 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -124,4 +124,54 @@ test('saveTimezone', () => { comp.zone = "Test"; comp.loadLocalSettings(); expect(comp.zone).toBe("Foo/Bar"); +}); + +test('removeFilter', () => { + comp.query = "abc def | groupby foo bar*"; + comp.removeFilter('def') + expect(comp.query).toBe("abc | groupby foo bar*"); + + comp.removeFilter('abc') + expect(comp.query).toBe("* | groupby foo bar*"); + + // no-op + comp.removeFilter('*') + expect(comp.query).toBe("* | groupby foo bar*"); +}); + +test('removeGroupBy', () => { + comp.query = "abc | groupby foo bar*"; + comp.removeGroupBy('foo') + expect(comp.query).toBe("abc | groupby bar*"); + + comp.removeGroupBy('bar*') + expect(comp.query).toBe("abc"); + + // no-op + comp.removeGroupBy('bar*') + expect(comp.query).toBe("abc"); +}); + +test('removeSortBy', () => { + comp.query = "abc | sortby foo bar^"; + comp.removeSortBy('foo') + expect(comp.query).toBe("abc | sortby bar^"); + + comp.removeSortBy('bar^') + expect(comp.query).toBe("abc"); + + // no-op + comp.removeSortBy('bar^') + expect(comp.query).toBe("abc"); + + comp.query = "abc | sortby foo bar^ | groupby xyz"; + comp.removeSortBy('foo') + expect(comp.query).toBe("abc | sortby bar^ | groupby xyz"); + + comp.removeSortBy('bar^') + expect(comp.query).toBe("abc | groupby xyz"); + + // no-op + comp.removeSortBy('bar^') + expect(comp.query).toBe("abc | groupby xyz"); }); \ No newline at end of file diff --git a/html/js/routes/job.js b/html/js/routes/job.js index 15cb4328..688714da 100644 --- a/html/js/routes/job.js +++ b/html/js/routes/job.js @@ -230,11 +230,11 @@ routes.push({ path: '/job/:jobId', name: 'job', component: { jobId: this.$route.params.jobId }}); this.job = response.data; - this.$root.populateJobDetails(this.job); + this.$root.populateUserDetails(this.job, "userId", "owner"); this.loadPackets(this.isOptionEnabled('unwrap')); } catch (error) { if (error.response != undefined && error.response.status == 404) { - this.$root.showError(this.i18n.jobNotFound); + this.$root.showError(this.i18n.notFound); } else { this.$root.showError(error); } diff --git a/html/js/routes/jobs.js b/html/js/routes/jobs.js index 722b5fe8..f8574e60 100644 --- a/html/js/routes/jobs.js +++ b/html/js/routes/jobs.js @@ -73,7 +73,7 @@ routes.push({ path: '/jobs', name: 'jobs', component: { }, loadUserDetails() { for (var i = 0; i < this.jobs.length; i++) { - this.$root.populateJobDetails(this.jobs[i]); + this.$root.populateUserDetails(this.jobs[i], "userId", "owner"); } }, saveLocalSettings() { @@ -102,7 +102,7 @@ routes.push({ path: '/jobs', name: 'jobs', component: { if (job.status == JobStatusDeleted) { this.jobs.splice(i, 1); } else { - this.$root.populateJobDetails(job); + this.$root.populateUserDetails(job, "userId", "owner"); this.$set(this.jobs, i, job); } break; @@ -161,7 +161,7 @@ routes.push({ path: '/jobs', name: 'jobs', component: { endTime: endDate } }); - this.$root.populateJobDetails(response.data); + this.$root.populateUserDetails(response.data, "userId", "owner"); this.jobs.push(response.data); } } catch (error) { diff --git a/html/js/test_common.js b/html/js/test_common.js index 008662fa..2f5abaed 100644 --- a/html/js/test_common.js +++ b/html/js/test_common.js @@ -22,11 +22,17 @@ global.document.ready = function(fn) { fn(); }; var app = null; global.Vue = function(obj) { app = this; + app.$root = this; + app.debug = true; Object.assign(app, obj.data, obj.methods); + this.ensureConnected = jest.fn(); }; global.Vue.delete = function(data, i) { data.splice(i, 1); }; +global.Vue.set = function(array, idx, value) { + array[idx] = value; +}; global.Vuetify = function(obj) {}; global.VueRouter = function(obj) {}; @@ -49,14 +55,49 @@ global.getComponent = function(name) { break; } } + comp.$root = app; + // Setup route mock data + comp.$route = { params: {}}; + comp.$router = []; + const data = global.initComponentData(comp); Object.assign(comp, data, comp.methods); return comp; } +//////////////////////////////////// +// Mock API calls +//////////////////////////////////// +global.resetPapi = function() { + app.papi = {}; + return global; +} + +global.mockPapi = function(method, mockedResponse, error) { + mock = app.papi[method]; + if (!mock) { + mock = jest.fn(); + app.papi[method] = mock; + } + if (error) { + mock.mockImplementation(() => { + throw error; + }); + } else { + mock.mockReturnValueOnce(mockedResponse); + } + return mock +} + +global.mockShowError = function(logError = false) { + const mock = jest.fn().mockImplementation(err => { if (logError) console.log(err.stack) }); + app.showError = mock; + return mock; +} + //////////////////////////////////// // Import SO app modules //////////////////////////////////// diff --git a/model/case.go b/model/case.go index 854b4332..7fc9c41d 100644 --- a/model/case.go +++ b/model/case.go @@ -13,20 +13,46 @@ import ( "time" ) +const CASE_STATUS_NEW = "new" + +type Auditable struct { + Id string `json:"id,omitempty"` + CreateTime *time.Time `json:"createTime"` + UpdateTime *time.Time `json:"updateTime,omitempty"` + UserId string `json:"userId"` + Kind string `json:"kind,omitempty"` + Operation string `json:"operation,omitempty"` +} + type Case struct { - Id string `json:"id"` - CreateTime time.Time `json:"createTime"` - StartTime time.Time `json:"startTime"` - CompleteTime time.Time `json:"completeTime"` - Title string `json:"title"` - Description string `json:"description"` - Priority int `json:"priority"` - Severity int `json:"severity"` - Status string `json:"status"` - Template string `json:"template"` + Auditable + StartTime *time.Time `json:"startTime"` + CompleteTime *time.Time `json:"completeTime"` + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` + Severity int `json:"severity"` + Status string `json:"status"` + Template string `json:"template"` + Tlp string `json:"tlp"` + Pap string `json:"pap"` + Category string `json:"category"` + AssigneeId string `json:"assigneeId"` + Tags []string `json:"tags"` } func NewCase() *Case { newCase := &Case{} return newCase -} \ No newline at end of file +} + +type Comment struct { + Auditable + CaseId string `json:"caseId"` + Description string `json:"description"` +} + +func NewComment() *Comment { + newComment := &Comment{} + return newComment +} diff --git a/model/event.go b/model/event.go index 30d6451d..b5a400ff 100644 --- a/model/event.go +++ b/model/event.go @@ -50,6 +50,11 @@ func NewEventSearchResults() *EventSearchResults { return results } +type SortCriteria struct { + Field string + Order string +} + type EventSearchCriteria struct { RawQuery string `json:"query"` DateRange string `json:"dateRange"` @@ -59,6 +64,7 @@ type EventSearchCriteria struct { EndTime time.Time CreateTime time.Time ParsedQuery *Query + SortFields []*SortCriteria } func (criteria *EventSearchCriteria) initSearchCriteria() { @@ -118,7 +124,8 @@ type EventMetric struct { } type EventRecord struct { - Source string `json:"source"` + Source string `json:"source"` + Time time.Time Timestamp string `json:"timestamp"` Id string `json:"id"` Type string `json:"type"` @@ -174,3 +181,13 @@ type EventAckCriteria struct { func NewEventAckCriteria() *EventAckCriteria { return &EventAckCriteria{} } + +type EventIndexResults struct { + Success bool `json:"success"` + DocumentId string `json:"id"` +} + +func NewEventIndexResults() *EventIndexResults { + results := &EventIndexResults{} + return results +} diff --git a/model/query.go b/model/query.go index 67287858..16cb9ca1 100644 --- a/model/query.go +++ b/model/query.go @@ -85,6 +85,7 @@ func (segment *BaseSegment) Terms() []*QueryTerm { const SegmentKind_Search = "search" const SegmentKind_GroupBy = "groupby" +const SegmentKind_SortBy = "sortby" func NewSegment(kind string, terms []*QueryTerm) (QuerySegment, error) { switch kind { @@ -92,6 +93,8 @@ func NewSegment(kind string, terms []*QueryTerm) (QuerySegment, error) { return NewSearchSegment(terms) case SegmentKind_GroupBy: return NewGroupBySegment(terms) + case SegmentKind_SortBy: + return NewSortBySegment(terms) } return nil, errors.New("QUERY_INVALID__SEGMENT_UNSUPPORTED") } @@ -168,7 +171,7 @@ func (segment *SearchSegment) escape(value string) string { func (segment *SearchSegment) AddFilter(field string, value string, scalar bool, inclusive bool) error { // This flag can be adjust to true once the query parser is more robust and better able to determine // when an inclusive filter already exists in a query, so that two inclusive filters are not allowed - // to exist in a query together. For example, the following filters will prevent any matches: + // to exist in a query together. For example, the following filters will prevent any matches: // Ex: foo:1 AND foo:2 alreadyFiltered := false @@ -264,6 +267,63 @@ func (segment *GroupBySegment) AddGrouping(group string) error { return err } +type SortBySegment struct { + *BaseSegment +} + +func NewSortBySegmentEmpty() *SortBySegment { + return &SortBySegment{ + &BaseSegment{ + terms: make([]*QueryTerm, 0, 0), + }, + } +} + +func NewSortBySegment(terms []*QueryTerm) (*SortBySegment, error) { + if terms == nil || len(terms) == 0 { + return nil, errors.New("QUERY_INVALID__SORTBY_TERMS_MISSING") + } + + segment := NewSortBySegmentEmpty() + segment.terms = terms + + return segment, nil +} + +func (segment *SortBySegment) Kind() string { + return SegmentKind_SortBy +} + +func (segment *SortBySegment) String() string { + return segment.Kind() + " " + segment.TermsAsString() +} + +func (segment *SortBySegment) Fields() []string { + fields := make([]string, 0, 0) + for _, field := range segment.terms { + fields = append(fields, field.String()) + } + return fields +} + +func (segment *SortBySegment) AddSortField(sortField string) error { + fields := segment.Fields() + alreadySorted := false + for _, field := range fields { + if field == sortField { + alreadySorted = true + } + } + var err error + if !alreadySorted { + term, err := NewQueryTerm(sortField) + if err == nil { + segment.terms = append(segment.terms, term) + } + } + return err +} + type Query struct { Segments []QuerySegment } @@ -480,3 +540,17 @@ func (query *Query) Group(field string) (string, error) { return query.String(), err } + +func (query *Query) Sort(field string) (string, error) { + var err error + + segment := query.NamedSegment(SegmentKind_SortBy) + if segment == nil { + segment = NewSortBySegmentEmpty() + query.AddSegment(segment) + } + sortBySegment := segment.(*SortBySegment) + err = sortBySegment.AddSortField(field) + + return query.String(), err +} diff --git a/model/query_test.go b/model/query_test.go index c166d9ea..a26f6a18 100644 --- a/model/query_test.go +++ b/model/query_test.go @@ -72,6 +72,12 @@ func TestQueries(tester *testing.T) { validateQuery(tester, "abcA|groupby", "QUERY_INVALID__GROUPBY_TERMS_MISSING") validateQuery(tester, "abcA|groupby ", "QUERY_INVALID__GROUPBY_TERMS_MISSING") + + validateQuery(tester, "abcA|sortby\njjj, lll", "abcA | sortby jjj lll") + validateQuery(tester, "abcA|\nsortby\tjjj", "abcA | sortby jjj") + + validateQuery(tester, "abcA|sortby", "QUERY_INVALID__SORTBY_TERMS_MISSING") + validateQuery(tester, "abcA|sortby ", "QUERY_INVALID__SORTBY_TERMS_MISSING") } func validateGroup(tester *testing.T, orig string, group string, expected string) { @@ -90,6 +96,22 @@ func TestGroup(tester *testing.T) { validateGroup(tester, "a|groupby b", "b", "a | groupby b") } +func validateSort(tester *testing.T, orig string, sort string, expected string) { + query := NewQuery() + query.Parse(orig) + actual, err := query.Sort(sort) + if err != nil { + actual = err.Error() + } + assert.Equal(tester, expected, actual) +} + +func TestSort(tester *testing.T) { + validateSort(tester, "a", "b", "a | sortby b") + validateSort(tester, "a|sortby b", "c", "a | sortby b c") + validateSort(tester, "a|sortby b", "b", "a | sortby b") +} + func validateFilter(tester *testing.T, orig string, key string, value string, scalar bool, mode string, expected string) { query := NewQuery() query.Parse(orig) diff --git a/server/casehandler.go b/server/casehandler.go index 0edba00c..e1b314a6 100644 --- a/server/casehandler.go +++ b/server/casehandler.go @@ -40,24 +40,121 @@ func (caseHandler *CaseHandler) HandleNow(ctx context.Context, writer http.Respo switch request.Method { case http.MethodPost: return caseHandler.create(ctx, writer, request) + case http.MethodPut: + return caseHandler.update(ctx, writer, request) + case http.MethodGet: + return caseHandler.get(ctx, writer, request) + case http.MethodDelete: + return caseHandler.delete(ctx, writer, request) } } return http.StatusMethodNotAllowed, nil, errors.New("Method not supported") } func (caseHandler *CaseHandler) create(ctx context.Context, writer http.ResponseWriter, request *http.Request) (int, interface{}, error) { + var err error + var obj interface{} statusCode := http.StatusBadRequest - var outputCase *model.Case - - inputCase := model.NewCase() - err := json.NewDecoder(request.Body).Decode(&inputCase) + subpath := caseHandler.GetPathParameter(request.URL.Path, 2) + switch subpath { + case "events": + case "comments": + inputComment := model.NewComment() + err = json.NewDecoder(request.Body).Decode(&inputComment) + if err == nil { + obj, err = caseHandler.server.Casestore.CreateComment(ctx, inputComment) + } + case "tasks": + case "artifacts": + default: + inputCase := model.NewCase() + err = json.NewDecoder(request.Body).Decode(&inputCase) + if err == nil { + obj, err = caseHandler.server.Casestore.Create(ctx, inputCase) + } + } if err == nil { - outputCase, err = caseHandler.server.Casestore.Create(ctx, inputCase) + statusCode = http.StatusOK + } else { + statusCode = http.StatusBadRequest + } + return statusCode, obj, err +} + +func (caseHandler *CaseHandler) update(ctx context.Context, writer http.ResponseWriter, request *http.Request) (int, interface{}, error) { + var err error + var obj interface{} + statusCode := http.StatusBadRequest + subpath := caseHandler.GetPathParameter(request.URL.Path, 2) + switch subpath { + case "comments": + inputComment := model.NewComment() + err = json.NewDecoder(request.Body).Decode(&inputComment) if err == nil { - statusCode = http.StatusOK - } else { - statusCode = http.StatusBadRequest + obj, err = caseHandler.server.Casestore.UpdateComment(ctx, inputComment) } + case "tasks": + case "artifacts": + default: + inputCase := model.NewCase() + err = json.NewDecoder(request.Body).Decode(&inputCase) + if err == nil { + obj, err = caseHandler.server.Casestore.Update(ctx, inputCase) + } + } + + if err == nil { + statusCode = http.StatusOK + } else { + statusCode = http.StatusBadRequest + } + return statusCode, obj, err +} + +func (caseHandler *CaseHandler) delete(ctx context.Context, writer http.ResponseWriter, request *http.Request) (int, interface{}, error) { + var err error + var obj interface{} + statusCode := http.StatusBadRequest + id := request.URL.Query().Get("id") + subpath := caseHandler.GetPathParameter(request.URL.Path, 2) + switch subpath { + case "comments": + err = caseHandler.server.Casestore.DeleteComment(ctx, id) + case "tasks": + case "artifacts": + default: + err = errors.New("Delete not supported") + } + + if err == nil { + statusCode = http.StatusOK + } else { + statusCode = http.StatusBadRequest + } + return statusCode, obj, err +} + +func (caseHandler *CaseHandler) get(ctx context.Context, writer http.ResponseWriter, request *http.Request) (int, interface{}, error) { + statusCode := http.StatusBadRequest + id := request.URL.Query().Get("id") + var err error + var obj interface{} + subpath := caseHandler.GetPathParameter(request.URL.Path, 2) + switch subpath { + case "events": + case "comments": + obj, err = caseHandler.server.Casestore.GetComments(ctx, id) + case "tasks": + case "artifacts": + case "history": + obj, err = caseHandler.server.Casestore.GetCaseHistory(ctx, id) + default: + obj, err = caseHandler.server.Casestore.GetCase(ctx, id) + } + if obj != nil { + statusCode = http.StatusOK + } else { + statusCode = http.StatusNotFound } - return statusCode, outputCase, err + return statusCode, obj, err } diff --git a/server/casestore.go b/server/casestore.go index b3c0e416..bfd9f2ad 100644 --- a/server/casestore.go +++ b/server/casestore.go @@ -16,4 +16,13 @@ import ( type Casestore interface { Create(ctx context.Context, newCase *model.Case) (*model.Case, error) + Update(ctx context.Context, socCase *model.Case) (*model.Case, error) + GetCase(ctx context.Context, caseId string) (*model.Case, error) + GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) + + CreateComment(ctx context.Context, newComment *model.Comment) (*model.Comment, error) + GetComment(ctx context.Context, commentId string) (*model.Comment, error) + GetComments(ctx context.Context, caseId string) ([]*model.Comment, error) + UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) + DeleteComment(ctx context.Context, id string) error } diff --git a/server/eventstore.go b/server/eventstore.go index ade4dfdf..9b5514b1 100644 --- a/server/eventstore.go +++ b/server/eventstore.go @@ -12,11 +12,13 @@ package server import ( "context" - "github.com/security-onion-solutions/securityonion-soc/model" + "github.com/security-onion-solutions/securityonion-soc/model" ) type Eventstore interface { Search(context context.Context, criteria *model.EventSearchCriteria) (*model.EventSearchResults, error) + Index(ctx context.Context, index string, document map[string]interface{}, id string) (*model.EventIndexResults, error) Update(context context.Context, criteria *model.EventUpdateCriteria) (*model.EventUpdateResults, error) + Delete(context context.Context, index string, id string) error Acknowledge(context context.Context, criteria *model.EventAckCriteria) (*model.EventUpdateResults, error) -} \ No newline at end of file +} diff --git a/server/eventstore_fake.go b/server/eventstore_fake.go new file mode 100644 index 00000000..e0f7dd92 --- /dev/null +++ b/server/eventstore_fake.go @@ -0,0 +1,78 @@ +// Copyright 2019 Jason Ertel (jertel). All rights reserved. +// Copyright 2020-2021 Security Onion Solutions, LLC. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +package server + +import ( + "context" + "github.com/security-onion-solutions/securityonion-soc/model" +) + +type FakeEventstore struct { + InputDocuments []map[string]interface{} + InputContexts []context.Context + InputIndexes []string + InputIds []string + InputSearchCriterias []*model.EventSearchCriteria + InputUpdateCriterias []*model.EventUpdateCriteria + InputAckCriterias []*model.EventAckCriteria + Err error + SearchResults *model.EventSearchResults + IndexResults *model.EventIndexResults + UpdateResults *model.EventUpdateResults +} + +func NewFakeEventstore() *FakeEventstore { + store := &FakeEventstore{} + store.InputDocuments = make([]map[string]interface{}, 0) + store.InputContexts = make([]context.Context, 0) + store.InputIndexes = make([]string, 0) + store.InputIds = make([]string, 0) + store.InputSearchCriterias = make([]*model.EventSearchCriteria, 0) + store.InputUpdateCriterias = make([]*model.EventUpdateCriteria, 0) + store.InputAckCriterias = make([]*model.EventAckCriteria, 0) + store.SearchResults = model.NewEventSearchResults() + store.IndexResults = model.NewEventIndexResults() + store.UpdateResults = model.NewEventUpdateResults() + return store +} + +func (store *FakeEventstore) Search(context context.Context, criteria *model.EventSearchCriteria) (*model.EventSearchResults, error) { + store.InputContexts = append(store.InputContexts, context) + store.InputSearchCriterias = append(store.InputSearchCriterias, criteria) + return store.SearchResults, store.Err +} + +func (store *FakeEventstore) Index(context context.Context, index string, document map[string]interface{}, id string) (*model.EventIndexResults, error) { + store.InputContexts = append(store.InputContexts, context) + store.InputIndexes = append(store.InputIndexes, index) + store.InputDocuments = append(store.InputDocuments, document) + store.InputIds = append(store.InputIds, id) + return store.IndexResults, store.Err +} + +func (store *FakeEventstore) Update(context context.Context, criteria *model.EventUpdateCriteria) (*model.EventUpdateResults, error) { + store.InputContexts = append(store.InputContexts, context) + store.InputUpdateCriterias = append(store.InputUpdateCriterias, criteria) + return store.UpdateResults, store.Err +} + +func (store *FakeEventstore) Delete(context context.Context, index string, id string) error { + store.InputContexts = append(store.InputContexts, context) + store.InputIndexes = append(store.InputIndexes, index) + store.InputIds = append(store.InputIds, id) + return store.Err +} + +func (store *FakeEventstore) Acknowledge(context context.Context, criteria *model.EventAckCriteria) (*model.EventUpdateResults, error) { + store.InputContexts = append(store.InputContexts, context) + store.InputAckCriterias = append(store.InputAckCriterias, criteria) + return store.UpdateResults, store.Err +} diff --git a/server/modules/elastic/converter.go b/server/modules/elastic/converter.go index 3c690371..467bfe8c 100644 --- a/server/modules/elastic/converter.go +++ b/server/modules/elastic/converter.go @@ -93,21 +93,22 @@ func makeQuery(store *ElasticEventstore, parsedQuery *model.Query, beginTime tim query := make(map[string]interface{}) query["query_string"] = queryDetails + must := make([]interface{}, 0, 0) + must = append(must, query) - timestampDetails := make(map[string]interface{}) - timestampDetails["gte"] = beginTime.Format(time.RFC3339) - timestampDetails["lte"] = endTime.Format(time.RFC3339) - timestampDetails["format"] = "strict_date_optional_time" - - timerangeDetails := make(map[string]interface{}) - timerangeDetails["@timestamp"] = timestampDetails + if !endTime.IsZero() { + timestampDetails := make(map[string]interface{}) + timestampDetails["gte"] = beginTime.Format(time.RFC3339) + timestampDetails["lte"] = endTime.Format(time.RFC3339) + timestampDetails["format"] = "strict_date_optional_time" - timerange := make(map[string]interface{}) - timerange["range"] = timerangeDetails + timerangeDetails := make(map[string]interface{}) + timerangeDetails["@timestamp"] = timestampDetails - must := make([]interface{}, 0, 0) - must = append(must, query) - must = append(must, timerange) + timerange := make(map[string]interface{}) + timerange["range"] = timerangeDetails + must = append(must, timerange) + } terms := make(map[string]interface{}) terms["must"] = must @@ -186,17 +187,44 @@ func convertToElasticRequest(store *ElasticEventstore, criteria *model.EventSear esMap["query"] = makeQuery(store, criteria.ParsedQuery, criteria.BeginTime, criteria.EndTime) aggregations := make(map[string]interface{}) - esMap["aggs"] = aggregations - aggregations["timeline"] = makeTimeline(calcTimelineInterval(store.intervals, criteria.BeginTime, criteria.EndTime)) - segment := criteria.ParsedQuery.NamedSegment(model.SegmentKind_GroupBy) + if criteria.MetricLimit > 0 { + if !criteria.EndTime.IsZero() { + aggregations["timeline"] = makeTimeline(calcTimelineInterval(store.intervals, criteria.BeginTime, criteria.EndTime)) + } + segment := criteria.ParsedQuery.NamedSegment(model.SegmentKind_GroupBy) + if segment != nil { + groupBySegment := segment.(*model.GroupBySegment) + fields := groupBySegment.Fields() + if len(fields) > 0 { + agg, name := makeAggregation(store, "groupby", fields, criteria.MetricLimit, false) + aggregations[name] = agg + aggregations["bottom"], _ = makeAggregation(store, "", fields[0:1], criteria.MetricLimit, true) + } + } + } + + if len(aggregations) > 0 { + esMap["aggs"] = aggregations + } + + segment := criteria.ParsedQuery.NamedSegment(model.SegmentKind_SortBy) if segment != nil { - groupBySegment := segment.(*model.GroupBySegment) - fields := groupBySegment.Fields() + sortBySegment := segment.(*model.SortBySegment) + fields := sortBySegment.Fields() if len(fields) > 0 { - agg, name := makeAggregation(store, "groupby", fields, criteria.MetricLimit, false) - aggregations[name] = agg - aggregations["bottom"], _ = makeAggregation(store, "", fields[0:1], criteria.MetricLimit, true) + sorting := make([]map[string]string, 0, 0) + for _, field := range fields { + newSort := make(map[string]string) + order := "desc" + if strings.HasSuffix(field, "^") { + field = strings.TrimSuffix(field, "^") + order = "asc" + } + newSort[field] = order + sorting = append(sorting, newSort) + } + esMap["sort"] = sorting } } @@ -290,15 +318,17 @@ func convertFromElasticResults(store *ElasticEventstore, esJson string, results event.Source = esRecord["_index"].(string) event.Id = esRecord["_id"].(string) event.Type = esRecord["_type"].(string) - event.Score = esRecord["_score"].(float64) + if esRecord["_score"] != nil { + event.Score = esRecord["_score"].(float64) + } event.Payload = flatten(store, esRecord["_source"].(map[string]interface{})) - var ts time.Time + if event.Payload["@timestamp"] != nil { - ts, _ = time.Parse(time.RFC3339, event.Payload["@timestamp"].(string)) + event.Time, _ = time.Parse(time.RFC3339, event.Payload["@timestamp"].(string)) } else if event.Payload["timestamp"] != nil { - ts, _ = time.Parse(time.RFC3339, event.Payload["timestamp"].(string)) + event.Time, _ = time.Parse(time.RFC3339, event.Payload["timestamp"].(string)) } - event.Timestamp = ts.Format("2006-01-02T15:04:05.000Z") + event.Timestamp = event.Time.Format("2006-01-02T15:04:05.000Z") results.Events = append(results.Events, event) } @@ -313,6 +343,136 @@ func convertFromElasticResults(store *ElasticEventstore, esJson string, results return err } +func parseTime(fieldmap map[string]interface{}, key string) *time.Time { + var t time.Time + + if value, ok := fieldmap[key]; ok { + switch value.(type) { + case time.Time: + t = value.(time.Time) + case *time.Time: + t = *(value.(*time.Time)) + case string: + t, _ = time.Parse(time.RFC3339, value.(string)) + } + } + + return &t +} + +func convertElasticEventToAuditable(event *model.EventRecord, auditable *model.Auditable) error { + auditable.Id = event.Id + auditable.UpdateTime = &event.Time + if value, ok := event.Payload["kind"]; ok { + auditable.Kind = value.(string) + } + if value, ok := event.Payload["operation"]; ok { + auditable.Operation = value.(string) + } + return nil +} + +func convertElasticEventToCase(event *model.EventRecord) (*model.Case, error) { + var err error + var obj *model.Case + + if event != nil { + obj = model.NewCase() + err = convertElasticEventToAuditable(event, &obj.Auditable) + if err == nil { + if value, ok := event.Payload["case.title"]; ok { + obj.Title = value.(string) + } + if value, ok := event.Payload["case.description"]; ok { + obj.Description = value.(string) + } + if value, ok := event.Payload["case.priority"]; ok { + obj.Priority = int(value.(float64)) + } + if value, ok := event.Payload["case.severity"]; ok { + obj.Severity = int(value.(float64)) + } + if value, ok := event.Payload["case.status"]; ok { + obj.Status = value.(string) + } + if value, ok := event.Payload["case.template"]; ok { + obj.Template = value.(string) + } + if value, ok := event.Payload["case.userId"]; ok { + obj.UserId = value.(string) + } + if value, ok := event.Payload["case.assigneeId"]; ok { + obj.AssigneeId = value.(string) + } + if value, ok := event.Payload["case.tlp"]; ok { + obj.Tlp = value.(string) + } + if value, ok := event.Payload["case.category"]; ok { + obj.Category = value.(string) + } + if value, ok := event.Payload["case.pap"]; ok { + obj.Pap = value.(string) + } + if value, ok := event.Payload["case.tags"]; ok { + obj.Tags = convertToStringArray(value.([]interface{})) + } + obj.CreateTime = parseTime(event.Payload, "case.createTime") + obj.StartTime = parseTime(event.Payload, "case.startTime") + obj.CompleteTime = parseTime(event.Payload, "case.completeTime") + } + } + return obj, err +} + +func convertToStringArray(input []interface{}) []string { + out := make([]string, len(input), len(input)) + for idx, value := range input { + out[idx] = value.(string) + } + return out +} + +func convertElasticEventToComment(event *model.EventRecord) (*model.Comment, error) { + var err error + var obj *model.Comment + + if event != nil { + obj = model.NewComment() + err = convertElasticEventToAuditable(event, &obj.Auditable) + if err == nil { + if value, ok := event.Payload["comment.description"]; ok { + obj.Description = value.(string) + } + if value, ok := event.Payload["comment.userId"]; ok { + obj.UserId = value.(string) + } + if value, ok := event.Payload["comment.caseId"]; ok { + obj.CaseId = value.(string) + } + obj.CreateTime = parseTime(event.Payload, "comment.createTime") + } + } + + return obj, err +} + +func convertElasticEventToObject(event *model.EventRecord) (interface{}, error) { + var obj interface{} + var err error + + if value, ok := event.Payload["kind"]; ok { + switch value.(string) { + case "case": + obj, err = convertElasticEventToCase(event) + case "comment": + obj, err = convertElasticEventToComment(event) + } + } else { + err = errors.New("Unknown object kind; id=" + event.Id) + } + return obj, err +} + func convertToElasticUpdateRequest(store *ElasticEventstore, criteria *model.EventUpdateCriteria) (string, error) { var err error var esJson string @@ -350,3 +510,33 @@ func convertFromElasticUpdateResults(store *ElasticEventstore, esJson string, re return err } + +func convertObjectToDocumentMap(name string, obj interface{}) map[string]interface{} { + doc := make(map[string]interface{}) + doc[name] = obj + doc["@timestamp"] = time.Now() + return doc +} + +func convertToElasticIndexRequest(store *ElasticEventstore, event map[string]interface{}) (string, error) { + var err error + var esJson string + + bytes, err := json.WriteJson(event) + if err == nil { + esJson = string(bytes) + } + + return esJson, err +} + +func convertFromElasticIndexResults(store *ElasticEventstore, esJson string, results *model.EventIndexResults) error { + esResults := make(map[string]interface{}) + err := json.LoadJson([]byte(esJson), &esResults) + + results.DocumentId = esResults["_id"].(string) + result := esResults["result"].(string) + results.Success = result == "created" || result == "updated" + + return err +} diff --git a/server/modules/elastic/converter_test.go b/server/modules/elastic/converter_test.go index 1ea78c22..dfbd184c 100644 --- a/server/modules/elastic/converter_test.go +++ b/server/modules/elastic/converter_test.go @@ -86,10 +86,11 @@ func TestCalcTimelineInterval(tester *testing.T) { func TestConvertToElasticRequestEmptyCriteria(tester *testing.T) { criteria := model.NewEventSearchCriteria() + criteria.MetricLimit = 0 actualJson, err := convertToElasticRequest(NewTestStore(), criteria) assert.Nil(tester, err) - expectedJson := `{"aggs":{"timeline":{"date_histogram":{"field":"@timestamp","fixed_interval":"1s","min_doc_count":1}}},"query":{"bool":{"filter":[],"must":[{"query_string":{"analyze_wildcard":true,"default_field":"*","query":"*"}},{"range":{"@timestamp":{"format":"strict_date_optional_time","gte":"0001-01-01T00:00:00Z","lte":"0001-01-01T00:00:00Z"}}}],"must_not":[],"should":[]}},"size":25}` + expectedJson := `{"query":{"bool":{"filter":[],"must":[{"query_string":{"analyze_wildcard":true,"default_field":"*","query":"*"}}],"must_not":[],"should":[]}},"size":25}` assert.Equal(tester, expectedJson, actualJson) } @@ -103,6 +104,26 @@ func TestConvertToElasticRequestGroupByCriteria(tester *testing.T) { assert.Equal(tester, expectedJson, actualJson) } +func TestConvertToElasticRequestSortByCriteria(tester *testing.T) { + criteria := model.NewEventSearchCriteria() + criteria.Populate(`abc AND def AND q:"\\\\file\\path" | sortby ghi jkl^`, "2020-01-02T12:13:14Z - 2020-01-02T13:13:14Z", time.RFC3339, "America/New_York", "10", "25") + actualJson, err := convertToElasticRequest(NewTestStore(), criteria) + assert.Nil(tester, err) + + expectedJson := `{"aggs":{"timeline":{"date_histogram":{"field":"@timestamp","fixed_interval":"1m","min_doc_count":1}}},"query":{"bool":{"filter":[],"must":[{"query_string":{"analyze_wildcard":true,"default_field":"*","query":"abc AND def AND q: \"\\\\\\\\file\\\\path\""}},{"range":{"@timestamp":{"format":"strict_date_optional_time","gte":"2020-01-02T12:13:14Z","lte":"2020-01-02T13:13:14Z"}}}],"must_not":[],"should":[]}},"size":25,"sort":[{"ghi":"desc"},{"jkl":"asc"}]}` + assert.Equal(tester, expectedJson, actualJson) +} + +func TestConvertToElasticRequestGroupBySortByCriteria(tester *testing.T) { + criteria := model.NewEventSearchCriteria() + criteria.Populate(`abc AND def AND q:"\\\\file\\path" | groupby ghi jkl* | sortby ghi jkl^`, "2020-01-02T12:13:14Z - 2020-01-02T13:13:14Z", time.RFC3339, "America/New_York", "10", "25") + actualJson, err := convertToElasticRequest(NewTestStore(), criteria) + assert.Nil(tester, err) + + expectedJson := `{"aggs":{"bottom":{"terms":{"field":"ghi","order":{"_count":"asc"},"size":10}},"groupby|ghi":{"aggs":{"groupby|ghi|jkl":{"terms":{"field":"jkl","missing":"__missing__","order":{"_count":"desc"},"size":10}}},"terms":{"field":"ghi","order":{"_count":"desc"},"size":10}},"timeline":{"date_histogram":{"field":"@timestamp","fixed_interval":"1m","min_doc_count":1}}},"query":{"bool":{"filter":[],"must":[{"query_string":{"analyze_wildcard":true,"default_field":"*","query":"abc AND def AND q: \"\\\\\\\\file\\\\path\""}},{"range":{"@timestamp":{"format":"strict_date_optional_time","gte":"2020-01-02T12:13:14Z","lte":"2020-01-02T13:13:14Z"}}}],"must_not":[],"should":[]}},"size":25,"sort":[{"ghi":"desc"},{"jkl":"asc"}]}` + assert.Equal(tester, expectedJson, actualJson) +} + func TestConvertFromElasticResultsSuccess(tester *testing.T) { esData, err := ioutil.ReadFile("converter_response.json") assert.Nil(tester, err) @@ -228,3 +249,144 @@ func TestMapSearch(tester *testing.T) { validateMappedQuery(tester, "barfoo: \"bar\"", "barfoo: \"bar\"") validateMappedQuery(tester, "barfoo: \"foo: bar\"", "barfoo: \"foo: bar\"") } + +func TestConvertObjectToDocumentMap(tester *testing.T) { + caseObj := model.NewCase() + actual := convertObjectToDocumentMap("test", caseObj) + assert.NotNil(tester, actual) + assert.Equal(tester, caseObj, actual["test"]) + assert.NotNil(tester, actual["@timestamp"]) +} + +func TestConvertToElasticIndexRequest(tester *testing.T) { + store := NewTestStore() + event := make(map[string]interface{}) + event["foo"] = "bar" + expected := `{"foo":"bar"}` + + actual, err := convertToElasticIndexRequest(store, event) + assert.NoError(tester, err) + assert.Equal(tester, expected, actual) +} + +func TestConvertFromElasticIndexResults(tester *testing.T) { + store := NewTestStore() + results := model.NewEventIndexResults() + json := `{"_version":1, "_id":"123abc", "result": "successful"}` + + err := convertFromElasticIndexResults(store, json, results) + assert.NoError(tester, err) +} + +func TestConvertElasticEventToCaseNil(tester *testing.T) { + caseObj, err := convertElasticEventToCase(nil) + assert.NoError(tester, err) + assert.Nil(tester, caseObj) +} + +func TestConvertElasticEventToCase(tester *testing.T) { + myTime := time.Now() + myCreateTime := myTime.Add(time.Hour * -1) + myCompleteTime := myTime.Add(time.Hour * -2) + myStartTime := myTime.Add(time.Hour * -3) + + event := &model.EventRecord{} + event.Payload = make(map[string]interface{}) + event.Payload["kind"] = "case" + event.Payload["operation"] = "update" + event.Payload["case.title"] = "myTitle" + event.Payload["case.description"] = "myDesc" + event.Payload["case.priority"] = float64(123) + event.Payload["case.severity"] = float64(456) + event.Payload["case.status"] = "myStatus" + event.Payload["case.template"] = "myTemplate" + event.Payload["case.userId"] = "myUserId" + event.Payload["case.assigneeId"] = "myAssigneeId" + event.Payload["case.tlp"] = "myTlp" + event.Payload["case.pap"] = "myPap" + event.Payload["case.category"] = "myCategory" + tags := make([]interface{}, 2, 2) + tags[0] = "tag1" + tags[1] = "tag2" + event.Payload["case.tags"] = tags + event.Time = myTime + event.Payload["case.createTime"] = myCreateTime + event.Payload["case.completeTime"] = myCompleteTime + event.Payload["case.startTime"] = myStartTime + caseObj, err := convertElasticEventToCase(event) + assert.NoError(tester, err) + assert.Equal(tester, "case", caseObj.Kind) + assert.Equal(tester, "update", caseObj.Operation) + assert.Equal(tester, "myTitle", caseObj.Title) + assert.Equal(tester, "myDesc", caseObj.Description) + assert.Equal(tester, 123, caseObj.Priority) + assert.Equal(tester, 456, caseObj.Severity) + assert.Equal(tester, "myStatus", caseObj.Status) + assert.Equal(tester, "myTemplate", caseObj.Template) + assert.Equal(tester, "myUserId", caseObj.UserId) + assert.Equal(tester, "myAssigneeId", caseObj.AssigneeId) + assert.Equal(tester, "myPap", caseObj.Pap) + assert.Equal(tester, "myTlp", caseObj.Tlp) + assert.Equal(tester, "myCategory", caseObj.Category) + assert.Equal(tester, tags[0], "tag1") + assert.Equal(tester, tags[1], "tag2") + assert.Equal(tester, &myTime, caseObj.UpdateTime) + assert.Equal(tester, &myCreateTime, caseObj.CreateTime) + assert.Equal(tester, &myCompleteTime, caseObj.CompleteTime) + assert.Equal(tester, &myStartTime, caseObj.StartTime) +} + +func TestParseTime(tester *testing.T) { + m := make(map[string]interface{}) + + format := "2006-01-02 03:04pm" + t, _ := time.Parse(format, "2021-12-20 12:43pm") + m["obj"] = t + m["ptr"] = &t + m["str"] = "2021-12-20T12:43:00Z" + m["bad"] = 12 + + expected := "2021-12-20 12:43pm" + + actual := parseTime(m, "obj").Format(format) + assert.Equal(tester, expected, actual) + + actual = parseTime(m, "ptr").Format(format) + assert.Equal(tester, expected, actual) + + actual = parseTime(m, "str").Format(format) + assert.Equal(tester, expected, actual) + + actualObj := parseTime(m, "bad") + assert.True(tester, actualObj.IsZero()) +} + +func TestConvertElasticEventToCommentNil(tester *testing.T) { + obj, err := convertElasticEventToComment(nil) + assert.NoError(tester, err) + assert.Nil(tester, obj) +} + +func TestConvertElasticEventToComment(tester *testing.T) { + myTime := time.Now() + myCreateTime := myTime.Add(time.Hour * -1) + + event := &model.EventRecord{} + event.Payload = make(map[string]interface{}) + event.Payload["kind"] = "comment" + event.Payload["operation"] = "create" + event.Payload["comment.description"] = "myDesc" + event.Payload["comment.userId"] = "myUserId" + event.Payload["comment.caseId"] = "myCaseId" + event.Time = myTime + event.Payload["comment.createTime"] = myCreateTime + obj, err := convertElasticEventToComment(event) + assert.NoError(tester, err) + assert.Equal(tester, "comment", obj.Kind) + assert.Equal(tester, "create", obj.Operation) + assert.Equal(tester, "myDesc", obj.Description) + assert.Equal(tester, "myUserId", obj.UserId) + assert.Equal(tester, "myCaseId", obj.CaseId) + assert.Equal(tester, &myTime, obj.UpdateTime) + assert.Equal(tester, &myCreateTime, obj.CreateTime) +} diff --git a/server/modules/elastic/elastic.go b/server/modules/elastic/elastic.go index f2f707a1..5a825529 100644 --- a/server/modules/elastic/elastic.go +++ b/server/modules/elastic/elastic.go @@ -11,10 +11,14 @@ package elastic import ( + "errors" "github.com/security-onion-solutions/securityonion-soc/module" "github.com/security-onion-solutions/securityonion-soc/server" ) +const DEFAULT_CASE_INDEX = "so-case" +const DEFAULT_CASE_AUDIT_INDEX = "so-case-events" +const DEFAULT_CASE_ASSOCIATIONS_MAX = 1000 const DEFAULT_TIME_SHIFT_MS = 120000 const DEFAULT_DURATION_MS = 1800000 const DEFAULT_ES_SEARCH_OFFSET_MS = 1800000 @@ -59,9 +63,24 @@ func (elastic *Elastic) Init(cfg module.ModuleConfig) error { index := module.GetStringDefault(cfg, "index", DEFAULT_INDEX) asyncThreshold := module.GetIntDefault(cfg, "asyncThreshold", DEFAULT_ASYNC_THRESHOLD) intervals := module.GetIntDefault(cfg, "intervals", DEFAULT_INTERVALS) + casesEnabled := module.GetBoolDefault(cfg, "casesEnabled", true) err := elastic.store.Init(host, remoteHosts, username, password, verifyCert, timeShiftMs, defaultDurationMs, esSearchOffsetMs, timeoutMs, cacheMs, index, asyncThreshold, intervals) if err == nil && elastic.server != nil { elastic.server.Eventstore = elastic.store + if casesEnabled { + if elastic.server.Casestore != nil { + err = errors.New("Multiple case modules cannot be enabled concurrently") + } else { + caseIndex := module.GetStringDefault(cfg, "caseIndex", DEFAULT_CASE_INDEX) + auditIndex := module.GetStringDefault(cfg, "auditIndex", DEFAULT_CASE_AUDIT_INDEX) + maxCaseAssociations := module.GetIntDefault(cfg, "maxCaseAssociations", DEFAULT_CASE_ASSOCIATIONS_MAX) + casestore := NewElasticCasestore(elastic.server) + err = casestore.Init(caseIndex, auditIndex, maxCaseAssociations) + if err == nil { + elastic.server.Casestore = casestore + } + } + } } return err } diff --git a/server/modules/elastic/elastic_test.go b/server/modules/elastic/elastic_test.go index b88171ba..1be16925 100644 --- a/server/modules/elastic/elastic_test.go +++ b/server/modules/elastic/elastic_test.go @@ -15,27 +15,35 @@ import ( "time" "github.com/security-onion-solutions/securityonion-soc/module" + "github.com/security-onion-solutions/securityonion-soc/server" "github.com/stretchr/testify/assert" ) func TestElasticInit(tester *testing.T) { - elastic := NewElastic(nil) + srv := server.NewFakeUnauthorizedServer() + elastic := NewElastic(srv) cfg := make(module.ModuleConfig) err := elastic.Init(cfg) - if assert.Nil(tester, err) { - if assert.Len(tester, elastic.store.hostUrls, 1) { - assert.Equal(tester, "elasticsearch", elastic.store.hostUrls[0]) - } - assert.Len(tester, elastic.store.esRemoteClients, 0) - assert.Len(tester, elastic.store.esAllClients, 1) - assert.Equal(tester, DEFAULT_TIME_SHIFT_MS, elastic.store.timeShiftMs) - assert.Equal(tester, DEFAULT_DURATION_MS, elastic.store.defaultDurationMs) - assert.Equal(tester, DEFAULT_ES_SEARCH_OFFSET_MS, elastic.store.esSearchOffsetMs) - expectedTimeout := time.Duration(DEFAULT_TIMEOUT_MS) * time.Millisecond - assert.Equal(tester, expectedTimeout, elastic.store.timeoutMs) - expectedCache := time.Duration(DEFAULT_CACHE_MS) * time.Millisecond - assert.Equal(tester, expectedCache, elastic.store.cacheMs) - assert.Equal(tester, DEFAULT_INDEX, elastic.store.index) - assert.Equal(tester, DEFAULT_INTERVALS, elastic.store.intervals) - } + assert.Nil(tester, err) + assert.NotNil(tester, srv.Eventstore) + assert.Len(tester, elastic.store.hostUrls, 1) + assert.Equal(tester, "elasticsearch", elastic.store.hostUrls[0]) + assert.Len(tester, elastic.store.esRemoteClients, 0) + assert.Len(tester, elastic.store.esAllClients, 1) + assert.Equal(tester, DEFAULT_TIME_SHIFT_MS, elastic.store.timeShiftMs) + assert.Equal(tester, DEFAULT_DURATION_MS, elastic.store.defaultDurationMs) + assert.Equal(tester, DEFAULT_ES_SEARCH_OFFSET_MS, elastic.store.esSearchOffsetMs) + expectedTimeout := time.Duration(DEFAULT_TIMEOUT_MS) * time.Millisecond + assert.Equal(tester, expectedTimeout, elastic.store.timeoutMs) + expectedCache := time.Duration(DEFAULT_CACHE_MS) * time.Millisecond + assert.Equal(tester, expectedCache, elastic.store.cacheMs) + assert.Equal(tester, DEFAULT_INDEX, elastic.store.index) + assert.Equal(tester, DEFAULT_INTERVALS, elastic.store.intervals) + + // Ensure casestore has been setup + assert.NotNil(tester, srv.Casestore) + + // Ensure failure it attempting to init when a casestore is already setup + err = elastic.Init(cfg) + assert.Error(tester, err) } diff --git a/server/modules/elastic/elasticcasestore.go b/server/modules/elastic/elasticcasestore.go new file mode 100644 index 00000000..08135f49 --- /dev/null +++ b/server/modules/elastic/elasticcasestore.go @@ -0,0 +1,284 @@ +// Copyright 2020-2021 Security Onion Solutions, LLC. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +package elastic + +import ( + "context" + "errors" + "fmt" + "github.com/apex/log" + "github.com/security-onion-solutions/securityonion-soc/model" + "github.com/security-onion-solutions/securityonion-soc/server" + "github.com/security-onion-solutions/securityonion-soc/web" + "strconv" + "time" +) + +const AUDIT_DOC_ID = "so_audit_doc_id" + +type ElasticCasestore struct { + server *server.Server + index string + auditIndex string + maxAssociations int +} + +func NewElasticCasestore(srv *server.Server) *ElasticCasestore { + return &ElasticCasestore{ + server: srv, + } +} + +func (store *ElasticCasestore) Init(index string, auditIndex string, maxAssociations int) error { + store.index = index + store.auditIndex = auditIndex + store.maxAssociations = maxAssociations + return nil +} + +func (store *ElasticCasestore) prepareForSave(ctx context.Context, obj *model.Auditable) string { + obj.UserId = ctx.Value(web.ContextKeyRequestorId).(string) + + // Don't waste space by saving the these values which are already part of ES documents + id := obj.Id + obj.Id = "" + obj.UpdateTime = nil + + return id +} + +func (store *ElasticCasestore) save(ctx context.Context, obj interface{}, kind string, id string) (*model.EventIndexResults, error) { + var results *model.EventIndexResults + var err error + + if err = store.server.CheckAuthorized(ctx, "write", "cases"); err == nil { + document := convertObjectToDocumentMap(kind, obj) + document["kind"] = kind + results, err = store.server.Eventstore.Index(ctx, store.index, document, id) + if err == nil { + document[AUDIT_DOC_ID] = results.DocumentId + if id == "" { + document["operation"] = "create" + } else { + document["operation"] = "update" + } + _, err = store.server.Eventstore.Index(ctx, store.auditIndex, document, "") + if err != nil { + log.WithFields(log.Fields{ + "documentId": results.DocumentId, + "kind": kind, + }).WithError(err).Error("Object indexed successfully however audit record failed to index") + } + } + } + + return results, err +} + +func (store *ElasticCasestore) delete(ctx context.Context, obj interface{}, kind string, id string) error { + var err error + + if err = store.server.CheckAuthorized(ctx, "write", "cases"); err == nil { + err = store.server.Eventstore.Delete(ctx, store.index, id) + if err == nil { + document := convertObjectToDocumentMap(kind, obj) + document[AUDIT_DOC_ID] = id + document["kind"] = kind + document["operation"] = "delete" + _, err = store.server.Eventstore.Index(ctx, store.auditIndex, document, "") + if err != nil { + log.WithFields(log.Fields{ + "documentId": id, + "kind": kind, + }).WithError(err).Error("Object deleted successfully however audit record failed to index") + } + } + } + + return err +} + +func (store *ElasticCasestore) get(ctx context.Context, id string, kind string) (interface{}, error) { + query := fmt.Sprintf(`_index:"%s" AND kind:"%s" AND _id:"%s"`, store.index, kind, id) + objects, err := store.getAll(ctx, query, 1) + if err == nil && len(objects) > 0 { + return objects[0], err + } + return nil, err +} + +func (store *ElasticCasestore) getAll(ctx context.Context, query string, max int) ([]interface{}, error) { + var err error + var objects []interface{} + + if err = store.server.CheckAuthorized(ctx, "read", "cases"); err == nil { + criteria := model.NewEventSearchCriteria() + format := "2006-01-02 3:04:05 PM" + var zeroTime time.Time + zeroTimeStr := zeroTime.Format(format) + now := time.Now() + endTime := now.Format(format) + zone := now.Location().String() + err = criteria.Populate(query, + zeroTimeStr+" - "+endTime, // timeframe range + format, // timeframe format + zone, // timezone + "0", // no metrics + strconv.Itoa(max)) + + if err == nil { + var results *model.EventSearchResults + results, err = store.server.Eventstore.Search(ctx, criteria) + if err == nil { + for _, event := range results.Events { + var obj interface{} + obj, err = convertElasticEventToObject(event) + if err == nil { + objects = append(objects, obj) + } else { + log.WithField("event", event).WithError(err).Error("Unable to convert case object") + } + } + } + } + } + + return objects, err +} + +func (store *ElasticCasestore) Create(ctx context.Context, socCase *model.Case) (*model.Case, error) { + var err error + + if socCase.Id != "" { + err = errors.New("Unexpected ID found in new case") + } else { + socCase.Status = model.CASE_STATUS_NEW + now := time.Now() + socCase.CreateTime = &now + var results *model.EventIndexResults + results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + socCase, err = store.GetCase(ctx, results.DocumentId) + } + } + return socCase, err +} + +func (store *ElasticCasestore) Update(ctx context.Context, socCase *model.Case) (*model.Case, error) { + var err error + + if socCase.Id == "" { + err = errors.New("Missing case ID") + } else { + var oldCase *model.Case + oldCase, err = store.GetCase(ctx, socCase.Id) + if err == nil { + // Preserve read-only fields + socCase.CreateTime = oldCase.CreateTime + socCase.CompleteTime = oldCase.CompleteTime + socCase.StartTime = oldCase.StartTime + var results *model.EventIndexResults + results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + socCase, err = store.GetCase(ctx, results.DocumentId) + } + } + } + return socCase, err +} + +func (store *ElasticCasestore) GetCase(ctx context.Context, id string) (*model.Case, error) { + obj, err := store.get(ctx, id, "case") + if err == nil { + return obj.(*model.Case), nil + } + return nil, err +} + +func (store *ElasticCasestore) GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) { + query := fmt.Sprintf(`_index:"%s" AND (%s:"%s" OR comment.caseId:"%s")`, store.auditIndex, AUDIT_DOC_ID, caseId, caseId) + return store.getAll(ctx, query, store.maxAssociations) +} + +func (store *ElasticCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + var err error + + if comment.Id != "" { + return nil, errors.New("Unexpected ID found in new comment") + } else if comment.CaseId == "" { + return nil, errors.New("Missing Case ID in new comment") + } else { + now := time.Now() + comment.CreateTime = &now + var results *model.EventIndexResults + results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + comment, err = store.GetComment(ctx, results.DocumentId) + } + } + return comment, err +} + +func (store *ElasticCasestore) GetComment(ctx context.Context, id string) (*model.Comment, error) { + obj, err := store.get(ctx, id, "comment") + if err == nil { + return obj.(*model.Comment), nil + } + return nil, err +} + +func (store *ElasticCasestore) GetComments(ctx context.Context, caseId string) ([]*model.Comment, error) { + comments := make([]*model.Comment, 0) + query := fmt.Sprintf(`_index:"%s" AND kind:"comment" AND comment.caseId:"%s" | sortby comment.createTime^`, store.index, caseId) + objects, err := store.getAll(ctx, query, store.maxAssociations) + if err == nil { + for _, obj := range objects { + comments = append(comments, obj.(*model.Comment)) + } + } + return comments, err +} + +func (store *ElasticCasestore) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + var err error + + if comment.Id == "" { + err = errors.New("Missing comment ID") + } else { + var old *model.Comment + old, err = store.GetComment(ctx, comment.Id) + if err == nil { + // Preserve read-only fields + comment.CreateTime = old.CreateTime + var results *model.EventIndexResults + results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + comment, err = store.GetComment(ctx, results.DocumentId) + } + } + } + return comment, err +} + +func (store *ElasticCasestore) DeleteComment(ctx context.Context, id string) error { + var err error + + var comment *model.Comment + comment, err = store.GetComment(ctx, id) + if err == nil { + err = store.delete(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + } + + return err +} diff --git a/server/modules/elastic/elasticcasestore_test.go b/server/modules/elastic/elasticcasestore_test.go new file mode 100644 index 00000000..9ef57ea9 --- /dev/null +++ b/server/modules/elastic/elasticcasestore_test.go @@ -0,0 +1,300 @@ +// Copyright 2019 Jason Ertel (jertel). All rights reserved. +// Copyright 2020-2021 Security Onion Solutions, LLC. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +package elastic + +import ( + "context" + "github.com/security-onion-solutions/securityonion-soc/model" + "github.com/security-onion-solutions/securityonion-soc/server" + "github.com/security-onion-solutions/securityonion-soc/web" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestInit(tester *testing.T) { + store := NewElasticCasestore(nil) + store.Init("myIndex", "myAuditIndex", 45) + assert.Equal(tester, "myIndex", store.index) + assert.Equal(tester, "myAuditIndex", store.auditIndex) + assert.Equal(tester, 45, store.maxAssociations) +} + +func TestPrepareForSave(tester *testing.T) { + store := NewElasticCasestore(nil) + obj := &model.Auditable{ + Id: "myId", + } + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + actualId := store.prepareForSave(ctx, obj) + assert.Equal(tester, "myId", actualId) + assert.Equal(tester, "myRequestorId", obj.UserId) + assert.Equal(tester, "", obj.Id) + assert.Nil(tester, obj.UpdateTime) +} + +func TestSaveCreate(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + obj := model.NewCase() + results, err := store.save(ctx, obj, "case", "") + assert.NoError(tester, err) + assert.Equal(tester, 2, len(fakeEventStore.InputIds)) + assert.Equal(tester, "", fakeEventStore.InputIds[0]) + assert.Equal(tester, "", fakeEventStore.InputIds[1]) + assert.Equal(tester, "myIndex", fakeEventStore.InputIndexes[0]) + assert.Equal(tester, "myAuditIndex", fakeEventStore.InputIndexes[1]) + assert.Equal(tester, 2, len(fakeEventStore.InputDocuments)) + assert.Equal(tester, "case", fakeEventStore.InputDocuments[0]["kind"]) + assert.Equal(tester, "create", fakeEventStore.InputDocuments[1]["operation"]) + assert.NotNil(tester, results) +} + +func TestSaveUpdate(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + obj := model.NewCase() + results, err := store.save(ctx, obj, "case", "myCaseId") + assert.NoError(tester, err) + assert.Equal(tester, 2, len(fakeEventStore.InputIds)) + assert.Equal(tester, "myCaseId", fakeEventStore.InputIds[0]) + assert.Equal(tester, "", fakeEventStore.InputIds[1]) + assert.Equal(tester, "myIndex", fakeEventStore.InputIndexes[0]) + assert.Equal(tester, "myAuditIndex", fakeEventStore.InputIndexes[1]) + assert.Equal(tester, 2, len(fakeEventStore.InputDocuments)) + assert.Equal(tester, "case", fakeEventStore.InputDocuments[0]["kind"]) + assert.Equal(tester, "update", fakeEventStore.InputDocuments[1]["operation"]) + assert.NotNil(tester, results) +} + +func TestDelete(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + obj := model.NewCase() + err := store.delete(ctx, obj, "case", "myCaseId") + assert.NoError(tester, err) + assert.Equal(tester, 2, len(fakeEventStore.InputIds)) + assert.Equal(tester, "myCaseId", fakeEventStore.InputIds[0]) + assert.Equal(tester, "", fakeEventStore.InputIds[1]) + assert.Equal(tester, "myIndex", fakeEventStore.InputIndexes[0]) + assert.Equal(tester, "myAuditIndex", fakeEventStore.InputIndexes[1]) + assert.Equal(tester, 1, len(fakeEventStore.InputDocuments)) + assert.Equal(tester, "case", fakeEventStore.InputDocuments[0]["kind"]) + assert.Equal(tester, "delete", fakeEventStore.InputDocuments[0]["operation"]) +} + +func TestGetAll(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := "some query" + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + } + + commentPayload := make(map[string]interface{}) + commentPayload["kind"] = "comment" + commentEvent := &model.EventRecord{ + Payload: commentPayload, + } + + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + results, err := store.getAll(ctx, query, 123) + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.Len(tester, results, 2) +} + +func TestGet(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"case" AND _id:"myCaseId"` + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + obj, err := store.get(ctx, "myCaseId", "case") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestCreateError(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + myCase := model.NewCase() + myCase.Id = "123" + newCase, err := store.Create(ctx, myCase) + assert.Error(tester, err) + assert.Equal(tester, "Unexpected ID found in new case", err.Error()) + assert.NotNil(tester, newCase) +} + +func TestUpdateError(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + myCase := model.NewCase() + myCase.Id = "" + newCase, err := store.Update(ctx, myCase) + assert.Error(tester, err) + assert.Equal(tester, "Missing case ID", err.Error()) + assert.NotNil(tester, newCase) +} + +func TestGetCase(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"case" AND _id:"myCaseId"` + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + obj, err := store.GetCase(ctx, "myCaseId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestGetCaseHistory(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myAuditIndex" AND (so_audit_doc_id:"myCaseId" OR comment.caseId:"myCaseId")` + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + results, err := store.GetCaseHistory(ctx, "myCaseId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.Len(tester, results, 1) +} + +func TestCreateCommentUnexpectedId(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + comment := model.NewComment() + comment.Id = "123" + _, err := store.CreateComment(ctx, comment) + assert.Error(tester, err) + assert.Equal(tester, "Unexpected ID found in new comment", err.Error()) +} + +func TestCreateCommentMissingCaseId(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + comment := model.NewComment() + _, err := store.CreateComment(ctx, comment) + assert.Error(tester, err) + assert.Equal(tester, "Missing Case ID in new comment", err.Error()) +} + +func TestGetComment(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"comment" AND _id:"myCommentId"` + commentPayload := make(map[string]interface{}) + commentPayload["kind"] = "comment" + commentEvent := &model.EventRecord{ + Payload: commentPayload, + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + obj, err := store.GetComment(ctx, "myCommentId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestGetComments(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"comment" AND comment.caseId:"myCaseId" | sortby comment.createTime^` + commentPayload := make(map[string]interface{}) + commentPayload["kind"] = "comment" + commentEvent := &model.EventRecord{ + Payload: commentPayload, + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + obj, err := store.GetComments(ctx, "myCaseId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestUpdateComment(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + comment := model.NewComment() + _, err := store.UpdateComment(ctx, comment) + assert.Error(tester, err) + assert.Equal(tester, "Missing comment ID", err.Error()) +} + +func TestDeleteComment(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"comment" AND _id:"myCommentId"` + commentPayload := make(map[string]interface{}) + commentPayload["kind"] = "comment" + commentEvent := &model.EventRecord{ + Payload: commentPayload, + Id: "myCommentId", + } + fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + err := store.DeleteComment(ctx, "myCommentId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) // Search to ensure it exists first + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.Len(tester, fakeEventStore.InputIds, 2) // Delete and Index (for audit) + assert.Equal(tester, "myCommentId", fakeEventStore.InputIds[0]) + assert.Equal(tester, "", fakeEventStore.InputIds[1]) +} diff --git a/server/modules/elastic/elasticeventstore.go b/server/modules/elastic/elasticeventstore.go index a2f16814..6d5e523a 100644 --- a/server/modules/elastic/elasticeventstore.go +++ b/server/modules/elastic/elasticeventstore.go @@ -242,6 +242,51 @@ func (store *ElasticEventstore) Update(ctx context.Context, criteria *model.Even return results, err } +func (store *ElasticEventstore) Index(ctx context.Context, index string, document map[string]interface{}, id string) (*model.EventIndexResults, error) { + var err error + results := model.NewEventIndexResults() + if err = store.server.CheckAuthorized(ctx, "write", "events"); err == nil { + store.refreshCache(ctx) + + var request string + request, err = convertToElasticIndexRequest(store, document) + if err == nil { + var response string + + log.Debug("Sending index request to primary Elasticsearch client") + response, err = store.indexDocument(ctx, index, request, id) + if err == nil { + err = convertFromElasticIndexResults(store, response, results) + if err != nil { + log.WithError(err).Error("Encountered error while converting document index results") + } + } else { + log.WithError(err).Error("Encountered error while indexing document into elasticsearch") + } + } + } + return results, err +} + +func (store *ElasticEventstore) Delete(ctx context.Context, index string, id string) error { + var err error + results := model.NewEventIndexResults() + if err = store.server.CheckAuthorized(ctx, "write", "events"); err == nil { + var response string + log.Debug("Sending delete request to primary Elasticsearch client") + response, err = store.deleteDocument(ctx, index, id) + if err == nil { + err = convertFromElasticIndexResults(store, response, results) + if err != nil { + log.WithError(err).Error("Encountered error while converting document index results") + } + } else { + log.WithError(err).Error("Encountered error while deleting document from elasticsearch") + } + } + return err +} + func (store *ElasticEventstore) luceneSearch(ctx context.Context, query string) (string, error) { return store.indexSearch(ctx, query, strings.Split(store.index, ",")) } @@ -298,16 +343,21 @@ func (store *ElasticEventstore) indexSearch(ctx context.Context, query string, i return json, err } -func (store *ElasticEventstore) indexDocument(ctx context.Context, document string, index string) (string, error) { +func (store *ElasticEventstore) indexDocument(ctx context.Context, index string, document string, id string) (string, error) { log.WithFields(log.Fields{ + "index": index, + "id": id, "document": document, "requestId": ctx.Value(web.ContextKeyRequestId), }).Debug("Adding document to Elasticsearch") - res, err := store.esClient.Index(store.transformIndex(index), strings.NewReader(document), store.esClient.Index.WithRefresh("true")) + res, err := store.esClient.Index(store.transformIndex(index), + strings.NewReader(document), + store.esClient.Index.WithRefresh("true"), + store.esClient.Index.WithDocumentID(id)) if err != nil { - log.WithError(err).Error("Unable to index acknowledgement into Elasticsearch") + log.WithError(err).Error("Unable to index document into Elasticsearch") return "", err } defer res.Body.Close() @@ -320,6 +370,35 @@ func (store *ElasticEventstore) indexDocument(ctx context.Context, document stri return json, err } +func (store *ElasticEventstore) deleteDocument(ctx context.Context, index string, id string) (string, error) { + log.WithFields(log.Fields{ + "index": index, + "id": id, + "requestId": ctx.Value(web.ContextKeyRequestId), + }).Debug("Deleting document from Elasticsearch") + + res, err := store.esClient.Delete(store.transformIndex(index), id) + + if err != nil { + log.WithFields(log.Fields{ + "index": index, + "id": id, + "requestId": ctx.Value(web.ContextKeyRequestId), + }).WithError(err).Error("Unable to delete document from Elasticsearch") + return "", err + } + defer res.Body.Close() + json, err := store.readJsonFromResponse(res) + + log.WithFields(log.Fields{ + "index": index, + "id": id, + "response": json, + "requestId": ctx.Value(web.ContextKeyRequestId), + }).Debug("Delete document finished") + return json, err +} + func (store *ElasticEventstore) updateDocuments(ctx context.Context, client *elasticsearch.Client, query string, indexes []string, waitForCompletion bool) (string, error) { log.WithFields(log.Fields{ "query": query, diff --git a/server/modules/elasticcases/elasticcaseconverter.go b/server/modules/elasticcases/elasticcaseconverter.go index 2a1d6dae..ee373fc1 100644 --- a/server/modules/elasticcases/elasticcaseconverter.go +++ b/server/modules/elasticcases/elasticcaseconverter.go @@ -39,13 +39,13 @@ func convertFromElasticCase(inputCase *ElasticCase) (*model.Case, error) { outputCase.Id = inputCase.Id outputCase.Status = inputCase.Status if inputCase.CreatedDate != nil { - outputCase.CreateTime = *inputCase.CreatedDate + outputCase.CreateTime = inputCase.CreatedDate } if inputCase.ModifiedDate != nil { - outputCase.StartTime = *inputCase.ModifiedDate + outputCase.UpdateTime = inputCase.ModifiedDate } if inputCase.ClosedDate != nil { - outputCase.CompleteTime = *inputCase.ClosedDate + outputCase.CompleteTime = inputCase.ClosedDate } return outputCase, nil } diff --git a/server/modules/elasticcases/elasticcaseconverter_test.go b/server/modules/elasticcases/elasticcaseconverter_test.go index 48c3645c..1ad86138 100644 --- a/server/modules/elasticcases/elasticcaseconverter_test.go +++ b/server/modules/elasticcases/elasticcaseconverter_test.go @@ -19,8 +19,6 @@ import ( ) func TestConvertFromElasticCase(tester *testing.T) { - var emptyTime time.Time - elasticCase := NewElasticCase() elasticCase.Title = "my title" elasticCase.Description = "my description.\nline 2.\n" @@ -35,9 +33,9 @@ func TestConvertFromElasticCase(tester *testing.T) { assert.Equal(tester, elasticCase.Title, socCase.Title) assert.Equal(tester, elasticCase.Description, socCase.Description) assert.Equal(tester, elasticCase.Id, socCase.Id) - assert.Equal(tester, tm, socCase.CreateTime) - assert.Equal(tester, emptyTime, socCase.StartTime) - assert.Equal(tester, emptyTime, socCase.CompleteTime) + assert.Equal(tester, &tm, socCase.CreateTime) + assert.Nil(tester, socCase.StartTime) + assert.Nil(tester, socCase.CompleteTime) } func TestConvertToElasticCase(tester *testing.T) { diff --git a/server/modules/elasticcases/elasticcasestore.go b/server/modules/elasticcases/elasticcasestore.go index be8015c1..ee5c2e40 100644 --- a/server/modules/elasticcases/elasticcasestore.go +++ b/server/modules/elasticcases/elasticcasestore.go @@ -12,6 +12,7 @@ package elasticcases import ( "context" "encoding/base64" + "errors" "github.com/security-onion-solutions/securityonion-soc/model" "github.com/security-onion-solutions/securityonion-soc/server" "github.com/security-onion-solutions/securityonion-soc/web" @@ -65,3 +66,35 @@ func (store *ElasticCasestore) Create(ctx context.Context, socCase *model.Case) } return newCase, err } + +func (store *ElasticCasestore) Update(ctx context.Context, socCase *model.Case) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetCase(ctx context.Context, caseId string) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetComment(ctx context.Context, commentId string) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetComments(ctx context.Context, commentId string) ([]*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) DeleteComment(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} diff --git a/server/modules/generichttp/generichttpreader_test.go b/server/modules/generichttp/generichttpreader_test.go index 4a0d8526..679c71a2 100644 --- a/server/modules/generichttp/generichttpreader_test.go +++ b/server/modules/generichttp/generichttpreader_test.go @@ -10,16 +10,18 @@ package generichttp import ( - "io" - "testing" - "github.com/security-onion-solutions/securityonion-soc/model" "github.com/stretchr/testify/assert" + "io" + "testing" + "time" ) func TestConvertCaseToReader(tester *testing.T) { socCase := model.NewCase() socCase.Id = "123" + tm, _ := time.Parse("2006-01-03 13:04 PM", "2006-01-03 13:04 PM") + socCase.CreateTime = &tm socCase.Title = "MyTitle" socCase.Description = "My \"Description\" is this." socCase.Severity = 44 diff --git a/server/modules/generichttp/httpcasestore.go b/server/modules/generichttp/httpcasestore.go index 3cc30d1f..357dc74a 100644 --- a/server/modules/generichttp/httpcasestore.go +++ b/server/modules/generichttp/httpcasestore.go @@ -71,3 +71,35 @@ func (store *HttpCasestore) Create(ctx context.Context, socCase *model.Case) (*m } return nil, err } + +func (store *HttpCasestore) Update(ctx context.Context, socCase *model.Case) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetCase(ctx context.Context, caseId string) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetComment(ctx context.Context, commentId string) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetComments(ctx context.Context, commentId string) ([]*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) DeleteComment(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} diff --git a/server/modules/thehive/thehivecasestore.go b/server/modules/thehive/thehivecasestore.go index 68254fb5..d19a25f7 100644 --- a/server/modules/thehive/thehivecasestore.go +++ b/server/modules/thehive/thehivecasestore.go @@ -12,6 +12,7 @@ package thehive import ( "context" + "errors" "github.com/security-onion-solutions/securityonion-soc/model" "github.com/security-onion-solutions/securityonion-soc/server" "github.com/security-onion-solutions/securityonion-soc/web" @@ -63,3 +64,35 @@ func (store *TheHiveCasestore) Create(ctx context.Context, socCase *model.Case) } return newCase, err } + +func (store *TheHiveCasestore) Update(ctx context.Context, socCase *model.Case) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetCase(ctx context.Context, caseId string) (*model.Case, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetComment(ctx context.Context, commentId string) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetComments(ctx context.Context, commentId string) ([]*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) DeleteComment(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} diff --git a/server/modules/thehive/thehiveconverter.go b/server/modules/thehive/thehiveconverter.go index 6226559e..15ffc9e6 100644 --- a/server/modules/thehive/thehiveconverter.go +++ b/server/modules/thehive/thehiveconverter.go @@ -11,9 +11,9 @@ package thehive import ( + "github.com/security-onion-solutions/securityonion-soc/model" "strconv" "time" - "github.com/security-onion-solutions/securityonion-soc/model" ) func convertToTheHiveCase(inputCase *model.Case) (*TheHiveCase, error) { @@ -39,8 +39,11 @@ func convertFromTheHiveCase(inputCase *TheHiveCase) (*model.Case, error) { outputCase.Description = inputCase.Description outputCase.Id = strconv.Itoa(inputCase.Id) outputCase.Status = inputCase.Status - outputCase.CreateTime = time.Unix(inputCase.CreateDate / 1000, 0) - outputCase.StartTime = time.Unix(inputCase.StartDate / 1000, 0) - outputCase.CompleteTime = time.Unix(inputCase.EndDate / 1000, 0) + createTime := time.Unix(inputCase.CreateDate/1000, 0) + outputCase.CreateTime = &createTime + startTime := time.Unix(inputCase.StartDate/1000, 0) + outputCase.StartTime = &startTime + completeTime := time.Unix(inputCase.EndDate/1000, 0) + outputCase.CompleteTime = &completeTime return outputCase, nil -} \ No newline at end of file +} diff --git a/server/queryhandler.go b/server/queryhandler.go index 5bed7ec6..9724769d 100644 --- a/server/queryhandler.go +++ b/server/queryhandler.go @@ -83,6 +83,9 @@ func (queryHandler *QueryHandler) get(ctx context.Context, writer http.ResponseW case "grouped": field := request.Form.Get("field") alteredQuery, err = query.Group(field) + case "sorted": + field := request.Form.Get("field") + alteredQuery, err = query.Sort(field) default: return http.StatusBadRequest, nil, errors.New("Unsupported query operation") } diff --git a/server/server.go b/server/server.go index 5d99e951..2893637d 100644 --- a/server/server.go +++ b/server/server.go @@ -64,7 +64,7 @@ func (server *Server) Start() { } else { log.Info("Starting server") - server.Host.Register("/api/case", NewCaseHandler(server)) + server.Host.Register("/api/case/", NewCaseHandler(server)) server.Host.Register("/api/events/", NewEventHandler(server)) server.Host.Register("/api/info", NewInfoHandler(server)) server.Host.Register("/api/job/", NewJobHandler(server)) From 411c9e32b592cbe5264139cc41363ee03f3700c3 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 30 Nov 2021 09:03:00 -0500 Subject: [PATCH 02/98] Add case perms and roles to RBAC --- rbac/permissions | 2 ++ rbac/roles | 1 + 2 files changed, 3 insertions(+) diff --git a/rbac/permissions b/rbac/permissions index 6c9817a6..b32a576a 100644 --- a/rbac/permissions +++ b/rbac/permissions @@ -2,6 +2,7 @@ # Syntax => permX: roleY roleZ # Explanation => roleY and roleZ are granted permission permX +cases/read: case-monitor cases/write: case-admin events/read: event-monitor events/write: event-admin @@ -24,6 +25,7 @@ users/delete: user-admin # Syntax => roleB: roleA # Explanation => roleA inherits all of roleA's permissions +case-monitor: case-admin event-monitor: event-admin job-monitor: job-admin job-user: job-admin diff --git a/rbac/roles b/rbac/roles index 70deae11..1b6744c9 100644 --- a/rbac/roles +++ b/rbac/roles @@ -2,6 +2,7 @@ # Syntax => roleX: roleY roleZ # Explanation => roleY and roleZ are granted permissions of roleX +case-monitor: auditor limited-auditor case-admin: analyst limited-analyst superuser event-admin: analyst limited-analyst superuser event-monitor: auditor limited-auditor From 7a3a78707e7bdf209f44daa0ea130f6a68dfc3fe Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Thu, 2 Dec 2021 19:50:05 -0500 Subject: [PATCH 03/98] Add input validation --- server/modules/elastic/elasticcasestore.go | 285 ++++++++++++++---- .../modules/elastic/elasticcasestore_test.go | 264 +++++++++++++++- 2 files changed, 483 insertions(+), 66 deletions(-) diff --git a/server/modules/elastic/elasticcasestore.go b/server/modules/elastic/elasticcasestore.go index 08135f49..f0732f00 100644 --- a/server/modules/elastic/elasticcasestore.go +++ b/server/modules/elastic/elasticcasestore.go @@ -17,11 +17,15 @@ import ( "github.com/security-onion-solutions/securityonion-soc/model" "github.com/security-onion-solutions/securityonion-soc/server" "github.com/security-onion-solutions/securityonion-soc/web" + "regexp" "strconv" "time" ) const AUDIT_DOC_ID = "so_audit_doc_id" +const SHORT_STRING_MAX = 100 +const LONG_STRING_MAX = 1000000 +const MAX_ARRAY_ELEMENTS = 50 type ElasticCasestore struct { server *server.Server @@ -43,6 +47,116 @@ func (store *ElasticCasestore) Init(index string, auditIndex string, maxAssociat return nil } +func (store *ElasticCasestore) validateId(id string, label string) error { + var err error + + isValidId := regexp.MustCompile(`^[A-Za-z0-9-_]{5,50}$`).MatchString + if !isValidId(id) { + err = errors.New(fmt.Sprintf("invalid ID for %s", label)) + } + return err +} + +func (store *ElasticCasestore) validateString(str string, max int, label string) error { + var err error + length := len(str) + if length > max { + err = errors.New(fmt.Sprintf("%s is too long (%d/%d)", label, length, max)) + } + return err +} + +func (store *ElasticCasestore) validateStringArray(array []string, maxLen int, maxElements int, label string) error { + var err error + length := len(array) + if length > maxElements { + err = errors.New(fmt.Sprintf("Field 'Tags' contains excessive elements (%d/%d)", length, maxElements)) + } else { + for idx, tag := range array { + err = store.validateString(tag, maxLen, fmt.Sprintf("tag[%d]", idx)) + if err != nil { + break + } + } + } + return err +} + +func (store *ElasticCasestore) validateCase(socCase *model.Case) error { + var err error + + if err == nil && socCase.Id != "" { + err = store.validateId(socCase.Id, "caseId") + } + if err == nil && socCase.UserId != "" { + err = store.validateId(socCase.UserId, "userId") + } + if err == nil && socCase.AssigneeId != "" { + err = store.validateId(socCase.AssigneeId, "assigneeId") + } + if err == nil && socCase.Priority < 0 { + err = errors.New("Invalid priority") + } + if err == nil && socCase.Severity < 0 { + err = errors.New("Invalid severity") + } + if err == nil && len(socCase.Kind) > 0 { + err = errors.New("Field 'Kind' must not be specified") + } + if err == nil && len(socCase.Operation) > 0 { + err = errors.New("Field 'Operation' must not be specified") + } + if err == nil { + err = store.validateString(socCase.Title, SHORT_STRING_MAX, "title") + } + if err == nil { + err = store.validateString(socCase.Category, SHORT_STRING_MAX, "category") + } + if err == nil { + err = store.validateString(socCase.Status, SHORT_STRING_MAX, "status") + } + if err == nil { + err = store.validateString(socCase.Template, SHORT_STRING_MAX, "template") + } + if err == nil { + err = store.validateString(socCase.Tlp, SHORT_STRING_MAX, "tlp") + } + if err == nil { + err = store.validateString(socCase.Pap, SHORT_STRING_MAX, "pap") + } + if err == nil { + err = store.validateString(socCase.Description, LONG_STRING_MAX, "description") + } + if err == nil { + err = store.validateStringArray(socCase.Tags, SHORT_STRING_MAX, MAX_ARRAY_ELEMENTS, "tags") + } + return err +} + +func (store *ElasticCasestore) validateComment(comment *model.Comment) error { + var err error + + if err == nil && comment.Id != "" { + err = store.validateId(comment.Id, "commentId") + } + if err == nil && comment.CaseId != "" { + err = store.validateId(comment.CaseId, "caseId") + } + if err == nil && comment.UserId != "" { + err = store.validateId(comment.UserId, "userId") + } + if err == nil && len(comment.Kind) > 0 { + err = errors.New("Field 'Kind' must not be specified") + } + if err == nil && len(comment.Operation) > 0 { + err = errors.New("Field 'Operation' must not be specified") + } + if err == nil { + err = store.validateString(comment.Description, LONG_STRING_MAX, "description") + } + return err +} + func (store *ElasticCasestore) prepareForSave(ctx context.Context, obj *model.Auditable) string { obj.UserId = ctx.Value(web.ContextKeyRequestorId).(string) @@ -156,17 +270,20 @@ func (store *ElasticCasestore) getAll(ctx context.Context, query string, max int func (store *ElasticCasestore) Create(ctx context.Context, socCase *model.Case) (*model.Case, error) { var err error - if socCase.Id != "" { - err = errors.New("Unexpected ID found in new case") - } else { - socCase.Status = model.CASE_STATUS_NEW - now := time.Now() - socCase.CreateTime = &now - var results *model.EventIndexResults - results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) - if err == nil { - // Read object back to get new modify date, etc - socCase, err = store.GetCase(ctx, results.DocumentId) + err = store.validateCase(socCase) + if err == nil { + if socCase.Id != "" { + err = errors.New("Unexpected ID found in new case") + } else { + socCase.Status = model.CASE_STATUS_NEW + now := time.Now() + socCase.CreateTime = &now + var results *model.EventIndexResults + results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + socCase, err = store.GetCase(ctx, results.DocumentId) + } } } return socCase, err @@ -175,21 +292,24 @@ func (store *ElasticCasestore) Create(ctx context.Context, socCase *model.Case) func (store *ElasticCasestore) Update(ctx context.Context, socCase *model.Case) (*model.Case, error) { var err error - if socCase.Id == "" { - err = errors.New("Missing case ID") - } else { - var oldCase *model.Case - oldCase, err = store.GetCase(ctx, socCase.Id) - if err == nil { - // Preserve read-only fields - socCase.CreateTime = oldCase.CreateTime - socCase.CompleteTime = oldCase.CompleteTime - socCase.StartTime = oldCase.StartTime - var results *model.EventIndexResults - results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) + err = store.validateCase(socCase) + if err == nil { + if socCase.Id == "" { + err = errors.New("Missing case ID") + } else { + var oldCase *model.Case + oldCase, err = store.GetCase(ctx, socCase.Id) if err == nil { - // Read object back to get new modify date, etc - socCase, err = store.GetCase(ctx, results.DocumentId) + // Preserve read-only fields + socCase.CreateTime = oldCase.CreateTime + socCase.CompleteTime = oldCase.CompleteTime + socCase.StartTime = oldCase.StartTime + var results *model.EventIndexResults + results, err = store.save(ctx, socCase, "case", store.prepareForSave(ctx, &socCase.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + socCase, err = store.GetCase(ctx, results.DocumentId) + } } } } @@ -197,53 +317,84 @@ func (store *ElasticCasestore) Update(ctx context.Context, socCase *model.Case) } func (store *ElasticCasestore) GetCase(ctx context.Context, id string) (*model.Case, error) { - obj, err := store.get(ctx, id, "case") + var err error + var socCase *model.Case + + err = store.validateId(id, "caseId") if err == nil { - return obj.(*model.Case), nil + var obj interface{} + obj, err = store.get(ctx, id, "case") + if err == nil { + socCase = obj.(*model.Case) + } } - return nil, err + return socCase, err } func (store *ElasticCasestore) GetCaseHistory(ctx context.Context, caseId string) ([]interface{}, error) { - query := fmt.Sprintf(`_index:"%s" AND (%s:"%s" OR comment.caseId:"%s")`, store.auditIndex, AUDIT_DOC_ID, caseId, caseId) - return store.getAll(ctx, query, store.maxAssociations) + var err error + var history []interface{} + + err = store.validateId(caseId, "caseId") + if err == nil { + query := fmt.Sprintf(`_index:"%s" AND (%s:"%s" OR comment.caseId:"%s")`, store.auditIndex, AUDIT_DOC_ID, caseId, caseId) + history, err = store.getAll(ctx, query, store.maxAssociations) + } + return history, err } func (store *ElasticCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { var err error - if comment.Id != "" { - return nil, errors.New("Unexpected ID found in new comment") - } else if comment.CaseId == "" { - return nil, errors.New("Missing Case ID in new comment") - } else { - now := time.Now() - comment.CreateTime = &now - var results *model.EventIndexResults - results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) - if err == nil { - // Read object back to get new modify date, etc - comment, err = store.GetComment(ctx, results.DocumentId) + err = store.validateComment(comment) + if err == nil { + if comment.Id != "" { + return nil, errors.New("Unexpected ID found in new comment") + } else if comment.CaseId == "" { + return nil, errors.New("Missing Case ID in new comment") + } else { + now := time.Now() + comment.CreateTime = &now + var results *model.EventIndexResults + results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + comment, err = store.GetComment(ctx, results.DocumentId) + } } } return comment, err } func (store *ElasticCasestore) GetComment(ctx context.Context, id string) (*model.Comment, error) { - obj, err := store.get(ctx, id, "comment") + var err error + var comment *model.Comment + + err = store.validateId(id, "commentId") if err == nil { - return obj.(*model.Comment), nil + var obj interface{} + obj, err = store.get(ctx, id, "comment") + if err == nil { + comment = obj.(*model.Comment) + } } - return nil, err + return comment, err } func (store *ElasticCasestore) GetComments(ctx context.Context, caseId string) ([]*model.Comment, error) { - comments := make([]*model.Comment, 0) - query := fmt.Sprintf(`_index:"%s" AND kind:"comment" AND comment.caseId:"%s" | sortby comment.createTime^`, store.index, caseId) - objects, err := store.getAll(ctx, query, store.maxAssociations) + var err error + var comments []*model.Comment + + err = store.validateId(caseId, "caseId") if err == nil { - for _, obj := range objects { - comments = append(comments, obj.(*model.Comment)) + comments = make([]*model.Comment, 0) + query := fmt.Sprintf(`_index:"%s" AND kind:"comment" AND comment.caseId:"%s" | sortby comment.createTime^`, store.index, caseId) + var objects []interface{} + objects, err = store.getAll(ctx, query, store.maxAssociations) + if err == nil { + for _, obj := range objects { + comments = append(comments, obj.(*model.Comment)) + } } } return comments, err @@ -252,19 +403,22 @@ func (store *ElasticCasestore) GetComments(ctx context.Context, caseId string) ( func (store *ElasticCasestore) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { var err error - if comment.Id == "" { - err = errors.New("Missing comment ID") - } else { - var old *model.Comment - old, err = store.GetComment(ctx, comment.Id) - if err == nil { - // Preserve read-only fields - comment.CreateTime = old.CreateTime - var results *model.EventIndexResults - results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + err = store.validateComment(comment) + if err == nil { + if comment.Id == "" { + err = errors.New("Missing comment ID") + } else { + var old *model.Comment + old, err = store.GetComment(ctx, comment.Id) if err == nil { - // Read object back to get new modify date, etc - comment, err = store.GetComment(ctx, results.DocumentId) + // Preserve read-only fields + comment.CreateTime = old.CreateTime + var results *model.EventIndexResults + results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + comment, err = store.GetComment(ctx, results.DocumentId) + } } } } @@ -275,9 +429,12 @@ func (store *ElasticCasestore) DeleteComment(ctx context.Context, id string) err var err error var comment *model.Comment - comment, err = store.GetComment(ctx, id) + err = store.validateId(id, "id") if err == nil { - err = store.delete(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + comment, err = store.GetComment(ctx, id) + if err == nil { + err = store.delete(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + } } return err diff --git a/server/modules/elastic/elasticcasestore_test.go b/server/modules/elastic/elasticcasestore_test.go index 9ef57ea9..16a23c68 100644 --- a/server/modules/elastic/elasticcasestore_test.go +++ b/server/modules/elastic/elasticcasestore_test.go @@ -40,6 +40,266 @@ func TestPrepareForSave(tester *testing.T) { assert.Nil(tester, obj.UpdateTime) } +func TestValidateIdInvalid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + err = store.validateId("", "test") + assert.Error(tester, err) + + err = store.validateId("1", "test") + assert.Error(tester, err) + + err = store.validateId("a", "test") + assert.Error(tester, err) + + err = store.validateId("this is invalid since it has spaces", "test") + assert.Error(tester, err) + + err = store.validateId("'quotes'", "test") + assert.Error(tester, err) + + err = store.validateId("\"dblquotes\"", "test") + assert.Error(tester, err) + + err = store.validateId("123456789012345678901234567890123456789012345678901", "test") + assert.Error(tester, err) +} + +func TestValidateIdValid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + err = store.validateId("12345", "test") + assert.NoError(tester, err) + + err = store.validateId("123456", "test") + assert.NoError(tester, err) + + err = store.validateId("1-2-A-b", "test") + assert.NoError(tester, err) + + err = store.validateId("1-2-a-b_2klj", "test") + assert.NoError(tester, err) + + err = store.validateId("12345678901234567890123456789012345678901234567890", "test") + assert.NoError(tester, err) +} + +func TestValidateStringInvalid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + err = store.validateString("1234567", 6, "test") + assert.Error(tester, err) + + err = store.validateString("12345678", 6, "test") + assert.Error(tester, err) +} + +func TestValidateStringValid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + err = store.validateString("12345", 6, "test") + assert.NoError(tester, err) + + err = store.validateString("123456", 6, "test") + assert.NoError(tester, err) + + err = store.validateString("", 6, "test") + assert.NoError(tester, err) +} + +func TestValidateCaseInvalid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + socCase := model.NewCase() + + socCase.Id = "this is an invalid id" + err = store.validateCase(socCase) + assert.EqualError(tester, err, "invalid ID for caseId") + socCase.Id = "" + + socCase.UserId = "this is an invalid id" + err = store.validateCase(socCase) + assert.EqualError(tester, err, "invalid ID for userId") + socCase.UserId = "" + + socCase.AssigneeId = "this is an invalid id" + err = store.validateCase(socCase) + assert.EqualError(tester, err, "invalid ID for assigneeId") + socCase.AssigneeId = "" + + for x := 1; x < 5; x++ { + socCase.Title += "this is my unreasonably long title\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "title is too long (140/100)") + socCase.Title = "myTitle" + + for x := 1; x < 30000; x++ { + socCase.Description += "this is my unreasonably long description\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "description is too long (1229959/1000000)") + socCase.Description = "myDescription" + + socCase.Priority = -12 + err = store.validateCase(socCase) + assert.EqualError(tester, err, "Invalid priority") + socCase.Priority = 12 + + socCase.Severity = -12 + err = store.validateCase(socCase) + assert.EqualError(tester, err, "Invalid severity") + socCase.Severity = 12 + + for x := 1; x < 5; x++ { + socCase.Tlp += "this is my unreasonably long tlp\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "tlp is too long (132/100)") + socCase.Tlp = "myTlp" + + for x := 1; x < 5; x++ { + socCase.Pap += "this is my unreasonably long pap\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "pap is too long (132/100)") + socCase.Pap = "myPap" + + for x := 1; x < 5; x++ { + socCase.Category += "this is my unreasonably long category\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "category is too long (152/100)") + socCase.Category = "myCategory" + + for x := 1; x < 5; x++ { + socCase.Template += "this is my unreasonably long template\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "template is too long (152/100)") + socCase.Template = "myTemplate" + + for x := 1; x < 5; x++ { + socCase.Status += "this is my unreasonably long status\n" + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "status is too long (144/100)") + socCase.Status = "myStatus" + + socCase.Kind = "myKind" + err = store.validateCase(socCase) + assert.EqualError(tester, err, "Field 'Kind' must not be specified") + socCase.Kind = "" + + socCase.Operation = "myOperation" + err = store.validateCase(socCase) + assert.EqualError(tester, err, "Field 'Operation' must not be specified") + socCase.Operation = "" + + tag := "" + for x := 1; x < 5; x++ { + tag += "this is my unreasonably long tag\n" + } + socCase.Tags = append(socCase.Tags, tag) + err = store.validateCase(socCase) + assert.EqualError(tester, err, "tag[0] is too long (132/100)") + socCase.Tags = nil + + for x := 1; x < 500; x++ { + socCase.Tags = append(socCase.Tags, "myTag") + } + err = store.validateCase(socCase) + assert.EqualError(tester, err, "Field 'Tags' contains excessive elements (499/50)") + socCase.Tags = nil +} + +func TestValidateCaseValid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + socCase := model.NewCase() // empty cases are valid cases + err = store.validateCase(socCase) + assert.NoError(tester, err) + + socCase.Id = "123456" + socCase.Title = "this is my reasonable long title - nothing excessive, just a normal title" + for x := 1; x < 500; x++ { + socCase.Description += "this is my reasonably long description\n" + } + socCase.Priority = 123 + socCase.Severity = 1 + socCase.Tags = append(socCase.Tags, "tag1") + socCase.Tags = append(socCase.Tags, "tag2") + socCase.Tlp = "amber" + socCase.Pap = "check" + socCase.Category = "confirmed" + socCase.Status = "in progress" + socCase.Template = "tbd" + socCase.UserId = "myUserId" + socCase.AssigneeId = "myAssigneeId" + err = store.validateCase(socCase) + assert.NoError(tester, err) +} + +func TestValidateCommentInvalid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + comment := model.NewComment() + + comment.Id = "this is an invalid id" + err = store.validateComment(comment) + assert.EqualError(tester, err, "invalid ID for commentId") + comment.Id = "" + + comment.CaseId = "this is an invalid id" + err = store.validateComment(comment) + assert.EqualError(tester, err, "invalid ID for caseId") + comment.CaseId = "" + + comment.UserId = "this is an invalid id" + err = store.validateComment(comment) + assert.EqualError(tester, err, "invalid ID for userId") + comment.UserId = "" + + for x := 1; x < 30000; x++ { + comment.Description += "this is my unreasonably long description\n" + } + err = store.validateComment(comment) + assert.EqualError(tester, err, "description is too long (1229959/1000000)") + comment.Description = "myDescription" +} + +func TestValidateCommentValid(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + + var err error + comment := model.NewComment() // empty comments are valid comments + err = store.validateComment(comment) + assert.NoError(tester, err) + + comment.Id = "123456" + comment.UserId = "myUserId" + for x := 1; x < 500; x++ { + comment.Description += "this is my reasonably long description\n" + } + err = store.validateComment(comment) + assert.NoError(tester, err) +} + func TestSaveCreate(tester *testing.T) { store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) store.Init("myIndex", "myAuditIndex", 45) @@ -153,7 +413,7 @@ func TestCreateError(tester *testing.T) { myCase.Id = "123" newCase, err := store.Create(ctx, myCase) assert.Error(tester, err) - assert.Equal(tester, "Unexpected ID found in new case", err.Error()) + assert.Equal(tester, "invalid ID for caseId", err.Error()) assert.NotNil(tester, newCase) } @@ -212,7 +472,7 @@ func TestCreateCommentUnexpectedId(tester *testing.T) { store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") comment := model.NewComment() - comment.Id = "123" + comment.Id = "123444" _, err := store.CreateComment(ctx, comment) assert.Error(tester, err) assert.Equal(tester, "Unexpected ID found in new comment", err.Error()) From b679c6e3abe7a31050d40ebc121051884c3ac638 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 6 Dec 2021 12:15:26 -0500 Subject: [PATCH 04/98] Correct tag conversion when no tags exist --- server/modules/elastic/converter.go | 2 +- server/modules/elastic/converter_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/modules/elastic/converter.go b/server/modules/elastic/converter.go index 467bfe8c..49ef065c 100644 --- a/server/modules/elastic/converter.go +++ b/server/modules/elastic/converter.go @@ -413,7 +413,7 @@ func convertElasticEventToCase(event *model.EventRecord) (*model.Case, error) { if value, ok := event.Payload["case.pap"]; ok { obj.Pap = value.(string) } - if value, ok := event.Payload["case.tags"]; ok { + if value, ok := event.Payload["case.tags"]; ok && value != nil { obj.Tags = convertToStringArray(value.([]interface{})) } obj.CreateTime = parseTime(event.Payload, "case.createTime") diff --git a/server/modules/elastic/converter_test.go b/server/modules/elastic/converter_test.go index dfbd184c..6b495f62 100644 --- a/server/modules/elastic/converter_test.go +++ b/server/modules/elastic/converter_test.go @@ -284,6 +284,17 @@ func TestConvertElasticEventToCaseNil(tester *testing.T) { assert.Nil(tester, caseObj) } +func TestConvertElasticEventToCaseWithoutTags(tester *testing.T) { + event := &model.EventRecord{} + event.Payload = make(map[string]interface{}) + event.Payload["kind"] = "case" + event.Payload["operation"] = "create" + event.Payload["case.tags"] = nil + + _, err := convertElasticEventToCase(event) + assert.NoError(tester, err) +} + func TestConvertElasticEventToCase(tester *testing.T) { myTime := time.Now() myCreateTime := myTime.Add(time.Hour * -1) From 565956410b423145504edc20773f6e57a49afb11 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 6 Dec 2021 17:17:12 -0500 Subject: [PATCH 05/98] [wip] First case ui changes --- html/css/app.css | 7 ++- html/index.html | 72 ++++++++++++++++++++-------- html/js/i18n.js | 4 +- html/js/routes/case.js | 106 ++++++++++++++++++++++++++++------------- 4 files changed, 132 insertions(+), 57 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index 3694a2c3..fd5286cf 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -178,6 +178,11 @@ a#title, a#title:visited, a#title:active, a#title:hover { white-space: wrap; } +.case.title.v-text-field input { + font-size: 1.2em; + margin-bottom: 0.15em; +} + td { white-space: nowrap; } @@ -203,4 +208,4 @@ td { #connection-indicator { color: white; -} \ No newline at end of file +} diff --git a/html/index.html b/html/index.html index e6a26b91..a87038ff 100644 --- a/html/index.html +++ b/html/index.html @@ -1172,15 +1172,48 @@

{{ i18n.viewJob }}

@@ -1247,6 +1271,10 @@

{{ i18n.viewCase }}

fa-comments {{ i18n.comments }} + + fa-link + {{ i18n.events }} + fa-history {{ i18n.history }} @@ -1292,6 +1320,30 @@

{{ i18n.viewCase }}

+ + +
+ {{ i18n.dateCreated }}: + {{ event.createTime | formatDateTime }} +
+
+ {{ i18n.author }}: + {{ event.owner }} +
+
+ authorId: + {{ event.userId }} +
+
+ {{ key }}: + {{ value }} +
+
+ fa-trash +
+ + +
diff --git a/html/js/i18n.js b/html/js/i18n.js index 63851138..32ff7235 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -147,9 +147,12 @@ const i18n = { error: 'Error', escalate: 'Escalate', escalationInvalid: 'Invalid alert - cannot escalate to a case due to insufficient alert information', - escalateHelp: 'Escalate alert to a case', + escalateExistingCase: 'Attach event to a recently viewed case:', + escalateExistingCaseHelp: 'Attach event to this existing case', + escalateNewCase: 'Escalate to new case', + escalateNewCaseHelp: 'Escalate this event to a new case (a new case will be created)', escalated: 'Escalated', - escalatedEventTip: 'Escalated event(s) to a new case.', + escalatedEventTip: 'Escalated event(s).', escalatedMultipleTip: 'Escalating groups of alerts may take a while and will continue in the background.', escalatedSingleTip: 'Escalated alert and removed from view.', event: 'Event', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index c319837e..832b7e25 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -69,6 +69,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { valid: false } }, + mruCaseLimit: 5, + mruCases: [], rules: { required: value => (!!value) || this.$root.i18n.required, }, @@ -77,7 +79,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { }, mounted() { this.loadData(); - this.$root.loadParameters('case', this.initActions); + this.$root.loadParameters('cases', this.initCase); }, destroyed() { this.$root.unsubscribe("case", this.updateCase); @@ -86,8 +88,10 @@ routes.push({ path: '/case/:id', name: 'case', component: { '$route': 'loadData', }, methods: { - initActions(params) { + initCase(params) { this.params = params; + this.mruCaseLimit = params["mostRecentlyUsedLimit"]; + this.loadLocalSettings(); }, async loadAssociations() { this.associationsLoading = true; @@ -130,6 +134,22 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.$root.showError(error); } }, + addMRUCaseObj(caseObj) { + if (caseObj) { + for (var idx = 0; idx < this.mruCases.length; idx++) { + const cur = this.mruCases[idx]; + if (cur.id == caseObj.id) { + this.mruCases.splice(idx, 1); + break; + } + } + this.mruCases.unshift(caseObj); + while (this.mruCases.length > this.mruCaseLimit) { + this.mruCases.pop(); + } + this.saveLocalSettings(); + } + }, async loadData() { this.$root.startLoading(); @@ -163,6 +183,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.form.assigneeId = caseObj.assigneeId; this.$root.populateUserDetails(caseObj, "userId", "owner"); this.$root.populateUserDetails(caseObj, "assigneeId", "assignee"); + this.addMRUCaseObj(caseObj); this.caseObj = caseObj; }, async modifyCase() { @@ -176,11 +197,15 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.form.tags = this.form.tags.split(",").map(tag => { return tag.trim(); }); + } else { + this.form.tags = []; } const json = JSON.stringify(this.form); this.form.tags = formattedTags; const response = await this.$root.papi.put('case/', json); - this.updateCaseDetails(response.data); + if (response.data) { + this.updateCaseDetails(response.data); + } } catch (error) { if (error.response != undefined && error.response.status == 404) { this.$root.showError(this.i18n.notFound); @@ -194,8 +219,10 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.$root.startLoading(); try { const response = await this.$root.papi.post('case/' + association, JSON.stringify(this.associatedForms[association])); - this.$root.populateUserDetails(response.data, "userId", "owner"); - this.associations[association].push(response.data); + if (response.data) { + this.$root.populateUserDetails(response.data, "userId", "owner"); + this.associations[association].push(response.data); + } } catch (error) { this.$root.showError(error); } @@ -213,8 +240,10 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.$root.startLoading(); try { const response = await this.$root.papi.put('case/' + association, JSON.stringify(this.associatedForms[association])); - this.$root.populateUserDetails(response.data, "userId", "owner"); - Vue.set(this.associations[association], idx, response.data); + if (response.data) { + this.$root.populateUserDetails(response.data, "userId", "owner"); + Vue.set(this.associations[association], idx, response.data); + } } catch (error) { if (error.response != undefined && error.response.status == 404) { this.$root.showError(this.i18n.notFound); @@ -260,6 +289,12 @@ routes.push({ path: '/case/:id', name: 'case', component: { // this.updateCaseDetails(caseObj) // this.loadAssociations(); }, + saveLocalSettings() { + localStorage['settings.case.mruCases'] = JSON.stringify(this.mruCases); + }, + loadLocalSettings() { + if (localStorage['settings.case.mruCases']) this.mruCases = JSON.parse(localStorage['settings.case.mruCases']); + }, } }}); diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js index db84e7aa..2109cbea 100644 --- a/html/js/routes/case.test.js +++ b/html/js/routes/case.test.js @@ -46,8 +46,68 @@ beforeEach(() => { }); test('initParams', () => { - comp.initActions({"foo":"bar"}); + comp.mruCases.push({id:"123"}); + comp.saveLocalSettings() + comp.mruCases = []; + comp.initCase({"foo":"bar", "mostRecentlyUsedLimit": 23}); expect(comp.params.foo).toBe("bar"); + expect(comp.mruCaseLimit).toBe(23); + expect(comp.mruCases.length).toBe(1) +}); + +test('addMRUCaseObj', () => { + const case1 = {id:'1'}; + const case2 = {id:'2'}; + const case3 = {id:'3'}; + const case4 = {id:'4'}; + const case5 = {id:'5'}; + const case6 = {id:'6'}; + expect(comp.mruCases.length).toBe(0); + comp.addMRUCaseObj(case1); + expect(comp.mruCases.length).toBe(1); + comp.addMRUCaseObj(case1); + expect(comp.mruCases.length).toBe(1); // still one + expect(comp.mruCases[0]).toBe(case1); + + comp.addMRUCaseObj(case2); + expect(comp.mruCases.length).toBe(2); + expect(comp.mruCases[0]).toBe(case2); + expect(comp.mruCases[1]).toBe(case1); + + comp.addMRUCaseObj(case3); + expect(comp.mruCases.length).toBe(3); + expect(comp.mruCases[0]).toBe(case3); + expect(comp.mruCases[1]).toBe(case2); + expect(comp.mruCases[2]).toBe(case1); + + comp.addMRUCaseObj(case2); + expect(comp.mruCases.length).toBe(3); + expect(comp.mruCases[0]).toBe(case2); // back on top + expect(comp.mruCases[1]).toBe(case3); + expect(comp.mruCases[2]).toBe(case1); + + comp.addMRUCaseObj(case4); + expect(comp.mruCases.length).toBe(4); + expect(comp.mruCases[0]).toBe(case4); + expect(comp.mruCases[1]).toBe(case2); + expect(comp.mruCases[2]).toBe(case3); + expect(comp.mruCases[3]).toBe(case1); + + comp.addMRUCaseObj(case5); + expect(comp.mruCases.length).toBe(5); + expect(comp.mruCases[0]).toBe(case5); + expect(comp.mruCases[1]).toBe(case4); + expect(comp.mruCases[2]).toBe(case2); + expect(comp.mruCases[3]).toBe(case3); + expect(comp.mruCases[4]).toBe(case1); + + comp.addMRUCaseObj(case6); + expect(comp.mruCases.length).toBe(5); + expect(comp.mruCases[0]).toBe(case6); + expect(comp.mruCases[1]).toBe(case5); + expect(comp.mruCases[2]).toBe(case4); + expect(comp.mruCases[3]).toBe(case2); + expect(comp.mruCases[4]).toBe(case3); }); test('loadAssociations', () => { @@ -171,6 +231,8 @@ test('modifyCase', async () => { comp.form.assigneeId = 'myAssigneeId'; const showErrorMock = mockShowError(true); + expect(comp.mruCases.length).toBe(0); + await comp.modifyCase(); const body = "{\"valid\":false,\"id\":\"myCaseId\",\"title\":\"myTitle\",\"description\":\"myDescription\",\"status\":\"open\",\"severity\":31,\"priority\":33,\"assigneeId\":\"myAssigneeId\",\"tags\":[\"tag1\",\"tag2\"],\"tlp\":\"myTlp\",\"pap\":\"myPap\",\"category\":\"myCategory\"}"; @@ -179,6 +241,7 @@ test('modifyCase', async () => { expectCaseDetails(); expect(comp.associations['history'].length).toBe(0); expect(comp.$root.loading).toBe(false); + expect(comp.mruCases.length).toBe(1); }); test('modifyCaseNotFound', async () => { diff --git a/html/js/routes/hunt.js b/html/js/routes/hunt.js index abd1698b..723b7062 100644 --- a/html/js/routes/hunt.js +++ b/html/js/routes/hunt.js @@ -93,6 +93,7 @@ const huntComponent = { fetchTimeSecs: 0, roundTripTimeSecs: 0, mruQueries: [], + mruCases: [], autohunt: true, @@ -107,6 +108,11 @@ const huntComponent = { quickActionEvent: null, quickActionField: "", quickActionValue: "", + escalationMenuVisible: false, + escalationMenuX: 0, + escalationMenuY: 0, + escalationItem: null, + escalateRelatedEventsEnabled: false, actions: [], }}, created() { @@ -192,6 +198,7 @@ const huntComponent = { this.advanced = params["advanced"]; this.ackEnabled = params["ackEnabled"]; this.escalateEnabled = params["escalateEnabled"]; + this.escalateRelatedEventsEnabled = params["escalateRelatedEventsEnabled"]; this.viewEnabled = params["viewEnabled"]; if (this.queries != null && this.queries.length > 0) { this.query = this.queries[0].query; @@ -407,7 +414,7 @@ const huntComponent = { this.$root.showError(error); } }, - async ack(event, item, idx, acknowledge, escalate = false) { + async ack(event, item, idx, acknowledge, escalate = false, caseId = null) { this.$root.startLoading(); try { var docEvent = item; @@ -417,45 +424,59 @@ const huntComponent = { } var isAlert = ('rule.name' in item || 'event.severity_label' in item); if (escalate) { - var title = item['rule.name']; - if (!title) { - title = this.i18n.eventCaseTitle; - if (item['event.module'] || item['event.dataset']) { - title = title + ": "; - if (item['event.module']) { - title = title + item['event.module']; + if (!caseId || !this.escalateRelatedEventsEnabled) { + // Add to new case + var title = item['rule.name']; + if (!title) { + title = this.i18n.eventCaseTitle; + if (item['event.module'] || item['event.dataset']) { + title = title + ": "; + if (item['event.module']) { + title = title + item['event.module']; + if (item['event.dataset']) { + title = title + " - "; + } + } if (item['event.dataset']) { - title = title + " - "; + title = title + item['event.dataset']; } } - if (item['event.dataset']) { - title = title + item['event.dataset']; + } + + var description = item['message']; + if (!description) description = JSON.stringify(item); + + var severity = item['event.severity']; + if (!severity || isNaN(severity)) { + switch (item['event.severity_label']) { + case 'low': severity = 1; break; + case 'medium': severity = 2; break; + case 'high': severity = 3; break; + case 'critical': severity = 4; break; + default: severity = 3; } } - } - var description = item['message']; - if (!description) description = JSON.stringify(item); + var template = 'rule.case_template' in item ? item['rule.case_template'] : ''; - var severity = item['event.severity']; - if (!severity || isNaN(severity)) { - switch (item['event.severity_label']) { - case 'low': severity = 1; break; - case 'medium': severity = 2; break; - case 'high': severity = 3; break; - case 'critical': severity = 4; break; - default: severity = 3; + const response = await this.$root.papi.post('case/', { + title: title, + description: description, + severity: severity, + template: template, + }); + if (response && response.data) { + caseId = response.data.id; } } - var template = 'rule.case_template' in item ? item['rule.case_template'] : ''; - - const response = await this.$root.papi.post('case/', { - title: title, - description: description, - severity: severity, - template: template, - }); + // Attach the event to the case + if (caseId && this.escalateRelatedEventsEnabled) { + const response = await this.$root.papi.post('case/events', { + fields: item, + caseId: caseId, + }); + } } if (isAlert) { const response = await this.$root.papi.post('events/ack', { @@ -660,9 +681,28 @@ const huntComponent = { this.$router.push(this.filterRouteDrilldown); } }, + toggleEscalationMenu(domEvent, event) { + if (!this.escalateRelatedEventsEnabled) { + this.ack(domEvent, event, 0, true, true); + return; + } + + if (!domEvent || this.quickActionVisible || this.escalationMenuVisible) { + this.quickActionVisible = false; + this.escalationMenuVisible = false; + return; + } + this.escalationMenuX = domEvent.clientX; + this.escalationMenuY = domEvent.clientY; + this.escalationItem = event; + this.$nextTick(() => { + this.escalationMenuVisible = true; + }); + }, toggleQuickAction(domEvent, event, field, value) { - if (!domEvent || this.quickActionVisible) { + if (!domEvent || this.quickActionVisible || this.escalationMenuVisible) { this.quickActionVisible = false; + this.escalationMenuVisible = false; return; } @@ -1090,6 +1130,9 @@ const huntComponent = { localStorage.removeItem(item); } }, + formatCaseSummary(socCase) { + return socCase.title; + }, saveTimezone() { localStorage['timezone'] = this.zone; }, @@ -1125,6 +1168,8 @@ const huntComponent = { if (localStorage[prefix + '.relativeTimeValue']) this.relativeTimeValue = parseInt(localStorage[prefix + '.relativeTimeValue']); if (localStorage[prefix + '.relativeTimeUnit']) this.relativeTimeUnit = parseInt(localStorage[prefix + '.relativeTimeUnit']); if (localStorage[prefix + '.autohunt']) this.autohunt = localStorage[prefix + '.autohunt'] == 'true'; + + if (localStorage['settings.case.mruCases']) this.mruCases = JSON.parse(localStorage['settings.case.mruCases']); }, toggleShowSection(item) { if (this.isExpandedSection(item)) { diff --git a/html/js/routes/hunt.test.js b/html/js/routes/hunt.test.js index a2e7b41a..597221ca 100644 --- a/html/js/routes/hunt.test.js +++ b/html/js/routes/hunt.test.js @@ -174,4 +174,31 @@ test('removeSortBy', () => { // no-op comp.removeSortBy('bar^') expect(comp.query).toBe("abc | groupby xyz"); -}); \ No newline at end of file +}); + +test('formatCaseSummary', () => { + const caseObj = {id:"12", title:"This is a case title"}; + const summary = comp.formatCaseSummary(caseObj); + expect(summary).toBe('This is a case title'); +}); + +test('toggleEscalationMenu', () => { + comp.escalateRelatedEventsEnabled = true; + const domEvent = {clientX: 12, clientY: 34}; + const event = {id:"33",foo:"bar"}; + comp.$nextTick = function(fn) { fn(); }; + comp.toggleEscalationMenu(domEvent, event); + expect(comp.escalationMenuX).toBe(12); + expect(comp.escalationMenuY).toBe(34); + expect(comp.escalationItem).toBe(event); + expect(comp.escalationMenuVisible).toBe(true); +}); + +test('toggleEscalationMenuAlreadyOpen', () => { + comp.escalateRelatedEventsEnabled = true; + comp.quickActionVisible = true; + comp.escalationMenuVisible = true; + comp.toggleEscalationMenu(); + expect(comp.quickActionVisible).toBe(false); + expect(comp.escalationMenuVisible).toBe(false); +}); diff --git a/model/case.go b/model/case.go index 7fc9c41d..f3d07f2a 100644 --- a/model/case.go +++ b/model/case.go @@ -56,3 +56,17 @@ func NewComment() *Comment { newComment := &Comment{} return newComment } + +type RelatedEvent struct { + Auditable + CaseId string `json:"caseId"` + Fields map[string]interface{} `json:"fields"` +} + +func NewRelatedEvent() *RelatedEvent { + newRelatedEvent := &RelatedEvent{} + now := time.Now() + newRelatedEvent.CreateTime = &now + newRelatedEvent.Fields = make(map[string]interface{}) + return newRelatedEvent +} diff --git a/model/case_test.go b/model/case_test.go new file mode 100644 index 00000000..4bcff27f --- /dev/null +++ b/model/case_test.go @@ -0,0 +1,22 @@ +// Copyright 2019 Jason Ertel (jertel). All rights reserved. +// Copyright 2020-2021 Security Onion Solutions, LLC. All rights reserved. +// +// This program is distributed under the terms of version 2 of the +// GNU General Public License. See LICENSE for further details. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRelatedEvent(tester *testing.T) { + event := NewRelatedEvent() + assert.NotZero(tester, event.CreateTime) +} diff --git a/server/casehandler.go b/server/casehandler.go index e1b314a6..e998334e 100644 --- a/server/casehandler.go +++ b/server/casehandler.go @@ -58,6 +58,11 @@ func (caseHandler *CaseHandler) create(ctx context.Context, writer http.Response subpath := caseHandler.GetPathParameter(request.URL.Path, 2) switch subpath { case "events": + inputEvent := model.NewRelatedEvent() + err = json.NewDecoder(request.Body).Decode(&inputEvent) + if err == nil { + obj, err = caseHandler.server.Casestore.CreateRelatedEvent(ctx, inputEvent) + } case "comments": inputComment := model.NewComment() err = json.NewDecoder(request.Body).Decode(&inputComment) @@ -120,6 +125,8 @@ func (caseHandler *CaseHandler) delete(ctx context.Context, writer http.Response switch subpath { case "comments": err = caseHandler.server.Casestore.DeleteComment(ctx, id) + case "events": + err = caseHandler.server.Casestore.DeleteRelatedEvent(ctx, id) case "tasks": case "artifacts": default: @@ -142,6 +149,7 @@ func (caseHandler *CaseHandler) get(ctx context.Context, writer http.ResponseWri subpath := caseHandler.GetPathParameter(request.URL.Path, 2) switch subpath { case "events": + obj, err = caseHandler.server.Casestore.GetRelatedEvents(ctx, id) case "comments": obj, err = caseHandler.server.Casestore.GetComments(ctx, id) case "tasks": diff --git a/server/casestore.go b/server/casestore.go index bfd9f2ad..f1a06217 100644 --- a/server/casestore.go +++ b/server/casestore.go @@ -25,4 +25,9 @@ type Casestore interface { GetComments(ctx context.Context, caseId string) ([]*model.Comment, error) UpdateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) DeleteComment(ctx context.Context, id string) error + + CreateRelatedEvent(ctx context.Context, event *model.RelatedEvent) (*model.RelatedEvent, error) + GetRelatedEvent(ctx context.Context, id string) (*model.RelatedEvent, error) + GetRelatedEvents(ctx context.Context, caseId string) ([]*model.RelatedEvent, error) + DeleteRelatedEvent(ctx context.Context, id string) error } diff --git a/server/eventstore_fake.go b/server/eventstore_fake.go index e0f7dd92..64b6ebb9 100644 --- a/server/eventstore_fake.go +++ b/server/eventstore_fake.go @@ -24,9 +24,12 @@ type FakeEventstore struct { InputUpdateCriterias []*model.EventUpdateCriteria InputAckCriterias []*model.EventAckCriteria Err error - SearchResults *model.EventSearchResults - IndexResults *model.EventIndexResults - UpdateResults *model.EventUpdateResults + SearchResults []*model.EventSearchResults + IndexResults []*model.EventIndexResults + UpdateResults []*model.EventUpdateResults + searchCount int + indexCount int + updateCount int } func NewFakeEventstore() *FakeEventstore { @@ -38,16 +41,24 @@ func NewFakeEventstore() *FakeEventstore { store.InputSearchCriterias = make([]*model.EventSearchCriteria, 0) store.InputUpdateCriterias = make([]*model.EventUpdateCriteria, 0) store.InputAckCriterias = make([]*model.EventAckCriteria, 0) - store.SearchResults = model.NewEventSearchResults() - store.IndexResults = model.NewEventIndexResults() - store.UpdateResults = model.NewEventUpdateResults() + store.SearchResults = make([]*model.EventSearchResults, 0, 0) + store.SearchResults = append(store.SearchResults, model.NewEventSearchResults()) + store.IndexResults = make([]*model.EventIndexResults, 0, 0) + store.IndexResults = append(store.IndexResults, model.NewEventIndexResults()) + store.UpdateResults = make([]*model.EventUpdateResults, 0, 0) + store.UpdateResults = append(store.UpdateResults, model.NewEventUpdateResults()) return store } func (store *FakeEventstore) Search(context context.Context, criteria *model.EventSearchCriteria) (*model.EventSearchResults, error) { store.InputContexts = append(store.InputContexts, context) store.InputSearchCriterias = append(store.InputSearchCriterias, criteria) - return store.SearchResults, store.Err + if store.searchCount >= len(store.SearchResults) { + store.searchCount = len(store.SearchResults) - 1 + } + result := store.SearchResults[store.searchCount] + store.searchCount += 1 + return result, store.Err } func (store *FakeEventstore) Index(context context.Context, index string, document map[string]interface{}, id string) (*model.EventIndexResults, error) { @@ -55,13 +66,23 @@ func (store *FakeEventstore) Index(context context.Context, index string, docume store.InputIndexes = append(store.InputIndexes, index) store.InputDocuments = append(store.InputDocuments, document) store.InputIds = append(store.InputIds, id) - return store.IndexResults, store.Err + if store.indexCount >= len(store.IndexResults) { + store.indexCount = len(store.IndexResults) - 1 + } + result := store.IndexResults[store.indexCount] + store.indexCount += 1 + return result, store.Err } func (store *FakeEventstore) Update(context context.Context, criteria *model.EventUpdateCriteria) (*model.EventUpdateResults, error) { store.InputContexts = append(store.InputContexts, context) store.InputUpdateCriterias = append(store.InputUpdateCriterias, criteria) - return store.UpdateResults, store.Err + if store.updateCount >= len(store.UpdateResults) { + store.updateCount = len(store.UpdateResults) - 1 + } + result := store.UpdateResults[store.updateCount] + store.updateCount += 1 + return result, store.Err } func (store *FakeEventstore) Delete(context context.Context, index string, id string) error { @@ -74,5 +95,10 @@ func (store *FakeEventstore) Delete(context context.Context, index string, id st func (store *FakeEventstore) Acknowledge(context context.Context, criteria *model.EventAckCriteria) (*model.EventUpdateResults, error) { store.InputContexts = append(store.InputContexts, context) store.InputAckCriterias = append(store.InputAckCriterias, criteria) - return store.UpdateResults, store.Err + if store.updateCount >= len(store.UpdateResults) { + store.updateCount = len(store.UpdateResults) - 1 + } + result := store.UpdateResults[store.updateCount] + store.updateCount += 1 + return result, store.Err } diff --git a/server/modules/elastic/converter.go b/server/modules/elastic/converter.go index 49ef065c..172a79fc 100644 --- a/server/modules/elastic/converter.go +++ b/server/modules/elastic/converter.go @@ -11,11 +11,10 @@ package elastic import ( "errors" - "strings" - "time" - "github.com/security-onion-solutions/securityonion-soc/json" "github.com/security-onion-solutions/securityonion-soc/model" + "strings" + "time" ) func makeAggregation(store *ElasticEventstore, prefix string, keys []string, count int, ascending bool) (map[string]interface{}, string) { @@ -456,6 +455,34 @@ func convertElasticEventToComment(event *model.EventRecord) (*model.Comment, err return obj, err } +func convertElasticEventToRelatedEvent(event *model.EventRecord) (*model.RelatedEvent, error) { + var err error + var obj *model.RelatedEvent + + if event != nil { + obj = model.NewRelatedEvent() + err = convertElasticEventToAuditable(event, &obj.Auditable) + if err == nil { + obj.Fields = make(map[string]interface{}) + for key, value := range event.Payload { + if strings.HasPrefix(key, "related.fields.") { + key = strings.TrimPrefix(key, "related.fields.") + obj.Fields[key] = value + } + } + if value, ok := event.Payload["related.userId"]; ok { + obj.UserId = value.(string) + } + if value, ok := event.Payload["related.caseId"]; ok { + obj.CaseId = value.(string) + } + obj.CreateTime = parseTime(event.Payload, "related.createTime") + } + } + + return obj, err +} + func convertElasticEventToObject(event *model.EventRecord) (interface{}, error) { var obj interface{} var err error @@ -466,6 +493,8 @@ func convertElasticEventToObject(event *model.EventRecord) (interface{}, error) obj, err = convertElasticEventToCase(event) case "comment": obj, err = convertElasticEventToComment(event) + case "related": + obj, err = convertElasticEventToRelatedEvent(event) } } else { err = errors.New("Unknown object kind; id=" + event.Id) diff --git a/server/modules/elastic/converter_test.go b/server/modules/elastic/converter_test.go index 6b495f62..150e6b16 100644 --- a/server/modules/elastic/converter_test.go +++ b/server/modules/elastic/converter_test.go @@ -324,8 +324,9 @@ func TestConvertElasticEventToCase(tester *testing.T) { event.Payload["case.createTime"] = myCreateTime event.Payload["case.completeTime"] = myCompleteTime event.Payload["case.startTime"] = myStartTime - caseObj, err := convertElasticEventToCase(event) + interf, err := convertElasticEventToObject(event) assert.NoError(tester, err) + caseObj := interf.(*model.Case) assert.Equal(tester, "case", caseObj.Kind) assert.Equal(tester, "update", caseObj.Operation) assert.Equal(tester, "myTitle", caseObj.Title) @@ -391,8 +392,9 @@ func TestConvertElasticEventToComment(tester *testing.T) { event.Payload["comment.caseId"] = "myCaseId" event.Time = myTime event.Payload["comment.createTime"] = myCreateTime - obj, err := convertElasticEventToComment(event) + interf, err := convertElasticEventToObject(event) assert.NoError(tester, err) + obj := interf.(*model.Comment) assert.Equal(tester, "comment", obj.Kind) assert.Equal(tester, "create", obj.Operation) assert.Equal(tester, "myDesc", obj.Description) @@ -401,3 +403,28 @@ func TestConvertElasticEventToComment(tester *testing.T) { assert.Equal(tester, &myTime, obj.UpdateTime) assert.Equal(tester, &myCreateTime, obj.CreateTime) } + +func TestConvertElasticEventToRelatedEvent(tester *testing.T) { + myTime := time.Now() + myCreateTime := myTime.Add(time.Hour * -1) + + event := &model.EventRecord{} + event.Payload = make(map[string]interface{}) + event.Payload["kind"] = "related" + event.Payload["operation"] = "create" + event.Payload["related.fields.foo"] = "bar" + event.Payload["related.userId"] = "myUserId" + event.Payload["related.caseId"] = "myCaseId" + event.Time = myTime + event.Payload["related.createTime"] = myCreateTime + interf, err := convertElasticEventToObject(event) + assert.NoError(tester, err) + obj := interf.(*model.RelatedEvent) + assert.Equal(tester, "related", obj.Kind) + assert.Equal(tester, "create", obj.Operation) + assert.Equal(tester, "myUserId", obj.UserId) + assert.Equal(tester, "myCaseId", obj.CaseId) + assert.Equal(tester, &myTime, obj.UpdateTime) + assert.Equal(tester, &myCreateTime, obj.CreateTime) + assert.Equal(tester, "bar", obj.Fields["foo"]) +} diff --git a/server/modules/elastic/elastic.go b/server/modules/elastic/elastic.go index 5a825529..66486ad5 100644 --- a/server/modules/elastic/elastic.go +++ b/server/modules/elastic/elastic.go @@ -17,7 +17,7 @@ import ( ) const DEFAULT_CASE_INDEX = "so-case" -const DEFAULT_CASE_AUDIT_INDEX = "so-case-events" +const DEFAULT_CASE_AUDIT_INDEX = "so-casehistory" const DEFAULT_CASE_ASSOCIATIONS_MAX = 1000 const DEFAULT_TIME_SHIFT_MS = 120000 const DEFAULT_DURATION_MS = 1800000 diff --git a/server/modules/elastic/elasticcasestore.go b/server/modules/elastic/elasticcasestore.go index f0732f00..88802671 100644 --- a/server/modules/elastic/elasticcasestore.go +++ b/server/modules/elastic/elasticcasestore.go @@ -133,6 +133,30 @@ func (store *ElasticCasestore) validateCase(socCase *model.Case) error { return err } +func (store *ElasticCasestore) validateRelatedEvent(event *model.RelatedEvent) error { + var err error + + if err == nil && event.Id != "" { + err = store.validateId(event.Id, "relatedEventId") + } + if err == nil && event.CaseId != "" { + err = store.validateId(event.CaseId, "caseId") + } + if err == nil && event.UserId != "" { + err = store.validateId(event.UserId, "userId") + } + if err == nil && len(event.Kind) > 0 { + err = errors.New("Field 'Kind' must not be specified") + } + if err == nil && len(event.Operation) > 0 { + err = errors.New("Field 'Operation' must not be specified") + } + if err == nil && len(event.Fields) == 0 { + err = errors.New("Related event fields cannot not be empty") + } + return err +} + func (store *ElasticCasestore) validateComment(comment *model.Comment) error { var err error @@ -222,8 +246,11 @@ func (store *ElasticCasestore) delete(ctx context.Context, obj interface{}, kind func (store *ElasticCasestore) get(ctx context.Context, id string, kind string) (interface{}, error) { query := fmt.Sprintf(`_index:"%s" AND kind:"%s" AND _id:"%s"`, store.index, kind, id) objects, err := store.getAll(ctx, query, 1) - if err == nil && len(objects) > 0 { - return objects[0], err + if err == nil { + if len(objects) > 0 { + return objects[0], err + } + err = errors.New("Object not found") } return nil, err } @@ -337,12 +364,86 @@ func (store *ElasticCasestore) GetCaseHistory(ctx context.Context, caseId string err = store.validateId(caseId, "caseId") if err == nil { - query := fmt.Sprintf(`_index:"%s" AND (%s:"%s" OR comment.caseId:"%s")`, store.auditIndex, AUDIT_DOC_ID, caseId, caseId) + query := fmt.Sprintf(`_index:"%s" AND (%s:"%s" OR comment.caseId:"%s" OR related.caseId:"%s")`, store.auditIndex, AUDIT_DOC_ID, caseId, caseId, caseId) history, err = store.getAll(ctx, query, store.maxAssociations) } return history, err } +func (store *ElasticCasestore) CreateRelatedEvent(ctx context.Context, event *model.RelatedEvent) (*model.RelatedEvent, error) { + var err error + + err = store.validateRelatedEvent(event) + if err == nil { + if event.Id != "" { + return nil, errors.New("Unexpected ID found in new related event") + } else if event.CaseId == "" { + return nil, errors.New("Missing Case ID in new related event") + } else { + _, err = store.GetCase(ctx, event.CaseId) + if err == nil { + var results *model.EventIndexResults + results, err = store.save(ctx, event, "related", store.prepareForSave(ctx, &event.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + event, err = store.GetRelatedEvent(ctx, results.DocumentId) + } + } + } + } + + return event, err +} + +func (store *ElasticCasestore) GetRelatedEvent(ctx context.Context, id string) (*model.RelatedEvent, error) { + var err error + var event *model.RelatedEvent + + err = store.validateId(id, "id") + if err == nil { + var obj interface{} + obj, err = store.get(ctx, id, "related") + if err == nil { + event = obj.(*model.RelatedEvent) + } + } + return event, err +} + +func (store *ElasticCasestore) GetRelatedEvents(ctx context.Context, caseId string) ([]*model.RelatedEvent, error) { + var err error + var events []*model.RelatedEvent + + err = store.validateId(caseId, "caseId") + if err == nil { + events = make([]*model.RelatedEvent, 0) + query := fmt.Sprintf(`_index:"%s" AND kind:"related" AND related.caseId:"%s" | sortby related.fields.timestamp^`, store.index, caseId) + var objects []interface{} + objects, err = store.getAll(ctx, query, store.maxAssociations) + if err == nil { + for _, obj := range objects { + events = append(events, obj.(*model.RelatedEvent)) + } + } + } + return events, err +} + +func (store *ElasticCasestore) DeleteRelatedEvent(ctx context.Context, id string) error { + var err error + + var event *model.RelatedEvent + err = store.validateId(id, "id") + if err == nil { + event, err = store.GetRelatedEvent(ctx, id) + if err == nil { + err = store.delete(ctx, event, "related", store.prepareForSave(ctx, &event.Auditable)) + } + } + + return err +} + func (store *ElasticCasestore) CreateComment(ctx context.Context, comment *model.Comment) (*model.Comment, error) { var err error @@ -353,13 +454,16 @@ func (store *ElasticCasestore) CreateComment(ctx context.Context, comment *model } else if comment.CaseId == "" { return nil, errors.New("Missing Case ID in new comment") } else { - now := time.Now() - comment.CreateTime = &now - var results *model.EventIndexResults - results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + _, err = store.GetCase(ctx, comment.CaseId) if err == nil { - // Read object back to get new modify date, etc - comment, err = store.GetComment(ctx, results.DocumentId) + now := time.Now() + comment.CreateTime = &now + var results *model.EventIndexResults + results, err = store.save(ctx, comment, "comment", store.prepareForSave(ctx, &comment.Auditable)) + if err == nil { + // Read object back to get new modify date, etc + comment, err = store.GetComment(ctx, results.DocumentId) + } } } } @@ -388,7 +492,7 @@ func (store *ElasticCasestore) GetComments(ctx context.Context, caseId string) ( err = store.validateId(caseId, "caseId") if err == nil { comments = make([]*model.Comment, 0) - query := fmt.Sprintf(`_index:"%s" AND kind:"comment" AND comment.caseId:"%s" | sortby comment.createTime^`, store.index, caseId) + query := fmt.Sprintf(`_index:"%s" AND kind:"comment" AND comment.caseId:"%s" | sortby @timestamp^`, store.index, caseId) var objects []interface{} objects, err = store.getAll(ctx, query, store.maxAssociations) if err == nil { diff --git a/server/modules/elastic/elasticcasestore_test.go b/server/modules/elastic/elasticcasestore_test.go index 16a23c68..510131e7 100644 --- a/server/modules/elastic/elasticcasestore_test.go +++ b/server/modules/elastic/elasticcasestore_test.go @@ -377,8 +377,8 @@ func TestGetAll(tester *testing.T) { Payload: commentPayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, commentEvent) results, err := store.getAll(ctx, query, 123) assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -398,7 +398,7 @@ func TestGet(tester *testing.T) { caseEvent := &model.EventRecord{ Payload: casePayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) obj, err := store.get(ctx, "myCaseId", "case") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -406,6 +406,16 @@ func TestGet(tester *testing.T) { assert.NotNil(tester, obj) } +func TestGetNotFound(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + _, err := store.get(ctx, "myCaseId", "case") + assert.EqualError(tester, err, "Object not found") +} + func TestCreateError(tester *testing.T) { store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") @@ -440,7 +450,7 @@ func TestGetCase(tester *testing.T) { caseEvent := &model.EventRecord{ Payload: casePayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) obj, err := store.GetCase(ctx, "myCaseId") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -454,13 +464,13 @@ func TestGetCaseHistory(tester *testing.T) { fakeEventStore := server.NewFakeEventstore() store.server.Eventstore = fakeEventStore ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") - query := `_index:"myAuditIndex" AND (so_audit_doc_id:"myCaseId" OR comment.caseId:"myCaseId")` + query := `_index:"myAuditIndex" AND (so_audit_doc_id:"myCaseId" OR comment.caseId:"myCaseId" OR related.caseId:"myCaseId")` casePayload := make(map[string]interface{}) casePayload["kind"] = "case" caseEvent := &model.EventRecord{ Payload: casePayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, caseEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) results, err := store.GetCaseHistory(ctx, "myCaseId") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -487,6 +497,38 @@ func TestCreateCommentMissingCaseId(tester *testing.T) { assert.Equal(tester, "Missing Case ID in new comment", err.Error()) } +func TestCreateComment(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + Id: "123444", + } + commentPayload := make(map[string]interface{}) + commentPayload["kind"] = "comment" + commentEvent := &model.EventRecord{ + Payload: commentPayload, + } + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) + fakeEventStore.IndexResults[0].Success = true + fakeEventStore.IndexResults[0].DocumentId = "myCaseId" + commentSearchResults := model.NewEventSearchResults() + commentSearchResults.Events = append(commentSearchResults.Events, commentEvent) + fakeEventStore.SearchResults = append(fakeEventStore.SearchResults, commentSearchResults) + comment := model.NewComment() + comment.CaseId = "123444" + comment.Description = "Foo Bar" + newComment, err := store.CreateComment(ctx, comment) + assert.NoError(tester, err) + assert.NotNil(tester, newComment) +} + func TestGetComment(tester *testing.T) { store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) store.Init("myIndex", "myAuditIndex", 45) @@ -499,7 +541,7 @@ func TestGetComment(tester *testing.T) { commentEvent := &model.EventRecord{ Payload: commentPayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, commentEvent) obj, err := store.GetComment(ctx, "myCommentId") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -513,13 +555,13 @@ func TestGetComments(tester *testing.T) { fakeEventStore := server.NewFakeEventstore() store.server.Eventstore = fakeEventStore ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") - query := `_index:"myIndex" AND kind:"comment" AND comment.caseId:"myCaseId" | sortby comment.createTime^` + query := `_index:"myIndex" AND kind:"comment" AND comment.caseId:"myCaseId" | sortby @timestamp^` commentPayload := make(map[string]interface{}) commentPayload["kind"] = "comment" commentEvent := &model.EventRecord{ Payload: commentPayload, } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, commentEvent) obj, err := store.GetComments(ctx, "myCaseId") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) @@ -549,7 +591,7 @@ func TestDeleteComment(tester *testing.T) { Payload: commentPayload, Id: "myCommentId", } - fakeEventStore.SearchResults.Events = append(fakeEventStore.SearchResults.Events, commentEvent) + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, commentEvent) err := store.DeleteComment(ctx, "myCommentId") assert.NoError(tester, err) assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) // Search to ensure it exists first @@ -558,3 +600,128 @@ func TestDeleteComment(tester *testing.T) { assert.Equal(tester, "myCommentId", fakeEventStore.InputIds[0]) assert.Equal(tester, "", fakeEventStore.InputIds[1]) } + +func TestCreateRelatedEventUnexpectedId(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + event := model.NewRelatedEvent() + event.Id = "123444" + event.Fields["foo"] = "bar" + _, err := store.CreateRelatedEvent(ctx, event) + assert.Error(tester, err) + assert.Equal(tester, "Unexpected ID found in new related event", err.Error()) +} + +func TestCreateRelatedEventMissingCaseId(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + event := model.NewRelatedEvent() + event.Fields["foo"] = "bar" + _, err := store.CreateRelatedEvent(ctx, event) + assert.Error(tester, err) + assert.Equal(tester, "Missing Case ID in new related event", err.Error()) +} + +func TestCreateRelatedEventMissingFields(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + event := model.NewRelatedEvent() + _, err := store.CreateRelatedEvent(ctx, event) + assert.Error(tester, err) + assert.Equal(tester, "Related event fields cannot not be empty", err.Error()) +} + +func TestCreateRelatedEvent(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + + casePayload := make(map[string]interface{}) + casePayload["kind"] = "case" + caseEvent := &model.EventRecord{ + Payload: casePayload, + Id: "123444", + } + eventPayload := make(map[string]interface{}) + eventPayload["kind"] = "related" + elasticEvent := &model.EventRecord{ + Payload: eventPayload, + } + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, caseEvent) + fakeEventStore.IndexResults[0].Success = true + fakeEventStore.IndexResults[0].DocumentId = "myCaseId" + eventSearchResults := model.NewEventSearchResults() + eventSearchResults.Events = append(eventSearchResults.Events, elasticEvent) + fakeEventStore.SearchResults = append(fakeEventStore.SearchResults, eventSearchResults) + event := model.NewRelatedEvent() + event.CaseId = "123444" + event.Fields["foo"] = "bar" + newEvent, err := store.CreateRelatedEvent(ctx, event) + assert.NoError(tester, err) + assert.NotNil(tester, newEvent) +} + +func TestGetRelatedEvent(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"related" AND _id:"myEventId"` + eventPayload := make(map[string]interface{}) + eventPayload["kind"] = "related" + elasticEvent := &model.EventRecord{ + Payload: eventPayload, + } + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, elasticEvent) + obj, err := store.GetRelatedEvent(ctx, "myEventId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestGetRelatedEvents(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"related" AND related.caseId:"myCaseId" | sortby related.fields.timestamp^` + eventPayload := make(map[string]interface{}) + eventPayload["kind"] = "related" + elasticEvent := &model.EventRecord{ + Payload: eventPayload, + } + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, elasticEvent) + obj, err := store.GetRelatedEvents(ctx, "myCaseId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.NotNil(tester, obj) +} + +func TestDeleteRelatedEvent(tester *testing.T) { + store := NewElasticCasestore(server.NewFakeAuthorizedServer(nil)) + store.Init("myIndex", "myAuditIndex", 45) + fakeEventStore := server.NewFakeEventstore() + store.server.Eventstore = fakeEventStore + ctx := context.WithValue(context.Background(), web.ContextKeyRequestorId, "myRequestorId") + query := `_index:"myIndex" AND kind:"related" AND _id:"myEventId"` + elasticPayload := make(map[string]interface{}) + elasticPayload["kind"] = "related" + elasticEvent := &model.EventRecord{ + Payload: elasticPayload, + Id: "myEventId", + } + fakeEventStore.SearchResults[0].Events = append(fakeEventStore.SearchResults[0].Events, elasticEvent) + err := store.DeleteRelatedEvent(ctx, "myEventId") + assert.NoError(tester, err) + assert.Len(tester, fakeEventStore.InputSearchCriterias, 1) // Search to ensure it exists first + assert.Equal(tester, query, fakeEventStore.InputSearchCriterias[0].RawQuery) + assert.Len(tester, fakeEventStore.InputIds, 2) // Delete and Index (for audit) + assert.Equal(tester, "myEventId", fakeEventStore.InputIds[0]) + assert.Equal(tester, "", fakeEventStore.InputIds[1]) +} diff --git a/server/modules/elasticcases/elasticcasestore.go b/server/modules/elasticcases/elasticcasestore.go index ee5c2e40..e69e6588 100644 --- a/server/modules/elasticcases/elasticcasestore.go +++ b/server/modules/elasticcases/elasticcasestore.go @@ -98,3 +98,19 @@ func (store *ElasticCasestore) UpdateComment(ctx context.Context, comment *model func (store *ElasticCasestore) DeleteComment(ctx context.Context, id string) error { return errors.New("Unsupported operation by this module") } + +func (store *ElasticCasestore) CreateRelatedEvent(ctx context.Context, event *model.RelatedEvent) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetRelatedEvent(ctx context.Context, id string) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) GetRelatedEvents(ctx context.Context, caseId string) ([]*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *ElasticCasestore) DeleteRelatedEvent(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} diff --git a/server/modules/generichttp/httpcasestore.go b/server/modules/generichttp/httpcasestore.go index 357dc74a..375c5c90 100644 --- a/server/modules/generichttp/httpcasestore.go +++ b/server/modules/generichttp/httpcasestore.go @@ -103,3 +103,19 @@ func (store *HttpCasestore) UpdateComment(ctx context.Context, comment *model.Co func (store *HttpCasestore) DeleteComment(ctx context.Context, id string) error { return errors.New("Unsupported operation by this module") } + +func (store *HttpCasestore) CreateRelatedEvent(ctx context.Context, event *model.RelatedEvent) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetRelatedEvent(ctx context.Context, id string) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) GetRelatedEvents(ctx context.Context, caseId string) ([]*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *HttpCasestore) DeleteRelatedEvent(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} diff --git a/server/modules/thehive/thehivecasestore.go b/server/modules/thehive/thehivecasestore.go index d19a25f7..4967bab7 100644 --- a/server/modules/thehive/thehivecasestore.go +++ b/server/modules/thehive/thehivecasestore.go @@ -96,3 +96,19 @@ func (store *TheHiveCasestore) UpdateComment(ctx context.Context, comment *model func (store *TheHiveCasestore) DeleteComment(ctx context.Context, id string) error { return errors.New("Unsupported operation by this module") } + +func (store *TheHiveCasestore) CreateRelatedEvent(ctx context.Context, event *model.RelatedEvent) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetRelatedEvent(ctx context.Context, id string) (*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) GetRelatedEvents(ctx context.Context, caseId string) ([]*model.RelatedEvent, error) { + return nil, errors.New("Unsupported operation by this module") +} + +func (store *TheHiveCasestore) DeleteRelatedEvent(ctx context.Context, id string) error { + return errors.New("Unsupported operation by this module") +} From 987e78c85024f379e4b5fd4286431c0feb085e54 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 7 Dec 2021 11:24:41 -0500 Subject: [PATCH 07/98] [wip] Testing imp to edit fields individually --- html/index.html | 35 ++++++++++++++++++++++++++++------- html/js/routes/case.js | 12 ++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/html/index.html b/html/index.html index a87038ff..bbea4971 100644 --- a/html/index.html +++ b/html/index.html @@ -1173,20 +1173,41 @@

{{ i18n.viewJob }}

diff --git a/html/js/i18n.js b/html/js/i18n.js index 2d47229b..bc6d4cde 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -92,12 +92,12 @@ const i18n = { copyToClipboard: 'Copy to clipboard', custom: 'Custom', darkMode: 'Dark Mode', - dateClosed: 'Date Closed', + dateClosed: 'Closed', dateCompleted: 'Date Completed', dateCreated: 'Created', dateDataEpoch: 'Earliest PCAP', dateFailed: 'Date Failed', - dateModified: 'Date Modified', + dateModified: 'Updated', dateOnline: 'Online Since:', dateQueued: 'Date Queued', datePreselectToday: 'Today', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index f635d863..1be3b8ee 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -35,24 +35,20 @@ routes.push({ path: '/case/:id', name: 'case', component: { footerProps: { 'items-per-page-options': [10,50,250,1000] }, count: 500, userList: [], - mainForms: { - info: { - valid: false, - title: null, - assigneeId: null, - status: null - }, - details: { - valid: false, - id: null, - description: null, - severity: null, - priority: null, - tags: null, - tlp: null, - pap: null, - category: null - } + collapsedSections: [], + mainForm: { + valid: false, + title: null, + assigneeId: null, + status: null, + id: null, + description: null, + severity: null, + priority: null, + tags: [], + tlp: null, + pap: null, + category: null }, associatedForms: { comments: { @@ -75,28 +71,66 @@ routes.push({ path: '/case/:id', name: 'case', component: { valid: false } }, - editFields: [], + editField: "", mruCaseLimit: 5, mruCases: [], presets: {}, rules: { - required: value => (!!value) || this.$root.i18n.required, + required: value => (value != [] && value != "") || this.$root.i18n.required, + number: value => (! isNaN(+value) && Number.isInteger(parseFloat(value))) || this.$root.i18n.required, }, }}, computed: { severityList() { - return [ - { text: 'High', value: 0 }, - { text: 'Medium', value: 1 }, - { text: 'Low', value: 2 }, - { text: 'Extra Low', value: 3} - ] + const sevPresets = this.getPresets('severity'); + if (sevPresets != []) { + let formattedSevList = []; + for (let index = 0; index < sevPresets.length; index++) { + formattedSevList.push({ + text: sevPresets[index], + value: index+1 + }); + } + return formattedSevList; + } else { + return [ + { text: 'Critical', value: 1 }, + { text: 'High', value: 2 }, + { text: 'Medium', value: 3 }, + { text: 'Low', value: 4} + ]; + } + }, + tagList() { + const tagPresets = this.getPresets('tags'); + return tagPresets.concat(this.mainForm.tags); + }, + categoryList() { + const catPresets = this.getPresets('category') + if (this.mainForm.category !== null) { + return catPresets.concat(this.mainForm.category) + } else { + return catPresets + } + }, + tlpList() { + const tlpPresets = this.getPresets('tlp') + return tlpPresets.map((value) => { + return { + text: value.split(' ').map(word => word.charAt(0).toLocaleUpperCase() + word.substring(1)).join(' '), + value: value + } + }) + }, + papList() { + const papPresets = this.getPresets('pap') + return papPresets.map((value) => { + return { + text: value.split(' ').map(word => word.charAt(0).toLocaleUpperCase() + word.substring(1)).join(' '), + value: value + } + }) }, - severityString() { - return typeof(this.mainForms.details.severity) == Number - ? this.severityList.find(el => el.value == this.mainForms.details.severity).text - : this.mainForms.details.severity - }, statusList() { const statuses = [ 'new', @@ -207,6 +241,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { id: this.$route.params.id }}); this.userList = await this.$root.getUsers(); + console.log(response.data) this.updateCaseDetails(response.data); this.loadAssociations(); } catch (error) { @@ -220,43 +255,40 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.$root.subscribe("case", this.updateCase); }, updateCaseDetails(caseObj) { - this.mainForms.details.id = caseObj.id; - this.mainForms.info.title = caseObj.title; - this.mainForms.details.description = caseObj.description; - this.mainForms.details.severity = caseObj.severity; - this.mainForms.details.priority = caseObj.priority; - this.mainForms.info.status = caseObj.status; - this.mainForms.details.tags = caseObj.tags ? caseObj.tags.join(", ") : ""; - this.mainForms.details.tlp = caseObj.tlp; - this.mainForms.details.pap = caseObj.pap; - this.mainForms.details.category = caseObj.category; - this.mainForms.info.assigneeId = caseObj.assigneeId; + + this.mainForm.id = caseObj.id; + this.mainForm.title = caseObj.title; + this.mainForm.description = caseObj.description; + this.mainForm.severity = caseObj.severity; + this.mainForm.priority = caseObj.priority; + this.mainForm.status = caseObj.status; + this.mainForm.tags = caseObj.tags; + this.mainForm.tlp = caseObj.tlp; + this.mainForm.pap = caseObj.pap; + this.mainForm.category = caseObj.category; + this.mainForm.assigneeId = caseObj.assigneeId; this.$root.populateUserDetails(caseObj, "userId", "owner"); this.$root.populateUserDetails(caseObj, "assigneeId", "assignee"); this.addMRUCaseObj(caseObj); this.caseObj = caseObj; }, - async modifyCase() { + async modifyCase(keyStr = null) { this.$root.startLoading(); try { + let jsonObj = {...this.mainForm }; + if (keyStr !== null) { + jsonObj[keyStr] = this.editField.val; + } // Convert priority and severity to ints - this.mainForms.details.severity = parseInt(this.mainForms.details.severity, 10); - this.mainForms.details.severity = this.severityString; - this.mainForms.details.priority = parseInt(this.mainForms.details.priority, 10); - const formattedTags = this.mainForms.details.tags; - if (this.mainForms.details.tags) { - this.mainForms.details.tags = this.mainForms.details.tags.split(",").map(tag => { - return tag.trim(); - }); - } else { this.mainForms.details.tags = []; } - const caseInfo = { - ...this.mainForms.info, - ...this.mainForms.details - }; - const json = JSON.stringify(caseInfo); - this.mainForms.details.tags = formattedTags; + jsonObj.severity = parseInt(jsonObj.severity, 10); + jsonObj.priority = parseInt(jsonObj.priority, 10); + // if (jsonObj.tags) { + // jsonObj.tags = jsonObj.tags.split(",").map(tag => tag.trim()); + // } else { jsonObj.tags = []; } + const json = JSON.stringify(jsonObj); const response = await this.$root.papi.put('case/', json); if (response.data) { + this.stopEdit(); this.updateCaseDetails(response.data); } } catch (error) { @@ -343,14 +375,22 @@ routes.push({ path: '/case/:id', name: 'case', component: { // this.loadAssociations(); }, isEdit(id) { - return this.editFields.find(item => item == id) != null + return this.editField !== {} && this.editField.id === id; + }, + startEdit(val, id) { + this.editField = { + val, + id + }; }, - async toggleEdit(id) { - if (this.editFields.find(item => item == id) == null) { - this.editFields.push(id) + stopEdit() { + this.editField = {} + }, + async saveEdit(keyStr) { + if (this.mainForm[keyStr] === this.editField.val) { + this.stopEdit(); } else { - this.editFields = this.editFields.filter(item => item != id) - await this.modifyCase() + await this.modifyCase(keyStr); } }, saveLocalSettings() { @@ -359,6 +399,16 @@ routes.push({ path: '/case/:id', name: 'case', component: { loadLocalSettings() { if (localStorage['settings.case.mruCases']) this.mruCases = JSON.parse(localStorage['settings.case.mruCases']); }, + toggleShowSection(item) { + if (this.isExpandedSection(item)) { + this.collapsedSections.push(item); + } else { + this.collapsedSections.splice(this.collapsedSections.indexOf(item), 1); + } + }, + isExpandedSection(item) { + return (this.collapsedSections.indexOf(item) == -1); + } } }}); From a0a65c1e99d3ce4630e1ae0cd957ab4b48e12547 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 13 Dec 2021 12:42:59 -0500 Subject: [PATCH 16/98] await populateUserDetails to ensure the value is always rendered --- html/js/routes/case.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 1be3b8ee..326b3937 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -254,8 +254,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.$root.stopLoading(); this.$root.subscribe("case", this.updateCase); }, - updateCaseDetails(caseObj) { - + async updateCaseDetails(caseObj) { this.mainForm.id = caseObj.id; this.mainForm.title = caseObj.title; this.mainForm.description = caseObj.description; @@ -267,8 +266,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.mainForm.pap = caseObj.pap; this.mainForm.category = caseObj.category; this.mainForm.assigneeId = caseObj.assigneeId; - this.$root.populateUserDetails(caseObj, "userId", "owner"); - this.$root.populateUserDetails(caseObj, "assigneeId", "assignee"); + await this.$root.populateUserDetails(caseObj, "userId", "owner"); + await this.$root.populateUserDetails(caseObj, "assigneeId", "assignee"); this.addMRUCaseObj(caseObj); this.caseObj = caseObj; }, From 5ace5d28e1bdf8ac9a51745ef109f1df6ee20f0d Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 13 Dec 2021 12:44:41 -0500 Subject: [PATCH 17/98] Add further awaits --- html/js/routes/case.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 326b3937..d5c758fb 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -304,7 +304,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { try { const response = await this.$root.papi.post('case/' + association, JSON.stringify(this.associatedForms[association])); if (response.data) { - this.$root.populateUserDetails(response.data, "userId", "owner"); + await this.$root.populateUserDetails(response.data, "userId", "owner"); this.associations[association].push(response.data); } } catch (error) { @@ -325,7 +325,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { try { const response = await this.$root.papi.put('case/' + association, JSON.stringify(this.associatedForms[association])); if (response.data) { - this.$root.populateUserDetails(response.data, "userId", "owner"); + await this.$root.populateUserDetails(response.data, "userId", "owner"); Vue.set(this.associations[association], idx, response.data); } } catch (error) { From 7136134312c574d2d8b7f51ebb40763037bcc373 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 13 Dec 2021 12:45:11 -0500 Subject: [PATCH 18/98] Fix sizing and remove hardcoded color --- html/css/app.css | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index e44f5483..db7bfa46 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -171,13 +171,15 @@ a#title, a#title:visited, a#title:active, a#title:hover { text-align: right; white-space: nowrap; font-weight: bold; +} + +.case.details { font-size: 13px; } .case.value { text-align: left; white-space: wrap; - font-size: 13px; } .case.title.v-text-field input { @@ -191,12 +193,12 @@ a#title, a#title:visited, a#title:active, a#title:hover { } .case-detail-field:hover { - background-color: #3d3d3d !important; + background-color: var(--v-drawer_background-base) !important; } .case-detail-field:hover > .v-icon { /* transition: none !important; */ - color: var(--v-primary-lighten1); + color: var(--v-secondary-lighten1); } .v-text-field {} From 0091d42eb07d8dc4891a793352ab7171b4210c6f Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 13 Dec 2021 12:45:44 -0500 Subject: [PATCH 19/98] Further changes to details sidebar --- html/index.html | 134 +++++++++--------------------------------------- 1 file changed, 24 insertions(+), 110 deletions(-) diff --git a/html/index.html b/html/index.html index b9db1cfd..53b423dd 100644 --- a/html/index.html +++ b/html/index.html @@ -1198,16 +1198,6 @@

{{ i18n.viewJob }}

-

{{ mainForm.title }}

@@ -1396,9 +1386,6 @@

{{ mainForm.title }}

Info -
{{i18n.caseAssignee}}: @@ -1407,7 +1394,7 @@

Info

dense eager flat - solo + solo-inverted hide-details v-model="mainForm.assigneeId" :items="userList" @@ -1425,7 +1412,7 @@

Info

dense eager flat - solo + solo-inverted hide-details v-model="mainForm.status" :items="statusList" @@ -1445,21 +1432,6 @@

Info

fa-minus -
@@ -1470,7 +1442,7 @@

Details

dense eager flat - solo + solo-inverted hide-details v-model="mainForm.severity" :items="severityList" @@ -1489,7 +1461,7 @@

Details

- +
@@ -1509,7 +1481,7 @@

Details

dense eager flat - solo + solo-inverted hide-details v-model="mainForm.tlp" :items="tlpList" @@ -1517,7 +1489,8 @@

Details

item-value="value" v-on:change="modifyCase()" class="case-select" - /> + /> + @@ -1527,7 +1500,7 @@

Details

dense eager flat - solo + solo-inverted hide-details v-model="mainForm.pap" :items="papList" @@ -1544,9 +1517,8 @@

Details

Details
{{i18n.caseTags}}: Details
:items="tagList" v-on:change="modifyCase()" class="case-select" - /> + > + + @@ -1579,24 +1554,24 @@

Details

- {{ i18n.caseId }}: - {{ caseObj.id }} + {{ i18n.caseId }}: + {{ caseObj.id }}
- {{ i18n.author }}: - {{ caseObj.owner }} + {{ i18n.author }}: + {{ caseObj.owner }}
- {{ i18n.dateCreated }}: - {{ caseObj.createTime | formatDateTime}} + {{ i18n.dateCreated }}: + {{ caseObj.createTime | formatDateTime}}
- {{ i18n.dateModified }}: - {{ caseObj.updateTime | formatDateTime}} + {{ i18n.dateModified }}: + {{ caseObj.updateTime | formatDateTime}}
- {{ i18n.dateClosed }}: - {{ caseObj.completeTime | formatDateTime}} + {{ i18n.dateClosed }}: + {{ caseObj.completeTime | formatDateTime}}
@@ -1604,67 +1579,6 @@

Details

- - - -
- {{ i18n.caseId }}: - {{ caseObj.id }} -
-
- {{ i18n.dateCreated }}: - {{ caseObj.createTime | formatDateTime}} -
-
- {{ i18n.dateModified }}: - {{ caseObj.updateTime | formatDateTime}} -
-
- {{ i18n.dateClosed }}: - {{ caseObj.completeTime | formatDateTime}} -
-
- {{ i18n.author }}: - {{ caseObj.owner }} -
-
- {{ i18n.caseAssignee }}: - {{ caseObj.assignee }} -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
- - - - From ec7588cd3c043d465da963fc203660c80e93d33a Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 14 Dec 2021 10:29:58 -0500 Subject: [PATCH 20/98] Wait during mounted for the async loadData function --- html/js/routes/case.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index d5c758fb..d8a8336c 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -147,8 +147,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { }, created() { }, - mounted() { - this.loadData(); + async mounted() { + await this.loadData(); this.$root.loadParameters('case', this.initCase); }, destroyed() { From 7cec1e98435ade7bd93f6cf3a4616aae37f21e8c Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 14 Dec 2021 10:36:37 -0500 Subject: [PATCH 21/98] Remove unnecessary console log --- html/js/routes/case.js | 1 - 1 file changed, 1 deletion(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index d8a8336c..fde53409 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -241,7 +241,6 @@ routes.push({ path: '/case/:id', name: 'case', component: { id: this.$route.params.id }}); this.userList = await this.$root.getUsers(); - console.log(response.data) this.updateCaseDetails(response.data); this.loadAssociations(); } catch (error) { From 94e01863a51259a67437290534131d2bc31a9279 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 14 Dec 2021 10:37:14 -0500 Subject: [PATCH 22/98] Match collapse icons in hunt to case details --- html/index.html | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/html/index.html b/html/index.html index 53b423dd..164e35a3 100644 --- a/html/index.html +++ b/html/index.html @@ -417,7 +417,10 @@
{{ i18n.graphs }} fa-angle-down @@ -450,7 +453,10 @@
{{ i18n.groups }} fa-angle-down @@ -516,7 +522,10 @@
{{ i18n.events }} fa-angle-down From d52e0b02523b402cd36e5d8457e4ce99e8e0c4ee Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 14 Dec 2021 10:38:53 -0500 Subject: [PATCH 23/98] Make case details collapsible + styling changes * Return selects + comboboxes to solo * Make highlight on hover of editable fields change text color to blue * Move case details to left column above main content --- html/css/app.css | 25 +++++++++----- html/index.html | 78 +++++++++++++++++++++++------------------- html/js/routes/case.js | 45 +++++++++++++++++++----- 3 files changed, 97 insertions(+), 51 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index db7bfa46..9d9e4f26 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -187,21 +187,30 @@ a#title, a#title:visited, a#title:active, a#title:hover { margin-bottom: 0.15em; } -.case-detail-field { - cursor: pointer; +.case.detail-field { + padding-left: .75em; border-radius: .25em; + overflow: hidden; + white-space: pre-wrap; + word-wrap: break-word; } -.case-detail-field:hover { - background-color: var(--v-drawer_background-base) !important; +.case.detail-field:hover { + cursor: pointer; + color: var(--v-primary-lighten1) !important; } -.case-detail-field:hover > .v-icon { - /* transition: none !important; */ - color: var(--v-secondary-lighten1); +.case.collapse-field { + max-height: 7.5em; + overflow: hidden; + white-space: pre-wrap; + word-wrap: break-word; } -.v-text-field {} +.case.collapse-text:hover { + cursor: pointer; + color: var(--v-primary-lighten2) !important; +} td { white-space: nowrap; diff --git a/html/index.html b/html/index.html index 164e35a3..4b9c6369 100644 --- a/html/index.html +++ b/html/index.html @@ -1205,11 +1205,10 @@

{{ i18n.viewJob }}

+ diff --git a/html/js/app.js b/html/js/app.js index 4eefc032..ff087449 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -429,6 +429,14 @@ $(document).ready(function() { } return ""; }, + formatMarkdown(str) { + var md = str; + if (str) { + md = marked(str); + md = DOMPurify.sanitize(md); + } + return md; + }, generateDatePickerPreselects() { var preselects = {}; preselects[this.i18n.datePreselectToday] = [moment().startOf('day'), moment().endOf('day')]; @@ -755,6 +763,7 @@ $(document).ready(function() { Vue.filter('formatDateTime', this.formatDateTime); Vue.filter('formatDuration', this.formatDuration); Vue.filter('formatCount', this.formatCount); + Vue.filter('formatMarkdown', this.formatMarkdown); Vue.filter('formatTimestamp', this.formatTimestamp); $('#app')[0].style.display = "block"; this.log("Initialization complete"); diff --git a/html/js/external/purify-2.3.4.min.js b/html/js/external/purify-2.3.4.min.js new file mode 100644 index 00000000..de5624ae --- /dev/null +++ b/html/js/external/purify-2.3.4.min.js @@ -0,0 +1,2 @@ +/*! @license DOMPurify 2.3.4 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.3.4/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).DOMPurify=t()}(this,(function(){"use strict";var e=Object.hasOwnProperty,t=Object.setPrototypeOf,n=Object.isFrozen,r=Object.getPrototypeOf,o=Object.getOwnPropertyDescriptor,i=Object.freeze,a=Object.seal,l=Object.create,c="undefined"!=typeof Reflect&&Reflect,s=c.apply,u=c.construct;s||(s=function(e,t,n){return e.apply(t,n)}),i||(i=function(e){return e}),a||(a=function(e){return e}),u||(u=function(e,t){return new(Function.prototype.bind.apply(e,[null].concat(function(e){if(Array.isArray(e)){for(var t=0,n=Array(e.length);t1?n-1:0),o=1;o/gm),z=a(/^data-[\-\w.\u00B7-\uFFFF]/),B=a(/^aria-[\-\w]+$/),j=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),P=a(/^(?:\w+script|data):/i),G=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),W="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};function q(e){if(Array.isArray(e)){for(var t=0,n=Array(e.length);t0&&void 0!==arguments[0]?arguments[0]:Y(),n=function(t){return e(t)};if(n.version="2.3.4",n.removed=[],!t||!t.document||9!==t.document.nodeType)return n.isSupported=!1,n;var r=t.document,o=t.document,a=t.DocumentFragment,l=t.HTMLTemplateElement,c=t.Node,s=t.Element,u=t.NodeFilter,m=t.NamedNodeMap,A=void 0===m?t.NamedNodeMap||t.MozNamedAttrMap:m,V=t.HTMLFormElement,X=t.DOMParser,$=t.trustedTypes,Z=s.prototype,J=k(Z,"cloneNode"),Q=k(Z,"nextSibling"),ee=k(Z,"childNodes"),te=k(Z,"parentNode");if("function"==typeof l){var ne=o.createElement("template");ne.content&&ne.content.ownerDocument&&(o=ne.content.ownerDocument)}var re=K($,r),oe=re&&Fe?re.createHTML(""):"",ie=o,ae=ie.implementation,le=ie.createNodeIterator,ce=ie.createDocumentFragment,se=ie.getElementsByTagName,ue=r.importNode,me={};try{me=x(o).documentMode?o.documentMode:{}}catch(e){}var fe={};n.isSupported="function"==typeof te&&ae&&void 0!==ae.createHTMLDocument&&9!==me;var de=H,pe=U,he=z,ge=B,ye=P,ve=G,be=j,Te=null,Ne=E({},[].concat(q(S),q(w),q(_),q(D),q(C))),Ae=null,Ee=E({},[].concat(q(L),q(R),q(I),q(F))),xe=Object.seal(Object.create(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ke=null,Se=null,we=!0,_e=!0,Oe=!1,De=!1,Me=!1,Ce=!1,Le=!1,Re=!1,Ie=!1,Fe=!1,He=!0,Ue=!0,ze=!1,Be={},je=null,Pe=E({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]),Ge=null,We=E({},["audio","video","img","source","image","track"]),qe=null,Ye=E({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Ke="http://www.w3.org/1998/Math/MathML",Ve="http://www.w3.org/2000/svg",Xe="http://www.w3.org/1999/xhtml",$e=Xe,Ze=!1,Je=void 0,Qe=["application/xhtml+xml","text/html"],et="text/html",tt=void 0,nt=null,rt=o.createElement("form"),ot=function(e){return e instanceof RegExp||e instanceof Function},it=function(e){nt&&nt===e||(e&&"object"===(void 0===e?"undefined":W(e))||(e={}),e=x(e),Te="ALLOWED_TAGS"in e?E({},e.ALLOWED_TAGS):Ne,Ae="ALLOWED_ATTR"in e?E({},e.ALLOWED_ATTR):Ee,qe="ADD_URI_SAFE_ATTR"in e?E(x(Ye),e.ADD_URI_SAFE_ATTR):Ye,Ge="ADD_DATA_URI_TAGS"in e?E(x(We),e.ADD_DATA_URI_TAGS):We,je="FORBID_CONTENTS"in e?E({},e.FORBID_CONTENTS):Pe,ke="FORBID_TAGS"in e?E({},e.FORBID_TAGS):{},Se="FORBID_ATTR"in e?E({},e.FORBID_ATTR):{},Be="USE_PROFILES"in e&&e.USE_PROFILES,we=!1!==e.ALLOW_ARIA_ATTR,_e=!1!==e.ALLOW_DATA_ATTR,Oe=e.ALLOW_UNKNOWN_PROTOCOLS||!1,De=e.SAFE_FOR_TEMPLATES||!1,Me=e.WHOLE_DOCUMENT||!1,Re=e.RETURN_DOM||!1,Ie=e.RETURN_DOM_FRAGMENT||!1,Fe=e.RETURN_TRUSTED_TYPE||!1,Le=e.FORCE_BODY||!1,He=!1!==e.SANITIZE_DOM,Ue=!1!==e.KEEP_CONTENT,ze=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||be,$e=e.NAMESPACE||Xe,e.CUSTOM_ELEMENT_HANDLING&&ot(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(xe.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ot(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(xe.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(xe.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Je=Je=-1===Qe.indexOf(e.PARSER_MEDIA_TYPE)?et:e.PARSER_MEDIA_TYPE,tt="application/xhtml+xml"===Je?function(e){return e}:h,De&&(_e=!1),Ie&&(Re=!0),Be&&(Te=E({},[].concat(q(C))),Ae=[],!0===Be.html&&(E(Te,S),E(Ae,L)),!0===Be.svg&&(E(Te,w),E(Ae,R),E(Ae,F)),!0===Be.svgFilters&&(E(Te,_),E(Ae,R),E(Ae,F)),!0===Be.mathMl&&(E(Te,D),E(Ae,I),E(Ae,F))),e.ADD_TAGS&&(Te===Ne&&(Te=x(Te)),E(Te,e.ADD_TAGS)),e.ADD_ATTR&&(Ae===Ee&&(Ae=x(Ae)),E(Ae,e.ADD_ATTR)),e.ADD_URI_SAFE_ATTR&&E(qe,e.ADD_URI_SAFE_ATTR),e.FORBID_CONTENTS&&(je===Pe&&(je=x(je)),E(je,e.FORBID_CONTENTS)),Ue&&(Te["#text"]=!0),Me&&E(Te,["html","head","body"]),Te.table&&(E(Te,["tbody"]),delete ke.tbody),i&&i(e),nt=e)},at=E({},["mi","mo","mn","ms","mtext"]),lt=E({},["foreignobject","desc","title","annotation-xml"]),ct=E({},w);E(ct,_),E(ct,O);var st=E({},D);E(st,M);var ut=function(e){var t=te(e);t&&t.tagName||(t={namespaceURI:Xe,tagName:"template"});var n=h(e.tagName),r=h(t.tagName);if(e.namespaceURI===Ve)return t.namespaceURI===Xe?"svg"===n:t.namespaceURI===Ke?"svg"===n&&("annotation-xml"===r||at[r]):Boolean(ct[n]);if(e.namespaceURI===Ke)return t.namespaceURI===Xe?"math"===n:t.namespaceURI===Ve?"math"===n&<[r]:Boolean(st[n]);if(e.namespaceURI===Xe){if(t.namespaceURI===Ve&&!lt[r])return!1;if(t.namespaceURI===Ke&&!at[r])return!1;var o=E({},["title","style","font","a","script"]);return!st[n]&&(o[n]||!ct[n])}return!1},mt=function(e){p(n.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){try{e.outerHTML=oe}catch(t){e.remove()}}},ft=function(e,t){try{p(n.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){p(n.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Ae[e])if(Re||Ie)try{mt(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},dt=function(e){var t=void 0,n=void 0;if(Le)e=""+e;else{var r=g(e,/^[\r\n\t ]+/);n=r&&r[0]}"application/xhtml+xml"===Je&&(e=''+e+"");var i=re?re.createHTML(e):e;if($e===Xe)try{t=(new X).parseFromString(i,Je)}catch(e){}if(!t||!t.documentElement){t=ae.createDocument($e,"template",null);try{t.documentElement.innerHTML=Ze?"":i}catch(e){}}var a=t.body||t.documentElement;return e&&n&&a.insertBefore(o.createTextNode(n),a.childNodes[0]||null),$e===Xe?se.call(t,Me?"html":"body")[0]:Me?t.documentElement:a},pt=function(e){return le.call(e.ownerDocument||e,e,u.SHOW_ELEMENT|u.SHOW_COMMENT|u.SHOW_TEXT,null,!1)},ht=function(e){return e instanceof V&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof A)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore)},gt=function(e){return"object"===(void 0===c?"undefined":W(c))?e instanceof c:e&&"object"===(void 0===e?"undefined":W(e))&&"number"==typeof e.nodeType&&"string"==typeof e.nodeName},yt=function(e,t,r){fe[e]&&f(fe[e],(function(e){e.call(n,t,r,nt)}))},vt=function(e){var t=void 0;if(yt("beforeSanitizeElements",e,null),ht(e))return mt(e),!0;if(g(e.nodeName,/[\u0080-\uFFFF]/))return mt(e),!0;var r=tt(e.nodeName);if(yt("uponSanitizeElement",e,{tagName:r,allowedTags:Te}),!gt(e.firstElementChild)&&(!gt(e.content)||!gt(e.content.firstElementChild))&&T(/<[/\w]/g,e.innerHTML)&&T(/<[/\w]/g,e.textContent))return mt(e),!0;if("select"===r&&T(/
- +
@@ -1506,21 +1506,21 @@

Description

{{ i18n.evidenceAdd }}

- - - - - - - - - - + + + + + + + + + + - + @@ -1532,10 +1532,10 @@

Description

- + {{ i18n.cancel }} - + {{ i18n.add }}
@@ -1623,7 +1623,7 @@

Description

fa-briefcase fa-comments - fa-eye + fa-eye fa-link {{ props.item.kind }} @@ -1690,7 +1690,7 @@

Description

-
+
{{ i18n.artifactGroupType }}: {{ props.item.groupType }} diff --git a/html/js/i18n.js b/html/js/i18n.js index 84c30725..76b85592 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -53,6 +53,7 @@ const i18n = { artifactTypeHelp: 'Select a type for classification purposes (Note: choose "file" type to upload a file)', artifactValue: 'Value (hash, filename, etc.)', artifactValueHelp: 'Specify the observed value', + attachments: 'Attachments', attachmentHelp: 'Click to attach a file to upload. (Note: max upload size is {maxUploadSizeBytes} bytes)', attempt: 'Attempt', author: 'Author', @@ -200,6 +201,7 @@ const i18n = { field_soc_id: 'Event ID', field_soc_timestamp: 'Timestamp', + filename: 'Filename', fileTooLarge: 'The chosen file is too large to upload; max file size is {maxUploadSizeBytes} bytes', fileEmpty: 'The chosen file appears to have no content; consider using a "filename" artifact instead', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 1d14ca20..7d4470e0 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -18,7 +18,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { associationsLoading: false, associations: { comments: [], - artifacts: [], + evidence: [], events: [], tasks: [], history: [] @@ -40,7 +40,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { expanded: [], loading: false, }, - artifacts: { + evidence: { sortBy: 'createTime', sortDesc: false, search: '', @@ -108,7 +108,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { expanded: [0, 1], associatedForms: { comments: {}, - artifacts: {}, + evidence: {}, }, editForm: {}, mruCaseLimit: 5, @@ -148,7 +148,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.maxUploadSizeBytes = params.maxUploadSizeBytes; } this.loadLocalSettings(); - this.resetForm('artifacts'); + this.resetForm('evidence'); this.resetForm('comments'); }, getAttachmentHelp() { @@ -163,6 +163,18 @@ routes.push({ path: '/case/:id', name: 'case', component: { } return ""; }, + mapAssociatedPath(association, concatPath = false) { + var path = association; + switch (association) { + case 'evidence': + path = "artifacts"; + if (concatPath) { + path += "/" + association + } + break; + } + return path; + }, async loadAssociations() { this.associationsLoading = true; @@ -172,8 +184,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.associations["tasks"] = []; this.loadAssociation('tasks'); - this.associations["artifacts"] = []; - this.loadAssociation('artifacts', "/evidence"); + this.associations["evidence"] = []; + this.loadAssociation('evidence'); this.associations["events"] = []; this.loadAssociation('events'); @@ -183,19 +195,19 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.associationsLoading = false; }, - async loadAssociation(dataType, extraPath = "") { + async loadAssociation(association) { try { const route = this; - const response = await this.$root.papi.get('case/' + dataType + extraPath, { params: { + const response = await this.$root.papi.get('case/' + this.mapAssociatedPath(association, true), { params: { id: route.$route.params.id, - offset: route.associations[dataType].length, - count: route.associatedTable[dataType].count, + offset: route.associations[association].length, + count: route.associatedTable[association].count, }}); if (response && response.data) { for (var idx = 0; idx < response.data.length; idx++) { const obj = response.data[idx]; await this.$root.populateUserDetails(obj, "userId", "owner"); - this.associations[dataType].push(obj); + this.associations[association].push(obj); } } } catch (error) { @@ -343,7 +355,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { headers = { 'Content-Type': 'multipart/form-data; boundary=' + data._boundary } config = { 'headers': headers }; } - const response = await this.$root.papi.post('case/' + association, data, config); + const response = await this.$root.papi.post('case/' + this.mapAssociatedPath(association), data, config); if (response.data) { await this.$root.populateUserDetails(response.data, "userId", "owner"); this.associations[association].push(response.data); @@ -365,7 +377,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { try { const form = this.prepareModifyForm(obj); if (form) { - const response = await this.$root.papi.put('case/' + association, JSON.stringify(form)); + const response = await this.$root.papi.put('case/' + this.mapAssociatedPath(association), JSON.stringify(form)); if (response.data) { await this.$root.populateUserDetails(response.data, "userId", "owner"); Vue.set(this.associations[association], idx, response.data); @@ -460,7 +472,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { resetForm(ref) { const form = { valid: false }; switch (ref) { - case "artifacts": + case "evidence": form.tlp = this.getDefaultPreset('tlp'); form.artifactType = this.getDefaultPreset('artifactType'); break; diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js index 17ad4c26..8ce0bf10 100644 --- a/html/js/routes/case.test.js +++ b/html/js/routes/case.test.js @@ -129,7 +129,7 @@ test('loadAssociations', () => { expect(comp.loadAssociation).toHaveBeenCalledWith('comments'); expect(comp.loadAssociation).toHaveBeenCalledWith('tasks'); - expect(comp.loadAssociation).toHaveBeenCalledWith('artifacts', '/evidence'); + expect(comp.loadAssociation).toHaveBeenCalledWith('evidence'); expect(comp.loadAssociation).toHaveBeenCalledWith('events'); expect(comp.loadAssociation).toHaveBeenCalledWith('history'); expect(comp.associationsLoading).toBe(false); @@ -384,10 +384,10 @@ test('resetFormComments', () => { expect(comp.associatedForms['comments'].description).toBe(undefined); }); -test('resetFormArtifacts', () => { - comp.associatedForms['artifacts'].description = "something"; - comp.resetForm('artifacts'); - expect(comp.associatedForms['artifacts'].description).toBe(undefined); +test('resetFormEvidence', () => { + comp.associatedForms['evidence'].description = "something"; + comp.resetForm('evidence'); + expect(comp.associatedForms['evidence'].description).toBe(undefined); }); test('presets', () => { From 3e8fbdda7df17716a8c94837ce9e423b8526a577 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 27 Dec 2021 14:24:57 -0500 Subject: [PATCH 41/98] refactor for additional artifact groups --- html/js/routes/case.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js index 8ce0bf10..d5832d6e 100644 --- a/html/js/routes/case.test.js +++ b/html/js/routes/case.test.js @@ -580,4 +580,11 @@ test('isEdited', () => { }; expect(comp.isEdited(fakeArtifact1)).toBe(true); expect(comp.isEdited(fakeArtifact2)).toBe(false); +}); + +test('mapAssociatedPath', () => { + expect(comp.mapAssociatedPath('comments')).teBe('comments'); + expect(comp.mapAssociatedPath('comments', true)).teBe('comments'); + expect(comp.mapAssociatedPath('evidence')).teBe('artifacts'); + expect(comp.mapAssociatedPath('evidence', true)).teBe('artifacts/evidence'); }); \ No newline at end of file From 81a88c053df59036f6d5212106d09aa199b0ea47 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Mon, 27 Dec 2021 15:27:10 -0500 Subject: [PATCH 42/98] Add attachments --- html/index.html | 202 +++++++++++++++++++++++++++++++++++- html/js/app.js | 1 + html/js/app.test.js | 5 + html/js/i18n.js | 5 + html/js/routes/case.js | 49 ++++++++- html/js/routes/case.test.js | 12 ++- 6 files changed, 264 insertions(+), 10 deletions(-) diff --git a/html/index.html b/html/index.html index 368d4ffb..3e7c46b9 100644 --- a/html/index.html +++ b/html/index.html @@ -1267,6 +1267,10 @@

Description

fa-comments
{{ i18n.comments }}
+ + fa-paperclip +
{{ i18n.attachments }}
+
fa-eye
{{ i18n.evidence }}
@@ -1367,6 +1371,169 @@

Description

+ + + + + + + +
+ +
+ + +
+
+
+
+

{{ i18n.attachmentAdd }}

+ + + + + + + + + + + + +
+
+
+
+
+
+ + {{ i18n.cancel }} + + + {{ i18n.add }} + +
+
+
+
+
+ Description
fa-briefcase fa-comments + fa-paperclip fa-eye fa-link - {{ props.item.kind }} + {{ props.item.kindLocalized }} - {{ props.item.operation }} + {{ props.item.operationLocalized }} fa-pencil-alt @@ -1638,11 +1806,11 @@

Description

{{ i18n.kind }}: - {{ props.item.kind }} + {{ props.item.kindLocalized }}
{{ i18n.operation }}: - {{ props.item.operation }} + {{ props.item.operationLocalized }}
{{ i18n.dateCreated }}: @@ -1690,6 +1858,32 @@

Description

+
+
+ {{ i18n.artifactGroupType }}: + {{ props.item.groupType }} +
+
+ {{ i18n.artifactGroupId }}: + {{ props.item.groupId }} +
+
+ {{ i18n.filename }}: + {{ props.item.value }} +
+
+ {{ i18n.description }}: + {{ props.item.description }} +
+
+ {{ i18n.caseTlp }}: + {{ props.item.tlp }} +
+
+ {{ i18n.caseTags }}: + {{ props.item.tags }} +
+
{{ i18n.artifactGroupType }}: diff --git a/html/js/app.js b/html/js/app.js index ff087449..75ee0479 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -456,6 +456,7 @@ $(document).ready(function() { return preselects; }, localizeMessage(origMsg) { + if (!origMsg) return ""; var msg = origMsg; if (msg.response && msg.response.data) { msg = msg.response.data; diff --git a/html/js/app.test.js b/html/js/app.test.js index 547bac35..7f872a75 100644 --- a/html/js/app.test.js +++ b/html/js/app.test.js @@ -136,4 +136,9 @@ test('loadServerSettings', async () => { expect(app.tools[1].name).toBe('tool2'); expect(app.tools[1].enabled).toBe(false); expect(app.casesEnabled).toBe(true); +}); + +test('localizeMessage', () => { + expect(app.localizeMessage(null)).toBe(""); + expect(app.localizeMessage('create')).toBe("Create"); }); \ No newline at end of file diff --git a/html/js/i18n.js b/html/js/i18n.js index 76b85592..567f1e67 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -54,6 +54,7 @@ const i18n = { artifactValue: 'Value (hash, filename, etc.)', artifactValueHelp: 'Specify the observed value', attachments: 'Attachments', + attachmentAdd: 'Add Attachment', attachmentHelp: 'Click to attach a file to upload. (Note: max upload size is {maxUploadSizeBytes} bytes)', attempt: 'Attempt', author: 'Author', @@ -64,6 +65,7 @@ const i18n = { blog: 'Blog', bytes: 'Bytes', cancel: 'Cancel', + case: 'Case', cases: 'Cases', caseAssignee: 'Assignee', caseAssigneeHelp: 'Designate the assignee for this case', @@ -95,6 +97,7 @@ const i18n = { clear: 'Clear', collapse: 'Collapse', collapseHelp: 'Collapse all packet data', + comment: 'Comments', comments: 'Comments', commentAdd: 'Add Comment', commentDescription: 'Comment', @@ -106,6 +109,7 @@ const i18n = { copyFieldToClipboard: 'Copy this value only', copyFieldValueToClipboard: 'Copy as field:value', copyToClipboard: 'Copy to clipboard', + create: 'Create', custom: 'Custom', darkMode: 'Dark Mode', dataset: 'Dataset', @@ -320,6 +324,7 @@ const i18n = { reason: 'Reason', reconnecting: 'Attempting to connect to manager', refresh: 'Refresh', + related: 'Events', relatedEventId: 'Related Event ID', relativeTimeHelp: 'Click the clock icon to change to absolute time', required: 'Required.', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 7d4470e0..a8346130 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -18,6 +18,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { associationsLoading: false, associations: { comments: [], + attachments: [], evidence: [], events: [], tasks: [], @@ -40,6 +41,21 @@ routes.push({ path: '/case/:id', name: 'case', component: { expanded: [], loading: false, }, + attachments: { + sortBy: 'createTime', + sortDesc: false, + search: '', + headers: [ + { text: this.$root.i18n.dateCreated, value: 'createTime' }, + { text: this.$root.i18n.dateModified, value: 'updateTime' }, + { text: this.$root.i18n.filename, value: 'value' }, + ], + itemsPerPage: 10, + footerProps: { 'items-per-page-options': [10,50,250,1000] }, + count: 500, + expanded: [], + loading: false, + }, evidence: { sortBy: 'createTime', sortDesc: false, @@ -96,6 +112,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { { text: this.$root.i18n.time, value: 'updateTime' }, { text: this.$root.i18n.kind, value: 'kind' }, { text: this.$root.i18n.operation, value: 'operation' }, + { text: '', value: 'kindLocalized', align: ' d-none' }, + { text: '', value: 'operationLocalized', align: ' d-none' }, ], itemsPerPage: 10, footerProps: { 'items-per-page-options': [10,50,250,1000] }, @@ -108,6 +126,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { expanded: [0, 1], associatedForms: { comments: {}, + attachments: {}, evidence: {}, }, editForm: {}, @@ -148,6 +167,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.maxUploadSizeBytes = params.maxUploadSizeBytes; } this.loadLocalSettings(); + this.resetForm('attachments'); this.resetForm('evidence'); this.resetForm('comments'); }, @@ -166,6 +186,12 @@ routes.push({ path: '/case/:id', name: 'case', component: { mapAssociatedPath(association, concatPath = false) { var path = association; switch (association) { + case 'attachments': + path = "artifacts"; + if (concatPath) { + path += "/" + association + } + break; case 'evidence': path = "artifacts"; if (concatPath) { @@ -175,6 +201,19 @@ routes.push({ path: '/case/:id', name: 'case', component: { } return path; }, + mapAssociatedKind(obj) { + var name = ""; + if (obj) { + switch (obj.kind) { + case 'artifact': + name = obj.groupType; + break; + default: + name = obj.kind; + } + } + return name; + }, async loadAssociations() { this.associationsLoading = true; @@ -184,6 +223,9 @@ routes.push({ path: '/case/:id', name: 'case', component: { this.associations["tasks"] = []; this.loadAssociation('tasks'); + this.associations["attachments"] = []; + this.loadAssociation('attachments'); + this.associations["evidence"] = []; this.loadAssociation('evidence'); @@ -207,6 +249,8 @@ routes.push({ path: '/case/:id', name: 'case', component: { for (var idx = 0; idx < response.data.length; idx++) { const obj = response.data[idx]; await this.$root.populateUserDetails(obj, "userId", "owner"); + obj.kindLocalized = this.$root.localizeMessage(this.mapAssociatedKind(obj)); + obj.operationLocalized = this.$root.localizeMessage(obj.operation); this.associations[association].push(obj); } } @@ -402,7 +446,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { if (idx > -1) { this.$root.startLoading(); try { - await this.$root.papi.delete('case/' + association, { params: { + await this.$root.papi.delete('case/' + this.mapAssociatedPath(association), { params: { id: obj.id }}); this.associations[association].splice(idx, 1); @@ -472,6 +516,9 @@ routes.push({ path: '/case/:id', name: 'case', component: { resetForm(ref) { const form = { valid: false }; switch (ref) { + case "attachments": + form.tlp = this.getDefaultPreset('tlp'); + break; case "evidence": form.tlp = this.getDefaultPreset('tlp'); form.artifactType = this.getDefaultPreset('artifactType'); diff --git a/html/js/routes/case.test.js b/html/js/routes/case.test.js index d5832d6e..64ba0623 100644 --- a/html/js/routes/case.test.js +++ b/html/js/routes/case.test.js @@ -312,7 +312,7 @@ test('modifyAssociation', async () => { comp.associations['comments'] = [fakeComment]; await comp.modifyAssociation('comments', fakeComment); - const body = "{\"userId\":\"myUserId\",\"id\":\"myCommentId\",\"description\":\"myDescription2\",\"owner\":\"my@email.invalid\"}"; + const body = "{\"userId\":\"myUserId\",\"id\":\"myCommentId\",\"description\":\"myDescription2\",\"owner\":\"my@email.invalid\",\"kindLocalized\":\"\",\"operationLocalized\":\"\"}"; expect(mock).toHaveBeenCalledWith('case/comments', body); expect(showErrorMock).toHaveBeenCalledTimes(0); expect(comp.associations['comments'].length).toBe(1); @@ -583,8 +583,10 @@ test('isEdited', () => { }); test('mapAssociatedPath', () => { - expect(comp.mapAssociatedPath('comments')).teBe('comments'); - expect(comp.mapAssociatedPath('comments', true)).teBe('comments'); - expect(comp.mapAssociatedPath('evidence')).teBe('artifacts'); - expect(comp.mapAssociatedPath('evidence', true)).teBe('artifacts/evidence'); + expect(comp.mapAssociatedPath('comments')).toBe('comments'); + expect(comp.mapAssociatedPath('comments', true)).toBe('comments'); + expect(comp.mapAssociatedPath('evidence')).toBe('artifacts'); + expect(comp.mapAssociatedPath('evidence', true)).toBe('artifacts/evidence'); + expect(comp.mapAssociatedPath('attachments')).toBe('artifacts'); + expect(comp.mapAssociatedPath('attachments', true)).toBe('artifacts/attachments'); }); \ No newline at end of file From 2c672cf2e361608d50f626e401d27bbd6e7d75da Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 27 Dec 2021 15:32:46 -0500 Subject: [PATCH 43/98] Use KeyboardEvent.key instead of UIEvent.which --- html/js/routes/case.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/html/js/routes/case.js b/html/js/routes/case.js index 1d14ca20..227925bb 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -452,9 +452,9 @@ routes.push({ path: '/case/:id', name: 'case', component: { return okToClear; }, onEditKeyUp(event) { - switch (event.which) { - case 27: this.stopEdit(); break; - case 13: if (!this.editForm.isMultiline) this.stopEdit(true); break; + switch (event.key) { + case 'Escape': this.stopEdit(); break; + case 'Enter': if (!this.editForm.isMultiline) this.stopEdit(true); break; } }, resetForm(ref) { From 1203aa31c215bf325d814108863f838c171df60c Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 28 Dec 2021 11:41:23 -0500 Subject: [PATCH 44/98] Compute and render common hashes of uploaded files --- html/index.html | 44 ++++++++++++++++++- html/js/i18n.js | 3 ++ html/js/routes/case.js | 4 +- model/case.go | 35 ++++++++++----- model/case_test.go | 5 ++- server/casehandler.go | 2 +- server/modules/elastic/converter.go | 9 ++++ server/modules/elastic/converter_test.go | 6 +++ server/modules/elastic/elasticcasestore.go | 12 +++++ .../modules/elastic/elasticcasestore_test.go | 34 +++++++++++++- 10 files changed, 137 insertions(+), 17 deletions(-) diff --git a/html/index.html b/html/index.html index 3e7c46b9..a227e8e5 100644 --- a/html/index.html +++ b/html/index.html @@ -1395,6 +1395,27 @@

Description

({{ props.item.streamLength | formatCount }} {{ i18n.bytes }}) +
+
{{i18n.sha256}}:
+
+ {{ withDefault(props.item.sha256, i18n.unknown) }} + fa-copy +
+
+
+
{{i18n.sha1}}:
+
+ {{ withDefault(props.item.sha1, i18n.unknown) }} + fa-copy +
+
+
+
{{i18n.md5}}:
+
+ {{ withDefault(props.item.md5, i18n.unknown) }} + fa-copy +
+
@@ -1561,6 +1582,27 @@

Description

({{ props.item.streamLength | formatCount }} {{ i18n.bytes }}) +
+
{{i18n.sha256}}:
+
+ {{ withDefault(props.item.sha256, i18n.unknown) }} + fa-copy +
+
+
+
{{i18n.sha1}}:
+
+ {{ withDefault(props.item.sha1, i18n.unknown) }} + fa-copy +
+
+
+
{{i18n.md5}}:
+
+ {{ withDefault(props.item.md5, i18n.unknown) }} + fa-copy +
+
@@ -1676,7 +1718,7 @@

Description

- + diff --git a/html/js/i18n.js b/html/js/i18n.js index 567f1e67..96a28cd5 100644 --- a/html/js/i18n.js +++ b/html/js/i18n.js @@ -276,6 +276,7 @@ const i18n = { loginTitle: 'Login to Security Onion', logout: 'Logout', logoutFailure: 'Unable to initiate logout. Ensure server is accessible.', + md5: 'MD5', message: 'Message', minutes: 'minutes', model: 'Model', @@ -347,6 +348,8 @@ const i18n = { settingsInvalid: 'Unable to save settings: ', settingsSaved: 'Your new settings have been saved.', settingsTitle: 'User Settings', + sha1: 'SHA1', + sha256: 'SHA256', share: 'Clipboard', 'so-eval': 'Evaluation', 'so-eval-keywords': 'Elastic, Elasticsearch, Fleet, Forward, Ingest, Manager, Master, Search, Sensor, Sensoroni, Soc, Stenographer, Web', diff --git a/html/js/routes/case.js b/html/js/routes/case.js index a8346130..c617abbd 100644 --- a/html/js/routes/case.js +++ b/html/js/routes/case.js @@ -140,6 +140,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { longLengthLimit: value => (encodeURI(value).split(/%..|./).length - 1 < 10000000) || this.$root.i18n.required, fileSizeLimit: value => (value == null || value.size < this.maxUploadSizeBytes) || this.$root.i18n.fileTooLarge.replace("{maxUploadSizeBytes}", this.$root.formatCount(this.maxUploadSizeBytes)), fileNotEmpty: value => (value == null || value.size > 0) || this.$root.i18n.fileEmpty, + fileRequired: value => (value != null) || this.$root.i18n.required, }, attachment: null, maxUploadSizeBytes: 26214400, @@ -391,7 +392,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { let config = undefined; let data = JSON.stringify(form); - if (this.attachment) { + if (this.attachment && form.artifactType == 'file') { let jsonData = data; data = new FormData(); data.append("json", jsonData); @@ -515,6 +516,7 @@ routes.push({ path: '/case/:id', name: 'case', component: { }, resetForm(ref) { const form = { valid: false }; + this.attachment = null; switch (ref) { case "attachments": form.tlp = this.getDefaultPreset('tlp'); diff --git a/model/case.go b/model/case.go index 194328be..c8450271 100644 --- a/model/case.go +++ b/model/case.go @@ -11,10 +11,13 @@ package model import ( "bytes" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" "encoding/base64" "fmt" + "hash" "io" - "math" "net/http" "strings" "time" @@ -92,6 +95,9 @@ type Artifact struct { Tags []string `json:"tags"` Description string `json:"description"` Ioc bool `json:"ioc"` + Md5 string `json:"md5"` + Sha1 string `json:"sha1"` + Sha256 string `json:"sha256"` } func NewArtifact() *Artifact { @@ -113,20 +119,27 @@ func NewArtifactStream() *ArtifactStream { return newStream } -func (stream *ArtifactStream) Write(reader io.Reader) (int, string, error) { +func (stream *ArtifactStream) hashBytes(hasher hash.Hash, input []byte) string { + hasher.Write(input) + output := hasher.Sum(nil) + return fmt.Sprintf("%x", output) +} + +func (stream *ArtifactStream) Write(reader io.Reader) (int, string, string, string, string, error) { var buffer bytes.Buffer - var mimeType string - b64 := base64.NewEncoder(base64.StdEncoding, &buffer) - copyLen, err := io.Copy(b64, reader) + var mimeType, md5hash, sha1hash, sha256hash string + // Collect bytes in memory + copyLen, err := buffer.ReadFrom(reader) if err == nil { - b64.Close() - stream.Content = buffer.String() - preview := stream.Content[:int64(math.Min(1024, float64(copyLen)))] - previewDecoded, _ := base64.StdEncoding.DecodeString(preview) - mimeType = http.DetectContentType(previewDecoded) + raw := buffer.Bytes() + stream.Content = base64.StdEncoding.EncodeToString(raw) + mimeType = http.DetectContentType(raw) + md5hash = stream.hashBytes(md5.New(), raw) + sha1hash = stream.hashBytes(sha1.New(), raw) + sha256hash = stream.hashBytes(sha256.New(), raw) } - return int(copyLen), mimeType, err + return int(copyLen), mimeType, md5hash, sha1hash, sha256hash, err } func (stream *ArtifactStream) Read() io.Reader { diff --git a/model/case_test.go b/model/case_test.go index 6e373a7d..54291706 100644 --- a/model/case_test.go +++ b/model/case_test.go @@ -31,10 +31,13 @@ func TestNewArtifactStream(tester *testing.T) { event := NewArtifactStream() assert.NotZero(tester, event.CreateTime) reader := strings.NewReader("hello world") - len, mimeType, err := event.Write(reader) + len, mimeType, md5, sha1, sha256, err := event.Write(reader) assert.NoError(tester, err) assert.Equal(tester, 11, len) assert.Equal(tester, "text/plain; charset=utf-8", mimeType) + assert.Equal(tester, "5eb63bbbe01eeed093cb22bb8f5acdc3", md5) + assert.Equal(tester, "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed", sha1) + assert.Equal(tester, "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", sha256) assert.Equal(tester, "aGVsbG8gd29ybGQ=", event.Content) var buffer bytes.Buffer diff --git a/server/casehandler.go b/server/casehandler.go index a40379f7..29ea3586 100644 --- a/server/casehandler.go +++ b/server/casehandler.go @@ -101,7 +101,7 @@ func (caseHandler *CaseHandler) create(ctx context.Context, writer http.Response inputArtifact.ArtifactType = "file" artifactStream := model.NewArtifactStream() - inputArtifact.StreamLen, inputArtifact.MimeType, err = artifactStream.Write(file) + inputArtifact.StreamLen, inputArtifact.MimeType, inputArtifact.Md5, inputArtifact.Sha1, inputArtifact.Sha256, err = artifactStream.Write(file) if err == nil { if inputArtifact.StreamLen != int(handler.Size) { log.WithFields(log.Fields{ diff --git a/server/modules/elastic/converter.go b/server/modules/elastic/converter.go index 5a016c92..2ed69df1 100644 --- a/server/modules/elastic/converter.go +++ b/server/modules/elastic/converter.go @@ -534,6 +534,15 @@ func convertElasticEventToArtifact(event *model.EventRecord) (*model.Artifact, e if value, ok := event.Payload["artifact.ioc"]; ok { obj.Ioc = value.(bool) } + if value, ok := event.Payload["artifact.md5"]; ok { + obj.Md5 = value.(string) + } + if value, ok := event.Payload["artifact.sha1"]; ok { + obj.Sha1 = value.(string) + } + if value, ok := event.Payload["artifact.sha256"]; ok { + obj.Sha256 = value.(string) + } obj.CreateTime = parseTime(event.Payload, "artifact.createTime") } } diff --git a/server/modules/elastic/converter_test.go b/server/modules/elastic/converter_test.go index 93daf8cf..3fa96993 100644 --- a/server/modules/elastic/converter_test.go +++ b/server/modules/elastic/converter_test.go @@ -384,6 +384,9 @@ func TestConvertElasticEventToArtifact(tester *testing.T) { event.Payload["artifact.tlp"] = "myTlp" event.Payload["artifact.mimeType"] = "myMimeType" event.Payload["artifact.ioc"] = true + event.Payload["artifact.md5"] = "myMd5" + event.Payload["artifact.sha1"] = "mySha1" + event.Payload["artifact.sha256"] = "mySha256" tags := make([]interface{}, 2, 2) tags[0] = "tag1" tags[1] = "tag2" @@ -408,6 +411,9 @@ func TestConvertElasticEventToArtifact(tester *testing.T) { assert.Equal(tester, true, artifactObj.Ioc) assert.Equal(tester, tags[0], "tag1") assert.Equal(tester, tags[1], "tag2") + assert.Equal(tester, "myMd5", artifactObj.Md5) + assert.Equal(tester, "mySha1", artifactObj.Sha1) + assert.Equal(tester, "mySha256", artifactObj.Sha256) assert.Equal(tester, &myTime, artifactObj.UpdateTime) assert.Equal(tester, &myCreateTime, artifactObj.CreateTime) } diff --git a/server/modules/elastic/elasticcasestore.go b/server/modules/elastic/elasticcasestore.go index 89aed3a6..06858c96 100644 --- a/server/modules/elastic/elasticcasestore.go +++ b/server/modules/elastic/elasticcasestore.go @@ -232,6 +232,15 @@ func (store *ElasticCasestore) validateArtifact(artifact *model.Artifact) error if err == nil { err = store.validateStringArray(artifact.Tags, SHORT_STRING_MAX, MAX_ARRAY_ELEMENTS, "tags") } + if err == nil { + err = store.validateString(artifact.Md5, SHORT_STRING_MAX, "md5") + } + if err == nil { + err = store.validateString(artifact.Sha1, SHORT_STRING_MAX, "sha1") + } + if err == nil { + err = store.validateString(artifact.Sha256, SHORT_STRING_MAX, "sha256") + } return err } @@ -730,6 +739,9 @@ func (store *ElasticCasestore) UpdateArtifact(ctx context.Context, artifact *mod artifact.StreamLen = old.StreamLen artifact.MimeType = old.MimeType artifact.StreamId = old.StreamId + artifact.Md5 = old.Md5 + artifact.Sha1 = old.Sha1 + artifact.Sha256 = old.Sha256 var results *model.EventIndexResults results, err = store.save(ctx, artifact, "artifact", store.prepareForSave(ctx, &artifact.Auditable)) if err == nil { diff --git a/server/modules/elastic/elasticcasestore_test.go b/server/modules/elastic/elasticcasestore_test.go index aeb20ef6..503cc025 100644 --- a/server/modules/elastic/elasticcasestore_test.go +++ b/server/modules/elastic/elasticcasestore_test.go @@ -421,12 +421,33 @@ func TestValidateArtifactInvalid(tester *testing.T) { artifact.StreamLen = 0 for x := 1; x < 5; x++ { - artifact.MimeType += "this is my unreasonably long severity\n" + artifact.MimeType += "this is my unreasonably long str\n" } err = store.validateArtifact(artifact) - assert.EqualError(tester, err, "mimeType is too long (152/100)") + assert.EqualError(tester, err, "mimeType is too long (132/100)") artifact.MimeType = "image/jpg" + for x := 1; x < 5; x++ { + artifact.Md5 += "this is my unreasonably long str\n" + } + err = store.validateArtifact(artifact) + assert.EqualError(tester, err, "md5 is too long (132/100)") + artifact.Md5 = "myMd5" + + for x := 1; x < 5; x++ { + artifact.Sha1 += "this is my unreasonably long str\n" + } + err = store.validateArtifact(artifact) + assert.EqualError(tester, err, "sha1 is too long (132/100)") + artifact.Sha1 = "mySha1" + + for x := 1; x < 5; x++ { + artifact.Sha256 += "this is my unreasonably long str\n" + } + err = store.validateArtifact(artifact) + assert.EqualError(tester, err, "sha256 is too long (132/100)") + artifact.Sha256 = "mySha256" + for x := 1; x < 5; x++ { artifact.Tlp += "this is my unreasonably long tlp\n" } @@ -1297,6 +1318,9 @@ func TestUpdateArtifact(tester *testing.T) { eventPayload["artifact.value"] = "myValue" eventPayload["artifact.streamLength"] = 123.0 eventPayload["artifact.mimeType"] = "myMimeType" + eventPayload["artifact.md5"] = "myMd5" + eventPayload["artifact.sha1"] = "mySha1" + eventPayload["artifact.sha256"] = "mySha256" eventPayload["artifact.streamId"] = "myStreamId" eventPayload["artifact.description"] = "myDesc" elasticEvent := &model.EventRecord{ @@ -1312,6 +1336,9 @@ func TestUpdateArtifact(tester *testing.T) { artifact.ArtifactType = "file" artifact.GroupId = "myNewGroupId" artifact.MimeType = "myNewMimeType" + artifact.Md5 = "myNewMd5" + artifact.Sha1 = "myNewSha1" + artifact.Sha256 = "myNewSha256" artifact.StreamId = "myNewStreamId" artifact.StreamLen = 456 artifact.Description = "myNewDesc" @@ -1334,6 +1361,9 @@ func TestUpdateArtifact(tester *testing.T) { assert.Equal(tester, "myGroupId", newArtifact.GroupId) assert.Equal(tester, "myStreamId", newArtifact.StreamId) assert.Equal(tester, "myMimeType", newArtifact.MimeType) + assert.Equal(tester, "myMd5", newArtifact.Md5) + assert.Equal(tester, "mySha1", newArtifact.Sha1) + assert.Equal(tester, "mySha256", newArtifact.Sha256) assert.Equal(tester, "myValue", newArtifact.Value) assert.Equal(tester, 123, newArtifact.StreamLen) } From 88ecaea47dfbfef751a1cc2812aa84e887dca85c Mon Sep 17 00:00:00 2001 From: William Wernert Date: Tue, 28 Dec 2021 14:08:57 -0500 Subject: [PATCH 45/98] Fix markdown formatting in comments --- html/css/app.css | 36 ++++++++++++++++++++++++ html/index.html | 73 ++++++++++++++++++++++++++++++------------------ html/js/app.js | 5 ++++ 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index 380aa2ac..8aa49f24 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -241,3 +241,39 @@ td { #connection-indicator { color: white; } + +.comment-body { + width: 100%; +} + +.markdown-body { + overflow: auto; + position: relative; + padding: 0.75em 1.25em; + border-radius: 0.25em; + background-color: rgba(0,0,0,.2); +} + +.markdown-body pre { + overflow-x: scroll; +} + +.v-application .markdown-body pre code { + display: block; + background-color: revert !important; + padding: 0.25 !important; +} + +.theme--light.v-application .markdown-body pre { + background-color: rgba(0,0,0,.05); + border-radius: 0.25em; +} + +.theme--dark.v-application .markdown-body pre { + background-color: hsla(0,0%,100%,.1); + border-radius: 0.25em; +} + +.case.avatar-font { + font-size: 11px; +} diff --git a/html/index.html b/html/index.html index a227e8e5..f4b0fa28 100644 --- a/html/index.html +++ b/html/index.html @@ -1286,18 +1286,13 @@

Description

-
- -
- {{ $root.getAvatar(comment.owner) }} -
-
- -
+
+ +
-
-
-
+
+
+
@@ -1315,9 +1310,16 @@

Description

-
-
- {{ comment.owner }} +
+
+ +
+ {{ $root.getAvatar(comment.owner) }} +
+
+
+ {{ comment.owner }} +
• @@ -1325,10 +1327,10 @@

Description

{{ comment.createTime | formatDateTime }}
-
+
{{ i18n.edited }}
-
+
fa-edit fa-trash
@@ -1338,18 +1340,20 @@

Description

-
- -
- {{ $root.getAvatar($root.username) }} -
-
- -
+
+ +
-

{{ i18n.commentAdd }}

+
+ +
+ {{ $root.getAvatar($root.username) }} +
+
+

{{ i18n.commentAdd }}

+
@@ -1520,7 +1524,14 @@

Description

-

{{ i18n.attachmentAdd }}

+
+ +
+ {{ $root.getAvatar($root.username) }} +
+
+

{{ i18n.attachmentAdd }}

+
@@ -1714,7 +1725,14 @@

Description

-

{{ i18n.evidenceAdd }}

+
+ +
+ {{ $root.getAvatar($root.username) }} +
+
+

{{ i18n.evidenceAdd }}

+
@@ -2392,6 +2410,7 @@

+ diff --git a/html/js/app.js b/html/js/app.js index 75ee0479..448ada07 100644 --- a/html/js/app.js +++ b/html/js/app.js @@ -7,6 +7,7 @@ // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + const routes = []; if (typeof global !== 'undefined') global.routes = routes; @@ -430,6 +431,10 @@ $(document).ready(function() { return ""; }, formatMarkdown(str) { + marked.setOptions({ + renderer: new marked.Renderer(), + smartLists: true + }) var md = str; if (str) { md = marked(str); From b43601c7d41b0b53e7cc903655d5a409d2a4bb24 Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 28 Dec 2021 19:13:20 -0500 Subject: [PATCH 46/98] Enable CM in CCS mode --- server/modules/elastic/elastic.go | 4 ++-- server/modules/elastic/elasticeventstore.go | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/server/modules/elastic/elastic.go b/server/modules/elastic/elastic.go index e432795d..3112ffa3 100644 --- a/server/modules/elastic/elastic.go +++ b/server/modules/elastic/elastic.go @@ -16,8 +16,8 @@ import ( "github.com/security-onion-solutions/securityonion-soc/server" ) -const DEFAULT_CASE_INDEX = "so-case" -const DEFAULT_CASE_AUDIT_INDEX = "so-casehistory" +const DEFAULT_CASE_INDEX = "*:so-case" +const DEFAULT_CASE_AUDIT_INDEX = "*:so-casehistory" const DEFAULT_CASE_ASSOCIATIONS_MAX = 1000 const DEFAULT_TIME_SHIFT_MS = 120000 const DEFAULT_DURATION_MS = 1800000 diff --git a/server/modules/elastic/elasticeventstore.go b/server/modules/elastic/elasticeventstore.go index 3199534e..b9179747 100644 --- a/server/modules/elastic/elasticeventstore.go +++ b/server/modules/elastic/elasticeventstore.go @@ -198,12 +198,17 @@ func (store *ElasticEventstore) Search(ctx context.Context, criteria *model.Even return results, err } +func (store *ElasticEventstore) disableCrossClusterIndex(index string) string { + pieces := strings.SplitN(index, ":", 2) + if len(pieces) == 2 { + index = pieces[1] + } + return index +} + func (store *ElasticEventstore) disableCrossClusterIndexing(indexes []string) []string { for idx, index := range indexes { - pieces := strings.SplitN(index, ":", 2) - if len(pieces) == 2 { - indexes[idx] = pieces[1] - } + indexes[idx] = store.disableCrossClusterIndex(index) } return indexes } @@ -264,7 +269,7 @@ func (store *ElasticEventstore) Index(ctx context.Context, index string, documen var response string log.Debug("Sending index request to primary Elasticsearch client") - response, err = store.indexDocument(ctx, index, request, id) + response, err = store.indexDocument(ctx, store.disableCrossClusterIndex(index), request, id) if err == nil { err = convertFromElasticIndexResults(store, response, results) if err != nil { @@ -284,7 +289,7 @@ func (store *ElasticEventstore) Delete(ctx context.Context, index string, id str if err = store.server.CheckAuthorized(ctx, "write", "events"); err == nil { var response string log.Debug("Sending delete request to primary Elasticsearch client") - response, err = store.deleteDocument(ctx, index, id) + response, err = store.deleteDocument(ctx, store.disableCrossClusterIndex(index), id) if err == nil { err = convertFromElasticIndexResults(store, response, results) if err != nil { From c227ff703009c9008dbe8a599d7d13854523779a Mon Sep 17 00:00:00 2001 From: Jason Ertel Date: Tue, 28 Dec 2021 19:16:47 -0500 Subject: [PATCH 47/98] Correct link to related event --- html/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/html/index.html b/html/index.html index f4b0fa28..1257395b 100644 --- a/html/index.html +++ b/html/index.html @@ -1779,7 +1779,7 @@

{{ i18n.evidenceAdd }}