diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts index 86b8ca1ff3894e..b7e42f7e46a706 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules_custom.spec.ts @@ -8,6 +8,7 @@ import { newRule, totalNumberOfPrebuiltRules } from '../objects/rule'; import { ABOUT_FALSE_POSITIVES, + ABOUT_INVESTIGATION_NOTES, ABOUT_MITRE, ABOUT_RISK, ABOUT_RULE_DESCRIPTION, @@ -19,6 +20,9 @@ import { DEFINITION_INDEX_PATTERNS, DEFINITION_TIMELINE, DEFINITION_STEP, + INVESTIGATION_NOTES_MARKDOWN, + INVESTIGATION_NOTES_TOGGLE, + RULE_ABOUT_DETAILS_HEADER_TOGGLE, RULE_NAME_HEADER, SCHEDULE_LOOPBACK, SCHEDULE_RUNS, @@ -170,6 +174,13 @@ describe('Signal detection rules, custom', () => { .invoke('text') .should('eql', expectedTags); + cy.get(RULE_ABOUT_DETAILS_HEADER_TOGGLE) + .eq(INVESTIGATION_NOTES_TOGGLE) + .click({ force: true }); + cy.get(ABOUT_INVESTIGATION_NOTES) + .invoke('text') + .should('eql', INVESTIGATION_NOTES_MARKDOWN); + cy.get(DEFINITION_INDEX_PATTERNS).then(patterns => { cy.wrap(patterns).each((pattern, index) => { cy.wrap(pattern) diff --git a/x-pack/legacy/plugins/siem/cypress/objects/rule.ts b/x-pack/legacy/plugins/siem/cypress/objects/rule.ts index a3c648c9cc9349..37c325c3b80307 100644 --- a/x-pack/legacy/plugins/siem/cypress/objects/rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/objects/rule.ts @@ -22,6 +22,7 @@ export interface CustomRule { referenceUrls: string[]; falsePositivesExamples: string[]; mitre: Mitre[]; + note: string; } export interface MachineLearningRule { @@ -36,6 +37,7 @@ export interface MachineLearningRule { referenceUrls: string[]; falsePositivesExamples: string[]; mitre: Mitre[]; + note: string; } const mitre1: Mitre = { @@ -58,6 +60,7 @@ export const newRule: CustomRule = { referenceUrls: ['https://www.google.com/', 'https://elastic.co/'], falsePositivesExamples: ['False1', 'False2'], mitre: [mitre1, mitre2], + note: '# test markdown', }; export const machineLearningRule: MachineLearningRule = { @@ -71,4 +74,5 @@ export const machineLearningRule: MachineLearningRule = { referenceUrls: ['https://elastic.co/'], falsePositivesExamples: ['False1'], mitre: [mitre1], + note: '# test markdown', }; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts index e603e2ee5158ed..db9866cdf7f63b 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts @@ -24,7 +24,8 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; -export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]'; +export const INVESTIGATION_NOTES_TEXTAREA = + '[data-test-subj="detectionEngineStepAboutRuleNote"] textarea'; export const FALSE_POSITIVES_INPUT = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] input'; @@ -53,6 +54,8 @@ export const RULE_DESCRIPTION_INPUT = export const RULE_NAME_INPUT = '[data-test-subj="detectionEngineStepAboutRuleName"] [data-test-subj="input"]'; +export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]'; + export const SEVERITY_DROPDOWN = '[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts index fc9e4c56dd8249..ec57e142125da7 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts @@ -6,6 +6,8 @@ export const ABOUT_FALSE_POSITIVES = 3; +export const ABOUT_INVESTIGATION_NOTES = '[data-test-subj="stepAboutDetailsNoteContent"]'; + export const ABOUT_MITRE = 4; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -32,10 +34,16 @@ export const DEFINITION_INDEX_PATTERNS = export const DEFINITION_STEP = '[data-test-subj=definitionRule] [data-test-subj="listItemColumnStepRuleDescription"] .euiDescriptionList__description'; +export const INVESTIGATION_NOTES_MARKDOWN = 'test markdown'; + +export const INVESTIGATION_NOTES_TOGGLE = 1; + export const MACHINE_LEARNING_JOB_ID = '[data-test-subj="machineLearningJobId"]'; export const MACHINE_LEARNING_JOB_STATUS = '[data-test-subj="machineLearningJobStatus" ]'; +export const RULE_ABOUT_DETAILS_HEADER_TOGGLE = '[data-test-subj="stepAboutDetailsToggle"]'; + export const RULE_NAME_HEADER = '[data-test-subj="header-page-title"]'; export const RULE_TYPE = 0; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts index 59ed156bf56b11..a20ad372a689c5 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts @@ -14,6 +14,7 @@ import { CUSTOM_QUERY_INPUT, DEFINE_CONTINUE_BUTTON, FALSE_POSITIVES_INPUT, + INVESTIGATION_NOTES_TEXTAREA, MACHINE_LEARNING_DROPDOWN, MACHINE_LEARNING_LIST, MACHINE_LEARNING_TYPE, @@ -82,6 +83,8 @@ export const fillAboutRuleAndContinue = (rule: CustomRule | MachineLearningRule) cy.get(MITRE_BTN).click({ force: true }); }); + cy.get(INVESTIGATION_NOTES_TEXTAREA).type(rule.note, { force: true }); + cy.get(ABOUT_CONTINUE_BTN) .should('exist') .click({ force: true }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts index 60ebd2578b7c02..a779d579bf4d1c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.test.ts @@ -4,16 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ import { cloneDeep, omit } from 'lodash/fp'; +import { Dispatch } from 'redux'; -import { mockTimelineResults } from '../../mock/timeline_results'; +import { + mockTimelineResults, + mockTimelineResult, + mockTimelineModel, +} from '../../mock/timeline_results'; import { timelineDefaults } from '../../store/timeline/defaults'; +import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../store/inputs/actions'; +import { + setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, + applyKqlFilterQuery as dispatchApplyKqlFilterQuery, + addTimeline as dispatchAddTimeline, + addNote as dispatchAddGlobalTimelineNote, +} from '../../store/timeline/actions'; +import { + addNotes as dispatchAddNotes, + updateNote as dispatchUpdateNote, +} from '../../store/app/actions'; import { defaultTimelineToTimelineModel, getNotesCount, getPinnedEventCount, isUntitled, + omitTypenameInTimeline, + dispatchUpdateTimeline, } from './helpers'; -import { OpenTimelineResult } from './types'; +import { OpenTimelineResult, DispatchUpdateTimeline } from './types'; +import { KueryFilterQueryKind } from '../../store/model'; +import { Note } from '../../lib/note'; +import moment from 'moment'; +import sinon from 'sinon'; + +jest.mock('../../store/inputs/actions'); +jest.mock('../../store/timeline/actions'); +jest.mock('../../store/app/actions'); +jest.mock('uuid', () => { + return { + v1: jest.fn(() => 'uuid.v1()'), + v4: jest.fn(() => 'uuid.v4()'), + }; +}); describe('helpers', () => { let mockResults: OpenTimelineResult[]; @@ -620,4 +652,229 @@ describe('helpers', () => { }); }); }); + + describe('omitTypenameInTimeline', () => { + test('it does not modify the passed in timeline if no __typename exists', () => { + const result = omitTypenameInTimeline(mockTimelineResult); + + expect(result).toEqual(mockTimelineResult); + }); + + test('it returns timeline with __typename removed when it exists', () => { + const mockTimeline = { + ...mockTimelineResult, + __typename: 'something, something', + }; + const result = omitTypenameInTimeline(mockTimeline); + const expectedTimeline = { + ...mockTimeline, + __typename: undefined, + }; + + expect(result).toEqual(expectedTimeline); + }); + }); + + describe('dispatchUpdateTimeline', () => { + const dispatch = jest.fn() as Dispatch; + const anchor = '2020-03-27T20:34:51.337Z'; + const unix = moment(anchor).valueOf(); + let clock: sinon.SinonFakeTimers; + let timelineDispatch: DispatchUpdateTimeline; + + beforeEach(() => { + jest.clearAllMocks(); + + clock = sinon.useFakeTimers(unix); + timelineDispatch = dispatchUpdateTimeline(dispatch); + }); + + afterEach(function() { + clock.restore(); + }); + + test('it invokes date range picker dispatch', () => { + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimelineModel, + })(); + + expect(dispatchSetTimelineRangeDatePicker).toHaveBeenCalledWith({ + from: 1585233356356, + to: 1585233716356, + }); + }); + + test('it invokes add timeline dispatch', () => { + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimelineModel, + })(); + + expect(dispatchAddTimeline).toHaveBeenCalledWith({ + id: 'timeline-1', + timeline: mockTimelineModel, + }); + }); + + test('it does not invoke kql filter query dispatches if timeline.kqlQuery.filterQuery is null', () => { + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimelineModel, + })(); + + expect(dispatchSetKqlFilterQueryDraft).not.toHaveBeenCalled(); + expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); + }); + + test('it does not invoke notes dispatch if duplicate is true', () => { + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimelineModel, + })(); + + expect(dispatchAddNotes).not.toHaveBeenCalled(); + }); + + test('it does not invoke kql filter query dispatches if timeline.kqlQuery.kuery is null', () => { + const mockTimeline = { + ...mockTimelineModel, + kqlQuery: { + filterQuery: { + kuery: null, + serializedQuery: 'some-serialized-query', + }, + filterQueryDraft: null, + }, + }; + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimeline, + })(); + + expect(dispatchSetKqlFilterQueryDraft).not.toHaveBeenCalled(); + expect(dispatchApplyKqlFilterQuery).not.toHaveBeenCalled(); + }); + + test('it invokes kql filter query dispatches if timeline.kqlQuery.filterQuery.kuery is not null', () => { + const mockTimeline = { + ...mockTimelineModel, + kqlQuery: { + filterQuery: { + kuery: { expression: 'expression', kind: 'kuery' as KueryFilterQueryKind }, + serializedQuery: 'some-serialized-query', + }, + filterQueryDraft: null, + }, + }; + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimeline, + })(); + + expect(dispatchSetKqlFilterQueryDraft).toHaveBeenCalledWith({ + id: 'timeline-1', + filterQueryDraft: { + kind: 'kuery', + expression: 'expression', + }, + }); + expect(dispatchApplyKqlFilterQuery).toHaveBeenCalledWith({ + id: 'timeline-1', + filterQuery: { + kuery: { + kind: 'kuery', + expression: 'expression', + }, + serializedQuery: 'some-serialized-query', + }, + }); + }); + + test('it invokes dispatchAddNotes if duplicate is false', () => { + timelineDispatch({ + duplicate: false, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [ + { + created: 1585233356356, + updated: 1585233356356, + noteId: 'note-id', + note: 'I am a note', + }, + ], + timeline: mockTimelineModel, + })(); + + expect(dispatchAddGlobalTimelineNote).not.toHaveBeenCalled(); + expect(dispatchUpdateNote).not.toHaveBeenCalled(); + expect(dispatchAddNotes).toHaveBeenCalledWith({ + notes: [ + { + created: new Date('2020-03-26T14:35:56.356Z'), + id: 'note-id', + lastEdit: new Date('2020-03-26T14:35:56.356Z'), + note: 'I am a note', + user: 'unknown', + saveObjectId: 'note-id', + version: undefined, + }, + ], + }); + }); + + test('it invokes dispatch to create a timeline note if duplicate is true and ruleNote exists', () => { + timelineDispatch({ + duplicate: true, + id: 'timeline-1', + from: 1585233356356, + to: 1585233716356, + notes: [], + timeline: mockTimelineModel, + ruleNote: '# this would be some markdown', + })(); + const expectedNote: Note = { + created: new Date(anchor), + id: 'uuid.v4()', + lastEdit: null, + note: '# this would be some markdown', + saveObjectId: null, + user: 'elastic', + version: null, + }; + + expect(dispatchAddNotes).not.toHaveBeenCalled(); + expect(dispatchUpdateNote).toHaveBeenCalledWith({ note: expectedNote }); + expect(dispatchAddGlobalTimelineNote).toHaveBeenLastCalledWith({ + id: 'timeline-1', + noteId: 'uuid.v4()', + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts index 4f7d6cd64f1d92..16ba2de872bd1a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts @@ -5,18 +5,23 @@ */ import ApolloClient from 'apollo-client'; -import { getOr, set } from 'lodash/fp'; +import { getOr, set, isEmpty } from 'lodash/fp'; import { Action } from 'typescript-fsa'; +import uuid from 'uuid'; import { Dispatch } from 'redux'; import { oneTimelineQuery } from '../../containers/timeline/one/index.gql_query'; import { TimelineResult, GetOneTimeline, NoteResult } from '../../graphql/types'; -import { addNotes as dispatchAddNotes } from '../../store/app/actions'; +import { + addNotes as dispatchAddNotes, + updateNote as dispatchUpdateNote, +} from '../../store/app/actions'; import { setTimelineRangeDatePicker as dispatchSetTimelineRangeDatePicker } from '../../store/inputs/actions'; import { setKqlFilterQueryDraft as dispatchSetKqlFilterQueryDraft, applyKqlFilterQuery as dispatchApplyKqlFilterQuery, addTimeline as dispatchAddTimeline, + addNote as dispatchAddGlobalTimelineNote, } from '../../store/timeline/actions'; import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model'; @@ -32,6 +37,7 @@ import { import { OpenTimelineResult, UpdateTimeline, DispatchUpdateTimeline } from './types'; import { getTimeRangeSettings } from '../../utils/default_date_settings'; +import { createNote } from '../notes/helpers'; export const OPEN_TIMELINE_CLASS_NAME = 'open-timeline'; @@ -250,6 +256,7 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli notes, timeline, to, + ruleNote, }: UpdateTimeline): (() => void) => () => { dispatch(dispatchSetTimelineRangeDatePicker({ from, to })); dispatch(dispatchAddTimeline({ id, timeline })); @@ -281,6 +288,14 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli }) ); } + + if (duplicate && ruleNote != null && !isEmpty(ruleNote)) { + const getNewNoteId = (): string => uuid.v4(); + const newNote = createNote({ newNote: ruleNote, getNewNoteId }); + dispatch(dispatchUpdateNote({ note: newNote })); + dispatch(dispatchAddGlobalTimelineNote({ noteId: newNote.id, id })); + } + if (!duplicate) { dispatch( dispatchAddNotes({ diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 51c72681c08633..b7cc92ebd183f1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -173,6 +173,7 @@ export interface UpdateTimeline { notes: NoteResult[] | null | undefined; timeline: TimelineModel; to: number; + ruleNote?: string; } export type DispatchUpdateTimeline = ({ @@ -182,4 +183,5 @@ export type DispatchUpdateTimeline = ({ notes, timeline, to, + ruleNote, }: UpdateTimeline) => () => void; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/index.gql_query.ts index c54238c5d86872..53d0b98570bcbf 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/index.gql_query.ts @@ -206,6 +206,7 @@ export const timelineQuery = gql` query to filters + note } } suricata { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 5d43024625d0d1..2a9dd8f2aacfe8 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -4696,6 +4696,14 @@ "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "note", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToStringArray", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index a5d1e3fbcba27e..e15c099a007ad3 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -1012,6 +1012,8 @@ export interface RuleField { updated_by?: Maybe; version?: Maybe; + + note?: Maybe; } export interface SuricataEcsFields { @@ -4660,6 +4662,8 @@ export namespace GetTimelineQuery { to: Maybe; filters: Maybe; + + note: Maybe; }; export type Suricata = { diff --git a/x-pack/legacy/plugins/siem/public/mock/index.ts b/x-pack/legacy/plugins/siem/public/mock/index.ts index dbf5f2e55e7134..bdad0ab1712abe 100644 --- a/x-pack/legacy/plugins/siem/public/mock/index.ts +++ b/x-pack/legacy/plugins/siem/public/mock/index.ts @@ -13,3 +13,5 @@ export * from './mock_detail_item'; export * from './netflow'; export * from './test_providers'; export * from './utils'; +export * from './mock_ecs'; +export * from './timeline_results'; diff --git a/x-pack/legacy/plugins/siem/public/mock/mock_ecs.ts b/x-pack/legacy/plugins/siem/public/mock/mock_ecs.ts index 5d32d95804e69c..59e26039e6bffc 100644 --- a/x-pack/legacy/plugins/siem/public/mock/mock_ecs.ts +++ b/x-pack/legacy/plugins/siem/public/mock/mock_ecs.ts @@ -1280,3 +1280,69 @@ export const mockEcsData: Ecs[] = [ zeek: null, }, ]; + +export const mockEcsDataWithSignal: Ecs = { + _id: '1', + timestamp: '2018-11-05T19:03:25.937Z', + host: { + name: ['apache'], + ip: ['192.168.0.1'], + }, + event: { + id: ['1'], + action: ['Action'], + category: ['Access'], + module: ['nginx'], + severity: [3], + }, + source: { + ip: ['192.168.0.1'], + port: [80], + }, + destination: { + ip: ['192.168.0.3'], + port: [6343], + }, + user: { + id: ['1'], + name: ['john.dee'], + }, + geo: { + region_name: ['xx'], + country_iso_code: ['xx'], + }, + signal: { + rule: { + created_at: ['2020-01-10T21:11:45.839Z'], + updated_at: ['2020-01-10T21:11:45.839Z'], + created_by: ['elastic'], + description: ['24/7'], + enabled: [true], + false_positives: ['test-1'], + filters: [], + from: ['now-300s'], + id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'], + immutable: [false], + index: ['auditbeat-*'], + interval: ['5m'], + rule_id: ['rule-id-1'], + language: ['kuery'], + output_index: ['.siem-signals-default'], + max_signals: [100], + risk_score: ['21'], + query: ['user.name: root or user.name: admin'], + references: ['www.test.co'], + saved_id: ["Garrett's IP"], + timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'], + timeline_title: ['Untitled timeline'], + severity: ['low'], + updated_by: ['elastic'], + tags: [], + to: ['now'], + type: ['saved_query'], + threat: [], + note: ['# this is some markdown documentation'], + version: ['1'], + }, + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/mock/timeline_results.ts b/x-pack/legacy/plugins/siem/public/mock/timeline_results.ts index d6dc0ae131391d..363281e5633174 100644 --- a/x-pack/legacy/plugins/siem/public/mock/timeline_results.ts +++ b/x-pack/legacy/plugins/siem/public/mock/timeline_results.ts @@ -7,7 +7,10 @@ import { OpenTimelineResult } from '../components/open_timeline/types'; import { GetAllTimeline, SortFieldTimeline, TimelineResult, Direction } from '../graphql/types'; import { allTimelinesQuery } from '../containers/timeline/all/index.gql_query'; - +import { CreateTimelineProps } from '../pages/detection_engine/components/signals/types'; +import { TimelineModel } from '../store/timeline/model'; +import { timelineDefaults } from '../store/timeline/defaults'; +import { FilterStateStore } from '../../../../../../src/plugins/data/common/es_query/filters/meta_filter'; export interface MockedProvidedQuery { request: { query: GetAllTimeline.Query; @@ -2006,3 +2009,196 @@ export const mockTimelineResults: OpenTimelineResult[] = [ updatedBy: 'karen', }, ]; + +export const mockTimelineModel: TimelineModel = { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { + end: 1584539558929, + start: 1584539198929, + }, + deletedEventIds: [], + description: 'This is a sample rule description', + eventIdToNoteIds: {}, + eventType: 'all', + filters: [ + { + $state: { + store: FilterStateStore.APP_STATE, + }, + meta: { + alias: null, + disabled: true, + key: 'host.name', + negate: false, + params: '"{"query":"placeholder"}"', + type: 'phrase', + }, + query: '"{"match_phrase":{"host.name":"placeholder"}}"', + }, + ], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'ef579e40-jibber-jabber', + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + isSelectAllChecked: false, + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'ef579e40-jibber-jabber', + selectedEventIds: {}, + show: false, + showCheckboxes: false, + showRowRenderers: true, + sort: { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + title: 'Test rule', + version: '1', + width: 1100, +}; + +export const mockTimelineResult: TimelineResult = { + savedObjectId: 'ef579e40-jibber-jabber', + columns: timelineDefaults.columns.filter(column => column.id !== 'event.action'), + dateRange: { start: 1584539198929, end: 1584539558929 }, + description: 'This is a sample rule description', + eventType: 'all', + filters: [ + { + meta: { + key: 'host.name', + negate: false, + params: '"{"query":"placeholder"}"', + type: 'phrase', + }, + query: '"{"match_phrase":{"host.name":"placeholder"}}"', + }, + ], + kqlMode: 'filter', + title: 'Test rule', + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + version: '1', +}; + +export const mockTimelineApolloResult = { + data: { + getOneTimeline: mockTimelineResult, + }, + loading: false, + networkStatus: 7, + stale: false, +}; + +export const defaultTimelineProps: CreateTimelineProps = { + from: 1541444305937, + timeline: { + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp', width: 190 }, + { columnHeaderType: 'not-filtered', id: 'message', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.category', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'event.action', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'host.name', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'source.ip', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'destination.ip', width: 180 }, + { columnHeaderType: 'not-filtered', id: 'user.name', width: 180 }, + ], + dataProviders: [ + { + and: [], + enabled: true, + excluded: false, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-1', + kqlQuery: '', + name: '1', + queryMatch: { field: '_id', operator: ':', value: '1' }, + }, + ], + dateRange: { end: 1541444605937, start: 1541444305937 }, + deletedEventIds: [], + description: '', + eventIdToNoteIds: {}, + eventType: 'all', + filters: [], + highlightedDropAndProviderId: '', + historyIds: [], + id: 'timeline-1', + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { kuery: { expression: '', kind: 'kuery' }, serializedQuery: '' }, + filterQueryDraft: { expression: '', kind: 'kuery' }, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: null, + selectedEventIds: {}, + show: false, + showCheckboxes: false, + showRowRenderers: true, + sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + title: '', + version: null, + width: 1100, + }, + to: 1541444605937, + ruleNote: '# this is some markdown documentation', +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx new file mode 100644 index 00000000000000..8aaed08a0a0a1e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.test.tsx @@ -0,0 +1,380 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import sinon from 'sinon'; +import moment from 'moment'; + +import { sendSignalToTimelineAction, determineToAndFrom } from './actions'; +import { + mockEcsDataWithSignal, + defaultTimelineProps, + apolloClient, + mockTimelineApolloResult, +} from '../../../../mock/'; +import { CreateTimeline, UpdateTimelineLoading } from './types'; +import { Ecs } from '../../../../graphql/types'; + +jest.mock('apollo-client'); + +describe('signals actions', () => { + const anchor = '2020-03-01T17:59:46.349Z'; + const unix = moment(anchor).valueOf(); + let createTimeline: CreateTimeline; + let updateTimelineIsLoading: UpdateTimelineLoading; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + // jest carries state between mocked implementations when using + // spyOn. So now we're doing all three of these. + // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + createTimeline = jest.fn() as jest.Mocked; + updateTimelineIsLoading = jest.fn() as jest.Mocked; + + jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResult); + + clock = sinon.useFakeTimers(unix); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('sendSignalToTimelineAction', () => { + describe('timeline id is NOT empty string and apollo client exists', () => { + test('it invokes updateTimelineIsLoading to set to true', async () => { + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: mockEcsDataWithSignal, + updateTimelineIsLoading, + }); + + expect(updateTimelineIsLoading).toHaveBeenCalledTimes(1); + expect(updateTimelineIsLoading).toHaveBeenCalledWith({ id: 'timeline-1', isLoading: true }); + }); + + test('it invokes createTimeline with designated timeline template if "timelineTemplate" exists', async () => { + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: mockEcsDataWithSignal, + updateTimelineIsLoading, + }); + const expected = { + from: 1541444305937, + timeline: { + columns: [ + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: '@timestamp', + placeholder: undefined, + type: undefined, + width: 190, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'message', + placeholder: undefined, + type: undefined, + width: 180, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'event.category', + placeholder: undefined, + type: undefined, + width: 180, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'host.name', + placeholder: undefined, + type: undefined, + width: 180, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'source.ip', + placeholder: undefined, + type: undefined, + width: 180, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'destination.ip', + placeholder: undefined, + type: undefined, + width: 180, + }, + { + aggregatable: undefined, + category: undefined, + columnHeaderType: 'not-filtered', + description: undefined, + example: undefined, + id: 'user.name', + placeholder: undefined, + type: undefined, + width: 180, + }, + ], + dataProviders: [], + dateRange: { + end: 1541444605937, + start: 1541444305937, + }, + deletedEventIds: [], + description: 'This is a sample rule description', + eventIdToNoteIds: {}, + eventType: 'all', + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + key: 'host.name', + negate: false, + params: { + query: 'apache', + }, + type: 'phrase', + }, + query: { + match_phrase: { + 'host.name': 'apache', + }, + }, + }, + ], + highlightedDropAndProviderId: '', + historyIds: [], + id: '', + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + expression: '', + kind: 'kuery', + }, + serializedQuery: '', + }, + filterQueryDraft: { + expression: '', + kind: 'kuery', + }, + }, + loadingEventIds: [], + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: null, + selectedEventIds: {}, + show: true, + showCheckboxes: false, + showRowRenderers: true, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + title: '', + version: null, + width: 1100, + }, + to: 1541444605937, + ruleNote: '# this is some markdown documentation', + }; + + expect(createTimeline).toHaveBeenCalledWith(expected); + }); + + test('it invokes createTimeline with kqlQuery.filterQuery.kuery.kind as "kuery" if not specified in returned timeline template', async () => { + const mockTimelineApolloResultModified = { + ...mockTimelineApolloResult, + kqlQuery: { + filterQuery: { + kuery: { + expression: [''], + }, + }, + filterQueryDraft: { + expression: [''], + }, + }, + }; + jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResultModified); + + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: mockEcsDataWithSignal, + updateTimelineIsLoading, + }); + // @ts-ignore + const createTimelineArg = createTimeline.mock.calls[0][0]; + + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimelineArg.timeline.kqlQuery.filterQuery.kuery.kind).toEqual('kuery'); + }); + + test('it invokes createTimeline with kqlQuery.filterQueryDraft.kuery.kind as "kuery" if not specified in returned timeline template', async () => { + const mockTimelineApolloResultModified = { + ...mockTimelineApolloResult, + kqlQuery: { + filterQuery: { + kuery: { + expression: [''], + }, + }, + filterQueryDraft: { + expression: [''], + }, + }, + }; + jest.spyOn(apolloClient, 'query').mockResolvedValue(mockTimelineApolloResultModified); + + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: mockEcsDataWithSignal, + updateTimelineIsLoading, + }); + // @ts-ignore + const createTimelineArg = createTimeline.mock.calls[0][0]; + + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimelineArg.timeline.kqlQuery.filterQueryDraft.kind).toEqual('kuery'); + }); + + test('it invokes createTimeline with default timeline if apolloClient throws', async () => { + jest.spyOn(apolloClient, 'query').mockImplementation(() => { + throw new Error('Test error'); + }); + + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: mockEcsDataWithSignal, + updateTimelineIsLoading, + }); + + expect(updateTimelineIsLoading).toHaveBeenCalledWith({ id: 'timeline-1', isLoading: true }); + expect(updateTimelineIsLoading).toHaveBeenCalledWith({ + id: 'timeline-1', + isLoading: false, + }); + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps); + }); + }); + + describe('timelineId is empty string', () => { + test('it invokes createTimeline with timelineDefaults', async () => { + const ecsDataMock: Ecs = { + ...mockEcsDataWithSignal, + signal: { + rule: { + ...mockEcsDataWithSignal.signal?.rule!, + timeline_id: null, + }, + }, + }; + + await sendSignalToTimelineAction({ + apolloClient, + createTimeline, + ecsData: ecsDataMock, + updateTimelineIsLoading, + }); + + expect(updateTimelineIsLoading).not.toHaveBeenCalled(); + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps); + }); + }); + + describe('apolloClient is not defined', () => { + test('it invokes createTimeline with timelineDefaults', async () => { + const ecsDataMock: Ecs = { + ...mockEcsDataWithSignal, + signal: { + rule: { + ...mockEcsDataWithSignal.signal?.rule!, + timeline_id: [''], + }, + }, + }; + + await sendSignalToTimelineAction({ + createTimeline, + ecsData: ecsDataMock, + updateTimelineIsLoading, + }); + + expect(updateTimelineIsLoading).not.toHaveBeenCalled(); + expect(createTimeline).toHaveBeenCalledTimes(1); + expect(createTimeline).toHaveBeenCalledWith(defaultTimelineProps); + }); + }); + }); + + describe('determineToAndFrom', () => { + test('it uses ecs.Data.timestamp if one is provided', () => { + const ecsDataMock: Ecs = { + ...mockEcsDataWithSignal, + timestamp: '2020-03-20T17:59:46.349Z', + }; + const result = determineToAndFrom({ ecsData: ecsDataMock }); + + expect(result.from).toEqual(1584726886349); + expect(result.to).toEqual(1584727186349); + }); + + test('it uses current time timestamp if ecsData.timestamp is not provided', () => { + const { timestamp, ...ecsDataMock } = { + ...mockEcsDataWithSignal, + }; + const result = determineToAndFrom({ ecsData: ecsDataMock }); + + expect(result.from).toEqual(1583085286349); + expect(result.to).toEqual(1583085586349); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.tsx index b23b051e8b2e82..c71ede32d8403e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/actions.tsx @@ -10,7 +10,7 @@ import moment from 'moment'; import { updateSignalStatus } from '../../../../containers/detection_engine/signals/api'; import { SendSignalToTimelineActionProps, UpdateSignalStatusActionProps } from './types'; -import { TimelineNonEcsData, GetOneTimeline, TimelineResult } from '../../../../graphql/types'; +import { TimelineNonEcsData, GetOneTimeline, TimelineResult, Ecs } from '../../../../graphql/types'; import { oneTimelineQuery } from '../../../../containers/timeline/one/index.gql_query'; import { omitTypenameInTimeline, @@ -72,16 +72,7 @@ export const updateSignalStatusAction = async ({ } }; -export const sendSignalToTimelineAction = async ({ - apolloClient, - createTimeline, - ecsData, - updateTimelineIsLoading, -}: SendSignalToTimelineActionProps) => { - let openSignalInBasicTimeline = true; - const timelineId = - ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : ''; - +export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => { const ellapsedTimeRule = moment.duration( moment().diff( dateMath.parse(ecsData.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s') @@ -93,6 +84,21 @@ export const sendSignalToTimelineAction = async ({ .valueOf(); const to = moment(ecsData.timestamp ?? new Date()).valueOf(); + return { to, from }; +}; + +export const sendSignalToTimelineAction = async ({ + apolloClient, + createTimeline, + ecsData, + updateTimelineIsLoading, +}: SendSignalToTimelineActionProps) => { + let openSignalInBasicTimeline = true; + const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : ''; + const timelineId = + ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : ''; + const { to, from } = determineToAndFrom({ ecsData }); + if (timelineId !== '' && apolloClient != null) { try { updateTimelineIsLoading({ id: 'timeline-1', isLoading: true }); @@ -106,10 +112,10 @@ export const sendSignalToTimelineAction = async ({ id: timelineId, }, }); - const timelineTemplate: TimelineResult = omitTypenameInTimeline( - getOr({}, 'data.getOneTimeline', responseTimeline) - ); - if (!isEmpty(timelineTemplate)) { + const resultingTimeline: TimelineResult = getOr({}, 'data.getOneTimeline', responseTimeline); + + if (!isEmpty(resultingTimeline)) { + const timelineTemplate: TimelineResult = omitTypenameInTimeline(resultingTimeline); openSignalInBasicTimeline = false; const { timeline } = formatTimelineResultToModel(timelineTemplate, true); const query = replaceTemplateFieldFromQuery( @@ -148,6 +154,7 @@ export const sendSignalToTimelineAction = async ({ show: true, }, to, + ruleNote: noteContent, }); } } catch { @@ -197,6 +204,7 @@ export const sendSignalToTimelineAction = async ({ }, }, to, + ruleNote: noteContent, }); } }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.test.tsx new file mode 100644 index 00000000000000..6212cad7e18459 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.test.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; + +import { Filter } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { TimelineAction } from '../../../../components/timeline/body/actions'; +import { buildSignalsRuleIdFilter, getSignalsActions } from './default_config'; +import { + CreateTimeline, + SetEventsDeletedProps, + SetEventsLoadingProps, + UpdateTimelineLoading, +} from './types'; +import { mockEcsDataWithSignal } from '../../../../mock/mock_ecs'; +import { sendSignalToTimelineAction, updateSignalStatusAction } from './actions'; +import * as i18n from './translations'; + +jest.mock('./actions'); + +describe('signals default_config', () => { + describe('buildSignalsRuleIdFilter', () => { + test('given a rule id this will return an array with a single filter', () => { + const filters: Filter[] = buildSignalsRuleIdFilter('rule-id-1'); + const expectedFilter: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'signal.rule.id', + params: { + query: 'rule-id-1', + }, + }, + query: { + match_phrase: { + 'signal.rule.id': 'rule-id-1', + }, + }, + }; + expect(filters).toHaveLength(1); + expect(filters[0]).toEqual(expectedFilter); + }); + }); + + describe('getSignalsActions', () => { + let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; + let setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; + let createTimeline: CreateTimeline; + let updateTimelineIsLoading: UpdateTimelineLoading; + + beforeEach(() => { + setEventsLoading = jest.fn(); + setEventsDeleted = jest.fn(); + createTimeline = jest.fn(); + updateTimelineIsLoading = jest.fn(); + }); + + describe('timeline tooltip', () => { + test('it invokes sendSignalToTimelineAction when button clicked', () => { + const signalsActions = getSignalsActions({ + canUserCRUD: true, + hasIndexWrite: true, + setEventsLoading, + setEventsDeleted, + createTimeline, + status: 'open', + updateTimelineIsLoading, + }); + const timelineAction = signalsActions[0].getAction({ + eventId: 'even-id', + ecsData: mockEcsDataWithSignal, + }); + const wrapper = mount(timelineAction as React.ReactElement); + wrapper.find(EuiButtonIcon).simulate('click'); + + expect(sendSignalToTimelineAction).toHaveBeenCalled(); + }); + }); + + describe('signal open action', () => { + let signalsActions: TimelineAction[]; + let signalOpenAction: JSX.Element; + let wrapper: ReactWrapper; + + beforeEach(() => { + signalsActions = getSignalsActions({ + canUserCRUD: true, + hasIndexWrite: true, + setEventsLoading, + setEventsDeleted, + createTimeline, + status: 'open', + updateTimelineIsLoading, + }); + + signalOpenAction = signalsActions[1].getAction({ + eventId: 'event-id', + ecsData: mockEcsDataWithSignal, + }); + + wrapper = mount(signalOpenAction as React.ReactElement); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('it invokes updateSignalStatusAction when button clicked', () => { + wrapper.find(EuiButtonIcon).simulate('click'); + + expect(updateSignalStatusAction).toHaveBeenCalledWith({ + signalIds: ['event-id'], + status: 'open', + setEventsLoading, + setEventsDeleted, + }); + }); + + test('it displays expected text on hover', () => { + const openSignal = wrapper.find(EuiToolTip); + openSignal.simulate('mouseOver'); + const tooltip = wrapper.find('.euiToolTipPopover').text(); + + expect(tooltip).toEqual(i18n.ACTION_OPEN_SIGNAL); + }); + + test('it displays expected icon', () => { + const icon = wrapper.find(EuiButtonIcon).props().iconType; + + expect(icon).toEqual('securitySignalDetected'); + }); + }); + + describe('signal close action', () => { + let signalsActions: TimelineAction[]; + let signalCloseAction: JSX.Element; + let wrapper: ReactWrapper; + + beforeEach(() => { + signalsActions = getSignalsActions({ + canUserCRUD: true, + hasIndexWrite: true, + setEventsLoading, + setEventsDeleted, + createTimeline, + status: 'closed', + updateTimelineIsLoading, + }); + + signalCloseAction = signalsActions[1].getAction({ + eventId: 'event-id', + ecsData: mockEcsDataWithSignal, + }); + + wrapper = mount(signalCloseAction as React.ReactElement); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('it invokes updateSignalStatusAction when status button clicked', () => { + wrapper.find(EuiButtonIcon).simulate('click'); + + expect(updateSignalStatusAction).toHaveBeenCalledWith({ + signalIds: ['event-id'], + status: 'closed', + setEventsLoading, + setEventsDeleted, + }); + }); + + test('it displays expected text on hover', () => { + const closeSignal = wrapper.find(EuiToolTip); + closeSignal.simulate('mouseOver'); + const tooltip = wrapper.find('.euiToolTipPopover').text(); + expect(tooltip).toEqual(i18n.ACTION_CLOSE_SIGNAL); + }); + + test('it displays expected icon', () => { + const icon = wrapper.find(EuiButtonIcon).props().iconType; + + expect(icon).toEqual('securitySignalResolved'); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx index 44c48b1879e891..fd3b9a6f68e824 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx @@ -23,7 +23,12 @@ import { timelineDefaults } from '../../../../store/timeline/defaults'; import { FILTER_OPEN } from './signals_filter_group'; import { sendSignalToTimelineAction, updateSignalStatusAction } from './actions'; import * as i18n from './translations'; -import { CreateTimeline, SetEventsDeletedProps, SetEventsLoadingProps } from './types'; +import { + CreateTimeline, + SetEventsDeletedProps, + SetEventsLoadingProps, + UpdateTimelineLoading, +} from './types'; export const signalsOpenFilters: Filter[] = [ { @@ -198,13 +203,13 @@ export const getSignalsActions = ({ setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; createTimeline: CreateTimeline; status: 'open' | 'closed'; - updateTimelineIsLoading: ({ id, isLoading }: { id: string; isLoading: boolean }) => void; + updateTimelineIsLoading: UpdateTimelineLoading; }): TimelineAction[] => [ { getAction: ({ ecsData }: TimelineActionProps): JSX.Element => ( { let localValueToChange = valueToChange; - if (keuryNode.function === 'is' && templateFields.includes(keuryNode.arguments[0].value)) { + if (kueryNode.function === 'is' && templateFields.includes(kueryNode.arguments[0].value)) { localValueToChange = [ ...localValueToChange, { - field: keuryNode.arguments[0].value, - valueToChange: keuryNode.arguments[1].value, + field: kueryNode.arguments[0].value, + valueToChange: kueryNode.arguments[1].value, }, ]; } - return keuryNode.arguments.reduce( + return kueryNode.arguments.reduce( (addValueToChange: FindValueToChangeInQuery[], ast: KueryNode) => { if (ast.function === 'is' && templateFields.includes(ast.arguments[0].value)) { return [ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index afd325f5399666..6cdb2f326901ed 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -114,7 +114,7 @@ const SignalsTableComponent: React.FC = ({ // Callback for creating a new timeline -- utilized by row/batch actions const createTimelineCallback = useCallback( - ({ from: fromTimeline, timeline, to: toTimeline }: CreateTimelineProps) => { + ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { updateTimelineIsLoading({ id: 'timeline-1', isLoading: false }); updateTimeline({ duplicate: true, @@ -126,6 +126,7 @@ const SignalsTableComponent: React.FC = ({ show: true, }, to: toTimeline, + ruleNote, })(); }, [updateTimeline, updateTimelineIsLoading] diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/translations.ts index c2807db1797808..f68dcd932bc328 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/translations.ts @@ -95,9 +95,9 @@ export const ACTION_CLOSE_SIGNAL = i18n.translate( } ); -export const ACTION_VIEW_IN_TIMELINE = i18n.translate( - 'xpack.siem.detectionEngine.signals.actions.viewInTimelineTitle', +export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate( + 'xpack.siem.detectionEngine.signals.actions.investigateInTimelineTitle', { - defaultMessage: 'View in timeline', + defaultMessage: 'Investigate in timeline', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/types.ts index b3e7ed75cfb995..909b2176467461 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/types.ts @@ -45,13 +45,16 @@ export interface SendSignalToTimelineActionProps { apolloClient?: ApolloClient<{}>; createTimeline: CreateTimeline; ecsData: Ecs; - updateTimelineIsLoading: ({ id, isLoading }: { id: string; isLoading: boolean }) => void; + updateTimelineIsLoading: UpdateTimelineLoading; } +export type UpdateTimelineLoading = ({ id, isLoading }: { id: string; isLoading: boolean }) => void; + export interface CreateTimelineProps { from: number; timeline: TimelineModel; to: number; + ruleNote?: string; } export type CreateTimeline = ({ from, timeline, to }: CreateTimelineProps) => void; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap index 9a534297e5e297..31abea53462fae 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap @@ -145,7 +145,7 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against # this is some markdown documentation , - "title": "Investigation notes", + "title": "Investigation guide", }, ] } @@ -287,7 +287,7 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against # this is some markdown documentation , - "title": "Investigation notes", + "title": "Investigation guide", }, ] } @@ -430,7 +430,7 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against # this is some markdown documentation , - "title": "Investigation notes", + "title": "Investigation guide", }, ] } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx index a01aec0ccf2cf2..8e8927cb7bbd13 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx @@ -461,12 +461,12 @@ describe('description_step', () => { test('returns default "note" description', () => { const result: ListItems[] = getDescriptionItem( 'note', - 'Investigation notes', + 'Investigation guide', mockAboutStep, mockFilterManager ); - expect(result[0].title).toEqual('Investigation notes'); + expect(result[0].title).toEqual('Investigation guide'); expect(React.isValidElement(result[0].description)).toBeTruthy(); }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 8cb38b9dc7393b..7c088c068c9b25 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -178,12 +178,12 @@ export const schema: FormSchema = { }, note: { type: FIELD_TYPES.TEXTAREA, - label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.noteLabel', { - defaultMessage: 'Investigation notes', + label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.guideLabel', { + defaultMessage: 'Investigation guide', }), - helpText: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.noteHelpText', { + helpText: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.guideHelpText', { defaultMessage: - 'Provide helpful information for analysts that are performing a signal investigation. These notes will appear on both the rule details page and in timelines created from signals generated by this rule.', + 'Provide helpful information for analysts that are performing a signal investigation. This guide will appear on both the rule details page and in timelines created from signals generated by this rule.', }), labelAppend: OptionalFieldLabel, }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index dfa60268e903aa..0b1e712c663f3e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -72,6 +72,6 @@ export const URL_FORMAT_INVALID = i18n.translate( export const ADD_RULE_NOTE_HELP_TEXT = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutrule.noteHelpText', { - defaultMessage: 'Add rule investigation notes...', + defaultMessage: 'Add rule investigation guide...', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx index bbd037af10c3f0..76a3c590a62a6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx @@ -136,7 +136,7 @@ describe('StepAboutRuleToggleDetails', () => { expect(wrapper.find('EuiButtonGroup[idSelected="notes"]').exists()).toBeFalsy(); wrapper - .find('input[title="Investigation notes"]') + .find('input[title="Investigation guide"]') .at(0) .simulate('change', { target: { value: 'notes' } }); @@ -159,7 +159,7 @@ describe('StepAboutRuleToggleDetails', () => { ); wrapper - .find('input[title="Investigation notes"]') + .find('input[title="Investigation guide"]') .at(0) .simulate('change', { target: { value: 'notes' } }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/translations.ts index fa725366210deb..79c5eb12d46634 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/translations.ts @@ -20,8 +20,8 @@ export const ABOUT_TEXT = i18n.translate( ); export const ABOUT_PANEL_NOTES_TAB = i18n.translate( - 'xpack.siem.detectionEngine.details.stepAboutRule.investigationNotesLabel', + 'xpack.siem.detectionEngine.details.stepAboutRule.investigationGuideLabel', { - defaultMessage: 'Investigation notes', + defaultMessage: 'Investigation guide', } ); diff --git a/x-pack/legacy/plugins/siem/server/graphql/ecs/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/ecs/schema.gql.ts index f897236b3470e8..9bf55cfe1ed2a0 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/ecs/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/ecs/schema.gql.ts @@ -410,6 +410,7 @@ export const ecsSchema = gql` created_by: ToStringArray updated_by: ToStringArray version: ToStringArray + note: ToStringArray } type SignalField { diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index e2b365f8bfa5be..d272b7ff59b79a 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -1014,6 +1014,8 @@ export interface RuleField { updated_by?: Maybe; version?: Maybe; + + note?: Maybe; } export interface SuricataEcsFields { @@ -4822,6 +4824,8 @@ export namespace RuleFieldResolvers { updated_by?: UpdatedByResolver, TypeParent, TContext>; version?: VersionResolver, TypeParent, TContext>; + + note?: NoteResolver, TypeParent, TContext>; } export type IdResolver< @@ -4974,6 +4978,11 @@ export namespace RuleFieldResolvers { Parent = RuleField, TContext = SiemContext > = Resolver; + export type NoteResolver< + R = Maybe, + Parent = RuleField, + TContext = SiemContext + > = Resolver; } export namespace SuricataEcsFieldsResolvers { diff --git a/x-pack/legacy/plugins/siem/server/lib/ecs_fields/index.ts b/x-pack/legacy/plugins/siem/server/lib/ecs_fields/index.ts index eb483de000915a..f2662c79d33937 100644 --- a/x-pack/legacy/plugins/siem/server/lib/ecs_fields/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/ecs_fields/index.ts @@ -316,6 +316,7 @@ export const signalFieldsMap: Readonly> = { 'signal.rule.created_by': 'signal.rule.created_by', 'signal.rule.updated_by': 'signal.rule.updated_by', 'signal.rule.version': 'signal.rule.version', + 'signal.rule.note': 'signal.rule.note', }; export const ruleFieldsMap: Readonly> = {